TM-SGNL-iOS/SignalUI/ViewControllers/InteractiveSheetViewController.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

794 lines
29 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
return view
}()
private var sheetContainerContentView: UIView {
return (sheetContainerView as? UIVisualEffectView)?.contentView ?? sheetContainerView
}
private let sheetStackView: UIStackView = {
let view = UIStackView()
view.axis = .vertical
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 }
public weak var externalBackdropView: UIView?
private lazy var _internalBackdropView = UIView()
public var backdropView: UIView? { externalBackdropView ?? _internalBackdropView }
public var backdropColor = Theme.backdropColor
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()
sheetContainerView.backgroundColor = sheetBackgroundColor
// 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()
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()
}
open override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
dismissalDelegate?.didDismissPresentedSheet()
}
open override func themeDidChange() {
super.themeDidChange()
handle.backgroundColor = handleBackgroundColor
sheetContainerView.backgroundColor = 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 {
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.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? = Theme.backdropColor) {
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()
}