Signal-iOS/SignalUI/ViewControllers/ActionSheetController.swift
Adam Sharp b42710cf90 Work around use of deprecated UIButton API
The old UIButton API is still functional as long as we don't use
UIButton.Configuration, so we can safely ignore these warnings until we're
ready to adopt the configuration API across the codebase.
2024-07-03 14:27:48 -04:00

690 lines
25 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
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 = (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 trailingIcon: ThemeIcon? {
didSet {
trailingIconView.isHidden = trailingIcon == nil
if let trailingIcon = trailingIcon {
trailingIconView.setTemplateImage(
Theme.iconImage(trailingIcon),
tintColor: Theme.ActionSheet.default.buttonTextColor
)
}
updateEdgeInsets()
}
}
var leadingIcon: ThemeIcon? {
didSet {
leadingIconView.isHidden = leadingIcon == nil
if let leadingIcon = leadingIcon {
leadingIconView.setTemplateImage(
Theme.iconImage(leadingIcon),
tintColor: Theme.ActionSheet.default.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.ActionSheet.default.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.ActionSheet.default.buttonTextColor, for: .init())
case .cancel:
titleLabel?.font = .dynamicTypeBodyClamped.semibold()
setTitleColor(Theme.ActionSheet.default.buttonTextColor, for: .init())
case .destructive:
titleLabel?.font = isSingletonButton ? .dynamicTypeBodyClamped.semibold() : .dynamicTypeBodyClamped
setTitleColor(Theme.ActionSheet.default.destructiveButtonTextColor, for: .init())
}
}
public func applyActionSheetTheme(_ theme: Theme.ActionSheet) {
// 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))"
}
}