693 lines
25 KiB
Swift
693 lines
25 KiB
Swift
//
|
|
// 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))…"
|
|
}
|
|
}
|