Signal-iOS/SignalUI/ViewControllers/InteractiveSheetViewController.swift
Igor Solomennikov 6dd2815fde
Unify backdropColor values.
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.
2025-10-24 14:43:08 -07:00

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