// // 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() }