325 lines
10 KiB
Swift
325 lines
10 KiB
Swift
//
|
|
// Copyright 2019 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
|
|
open class TooltipView: UIView {
|
|
public enum TailDirection {
|
|
case up
|
|
case down
|
|
}
|
|
|
|
private let tailHeight: CGFloat = 8
|
|
private let tailWidth: CGFloat = 16
|
|
|
|
/// An invisible view that we use to compute the outline of this view.
|
|
///
|
|
/// We use the computed outline to mask our content and draw our shadow.
|
|
private lazy var outlineView: OWSLayerView = {
|
|
let outlineView = OWSLayerView()
|
|
|
|
outlineView.layoutCallback = { [weak self] view in
|
|
guard
|
|
let self,
|
|
let tailReferenceView = self.tailReferenceView
|
|
else {
|
|
return
|
|
}
|
|
|
|
let outlinePath = self.buildOutlinePath(
|
|
bounds: view.bounds,
|
|
tailReferenceView: tailReferenceView
|
|
)
|
|
|
|
let maskingLayer = CAShapeLayer()
|
|
maskingLayer.path = outlinePath
|
|
self.contentView.layer.mask = maskingLayer
|
|
|
|
self.layer.shadowPath = outlinePath
|
|
}
|
|
|
|
return outlineView
|
|
}()
|
|
|
|
/// A wrapper for our contents.
|
|
///
|
|
/// Important that our content is isolated into its own subview, so we can
|
|
/// mask that subview without masking ourselves and thereby clipping our
|
|
/// shadow.
|
|
private lazy var contentView: UIView = {
|
|
let view = UIView()
|
|
|
|
view.backgroundColor = bubbleColor
|
|
|
|
return view
|
|
}()
|
|
|
|
/// A view that this view's "tail" should point towards.
|
|
private weak var tailReferenceView: UIView?
|
|
|
|
private let wasTappedBlock: (() -> Void)?
|
|
|
|
public init(
|
|
fromView: UIView,
|
|
widthReferenceView: UIView,
|
|
tailReferenceView: UIView,
|
|
wasTappedBlock: (() -> Void)?
|
|
) {
|
|
self.tailReferenceView = tailReferenceView
|
|
self.wasTappedBlock = wasTappedBlock
|
|
|
|
super.init(frame: .zero)
|
|
|
|
setupContents(
|
|
fromView: fromView,
|
|
widthReferenceView: widthReferenceView,
|
|
tailReferenceView: tailReferenceView
|
|
)
|
|
}
|
|
|
|
required public init?(coder aDecoder: NSCoder) { owsFail("Not implemented!") }
|
|
|
|
// MARK: - Overrides
|
|
|
|
open func bubbleContentView() -> UIView {
|
|
owsFail("Implemented by subclasses!")
|
|
}
|
|
|
|
open var bubbleColor: UIColor {
|
|
owsFail("Implemented by subclasses!")
|
|
}
|
|
|
|
/// Whether or not to add a blur effect to the bubble.
|
|
///
|
|
/// Note that this is ignored if
|
|
/// ``UIAccessibility.isReduceTransparencyEnabled`` is `true`.
|
|
open var bubbleBlur: Bool {
|
|
false
|
|
}
|
|
|
|
open var bubbleRounding: CGFloat {
|
|
8
|
|
}
|
|
|
|
open var bubbleInsets: UIEdgeInsets {
|
|
return UIEdgeInsets(top: 7, left: 12, bottom: 7, right: 12)
|
|
}
|
|
|
|
open var bubbleHSpacing: CGFloat {
|
|
20
|
|
}
|
|
|
|
open var stretchesBubbleHorizontally: Bool {
|
|
false
|
|
}
|
|
|
|
open var tailDirection: TailDirection {
|
|
.down
|
|
}
|
|
|
|
open var dismissOnTap: Bool {
|
|
true
|
|
}
|
|
|
|
/// Whether the `tailReferenceView` is itself laid out with autolayout.
|
|
open var tailReferenceViewUsesAutolayout: Bool {
|
|
true
|
|
}
|
|
|
|
// MARK: - Contents
|
|
|
|
private func setupContents(
|
|
fromView: UIView,
|
|
widthReferenceView: UIView,
|
|
tailReferenceView: UIView
|
|
) {
|
|
layoutMargins = .zero
|
|
|
|
layer.shadowColor = UIColor.black.cgColor
|
|
layer.shadowOffset = CGSize(width: 0, height: 2)
|
|
layer.shadowRadius = 16
|
|
layer.shadowOpacity = 0.2
|
|
|
|
isUserInteractionEnabled = true
|
|
addGestureRecognizer(UITapGestureRecognizer(
|
|
target: self,
|
|
action: #selector(handleTap)
|
|
))
|
|
|
|
setupRelationshipWithSuperview(
|
|
superview: fromView,
|
|
tailReferenceView: tailReferenceView,
|
|
widthReferenceView: widthReferenceView
|
|
)
|
|
setupContentView()
|
|
}
|
|
|
|
public weak var verticalConstraint: NSLayoutConstraint?
|
|
|
|
private func setUpVerticalConstraintWithTailReferenceView(tailReferenceView: UIView) {
|
|
switch tailDirection {
|
|
case .up:
|
|
verticalConstraint = autoPinEdge(.top, to: .bottom, of: tailReferenceView, withOffset: 0)
|
|
case .down:
|
|
verticalConstraint = autoPinEdge(.bottom, to: .top, of: tailReferenceView, withOffset: 0)
|
|
}
|
|
}
|
|
|
|
override open func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
if !tailReferenceViewUsesAutolayout {
|
|
/// When the `tailReferenceView`'s frame is set, we must redo the vertical constraint.
|
|
/// Ideally, we wouldn't be operating in a dual autolayout + frame-setting world.
|
|
if let tailReferenceView {
|
|
setUpVerticalConstraintWithTailReferenceView(tailReferenceView: tailReferenceView)
|
|
}
|
|
}
|
|
}
|
|
|
|
open func setupRelationshipWithSuperview(
|
|
superview: UIView,
|
|
tailReferenceView: UIView,
|
|
widthReferenceView: UIView
|
|
) {
|
|
superview.addSubview(self)
|
|
|
|
setUpVerticalConstraintWithTailReferenceView(tailReferenceView: tailReferenceView)
|
|
|
|
// Insist on the tooltip fitting within the margins of the widthReferenceView.
|
|
if stretchesBubbleHorizontally {
|
|
autoPinEdge(.left, to: .left, of: widthReferenceView, withOffset: +bubbleHSpacing, relation: .equal)
|
|
autoPinEdge(.right, to: .right, of: widthReferenceView, withOffset: -bubbleHSpacing, relation: .equal)
|
|
} else {
|
|
autoPinEdge(.left, to: .left, of: widthReferenceView, withOffset: +bubbleHSpacing, relation: .greaterThanOrEqual)
|
|
autoPinEdge(.right, to: .right, of: widthReferenceView, withOffset: -bubbleHSpacing, relation: .lessThanOrEqual)
|
|
}
|
|
|
|
NSLayoutConstraint.autoSetPriority(UILayoutPriority.defaultLow) {
|
|
// Prefer that the tooltip's tail is as far as possible.
|
|
// It should point at the center of the "tail reference view".
|
|
let edgeOffset = bubbleRounding + tailWidth * 0.5 - tailReferenceView.width * 0.5
|
|
autoPinEdge(.right, to: .right, of: tailReferenceView, withOffset: edgeOffset)
|
|
}
|
|
}
|
|
|
|
private func setupContentView() {
|
|
addSubview(outlineView)
|
|
addSubview(contentView)
|
|
outlineView.autoPinEdgesToSuperviewEdges()
|
|
contentView.autoPinEdgesToSuperviewEdges()
|
|
|
|
if bubbleBlur, !UIAccessibility.isReduceTransparencyEnabled {
|
|
let blurEffect = UIBlurEffect(style: .regular)
|
|
let blurEffectView = UIVisualEffectView(effect: blurEffect)
|
|
let vibrancyEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: blurEffect, style: .label))
|
|
|
|
contentView.addSubview(blurEffectView)
|
|
contentView.addSubview(vibrancyEffectView)
|
|
blurEffectView.autoPinEdgesToSuperviewEdges()
|
|
vibrancyEffectView.autoPinEdgesToSuperviewEdges()
|
|
}
|
|
|
|
let bubbleContentView = self.bubbleContentView()
|
|
|
|
let contentEdgeInsets: UIEdgeInsets = {
|
|
switch tailDirection {
|
|
case .up:
|
|
return UIEdgeInsets(top: tailHeight, left: 0, bottom: 0, right: 0)
|
|
case .down:
|
|
return UIEdgeInsets(top: 0, left: 0, bottom: tailHeight, right: 0)
|
|
}
|
|
}()
|
|
|
|
contentView.addSubview(bubbleContentView)
|
|
bubbleContentView.autoPinEdgesToSuperviewEdges(with: contentEdgeInsets)
|
|
}
|
|
|
|
// MARK: - Outline
|
|
|
|
/// Build a path representing the outline of this view; namely, a bubble
|
|
/// with a cute lil' tail pointing at the given reference view, with the
|
|
/// given bounds.
|
|
private func buildOutlinePath(
|
|
bounds originalBubbleBounds: CGRect,
|
|
tailReferenceView: UIView
|
|
) -> CGPath {
|
|
let bezierPath = UIBezierPath()
|
|
|
|
// Bubble
|
|
|
|
var bubbleBounds = originalBubbleBounds
|
|
|
|
bubbleBounds.size.height -= tailHeight
|
|
if tailDirection == .up {
|
|
bubbleBounds.origin.y += tailHeight
|
|
}
|
|
|
|
bezierPath.append(UIBezierPath(
|
|
roundedRect: bubbleBounds,
|
|
cornerRadius: bubbleRounding
|
|
))
|
|
|
|
// Tail, which tries to point to the tail reference view.
|
|
|
|
let tailReferenceFrame = self.convert(tailReferenceView.bounds, from: tailReferenceView)
|
|
let tailHalfWidth = tailWidth * 0.5
|
|
let tailHCenterMin = bubbleRounding + tailHalfWidth
|
|
let tailHCenterMax = bubbleBounds.width - tailHCenterMin
|
|
let tailHCenter = tailReferenceFrame.center.x.clamp(tailHCenterMin, tailHCenterMax)
|
|
|
|
let tailPoint: CGPoint
|
|
let tailLeft: CGPoint
|
|
let tailRight: CGPoint
|
|
|
|
switch tailDirection {
|
|
case .down:
|
|
tailPoint = CGPoint(x: tailHCenter, y: originalBubbleBounds.height)
|
|
tailLeft = CGPoint(x: tailHCenter - tailHalfWidth, y: bubbleBounds.height)
|
|
tailRight = CGPoint(x: tailHCenter + tailHalfWidth, y: bubbleBounds.height)
|
|
case .up:
|
|
tailPoint = CGPoint(x: tailHCenter, y: 0)
|
|
tailLeft = CGPoint(x: tailHCenter - tailHalfWidth, y: tailHeight)
|
|
tailRight = CGPoint(x: tailHCenter + tailHalfWidth, y: tailHeight)
|
|
}
|
|
|
|
let tailPath = UIBezierPath()
|
|
tailPath.move(to: tailPoint)
|
|
tailPath.addLine(to: tailLeft)
|
|
tailPath.addLine(to: tailRight)
|
|
tailPath.addLine(to: tailPoint)
|
|
switch tailDirection {
|
|
case .down:
|
|
bezierPath.append(tailPath)
|
|
case .up:
|
|
// bezierPath and tailPath overlap slightly while animating, leaving
|
|
// the overlapping area excluded from the path. Adding tailPath with
|
|
// .reversing() forms the union of the two paths.
|
|
bezierPath.append(tailPath.reversing())
|
|
}
|
|
|
|
return bezierPath.cgPath
|
|
}
|
|
|
|
// MARK: - Events
|
|
|
|
@objc
|
|
private func handleTap(sender: UIGestureRecognizer) {
|
|
guard sender.state == .recognized else { return }
|
|
if dismissOnTap { removeFromSuperview() }
|
|
wasTappedBlock?()
|
|
}
|
|
}
|
|
|
|
public extension TooltipView {
|
|
func horizontalStack(forSubviews subviews: [UIView]) -> UIStackView {
|
|
let stackView = UIStackView(arrangedSubviews: subviews)
|
|
stackView.axis = .horizontal
|
|
stackView.alignment = .center
|
|
stackView.spacing = 6
|
|
stackView.layoutMargins = bubbleInsets
|
|
stackView.isLayoutMarginsRelativeArrangement = true
|
|
return stackView
|
|
}
|
|
}
|