TM-SGNL-iOS/SignalUI/Views/Tooltips/TooltipView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

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
}
}