Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/ViewControllers/InteractiveSheetViewController.swift
1 views
//
// 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: 52, 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
    }

    public var topCornerRadius: CGFloat = 16 {
        didSet {
            sheetContainerView.layer.cornerRadius = topCornerRadius
        }
    }

    private lazy var sheetContainerView: UIView = {
        let view: UIView
        if let visualEffect {
            view = UIVisualEffectView(effect: visualEffect)
        } else {
            view = UIView()
        }
        view.layer.cornerRadius = topCornerRadius
        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 { UIColor.Signal.primaryFill }

    /// 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, *) {
            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 visualEffect: UIVisualEffect?

    public weak var sheetPanDelegate: SheetPanDelegate?
    public weak var dismissalDelegate: (any SheetDismissalDelegate)?

    public init(visualEffect: UIVisualEffect? = nil) {
        self.visualEffect = visualEffect
        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)
        }

        override public 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?
    override open 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)
        }
    }

    override public 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 {
            sheetContainerView.backgroundColor = .clear
            let glassBackground = UIVisualEffectView(effect: UIGlassEffect(style: .regular))
            sheetContainerView.insertSubview(glassBackground, at: 0)
            glassBackground.autoPinEdges(toEdgesOf: sheetStackView)
            let topRadius: CGFloat = if UIDevice.current.hasIPhoneXNotch {
                40
            } else {
                20
            }
            glassBackground.cornerConfiguration = .uniformEdges(
                topRadius: .fixed(topRadius),
                bottomRadius: .containerConcentric(minimum: 20),
            )
        } else {
            sheetContainerView.backgroundColor = sheetBackgroundColor
        }
    }

    override open func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        dismissalDelegate?.didDismissPresentedSheet()
    }

    override open 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) final lazy 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)
    }

    override open 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 {
            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.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 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
    }

    override public func accessibilityPerformEscape() -> Bool {
        if presentingViewController != nil {
            dismiss(animated: true)
            return true
        }
        return super.accessibilityPerformEscape()
    }
}

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