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