// // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // public import BonMot import SafariServices import SignalServiceKit public protocol SheetDismissalDelegate: AnyObject { func didDismissPresentedSheet() } private final class OnDismissHandler: SheetDismissalDelegate { var handler: () -> Void init(handler: @escaping () -> Void) { self.handler = handler } func didDismissPresentedSheet() { handler() } } @objc open class ActionSheetController: OWSViewController { private enum Message { case text(String) case attributedText(NSAttributedString) } private let contentView = UIView() private let stackView = UIStackView() private let scrollView = UIScrollView() private var hasCompletedFirstLayout = false private var onDismissHandler: OnDismissHandler? /// Set this property to register a closure to be run when the sheet is /// dismissed. /// /// After dismissal, `ActionSheetController` sets the value of this property /// to `nil`. /// /// - Note: Setting an `onDismiss` handler discards the previous value of /// the `dismissalDelegate` property. public var onDismiss: (() -> Void)? { get { onDismissHandler?.handler } set { onDismissHandler = newValue.map(OnDismissHandler.init) dismissalDelegate = onDismissHandler } } /// Set this property to register a delegate object to be notified when the /// sheet is dismissed. /// /// After dismissal, `ActionSheetController` sets the value of this property /// to `nil`. /// /// - Note: Setting `dismissalDelegate` causes `onDismiss` to be set to `nil`. public weak var dismissalDelegate: (any SheetDismissalDelegate)? { didSet { if let dismissalDelegate, dismissalDelegate !== onDismissHandler { onDismissHandler = nil } } } private(set) public var actions = [ActionSheetAction]() { didSet { isCancelable = firstCancelAction != nil } } public var contentAlignment: ContentAlignment = .center { didSet { guard oldValue != contentAlignment else { return } actions.forEach { $0.button.contentAlignment = contentAlignment } } } public enum ContentAlignment: Int { case center case leading case trailing } /// Adds a header view to the top of the action sheet stack /// Note: It's the caller's responsibility to ensure the header view matches the style of the action sheet /// See: theme.backgroundColor, theme.headerTitleColor, etc. public var customHeader: UIView? { didSet { oldValue?.removeFromSuperview() guard let customHeader = customHeader else { return } stackView.insertArrangedSubview(customHeader, at: 0) } } public var isCancelable = false // Currently the theme must be set during initialization to take effect // There's probably a future use case where we want to recolor everything // as the theme changes. But for now we have initializers. public let theme: Theme.ActionSheet fileprivate static let minimumRowHeight: CGFloat = 60 /// The height of the entire action sheet, including any portion /// that extends off screen / is in the scrollable region var height: CGFloat { return stackView.height + view.safeAreaInsets.bottom } public static var messageLabelFont: UIFont { .dynamicTypeSubheadlineClamped } public static var messageBaseStyle: BonMot.StringStyle { return BonMot.StringStyle(.font(messageLabelFont), .alignment(.center)) } public init(theme: Theme.ActionSheet = .default) { self.theme = theme super.init() modalPresentationStyle = .custom transitioningDelegate = self } public override convenience init() { self.init(theme: .default) } @objc public convenience init(title: String? = nil, message: String? = nil) { self.init(title: title, message: message, theme: .default) } public convenience init(title: String? = nil, message: String? = nil, theme: Theme.ActionSheet = .default) { self.init(theme: theme) createHeader(title: title, message: { guard let message else { return nil } return .text(message) }()) } public convenience init( title: String? = nil, message: NSAttributedString, theme: Theme.ActionSheet = .default ) { self.init(theme: theme) createHeader(title: title, message: .attributedText(message)) } var firstCancelAction: ActionSheetAction? { return actions.first(where: { $0.style == .cancel }) } @objc public func addAction(_ action: ActionSheetAction) { if action.style == .cancel && firstCancelAction != nil { owsFailDebug("Only one cancel button permitted per action sheet.") } action.button.applyActionSheetTheme(theme) // If we've already added a cancel action, any non-cancel actions should come before it // This matches how UIAlertController handles cancel actions. if action.style != .cancel, let firstCancelAction = firstCancelAction, let index = stackView.arrangedSubviews.firstIndex(of: firstCancelAction.button) { // The hairline we're inserting is the divider between the new button and the cancel button stackView.insertHairline(with: theme.hairlineColor, at: index) stackView.insertArrangedSubview(action.button, at: index) } else { stackView.addHairline(with: theme.hairlineColor) stackView.addArrangedSubview(action.button) } action.button.contentAlignment = contentAlignment action.button.releaseAction = { [weak self, weak action] in guard let self = self, let action = action else { return } self.dismiss(animated: true) { action.handler?(action) } } actions.append(action) } // MARK: - public override var canBecomeFirstResponder: Bool { return true } override public var preferredStatusBarStyle: UIStatusBarStyle { return Theme.isDarkThemeEnabled ? .lightContent : .default } override public func loadView() { view = UIView() view.backgroundColor = .clear // Depending on the number of actions, the sheet may need // to scroll to allow access to all options. view.addSubview(scrollView) scrollView.clipsToBounds = false scrollView.showsVerticalScrollIndicator = false scrollView.autoPinEdge(toSuperviewEdge: .bottom) scrollView.autoHCenterInSuperview() scrollView.autoMatch(.height, to: .height, of: view, withOffset: 0, relation: .lessThanOrEqual) // Prefer to be full width, but don't exceed the maximum width scrollView.autoSetDimension(.width, toSize: 414, relation: .lessThanOrEqual) scrollView.autoMatch(.width, to: .width, of: view, withOffset: 0, relation: .lessThanOrEqual) NSLayoutConstraint.autoSetPriority(.defaultHigh) { scrollView.autoPinWidthToSuperview() } let topMargin: CGFloat = 18 scrollView.addSubview(contentView) contentView.autoPinWidthToSuperview() contentView.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin) contentView.autoPinEdge(toSuperviewEdge: .bottom) contentView.autoMatch(.width, to: .width, of: scrollView) // If possible, the scrollview should be as tall as the content (no scrolling) // but if it doesn't fit on screen, it's okay to be greater than the scroll view. contentView.autoMatch(.height, to: .height, of: scrollView, withOffset: -topMargin, relation: .greaterThanOrEqual) NSLayoutConstraint.autoSetPriority(.defaultHigh) { contentView.autoMatch(.height, to: .height, of: scrollView, withOffset: -topMargin) } // The backdrop view needs to extend from the top of the scroll view content to the bottom of the scroll view // If the backdrop was not pinned to the scroll view frame, we'd see empty space in the safe area as we bounce // // The backdrop has to be a subview of the scrollview's content because constraints that bridge from the inside // to outside of the scroll view cause the content to be pinned. Views outside the scrollview will not follow // the content offset. // // This means that the backdrop view will extend outside of the bounds of the content view as the user // scrolls the content out of the safe area let backgroundView = theme.createBackgroundView() contentView.addSubview(backgroundView) backgroundView.autoPinWidthToSuperview() backgroundView.autoPinEdge(.top, to: .top, of: contentView) scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: backgroundView.bottomAnchor).isActive = true // Stack views don't support corner masking pre-iOS 14 // Instead we add our stack view to a wrapper view with masksToBounds: true let stackViewContainer = UIView() contentView.addSubview(stackViewContainer) stackViewContainer.autoPinEdgesToSuperviewSafeArea() stackViewContainer.addSubview(stackView) stackView.autoPinEdgesToSuperviewEdges() stackView.axis = .vertical // We can't mask the content view because the backdrop intentionally extends outside of the content // view's bounds. But its two subviews are pinned at same top edge. We can just apply corner // radii to each layer individually to get a similar effect. let cornerRadius: CGFloat = 16 [backgroundView, stackViewContainer].forEach { subview in subview.layer.cornerRadius = cornerRadius subview.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] subview.layer.masksToBounds = true } // Support tapping the backdrop to cancel the action sheet. let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapBackdrop(_:))) view.addGestureRecognizer(tapGestureRecognizer) } open override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) actions.first?.button.isSingletonButton = actions.count == 1 } public override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() // Always scroll to the bottom initially, so it's clear to the // user that there's more to scroll to if it goes offscreen. // We only want to do this once after the first layout resulting in a nonzero frame guard !hasCompletedFirstLayout else { return } hasCompletedFirstLayout = (view.frame != .zero) // Ensure the scrollView's layout has completed // as we're about to use its bounds to calculate // the contentOffset. scrollView.layoutSubviews() let bottomInset = scrollView.adjustedContentInset.bottom scrollView.contentOffset = CGPoint(x: 0, y: scrollView.contentSize.height - scrollView.height + bottomInset) } open override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) dismissalDelegate?.didDismissPresentedSheet() onDismissHandler = nil } @objc private func didTapBackdrop(_ sender: UITapGestureRecognizer) { guard isCancelable else { return } // If we have a cancel action, treat tapping the background // as tapping the cancel button. let point = sender.location(in: self.scrollView) guard !contentView.frame.contains(point) else { return } dismiss(animated: true) { [firstCancelAction] in guard let firstCancelAction = firstCancelAction else { return } firstCancelAction.handler?(firstCancelAction) } } private func createHeader(title: String? = nil, message: Message? = nil) { guard title != nil || message != nil else { return } let headerStack = UIStackView() headerStack.axis = .vertical headerStack.isLayoutMarginsRelativeArrangement = true headerStack.layoutMargins = UIEdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16) headerStack.spacing = 2 headerStack.autoSetDimension(.height, toSize: ActionSheetController.minimumRowHeight, relation: .greaterThanOrEqual) stackView.addArrangedSubview(headerStack) let topSpacer = UIView.vStretchingSpacer() headerStack.addArrangedSubview(topSpacer) NSLayoutConstraint.autoSetPriority(.defaultHigh) { topSpacer.autoSetDimension(.height, toSize: 0) } // Title if let title = title { let titleLabel = UILabel() titleLabel.textColor = theme.headerTitleColor titleLabel.font = UIFont.dynamicTypeSubheadlineClamped.semibold() titleLabel.numberOfLines = 0 titleLabel.lineBreakMode = .byWordWrapping titleLabel.textAlignment = .center titleLabel.text = title titleLabel.setCompressionResistanceVerticalHigh() headerStack.addArrangedSubview(titleLabel) } // Message if let message = message { let messageView: UIView = { switch message { case let .text(text): let result = UILabel() result.numberOfLines = 0 result.lineBreakMode = .byWordWrapping result.textAlignment = .center result.textColor = theme.headerMessageColor result.font = Self.messageLabelFont result.text = text return result case let .attributedText(attributedText): let result = LinkingTextView() result.textContainer.lineBreakMode = .byWordWrapping result.textColor = theme.headerMessageColor result.font = Self.messageLabelFont result.attributedText = attributedText result.textAlignment = .center result.delegate = self return result } }() messageView.setCompressionResistanceVerticalHigh() headerStack.addArrangedSubview(messageView) } let bottomSpacer = UIView.vStretchingSpacer() headerStack.addArrangedSubview(bottomSpacer) bottomSpacer.autoMatch(.height, to: .height, of: topSpacer) } } // MARK: - @objc public class ActionSheetAction: NSObject { public let title: String public var accessibilityIdentifier: String? { didSet { button.accessibilityIdentifier = accessibilityIdentifier } } public let style: Style @objc(ActionSheetActionStyle) public enum Style: Int { case `default` case cancel case destructive } fileprivate let handler: Handler? public typealias Handler = @MainActor (ActionSheetAction) -> Void public var trailingIcon: ThemeIcon? { get { return button.trailingIcon } set { button.trailingIcon = newValue } } public var leadingIcon: ThemeIcon? { get { return button.leadingIcon } set { button.leadingIcon = newValue } } fileprivate(set) public lazy var button = Button(action: self) @objc public convenience init(title: String, style: Style = .default, handler: Handler? = nil) { self.init(title: title, accessibilityIdentifier: nil, style: style, handler: handler) } public init(title: String, accessibilityIdentifier: String?, style: Style = .default, handler: Handler? = nil) { self.title = title self.accessibilityIdentifier = accessibilityIdentifier self.style = style self.handler = handler } public class Button: UIButton { let style: Style public var releaseAction: (() -> Void)? var theme: Theme.ActionSheet = .default var trailingIcon: ThemeIcon? { didSet { trailingIconView.isHidden = trailingIcon == nil if let trailingIcon = trailingIcon { trailingIconView.setTemplateImage( Theme.iconImage(trailingIcon), tintColor: theme.buttonTextColor ) } updateEdgeInsets() } } var leadingIcon: ThemeIcon? { didSet { leadingIconView.isHidden = leadingIcon == nil if let leadingIcon = leadingIcon { leadingIconView.setTemplateImage( Theme.iconImage(leadingIcon), tintColor: theme.buttonTextColor ) } updateEdgeInsets() } } // Indicates that this button is the only button in an action sheet // and may update its display accordingly. fileprivate var isSingletonButton = false { didSet { updateTitleStyle() } } private let leadingIconView = UIImageView() private let trailingIconView = UIImageView() var contentAlignment: ActionSheetController.ContentAlignment = .center { didSet { switch contentAlignment { case .center: contentHorizontalAlignment = .center case .leading: contentHorizontalAlignment = CurrentAppContext().isRTL ? .right : .left case .trailing: contentHorizontalAlignment = CurrentAppContext().isRTL ? .left : .right } updateEdgeInsets() } } init(action: ActionSheetAction) { style = action.style super.init(frame: .zero) setBackgroundImage(UIImage.image(color: theme.buttonHighlightColor), for: .highlighted) [leadingIconView, trailingIconView].forEach { iconView in addSubview(iconView) iconView.isHidden = true iconView.autoSetDimensions(to: CGSize(square: 24)) iconView.autoVCenterInSuperview() iconView.autoPinEdge( toSuperviewEdge: iconView == leadingIconView ? .leading : .trailing, withInset: 16 ) } updateEdgeInsets() setTitle(action.title, for: .init()) updateTitleStyle() autoSetDimension(.height, toSize: ActionSheetController.minimumRowHeight, relation: .greaterThanOrEqual) addTarget(self, action: #selector(didTouchUpInside), for: .touchUpInside) accessibilityIdentifier = action.accessibilityIdentifier } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func updateTitleStyle() { switch style { case .default: titleLabel?.font = isSingletonButton ? .dynamicTypeBodyClamped.semibold() : .dynamicTypeBodyClamped setTitleColor(theme.buttonTextColor, for: .init()) case .cancel: titleLabel?.font = .dynamicTypeBodyClamped.semibold() setTitleColor(theme.buttonTextColor, for: .init()) case .destructive: titleLabel?.font = isSingletonButton ? .dynamicTypeBodyClamped.semibold() : .dynamicTypeBodyClamped setTitleColor(theme.destructiveButtonTextColor, for: .init()) } } public func applyActionSheetTheme(_ theme: Theme.ActionSheet) { self.theme = theme // Recolor everything based on the requested theme setBackgroundImage(UIImage.image(color: theme.buttonHighlightColor), for: .highlighted) leadingIconView.tintColor = theme.buttonTextColor trailingIconView.tintColor = theme.buttonTextColor switch style { case .default, .cancel: setTitleColor(theme.buttonTextColor, for: .normal) case .destructive: setTitleColor(theme.destructiveButtonTextColor, for: .normal) } } private func updateEdgeInsets() { if !leadingIconView.isHidden || !trailingIconView.isHidden || contentAlignment != .center { ows_contentEdgeInsets = UIEdgeInsets(top: 16, leading: 56, bottom: 16, trailing: 56) } else { ows_contentEdgeInsets = UIEdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16) } } @objc private func didTouchUpInside() { releaseAction?() } } } // MARK: Common Actions extension ActionSheetAction { public static var acknowledge: ActionSheetAction { ActionSheetAction( title: CommonStrings.acknowledgeButton, accessibilityIdentifier: UIView.accessibilityIdentifier(containerName: "alert", name: "acknowledge"), style: .default ) } public static var cancel: ActionSheetAction { ActionSheetAction( title: CommonStrings.cancelButton, accessibilityIdentifier: UIView.accessibilityIdentifier(containerName: "alert", name: "cancel"), style: .cancel ) } } // MARK: - private class ActionSheetPresentationController: UIPresentationController { let backdropView = UIView() override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) { super.init(presentedViewController: presentedViewController, presenting: presentingViewController) backdropView.backgroundColor = Theme.backdropColor } override func presentationTransitionWillBegin() { guard let containerView = containerView, let presentedVC = presentedViewController as? ActionSheetController else { return } backdropView.alpha = 0 containerView.addSubview(backdropView) backdropView.autoPinEdgesToSuperviewEdges() containerView.layoutIfNeeded() var startFrame = containerView.frame startFrame.origin.y = presentedVC.height presentedVC.view.frame = startFrame presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in presentedVC.view.frame = containerView.frame self.backdropView.alpha = 1 }, completion: nil) } override func dismissalTransitionWillBegin() { guard let containerView = containerView, let presentedVC = presentedViewController as? ActionSheetController else { return } var endFrame = containerView.frame endFrame.origin.y = presentedVC.height presentedVC.view.frame = containerView.frame presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in presentedVC.view.frame = endFrame self.backdropView.alpha = 0 }, completion: { _ in self.backdropView.removeFromSuperview() }) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) guard let presentedView = presentedView else { return } coordinator.animate(alongsideTransition: { _ in presentedView.frame = self.frameOfPresentedViewInContainerView presentedView.layoutIfNeeded() }, completion: nil) } } extension ActionSheetController: UIViewControllerTransitioningDelegate { public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { return ActionSheetPresentationController(presentedViewController: presented, presenting: presenting) } } extension ActionSheetController: UITextViewDelegate { public func textView( _ textView: UITextView, shouldInteractWith url: URL, in characterRange: NSRange, interaction: UITextItemInteraction ) -> Bool { // Because of our modal presentation style, we can't present another controller over this // one. We must dismiss it first. dismiss(animated: true) { let vc = SFSafariViewController(url: url) CurrentAppContext().frontmostViewController()?.present(vc, animated: true) } return false } } extension String { func formattedForActionSheetTitle() -> String { String.formattedDisplayName(self, maxLength: 20) } func formattedForActionSheetMessage() -> String { String.formattedDisplayName(self, maxLength: 127) } private static func formattedDisplayName(_ displayName: String, maxLength: Int) -> String { guard displayName.count > maxLength else { return displayName } return "\(displayName.prefix(maxLength))…" } }