// // TooltipAlertViewController.swift // VDS // // Created by Matt Bruce on 4/14/23. // import Foundation import UIKit import Combine import VDSColorTokens open class TooltipAlertViewController: UIViewController, Surfaceable { /// Set of Subscribers for any Publishers for this Control public var subscribers = Set() //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var onClickSubscriber: AnyCancellable? { willSet { if let onClickSubscriber { onClickSubscriber.cancel() } } } private let tooltipDialog = TooltipDialog() //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- open var surface: Surface = .light { didSet { updateView() }} open var titleText: String? { didSet { updateView() }} open var contentText: String? { didSet { updateView() }} open var contentView: UIView? { didSet { updateView() }} open var closeButtonText: String = "Close" { didSet { updateView() }} open var presenter: UIView? { didSet { updateView() }} //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- private let backgroundColorConfiguration = SurfaceColorConfiguration(VDSColor.backgroundPrimaryDark, VDSColor.backgroundPrimaryLight) //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- open override func viewDidLoad() { super.viewDidLoad() isModalInPresentation = true setup() } open override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) UIAccessibility.post(notification: .screenChanged, argument: tooltipDialog) } private func dismiss() { dismiss(animated: true) { [weak self] in guard let self, let presenter else { return } UIAccessibility.post(notification: .layoutChanged, argument: presenter) } } open func setup() { view.accessibilityElements = [tooltipDialog] //left-right swipe view.publisher(for: UISwipeGestureRecognizer().with{ $0.direction = .right }) .sink { [weak self] swipe in guard let self else { return } self.dismiss() }.store(in: &subscribers) //tapping in background view.publisher(for: UITapGestureRecognizer().with{ $0.numberOfTapsRequired = 1 }) .sink { [weak self] swipe in guard let self else { return } self.dismiss() }.store(in: &subscribers) //clicking button onClickSubscriber = tooltipDialog.closeButton.publisher(for: .touchUpInside) .sink {[weak self] button in guard let self else { return } self.dismiss() } view.addSubview(tooltipDialog) // Activate constraints NSLayoutConstraint.activate([ // Constraints for the floating modal view tooltipDialog.centerXAnchor.constraint(equalTo: view.centerXAnchor), tooltipDialog.centerYAnchor.constraint(equalTo: view.centerYAnchor), tooltipDialog.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor), tooltipDialog.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor), tooltipDialog.topAnchor.constraint(greaterThanOrEqualTo: view.topAnchor), tooltipDialog.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor) ]) } open func updateView() { view.backgroundColor = backgroundColorConfiguration.getColor(self).withAlphaComponent(0.3) tooltipDialog.surface = surface tooltipDialog.titleText = titleText tooltipDialog.contentText = contentText tooltipDialog.contentView = contentView } } open class TooltipDialog: View, UIScrollViewDelegate { private var scrollView = UIScrollView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.backgroundColor = .clear } private let contentStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .vertical $0.distribution = .fillProportionally $0.spacing = 0 } private var line = Line().with { instance in instance.lineViewColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsLowcontrastOnlight, VDSColor.elementsLowcontrastOndark).eraseToAnyColorable() } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- open var titleText: String? { didSet { setNeedsUpdate() }} open var titleLabel = Label().with { label in label.textStyle = .boldTitleMedium } open var contentText: String? { didSet { setNeedsUpdate() }} open var contentLabel = Label().with { label in label.textStyle = .bodyLarge } open var contentView: UIView? = nil open var closeButtonText: String = "Close" { didSet { setNeedsUpdate() }} open lazy var closeButton: UIButton = { let button = UIButton(type: .system) button.backgroundColor = .clear button.setTitle("Close", for: .normal) button.titleLabel?.font = TextStyle.bodyLarge.font button.translatesAutoresizingMaskIntoConstraints = false return button }() //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- private var closeButtonHeight: CGFloat = 44.0 private var fullWidth: CGFloat = 296 private var minHeight: CGFloat = 96.0 private var maxHeight: CGFloat = 312.0 private let containerViewInset = VDSLayout.Spacing.space4X.value private let backgroundColorConfiguration = SurfaceColorConfiguration(VDSColor.backgroundPrimaryLight, VDSColor.backgroundPrimaryDark) private let closeButtonTextColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark) private var contentStackViewBottomConstraint: NSLayoutConstraint? private var heightConstraint: NSLayoutConstraint? //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- open override func setup() { super.setup() layer.cornerRadius = 8 contentStackView.isAccessibilityElement = true contentStackView.addArrangedSubview(titleLabel) contentStackView.addArrangedSubview(contentLabel) scrollView.addSubview(contentStackView) addSubview(scrollView) addSubview(line) addSubview(closeButton) // Activate constraints NSLayoutConstraint.activate([ widthAnchor.constraint(equalToConstant: fullWidth), // Constraints for the scroll view scrollView.topAnchor.constraint(equalTo: topAnchor, constant: VDSLayout.Spacing.space4X.value), scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), scrollView.bottomAnchor.constraint(equalTo: line.topAnchor), line.leadingAnchor.constraint(equalTo: leadingAnchor), line.trailingAnchor.constraint(equalTo: trailingAnchor), closeButton.topAnchor.constraint(equalTo: line.bottomAnchor), closeButton.leadingAnchor.constraint(equalTo: leadingAnchor), closeButton.trailingAnchor.constraint(equalTo: trailingAnchor), closeButton.bottomAnchor.constraint(equalTo: bottomAnchor), closeButton.heightAnchor.constraint(equalToConstant: closeButtonHeight), contentStackView.topAnchor.constraint(equalTo: scrollView.topAnchor), contentStackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: containerViewInset), contentStackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -containerViewInset), contentStackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -(containerViewInset * 2)), ]) contentStackViewBottomConstraint = contentStackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor) contentStackViewBottomConstraint?.activate() heightConstraint = heightAnchor.constraint(equalToConstant: minHeight) heightConstraint?.activate() } open override func updateView() { super.updateView() backgroundColor = backgroundColorConfiguration.getColor(self) scrollView.indicatorStyle = surface == .light ? .black : .white titleLabel.removeFromSuperview() contentLabel.removeFromSuperview() contentView?.removeFromSuperview() titleLabel.surface = surface contentLabel.surface = surface line.surface = surface titleLabel.text = titleText contentLabel.text = contentText titleLabel.sizeToFit() contentLabel.sizeToFit() var addedTitle = false if let titleText, !titleText.isEmpty { contentStackView.addArrangedSubview(titleLabel) addedTitle = true } var addedContent = false if let contentText, !contentText.isEmpty { contentStackView.addArrangedSubview(contentLabel) addedContent = true } else if let contentView { contentView.translatesAutoresizingMaskIntoConstraints = false if var surfaceable = contentView as? Surfaceable { surfaceable.surface = surface } let wrapper = View() wrapper.addSubview(contentView) contentView.pinTop() contentView.pinLeading() contentView.pinBottom() contentView.pinTrailingLessThanOrEqualTo() contentView.setNeedsLayout() contentStackView.addArrangedSubview(wrapper) addedContent = true } if addedTitle && addedContent { contentStackView.setCustomSpacing(VDSLayout.Spacing.space1X.value, after: titleLabel) } let closeButtonTextColor = closeButtonTextColorConfiguration.getColor(self) closeButton.setTitleColor(closeButtonTextColor, for: .normal) closeButton.setTitleColor(closeButtonTextColor, for: .highlighted) closeButton.setTitle(closeButtonText, for: .normal) closeButton.accessibilityLabel = closeButtonText contentStackView.setNeedsLayout() contentStackView.layoutIfNeeded() scrollView.setNeedsLayout() scrollView.layoutIfNeeded() //dealing with height //we can't really use the minMax height and set constraints for //greaterThan or lessThan on the heightAnchor due to scrollView/stackView intrinsic size //therefore we can do a little math and manually set the height based off all of the content var contentHeight = closeButtonHeight + scrollView.contentSize.height + (containerViewInset * 2) //reset the bottomConstraint contentStackViewBottomConstraint?.constant = 0 if contentHeight < minHeight { contentHeight = minHeight } else if contentHeight > maxHeight { contentHeight = maxHeight //since we are now scrolling, add padding to the bottom of the //stackView between the bottom of the scrollView contentStackViewBottomConstraint?.constant = -containerViewInset } heightConstraint?.constant = contentHeight } open override func updateAccessibility() { var label = Tooltip.accessibleText(for: titleText, content: contentText, closeButtonText: closeButtonText) if !label.isEmpty { label += "," } contentStackView.accessibilityLabel = "\(label) Click on the \(closeButtonText) button to close." contentStackView.accessibilityHint = "Tooltip" contentStackView.accessibilityValue = "expanded" accessibilityElements = [contentStackView, closeButton] } }