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

295 lines
9.7 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import UIKit
import SignalServiceKit
public struct Tooltip {
// MARK: Properties
public let title: String?
public let message: String?
public let icon: ThemeIcon?
public let shouldShowCloseButton: Bool
/// Which views should be interactive while the tooltip is presented.
/// Default value is `nil`, meaning only the tooltip itself is interactive.
public let passthroughViews: [UIView]?
public enum TapAction {
case dismiss
case custom(() -> Void)
}
/// Action to perform when tapping the content of the tooltip.
/// Default value is `.dismiss`.
public let tapAction: TapAction?
// Layout
public let hSpacing: CGFloat = 12
public let vSpacing: CGFloat = 0
public init(
title: String? = nil,
message: String? = nil,
icon: ThemeIcon? = nil,
shouldShowCloseButton: Bool,
passthroughViews: [UIView]? = nil,
tapAction: TapAction = .dismiss
) {
self.title = title
self.message = message
self.icon = icon
self.shouldShowCloseButton = shouldShowCloseButton
self.passthroughViews = passthroughViews
self.tapAction = tapAction
}
var attributedTitle: NSAttributedString? {
guard let title else { return nil }
return NSAttributedString(string: title, attributes: [
.font: UIFont.dynamicTypeHeadline,
.foregroundColor: UIColor.Signal.label,
])
}
var attributedMessage: NSAttributedString? {
guard let message else { return nil }
let textColor = title != nil ? UIColor.Signal.secondaryLabel : UIColor.Signal.label
return NSAttributedString(string: message, attributes: [
.font: UIFont.dynamicTypeSubheadline,
.foregroundColor: textColor,
])
}
// MARK: Presentation
public func present(
from viewController: UIViewController,
sourceView: UIView,
sourceRect: CGRect? = nil,
arrowDirections: UIPopoverArrowDirection
) {
let tooltipViewController = TooltipViewController(tooltip: self, presenter: viewController)
tooltipViewController.overrideUserInterfaceStyle = viewController.overrideUserInterfaceStyle
tooltipViewController.modalPresentationStyle = .popover
guard let presentation = tooltipViewController.popoverPresentationController else {
owsFailDebug("Missing popoverPresentationController")
return
}
presentation.delegate = tooltipViewController
presentation.sourceView = sourceView
if let sourceRect {
presentation.sourceRect = sourceRect
}
presentation.permittedArrowDirections = arrowDirections
presentation.passthroughViews = self.passthroughViews
viewController.present(tooltipViewController, animated: true)
}
// MARK: - TooltipViewController
public class TooltipViewController: OWSViewController {
// MARK: Properties
private static var vMargins: CGFloat = 13
let tooltip: Tooltip
let presenter: UIViewController
init(tooltip: Tooltip, presenter: UIViewController) {
self.tooltip = tooltip
self.presenter = presenter
super.init()
}
private var hStack = UIStackView()
private lazy var iconImageView: UIImageView? = {
guard let icon = self.tooltip.icon else { return nil }
let imageView = UIImageView(image: Theme.iconImage(icon))
imageView.setCompressionResistanceHigh()
imageView.setContentHuggingHigh()
imageView.tintColor = UIColor.Signal.label
return imageView
}()
private lazy var titleLabel: UILabel? = {
guard let title = self.tooltip.attributedTitle else { return nil }
let label = UILabel()
label.attributedText = title
label.numberOfLines = 0
label.setContentHuggingHorizontalLow()
return label
}()
private lazy var messageLabel: UILabel? = {
guard let message = self.tooltip.attributedMessage else { return nil }
let label = UILabel()
label.attributedText = message
label.numberOfLines = 0
label.setContentHuggingHorizontalLow()
return label
}()
private lazy var closeButton: UIImageView? = {
guard tooltip.shouldShowCloseButton else { return nil }
let imageView = UIImageView(image: Theme.iconImage(.buttonX))
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(closeTapped))
imageView.addGestureRecognizer(tapGesture)
imageView.setCompressionResistanceHigh()
imageView.setContentHuggingHigh()
imageView.tintColor = UIColor.Signal.secondaryLabel
return imageView
}()
// MARK: Lifecycle
public override func viewDidLoad() {
super.viewDidLoad()
hStack.axis = .horizontal
hStack.spacing = self.tooltip.hSpacing
hStack.alignment = .top
let vStack = UIStackView()
vStack.axis = .vertical
vStack.spacing = self.tooltip.vSpacing
self.iconImageView.map(hStack.addArrangedSubview(_:))
hStack.addArrangedSubview(vStack)
self.titleLabel.map(vStack.addArrangedSubview(_:))
self.messageLabel.map(vStack.addArrangedSubview(_:))
self.closeButton.map(hStack.addArrangedSubview(_:))
self.view.addSubview(hStack)
hStack.autoCenterInSuperview()
hStack.layoutMargins = .init(hMargin: 0, vMargin: Self.vMargins)
hStack.isLayoutMarginsRelativeArrangement = true
hStack.autoPinEdgesToSuperviewMargins()
if tooltip.tapAction != nil {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap))
self.view.addGestureRecognizer(tapGesture)
}
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.updateContentSize()
}
private func updateContentSize() {
let popoverOuterMargin: CGFloat = 64
let maxWidth = presenter.view.width - popoverOuterMargin
var contentWidth: CGFloat = 0
let titleSize = tooltip.attributedTitle?.size() ?? .zero
let messageSize = tooltip.attributedMessage?.size() ?? .zero
let textWidth = max(titleSize.width, messageSize.width)
contentWidth += textWidth
if let iconImageView {
contentWidth += iconImageView.width
contentWidth += self.tooltip.hSpacing
}
if let closeButton {
contentWidth += closeButton.width
contentWidth += self.tooltip.hSpacing
}
// Controlled by the system
let popoverHMargin: CGFloat = 16
contentWidth += popoverHMargin * 2
if contentWidth >= maxWidth {
hStack.alignment = .top
// Let the system decide the size that will fit the max width
let targetWidth = presenter.view.width - popoverOuterMargin
let fittingSize = CGSize(
width: targetWidth,
height: UIView.layoutFittingCompressedSize.height
)
let targetHeight = self.view.systemLayoutSizeFitting(
fittingSize,
withHorizontalFittingPriority: .required,
verticalFittingPriority: .defaultLow
).height
self.preferredContentSize = .init(width: UIView.layoutFittingCompressedSize.width, height: targetHeight)
} else {
hStack.alignment = .center
// Manually size the tooltip
var contentHeight = titleSize.height + messageSize.height
if tooltip.title != nil && tooltip.message != nil {
contentHeight += tooltip.vSpacing
}
if let iconImageView {
contentHeight = max(iconImageView.height, contentHeight)
}
if let closeButton {
contentHeight = max(closeButton.height, contentHeight)
}
contentHeight += Self.vMargins * 2
self.preferredContentSize = .init(width: contentWidth, height: contentHeight)
}
}
// MARK: Actions
@objc
private func didTap() {
switch self.tooltip.tapAction {
case .none:
break
case .dismiss:
self.dismiss(animated: true)
case .custom(let action):
action()
}
}
@objc
func closeTapped() {
self.dismiss(animated: true)
}
}
}
// MARK: - UIPopoverPresentationControllerDelegate
extension Tooltip.TooltipViewController: UIPopoverPresentationControllerDelegate {
public func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
.none
}
}
// MARK: UIViewController + Tooltips
public extension UIViewController {
var isTooltipPresented: Bool {
self.presentedViewController is Tooltip.TooltipViewController
}
func dismissTooltip(animated: Bool = true, completion: (() -> Void)? = nil) {
guard self.isTooltipPresented else {
return
}
self.dismiss(animated: animated, completion: completion)
}
}