Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/Views/Tooltips/Tooltip.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import UIKit

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

        override public 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)
            }
        }

        override public 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)
    }
}