This is the color of UI layer that dims content underneath when presenting something modally (eg action sheet). Values were taken from UIDimmingView that UIKit uses to obscure content under modally presented view controller.
843 lines
31 KiB
Swift
843 lines
31 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
|
|
open class InteractiveSheetViewController: OWSViewController {
|
|
|
|
public enum Constants {
|
|
public static let handleSize = CGSize(width: 36, height: 5)
|
|
public static let handleInsideMargin: CGFloat = 12
|
|
public static let handleHeight = 2*handleInsideMargin + handleSize.height
|
|
|
|
/// Max height of the sheet has its top this far from the safe area top of the screen.
|
|
fileprivate static let extraTopPadding: CGFloat = 32
|
|
|
|
public static let defaultMinHeight: CGFloat = 346
|
|
|
|
/// Any absolute velocity below this amount counts as zero velocity, e.g. just releasing.
|
|
fileprivate static let baseVelocityThreshold: CGFloat = 200
|
|
/// Any upwards velocity greater this that amount maximizes the sheet.
|
|
fileprivate static let maximizeVelocityThreshold: CGFloat = 500
|
|
/// Any downwards velocity greater than this amount dismisses the sheet.
|
|
fileprivate static let dismissVelocityThreshold: CGFloat = 1000
|
|
}
|
|
|
|
private lazy var sheetContainerView: UIView = {
|
|
let view: UIView
|
|
if let blurEffect = blurEffect {
|
|
view = UIVisualEffectView(effect: blurEffect)
|
|
} else {
|
|
view = UIView()
|
|
}
|
|
view.layer.cornerRadius = 16
|
|
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
|
view.layer.masksToBounds = true
|
|
view.preservesSuperviewLayoutMargins = true
|
|
return view
|
|
}()
|
|
|
|
private var sheetContainerContentView: UIView {
|
|
return (sheetContainerView as? UIVisualEffectView)?.contentView ?? sheetContainerView
|
|
}
|
|
|
|
private let sheetStackView: UIStackView = {
|
|
let view = UIStackView()
|
|
view.axis = .vertical
|
|
view.preservesSuperviewLayoutMargins = true
|
|
return view
|
|
}()
|
|
|
|
public let contentView = UIView()
|
|
|
|
open var interactiveScrollViews: [UIScrollView] { [] }
|
|
|
|
open var dismissesWithHighVelocitySwipe: Bool { false }
|
|
open var shrinksWithHighVelocitySwipe: Bool { true }
|
|
open var canBeDismissed: Bool { true }
|
|
/// Allows taps above the sheet to pass through to the parent.
|
|
open var canInteractWithParent: Bool { false }
|
|
|
|
open var sheetBackgroundColor: UIColor { Theme.actionSheetBackgroundColor }
|
|
open var handleBackgroundColor: UIColor { Theme.tableView2PresentedSeparatorColor }
|
|
|
|
/// Override to `true` to make the content appear on a glass background on
|
|
/// iOS 26 and later. `sheetBackgroundColor` will be ignored when on glass,
|
|
/// but still be sure to set it for devices running iOS 18 and older.
|
|
open var placeOnGlassIfAvailable: Bool { false }
|
|
private var isOnGlass: Bool {
|
|
if #available(iOS 26, *), FeatureFlags.iOS26SDKIsAvailable {
|
|
placeOnGlassIfAvailable
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
public weak var externalBackdropView: UIView?
|
|
private lazy var _internalBackdropView = UIView()
|
|
public var backdropView: UIView? { externalBackdropView ?? _internalBackdropView }
|
|
public var backdropColor = UIColor.Signal.backdrop
|
|
|
|
public var maxWidth: CGFloat { 512 }
|
|
|
|
private let handle = UIView()
|
|
private lazy var handleContainer = UIView()
|
|
|
|
private let blurEffect: UIBlurEffect?
|
|
|
|
public weak var sheetPanDelegate: SheetPanDelegate?
|
|
public weak var dismissalDelegate: (any SheetDismissalDelegate)?
|
|
|
|
public init(blurEffect: UIBlurEffect? = nil) {
|
|
self.blurEffect = blurEffect
|
|
super.init()
|
|
modalPresentationStyle = .custom
|
|
transitioningDelegate = self
|
|
}
|
|
|
|
open func willDismissInteractively() {}
|
|
|
|
// MARK: -
|
|
|
|
public class SheetView: UIView {
|
|
weak var interactiveSheetViewController: InteractiveSheetViewController?
|
|
|
|
private let canInteractWithParent: Bool
|
|
|
|
init(
|
|
canInteractWithParent: Bool,
|
|
interactiveSheetViewController: InteractiveSheetViewController
|
|
) {
|
|
self.canInteractWithParent = canInteractWithParent
|
|
self.interactiveSheetViewController = interactiveSheetViewController
|
|
super.init(frame: .zero)
|
|
}
|
|
|
|
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
guard self.canInteractWithParent else {
|
|
return super.hitTest(point, with: event)
|
|
}
|
|
|
|
guard
|
|
let interactiveSheetViewController,
|
|
let presentingView = interactiveSheetViewController.presentingViewController?.view
|
|
else {
|
|
owsFailDebug("A parent view controller is missing")
|
|
return super.hitTest(point, with: event)
|
|
}
|
|
|
|
let sheetContent = interactiveSheetViewController.sheetContainerView
|
|
let pointInSheet = self.convert(point, to: sheetContent)
|
|
if sheetContent.bounds.contains(pointInSheet) {
|
|
// Hit in sheet
|
|
return super.hitTest(point, with: event)
|
|
}
|
|
|
|
// Hit in parent
|
|
return presentingView.hitTest(point, with: event)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
}
|
|
|
|
private var maxWidthConstraint: NSLayoutConstraint?
|
|
open override func viewWillTransition(to size: CGSize, with coordinator: any UIViewControllerTransitionCoordinator) {
|
|
super.viewWillTransition(to: size, with: coordinator)
|
|
|
|
maxWidthConstraint?.autoRemove()
|
|
let minScreenDimension = min(CurrentAppContext().frame.width, CurrentAppContext().frame.height)
|
|
if minScreenDimension <= maxWidth {
|
|
maxWidthConstraint = sheetContainerView.autoSetDimension(.width, toSize: minScreenDimension)
|
|
}
|
|
}
|
|
|
|
public override func loadView() {
|
|
let sheetView = SheetView(
|
|
canInteractWithParent: self.canInteractWithParent,
|
|
interactiveSheetViewController: self
|
|
)
|
|
view = sheetView
|
|
view.backgroundColor = .clear
|
|
|
|
view.addSubview(sheetContainerView)
|
|
sheetCurrentOffsetConstraint = sheetContainerView.autoPinEdge(toSuperviewEdge: .bottom)
|
|
sheetContainerView.autoHCenterInSuperview()
|
|
|
|
let margin: CGFloat = isOnGlass ? 8 : 0
|
|
|
|
// Prefer to be full width, but don't exceed the maximum width
|
|
sheetContainerView.autoSetDimension(.width, toSize: maxWidth, relation: .lessThanOrEqual)
|
|
sheetContainerView.autoPinWidthToSuperview(relation: .lessThanOrEqual)
|
|
|
|
NSLayoutConstraint.autoSetPriority(.defaultHigh) {
|
|
sheetContainerView.autoPinWidthToSuperview()
|
|
}
|
|
|
|
sheetContainerContentView.addSubview(sheetStackView)
|
|
sheetStackView.autoPinEdgesToSuperviewEdges(with: .init(
|
|
top: 0,
|
|
left: margin,
|
|
bottom: margin,
|
|
right: margin
|
|
))
|
|
|
|
contentView.preservesSuperviewLayoutMargins = true
|
|
sheetStackView.addArrangedSubview(contentView)
|
|
contentView.autoPinWidthToSuperview()
|
|
|
|
handle.autoSetDimensions(to: Constants.handleSize)
|
|
handle.layer.cornerRadius = Constants.handleSize.height / 2
|
|
sheetStackView.insertArrangedSubview(handleContainer, at: 0)
|
|
handleContainer.autoPinWidthToSuperview()
|
|
handleContainer.addSubview(handle)
|
|
handle.backgroundColor = handleBackgroundColor
|
|
handle.autoPinHeightToSuperview(withMargin: Constants.handleInsideMargin)
|
|
handle.autoHCenterInSuperview()
|
|
|
|
// Support tapping the backdrop to cancel the sheet.
|
|
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapBackdrop(_:)))
|
|
tapGestureRecognizer.delegate = self
|
|
view.addGestureRecognizer(tapGestureRecognizer)
|
|
|
|
// Setup handle for interactive dismissal / resizing
|
|
setupInteractiveSizing()
|
|
|
|
if #available(iOS 26.0, *), isOnGlass {
|
|
#if compiler(>=6.2)
|
|
sheetContainerView.backgroundColor = .clear
|
|
let glassBackground = UIVisualEffectView(effect: UIGlassEffect(style: .regular))
|
|
sheetContainerView.insertSubview(glassBackground, at: 0)
|
|
glassBackground.autoPinEdges(toEdgesOf: sheetStackView)
|
|
glassBackground.cornerConfiguration = .uniformCorners(radius: .containerConcentric(minimum: 20))
|
|
#endif
|
|
} else {
|
|
sheetContainerView.backgroundColor = sheetBackgroundColor
|
|
}
|
|
}
|
|
|
|
open override func viewDidDisappear(_ animated: Bool) {
|
|
super.viewDidDisappear(animated)
|
|
dismissalDelegate?.didDismissPresentedSheet()
|
|
}
|
|
|
|
open override func themeDidChange() {
|
|
super.themeDidChange()
|
|
|
|
handle.backgroundColor = handleBackgroundColor
|
|
sheetContainerView.backgroundColor = if isOnGlass {
|
|
.clear
|
|
} else {
|
|
sheetBackgroundColor
|
|
}
|
|
}
|
|
|
|
@objc
|
|
private func didTapBackdrop(_ sender: UITapGestureRecognizer) {
|
|
guard canBeDismissed else {
|
|
return
|
|
}
|
|
willDismissInteractively()
|
|
dismiss(animated: true)
|
|
}
|
|
|
|
// MARK: - Resize / Interactive Dismiss
|
|
|
|
private func updateMaxHeight() {
|
|
if allowsExpansion {
|
|
maxHeight = maximumPreferredHeight()
|
|
} else {
|
|
maxHeight = minHeight
|
|
}
|
|
}
|
|
|
|
public final var allowsExpansion: Bool = true {
|
|
didSet {
|
|
self.updateMaxHeight()
|
|
guard isViewLoaded else {
|
|
return
|
|
}
|
|
if
|
|
!isInInteractiveTransition,
|
|
!isDismissingFromPanGesture,
|
|
sheetCurrentHeightConstraint.constant > minHeight
|
|
{
|
|
sheetCurrentHeightConstraint.constant = minHeight
|
|
}
|
|
}
|
|
}
|
|
|
|
private var minHeight: CGFloat = Constants.defaultMinHeight {
|
|
didSet {
|
|
if !allowsExpansion {
|
|
maxHeight = minHeight
|
|
}
|
|
guard isViewLoaded else {
|
|
return
|
|
}
|
|
sheetHeightMinConstraint.constant = minHeight
|
|
if
|
|
!isInInteractiveTransition,
|
|
!isDismissingFromPanGesture,
|
|
sheetCurrentHeightConstraint.constant == oldValue
|
|
|| sheetCurrentHeightConstraint.constant < minHeight
|
|
{
|
|
sheetCurrentHeightConstraint.constant = minHeight
|
|
}
|
|
}
|
|
}
|
|
|
|
private var externalMinHeight: CGFloat?
|
|
|
|
public final var minimizedHeight: CGFloat {
|
|
get {
|
|
return minHeight
|
|
}
|
|
set {
|
|
externalMinHeight = newValue
|
|
self.minHeight = min(newValue, maximumPreferredHeight())
|
|
}
|
|
}
|
|
|
|
public private(set) lazy final var maxHeight = maximumPreferredHeight()
|
|
|
|
private lazy var sheetHeightMinConstraint = sheetContainerView.autoSetDimension(
|
|
.height,
|
|
toSize: minHeight,
|
|
relation: .greaterThanOrEqual
|
|
)
|
|
|
|
private lazy var sheetHeightMaxConstraint = sheetContainerView.autoSetDimension(
|
|
.height,
|
|
toSize: maxHeight,
|
|
relation: .lessThanOrEqual
|
|
)
|
|
|
|
private lazy var sheetCurrentHeightConstraint = sheetContainerView.autoSetDimension(.height, toSize: minHeight)
|
|
|
|
private var sheetCurrentOffsetConstraint: NSLayoutConstraint?
|
|
|
|
private var currentVisibleHeight: CGFloat {
|
|
sheetCurrentHeightConstraint.constant - (sheetCurrentOffsetConstraint?.constant ?? 0)
|
|
}
|
|
|
|
public func minimizeHeight(animated: Bool = true) {
|
|
self.cancelAnimationAndUpdateConstraints()
|
|
|
|
sheetCurrentHeightConstraint.constant = minHeight
|
|
guard animated else {
|
|
view.layoutIfNeeded()
|
|
self.heightDidChange(to: .min)
|
|
return
|
|
}
|
|
|
|
view.setNeedsUpdateConstraints()
|
|
self.animate {
|
|
self.view.layoutIfNeeded()
|
|
self.heightDidChange(to: .min)
|
|
}
|
|
}
|
|
|
|
public func maximizeHeight(animated: Bool = true, completion: (() -> Void)? = nil) {
|
|
self.cancelAnimationAndUpdateConstraints()
|
|
|
|
sheetCurrentHeightConstraint.constant = maxHeight
|
|
guard animated else {
|
|
view.layoutIfNeeded()
|
|
self.heightDidChange(to: .max)
|
|
completion?()
|
|
return
|
|
}
|
|
|
|
view.setNeedsUpdateConstraints()
|
|
self.animate(
|
|
animations: {
|
|
self.view.layoutIfNeeded()
|
|
self.heightDidChange(to: .max)
|
|
},
|
|
completion: completion
|
|
)
|
|
}
|
|
|
|
/// When `true`, uses a slower, smoother, interruptible animation curve for
|
|
/// height changes using a UIViewPropertyAnimator. This can have unintended
|
|
/// side effects, however, such as reloading table content in an animation
|
|
/// block resulting is strange behavior, so it is disabled by default.
|
|
public var animationsShouldBeInterruptible = false
|
|
|
|
private var animator: UIViewPropertyAnimator?
|
|
|
|
public func animate(
|
|
animations: @escaping () -> Void,
|
|
completion: (() -> Void)? = nil
|
|
) {
|
|
if animationsShouldBeInterruptible {
|
|
let animator = UIViewPropertyAnimator(
|
|
duration: 0.5,
|
|
controlPoint1: .init(x: 0.25, y: 1),
|
|
controlPoint2: .init(x: 0.25, y: 1)
|
|
)
|
|
animator.addAnimations(animations)
|
|
animator.addCompletion { [weak self] _ in
|
|
self?.animator = nil
|
|
completion?()
|
|
}
|
|
animator.startAnimation()
|
|
self.animator = animator
|
|
} else {
|
|
UIView.animate(
|
|
withDuration: 0.3,
|
|
delay: 0,
|
|
usingSpringWithDamping: 4 * .pi / 0.3,
|
|
initialSpringVelocity: 0,
|
|
animations: animations,
|
|
completion: completion.map { closure in { _ in closure() } }
|
|
)
|
|
}
|
|
}
|
|
|
|
// If either of these are set, min/max height changes will not take immediate effect.
|
|
private var isInInteractiveTransition = false
|
|
private var isDismissingFromPanGesture = false
|
|
|
|
private var startingHeight: CGFloat?
|
|
private var startingOffset: CGFloat?
|
|
private var startingTranslation: CGFloat?
|
|
|
|
private func setupInteractiveSizing() {
|
|
view.addConstraints([sheetCurrentHeightConstraint, sheetHeightMinConstraint, sheetHeightMaxConstraint])
|
|
|
|
// Create a pan gesture to handle when the user interacts with the
|
|
// view outside of any scroll views we want to follow.
|
|
let panGestureRecognizer = DirectionalPanGestureRecognizer(direction: .vertical, target: self, action: #selector(handlePan))
|
|
view.addGestureRecognizer(panGestureRecognizer)
|
|
panGestureRecognizer.delegate = self
|
|
|
|
// We also want to handle the pan gesture for all of the scroll
|
|
// views, so we can do a nice scroll to dismiss gesture, and
|
|
// so we can transfer any initial scrolling into maximizing
|
|
// the view.
|
|
interactiveScrollViews.forEach { $0.panGestureRecognizer.addTarget(self, action: #selector(handlePan)) }
|
|
}
|
|
|
|
/// The maximum height the sheet wants to be. It can be "sprung" past this
|
|
/// point up until `maximumAllowedHeight`, if that is higher than this.
|
|
///
|
|
/// By default, it returns `maximumAllowedHeight()`.
|
|
open func maximumPreferredHeight() -> CGFloat {
|
|
self.maximumAllowedHeight()
|
|
}
|
|
|
|
/// The maximum height the sheet can ever get.
|
|
open func maximumAllowedHeight() -> CGFloat {
|
|
return CurrentAppContext().frame.height - (view.safeAreaInsets.top + Constants.extraTopPadding)
|
|
}
|
|
|
|
open override func viewSafeAreaInsetsDidChange() {
|
|
super.viewSafeAreaInsetsDidChange()
|
|
let oldMaxHeight = maxHeight
|
|
let newMaxHeight = maximumPreferredHeight()
|
|
if allowsExpansion {
|
|
maxHeight = newMaxHeight
|
|
}
|
|
if minHeight > maxHeight {
|
|
minHeight = maxHeight
|
|
} else if minHeight == oldMaxHeight, let externalMinHeight = externalMinHeight {
|
|
minimizedHeight = externalMinHeight
|
|
}
|
|
|
|
guard isViewLoaded else {
|
|
return
|
|
}
|
|
sheetHeightMaxConstraint.constant = maxHeight
|
|
if
|
|
!isInInteractiveTransition,
|
|
!isDismissingFromPanGesture,
|
|
(
|
|
sheetCurrentHeightConstraint.constant == oldMaxHeight
|
|
&& sheetCurrentHeightConstraint.constant != minHeight
|
|
)
|
|
|| sheetCurrentHeightConstraint.constant > maxHeight
|
|
{
|
|
sheetCurrentHeightConstraint.constant = maxHeight
|
|
}
|
|
}
|
|
|
|
@objc
|
|
private func handlePan(_ sender: UIPanGestureRecognizer) {
|
|
let panningScrollView = interactiveScrollViews.first { $0.panGestureRecognizer == sender }
|
|
|
|
switch sender.state {
|
|
case .began:
|
|
self.cancelAnimationAndUpdateConstraints()
|
|
sheetPanDelegate?.sheetPanDidBegin()
|
|
fallthrough
|
|
case .changed:
|
|
guard
|
|
beginInteractiveTransitionIfNecessary(sender),
|
|
var startingHeight,
|
|
let startingOffset,
|
|
let startingTranslation
|
|
else {
|
|
return resetInteractiveTransition(panningScrollView: panningScrollView)
|
|
}
|
|
|
|
// We're in an interactive transition, so don't let the scrollView scroll.
|
|
if let panningScrollView = panningScrollView {
|
|
panningScrollView.contentOffset.y = -panningScrollView.contentInset.top
|
|
panningScrollView.showsVerticalScrollIndicator = false
|
|
}
|
|
|
|
// We may have panned some distance if we were scrolling before we started
|
|
// this interactive transition. Offset the translation we use to move the
|
|
// view by whatever the translation was when we started the interactive
|
|
// portion of the gesture.
|
|
let translation = sender.translation(in: view).y - startingTranslation
|
|
|
|
startingHeight -= startingOffset
|
|
|
|
let resistanceDivisor: CGFloat = 3
|
|
func adjustStartingHeightForBeingOutOfBounds(bound: CGFloat) {
|
|
let distanceOutOfBounds = startingHeight - bound
|
|
startingHeight = bound + distanceOutOfBounds * resistanceDivisor
|
|
}
|
|
|
|
if startingHeight > self.maxHeight {
|
|
adjustStartingHeightForBeingOutOfBounds(bound: self.maxHeight)
|
|
} else if !canBeDismissed && startingHeight < self.minHeight {
|
|
adjustStartingHeightForBeingOutOfBounds(bound: self.minHeight)
|
|
}
|
|
|
|
var newOffset = 0 as CGFloat
|
|
var newHeight = startingHeight - translation
|
|
|
|
// Add resistance above the max preferred height
|
|
if newHeight > maxHeight {
|
|
if isOnGlass {
|
|
// Doing a transform keeps the glass background the same
|
|
// height and prevents its concentric corners from shirking
|
|
// as they get farther from the edges of the screen.
|
|
sheetContainerView.transform = .translate(.init(x: 0, y: (maxHeight - newHeight) / resistanceDivisor))
|
|
newHeight = maxHeight
|
|
} else {
|
|
// When not on glass, we want the bottom of the sheet to
|
|
// extend to the bottom of the screen, so don't transform.
|
|
newHeight = maxHeight + (newHeight - maxHeight) / resistanceDivisor
|
|
}
|
|
}
|
|
|
|
// Don't go past the max allowed height
|
|
let maxAllowedHeight = self.maximumAllowedHeight()
|
|
if newHeight > maxAllowedHeight {
|
|
newHeight = maxAllowedHeight
|
|
}
|
|
|
|
// Don't shrink below minHeight and instead offset down
|
|
if newHeight < minHeight {
|
|
newOffset = minHeight - newHeight
|
|
newHeight = minHeight
|
|
}
|
|
|
|
// Add resistance below the min height
|
|
if !canBeDismissed {
|
|
newOffset /= resistanceDivisor
|
|
}
|
|
|
|
let newVisibleHeight = newHeight - newOffset
|
|
|
|
if newVisibleHeight != startingHeight {
|
|
heightDidChange(to: .height(newHeight))
|
|
}
|
|
|
|
// If the height is decreasing, adjust the relevant view's proportionally
|
|
if newHeight < startingHeight {
|
|
backdropView?.alpha = 1 - (startingHeight - newVisibleHeight) / startingHeight
|
|
}
|
|
|
|
// Update our offset/height to reflect the new position
|
|
sheetCurrentOffsetConstraint?.constant = newOffset
|
|
sheetCurrentHeightConstraint.constant = newHeight
|
|
view.layoutIfNeeded()
|
|
case .ended, .cancelled, .failed:
|
|
sheetPanDelegate?.sheetPanDidEnd()
|
|
let currentVisibleHeight = self.currentVisibleHeight
|
|
let currentVelocity = sender.velocity(in: view).y
|
|
|
|
enum CompletionState { case growing, shrinking, dismissing }
|
|
let completionState: CompletionState
|
|
|
|
if currentVelocity <= -Constants.maximizeVelocityThreshold {
|
|
completionState = .growing
|
|
} else if
|
|
canBeDismissed,
|
|
currentVelocity >= Constants.dismissVelocityThreshold,
|
|
(dismissesWithHighVelocitySwipe || isInInteractiveTransition)
|
|
{
|
|
completionState = .dismissing
|
|
} else if currentVisibleHeight >= minHeight {
|
|
if
|
|
currentVelocity > Constants.baseVelocityThreshold,
|
|
shrinksWithHighVelocitySwipe,
|
|
panningScrollView.map({ $0.contentOffset.y <= -$0.contentInset.top }) ?? true
|
|
{
|
|
completionState = .shrinking
|
|
} else if currentVelocity < -Constants.baseVelocityThreshold {
|
|
completionState = .growing
|
|
} else {
|
|
completionState =
|
|
currentVisibleHeight < (maxHeight + minHeight) / 2
|
|
? .shrinking : .growing
|
|
}
|
|
} else {
|
|
if abs(currentVelocity) > Constants.baseVelocityThreshold {
|
|
completionState = currentVelocity > 0 && canBeDismissed ? .dismissing : .shrinking
|
|
} else {
|
|
completionState =
|
|
currentVisibleHeight < minHeight / 2 && canBeDismissed
|
|
? .dismissing : .shrinking
|
|
}
|
|
}
|
|
|
|
self.updateMaxHeight()
|
|
|
|
let finalOffset: CGFloat
|
|
let finalHeight: CGFloat
|
|
switch completionState {
|
|
case .dismissing:
|
|
isDismissingFromPanGesture = true
|
|
finalOffset = minHeight
|
|
finalHeight = minHeight
|
|
case .growing:
|
|
finalOffset = 0
|
|
finalHeight = maxHeight
|
|
case .shrinking:
|
|
finalOffset = 0
|
|
finalHeight = minHeight
|
|
}
|
|
|
|
sheetPanDelegate?.sheetPanDecelerationDidBegin()
|
|
self.animate {
|
|
self.sheetContainerView.transform = .identity
|
|
self.sheetCurrentOffsetConstraint?.constant = finalOffset
|
|
self.sheetCurrentHeightConstraint.constant = finalHeight
|
|
self.view.layoutIfNeeded()
|
|
|
|
switch completionState {
|
|
case .growing:
|
|
self.heightDidChange(to: .max)
|
|
case .shrinking, .dismissing:
|
|
self.heightDidChange(to: .min)
|
|
}
|
|
|
|
self.backdropView?.alpha = completionState == .dismissing ? 0 : 1
|
|
} completion: {
|
|
self.sheetPanDelegate?.sheetPanDecelerationDidEnd()
|
|
self.heightDidChange(to: .height(finalHeight))
|
|
if completionState == .dismissing && self.canBeDismissed {
|
|
self.willDismissInteractively()
|
|
self.dismiss(animated: true, completion: { [weak self] in
|
|
self?.isDismissingFromPanGesture = false
|
|
})
|
|
}
|
|
}
|
|
|
|
resetInteractiveTransition(panningScrollView: panningScrollView)
|
|
default:
|
|
resetInteractiveTransition(panningScrollView: panningScrollView)
|
|
|
|
backdropView?.alpha = 1
|
|
|
|
guard let startingHeight = startingHeight else { break }
|
|
sheetCurrentOffsetConstraint?.constant = 0
|
|
sheetCurrentHeightConstraint.constant = startingHeight
|
|
heightDidChange(to: .height(startingHeight))
|
|
}
|
|
}
|
|
|
|
public func cancelAnimationAndUpdateConstraints() {
|
|
guard let animator else { return }
|
|
animator.stopAnimation(false)
|
|
animator.finishAnimation(at: .current)
|
|
self.updateConstraintsAfterCanceledAnimation()
|
|
}
|
|
|
|
private func updateConstraintsAfterCanceledAnimation() {
|
|
let sheetBottom = self.view.convert(sheetContainerView.frame, from: self.view).maxY
|
|
let offset = sheetBottom - self.view.frame.maxY
|
|
sheetCurrentOffsetConstraint?.constant = offset
|
|
|
|
sheetCurrentHeightConstraint.constant = sheetContainerView.height
|
|
|
|
self.view.layoutIfNeeded()
|
|
}
|
|
|
|
public final func refreshMaxHeight() {
|
|
guard !isInInteractiveTransition else { return }
|
|
|
|
let oldMaxHeight = self.maxHeight
|
|
self.maxHeight = maximumPreferredHeight()
|
|
self.sheetHeightMaxConstraint.constant = self.maxHeight
|
|
if self.sheetCurrentHeightConstraint.constant == oldMaxHeight {
|
|
self.cancelAnimationAndUpdateConstraints()
|
|
self.sheetCurrentOffsetConstraint?.constant = 0
|
|
self.sheetCurrentHeightConstraint.constant = self.maxHeight
|
|
self.animate {
|
|
self.view.setNeedsLayout()
|
|
self.view.layoutIfNeeded()
|
|
self.heightDidChange(to: .max)
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum SheetHeight {
|
|
case min
|
|
case height(CGFloat)
|
|
case max
|
|
}
|
|
|
|
open func heightDidChange(to height: SheetHeight) {}
|
|
|
|
private func beginInteractiveTransitionIfNecessary(_ sender: UIPanGestureRecognizer) -> Bool {
|
|
let panningScrollView = interactiveScrollViews.first { $0.panGestureRecognizer == sender }
|
|
|
|
// If we're at the top of the scrollView, the view is not
|
|
// currently maximized, or we're panning outside of the scroll
|
|
// view we want to do an interactive transition.
|
|
|
|
var isScrollingPastTop: Bool {
|
|
guard let panningScrollView else { return false }
|
|
return panningScrollView.contentOffset.y <= 0
|
|
}
|
|
|
|
var isScrollingPastBottom: Bool {
|
|
guard let panningScrollView else { return false }
|
|
let hasScrollableContent = panningScrollView.contentSize.height <= panningScrollView.height
|
|
let contentIsPastBottom = panningScrollView.contentOffset.y + panningScrollView.height > panningScrollView.contentSize.height
|
|
return hasScrollableContent && contentIsPastBottom
|
|
}
|
|
|
|
guard
|
|
isScrollingPastTop
|
|
|| isScrollingPastBottom
|
|
|| currentVisibleHeight < maxHeight
|
|
|| panningScrollView == nil
|
|
else {
|
|
return false
|
|
}
|
|
|
|
if !isInInteractiveTransition {
|
|
self.updateMaxHeight()
|
|
}
|
|
|
|
if startingTranslation == nil {
|
|
startingTranslation = sender.translation(in: view).y
|
|
}
|
|
|
|
if startingHeight == nil {
|
|
startingHeight = sheetContainerView.height
|
|
}
|
|
if startingOffset == nil {
|
|
startingOffset = sheetCurrentOffsetConstraint?.constant ?? 0
|
|
}
|
|
isInInteractiveTransition = true
|
|
return true
|
|
}
|
|
|
|
private func resetInteractiveTransition(panningScrollView: UIScrollView?) {
|
|
startingTranslation = nil
|
|
startingHeight = nil
|
|
startingOffset = nil
|
|
isInInteractiveTransition = false
|
|
panningScrollView?.showsVerticalScrollIndicator = true
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
extension InteractiveSheetViewController: UIGestureRecognizerDelegate {
|
|
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
switch gestureRecognizer {
|
|
case is UITapGestureRecognizer:
|
|
let point = gestureRecognizer.location(in: view)
|
|
return !sheetContainerView.frame.contains(point)
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
switch gestureRecognizer {
|
|
case is UIPanGestureRecognizer:
|
|
return interactiveScrollViews.map { $0.panGestureRecognizer }.contains(otherGestureRecognizer)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private class InteractiveSheetAnimationController: UIPresentationController {
|
|
|
|
var backdropView: UIView? {
|
|
guard let vc = presentedViewController as? InteractiveSheetViewController else { return nil }
|
|
return vc.backdropView
|
|
}
|
|
|
|
var isUsingExternalBackdropView: Bool {
|
|
guard let vc = presentedViewController as? InteractiveSheetViewController else { return false }
|
|
return vc.externalBackdropView != nil
|
|
}
|
|
|
|
init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?, backdropColor: UIColor? = .Signal.backdrop) {
|
|
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
|
|
backdropView?.backgroundColor = backdropColor
|
|
}
|
|
|
|
override func presentationTransitionWillBegin() {
|
|
if !isUsingExternalBackdropView, let containerView = containerView, let backdropView = backdropView {
|
|
backdropView.alpha = 0
|
|
containerView.addSubview(backdropView)
|
|
backdropView.autoPinEdgesToSuperviewEdges()
|
|
containerView.layoutIfNeeded()
|
|
}
|
|
|
|
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
|
|
self.backdropView?.alpha = 1
|
|
}, completion: nil)
|
|
}
|
|
|
|
override func dismissalTransitionWillBegin() {
|
|
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
|
|
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 InteractiveSheetViewController: UIViewControllerTransitioningDelegate {
|
|
open func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
|
|
let controller = InteractiveSheetAnimationController(presentedViewController: presented, presenting: presenting, backdropColor: self.backdropColor)
|
|
return controller
|
|
}
|
|
}
|
|
|
|
public protocol SheetPanDelegate: AnyObject {
|
|
func sheetPanDidBegin()
|
|
func sheetPanDidEnd()
|
|
func sheetPanDecelerationDidBegin()
|
|
func sheetPanDecelerationDidEnd()
|
|
}
|