Signal-iOS/SignalMessaging/ViewControllers/ActionSheetController.swift
2019-12-03 15:26:01 -08:00

503 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import UIKit
@objc
public class ActionSheetController: OWSViewController {
private let contentView = UIView()
private let stackView = UIStackView()
private let scrollView = UIScrollView()
@objc
private(set) public var actions = [ActionSheetAction]()
@objc
public var contentAlignment: ContentAlignment = .center {
didSet {
guard oldValue != contentAlignment else { return }
actions.forEach { $0.button.contentAlignment = contentAlignment }
}
}
@objc(ActionSheetContentAlignment)
public enum ContentAlignment: Int {
case center
case leading
case trailing
}
@objc
public var customHeader: UIView? {
didSet {
oldValue?.removeFromSuperview()
guard let customHeader = customHeader else { return }
stackView.insertArrangedSubview(customHeader, at: 0)
}
}
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() + bottomLayoutGuide.length
}
@objc
public init() {
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .custom
transitioningDelegate = self
}
@objc
public convenience init(title: String? = nil, message: String? = nil) {
self.init()
createHeader(title: title, message: message)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
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.")
}
// 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) {
stackView.insertArrangedSubview(action.button, at: index)
} else {
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)
NSLayoutConstraint.autoSetPriority(.defaultHigh) {
scrollView.autoPinWidthToSuperview()
}
let topMargin: CGFloat = 18
scrollView.addSubview(contentView)
contentView.backgroundColor = Theme.backgroundColor
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)
}
stackView.addBackgroundView(withBackgroundColor: Theme.hairlineColor)
stackView.axis = .vertical
stackView.spacing = CGHairlineWidth()
contentView.addSubview(stackView)
stackView.autoPinEdgesToSuperviewSafeArea()
// Add an extra view behind to:
// a) cover the safe area the scroll view automatically insures
// that the stack view can scroll above that range.
// b) avoid a gap at the bottom of the screen when bouncing vertically
let safeAreaBackdrop = UIView()
safeAreaBackdrop.backgroundColor = Theme.backgroundColor
view.insertSubview(safeAreaBackdrop, belowSubview: scrollView)
safeAreaBackdrop.autoHCenterInSuperview()
safeAreaBackdrop.autoPinEdge(toSuperviewEdge: .bottom)
safeAreaBackdrop.autoMatch(.height, to: .height, of: scrollView, withMultiplier: 0.5)
safeAreaBackdrop.autoMatch(.width, to: .width, of: scrollView)
// Support tapping the backdrop to cancel the action sheet.
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapBackdrop(_:)))
view.addGestureRecognizer(tapGestureRecognizer)
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// Ensure the scrollView's layout has completed
// as we're about to use its bounds to calculate
// the masking view and contentOffset.
scrollView.layoutIfNeeded()
let cornerRadius: CGFloat = 16
let path = UIBezierPath(
roundedRect: contentView.bounds,
byRoundingCorners: [.topLeft, .topRight],
cornerRadii: CGSize(width: cornerRadius, height: cornerRadius)
)
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
contentView.layer.mask = shapeLayer
let bottomInset: CGFloat = {
guard #available(iOS 11, *) else { return scrollView.contentInset.bottom }
return scrollView.adjustedContentInset.bottom
}()
// Always scroll to the bottom initially, so it's clear to the
// user that there's more to scroll to if it goes offscreen.
scrollView.contentOffset = CGPoint(x: 0, y: scrollView.contentSize.height - scrollView.height() + bottomInset)
}
@objc func didTapBackdrop(_ sender: UITapGestureRecognizer) {
// If we have a cancel action, treat tapping the background
// as tapping the cancel button.
guard let firstCancelAction = firstCancelAction else { return }
let point = sender.location(in: self.scrollView)
guard !contentView.frame.contains(point) else { return }
dismiss(animated: true) {
firstCancelAction.handler?(firstCancelAction)
}
}
func createHeader(title: String? = nil, message: String? = nil) {
guard title != nil || message != nil else { return }
let headerStack = UIStackView()
headerStack.addBackgroundView(withBackgroundColor: Theme.backgroundColor)
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.primaryTextColor
titleLabel.font = UIFont.ows_dynamicTypeSubheadlineClamped.ows_semibold()
titleLabel.numberOfLines = 0
titleLabel.lineBreakMode = .byWordWrapping
titleLabel.textAlignment = .center
titleLabel.text = title
titleLabel.setCompressionResistanceVerticalHigh()
headerStack.addArrangedSubview(titleLabel)
}
// Message
if let message = message {
let messageLabel = UILabel()
messageLabel.numberOfLines = 0
messageLabel.textAlignment = .center
messageLabel.lineBreakMode = .byWordWrapping
messageLabel.textColor = Theme.primaryTextColor
messageLabel.font = .ows_dynamicTypeSubheadlineClamped
messageLabel.text = message
messageLabel.setCompressionResistanceVerticalHigh()
headerStack.addArrangedSubview(messageLabel)
}
let bottomSpacer = UIView.vStretchingSpacer()
headerStack.addArrangedSubview(bottomSpacer)
bottomSpacer.autoMatch(.height, to: .height, of: topSpacer)
}
}
// MARK: -
@objc
public class ActionSheetAction: NSObject {
@objc
public let title: String
@objc
public var accessibilityIdentifier: String? {
didSet {
button.accessibilityIdentifier = accessibilityIdentifier
}
}
@objc
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
@objc
@available(swift, obsoleted: 1.0)
public func setTrailingIcon(_ icon: ThemeIcon) {
trailingIcon = icon
}
@objc
@available(swift, obsoleted: 1.0)
public func clearTrailingIcon() {
trailingIcon = nil
}
public var trailingIcon: ThemeIcon? {
set {
button.trailingIcon = newValue
}
get {
return button.trailingIcon
}
}
@objc
@available(swift, obsoleted: 1.0)
public func setLeadingIcon(_ icon: ThemeIcon) {
leadingIcon = icon
}
@objc
@available(swift, obsoleted: 1.0)
public func clearLeadingIcon() {
leadingIcon = nil
}
public var leadingIcon: ThemeIcon? {
set {
button.leadingIcon = newValue
}
get {
return button.leadingIcon
}
}
fileprivate 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)
}
@objc
public init(title: String, accessibilityIdentifier: String?, style: Style = .default, handler: Handler? = nil) {
self.title = title
self.accessibilityIdentifier = accessibilityIdentifier
self.style = style
self.handler = handler
}
fileprivate class Button: UIButton {
var releaseAction: (() -> Void)?
var trailingIcon: ThemeIcon? {
didSet {
trailingIconView.isHidden = trailingIcon == nil
if let trailingIcon = trailingIcon {
trailingIconView.setTemplateImage(
Theme.iconImage(trailingIcon),
tintColor: Theme.primaryTextColor
)
}
updateEdgeInsets()
}
}
var leadingIcon: ThemeIcon? {
didSet {
leadingIconView.isHidden = leadingIcon == nil
if let leadingIcon = leadingIcon {
leadingIconView.setTemplateImage(
Theme.iconImage(leadingIcon),
tintColor: Theme.primaryTextColor
)
}
updateEdgeInsets()
}
}
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) {
super.init(frame: .zero)
setBackgroundImage(UIImage(color: Theme.backgroundColor), for: .init())
setBackgroundImage(UIImage(color: Theme.cellSelectedColor), 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())
switch action.style {
case .default:
titleLabel?.font = .ows_dynamicTypeBodyClamped
setTitleColor(Theme.primaryTextColor, for: .init())
case .cancel:
titleLabel?.font = UIFont.ows_dynamicTypeBodyClamped.ows_semibold()
setTitleColor(Theme.primaryTextColor, for: .init())
case .destructive:
titleLabel?.font = .ows_dynamicTypeBodyClamped
setTitleColor(.ows_accentRed, for: .init())
}
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 updateEdgeInsets() {
if !leadingIconView.isHidden || !trailingIconView.isHidden || contentAlignment != .center {
contentEdgeInsets = UIEdgeInsets(top: 16, leading: 56, bottom: 16, trailing: 56)
} else {
contentEdgeInsets = UIEdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)
}
}
@objc func didTouchUpInside() {
releaseAction?()
}
}
}
// 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)
}
}