OWSTableViewSheetViewController is inherited from InteractiveSheetViewController and has additional size calculation logic that updates current sheet size based on UITableView's contentSize. However, there's also a "max height" constraint that also needs updating - this commits adds that.
386 lines
15 KiB
Swift
386 lines
15 KiB
Swift
//
|
|
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import UIKit
|
|
|
|
open class InteractiveSheetViewController: OWSViewController {
|
|
private let handleHeight: CGFloat = 5
|
|
private let handleInsideMargin: CGFloat = 12
|
|
|
|
public enum HandlePosition {
|
|
case outside
|
|
case inside
|
|
}
|
|
|
|
private let contentContainerView: UIStackView = {
|
|
let view = UIStackView()
|
|
view.axis = .vertical
|
|
return view
|
|
}()
|
|
|
|
public let contentView = UIView()
|
|
|
|
open var interactiveScrollViews: [UIScrollView] { [] }
|
|
|
|
open var sheetBackgroundColor: UIColor { Theme.actionSheetBackgroundColor }
|
|
|
|
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 }
|
|
open var minHeight: CGFloat { 346 }
|
|
|
|
open var handlePosition: HandlePosition { .inside }
|
|
private let handle = UIView()
|
|
private lazy var handleContainer = UIView()
|
|
|
|
public required override init() {
|
|
super.init()
|
|
modalPresentationStyle = .custom
|
|
transitioningDelegate = self
|
|
}
|
|
|
|
open func willDismissInteractively() {}
|
|
|
|
// MARK: -
|
|
|
|
public override func loadView() {
|
|
view = UIView()
|
|
view.backgroundColor = .clear
|
|
|
|
view.addSubview(contentContainerView)
|
|
contentContainerView.autoPinEdge(toSuperviewEdge: .bottom)
|
|
contentContainerView.autoHCenterInSuperview()
|
|
contentContainerView.backgroundColor = sheetBackgroundColor
|
|
|
|
// Prefer to be full width, but don't exceed the maximum width
|
|
contentContainerView.autoSetDimension(.width, toSize: maxWidth, relation: .lessThanOrEqual)
|
|
contentContainerView.autoPinWidthToSuperview(relation: .lessThanOrEqual)
|
|
NSLayoutConstraint.autoSetPriority(.defaultHigh) {
|
|
contentContainerView.autoPinWidthToSuperview()
|
|
}
|
|
|
|
contentContainerView.layer.cornerRadius = 16
|
|
contentContainerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
|
contentContainerView.layer.masksToBounds = true
|
|
|
|
contentContainerView.addArrangedSubview(contentView)
|
|
contentView.autoPinWidthToSuperview()
|
|
|
|
handle.autoSetDimensions(to: CGSize(width: 36, height: handleHeight))
|
|
handle.layer.cornerRadius = handleHeight / 2
|
|
switch handlePosition {
|
|
case .outside:
|
|
view.addSubview(handle)
|
|
handle.backgroundColor = .ows_whiteAlpha80
|
|
handle.autoPinEdge(.bottom, to: .top, of: contentContainerView, withOffset: -8)
|
|
case .inside:
|
|
contentContainerView.insertArrangedSubview(handleContainer, at: 0)
|
|
handleContainer.autoPinWidthToSuperview()
|
|
handleContainer.addSubview(handle)
|
|
handleContainer.backgroundColor = contentContainerView.backgroundColor
|
|
handleContainer.isOpaque = true
|
|
handle.backgroundColor = Theme.tableView2PresentedSeparatorColor
|
|
handle.autoPinHeightToSuperview(withMargin: 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 themeDidChange() {
|
|
super.themeDidChange()
|
|
|
|
contentContainerView.backgroundColor = sheetBackgroundColor
|
|
|
|
switch handlePosition {
|
|
case .outside:
|
|
break
|
|
case .inside:
|
|
handleContainer.backgroundColor = contentContainerView.backgroundColor
|
|
handle.backgroundColor = Theme.tableView2PresentedSeparatorColor
|
|
}
|
|
}
|
|
|
|
@objc
|
|
func didTapBackdrop(_ sender: UITapGestureRecognizer) {
|
|
willDismissInteractively()
|
|
dismiss(animated: true)
|
|
}
|
|
|
|
// MARK: - Resize / Interactive Dismiss
|
|
|
|
public private(set) lazy var heightConstraint = contentContainerView.heightAnchor.constraint(equalToConstant: minimizedHeight)
|
|
public private(set) lazy var maxHeightConstraint = contentContainerView.heightAnchor.constraint(equalToConstant: maximizedHeight)
|
|
open var minimizedHeight: CGFloat {
|
|
return min(maximizedHeight, minHeight)
|
|
}
|
|
open var maximizedHeight: CGFloat {
|
|
return CurrentAppContext().frame.height - (view.safeAreaInsets.top + 32)
|
|
}
|
|
|
|
public func maximizeHeight() {
|
|
heightConstraint.constant = maximizedHeight
|
|
view.layoutIfNeeded()
|
|
}
|
|
|
|
public let maxAnimationDuration: TimeInterval = 0.2
|
|
private var startingHeight: CGFloat?
|
|
private var startingTranslation: CGFloat?
|
|
|
|
private func setupInteractiveSizing() {
|
|
view.addConstraints( [ heightConstraint, maxHeightConstraint ] )
|
|
|
|
// 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)) }
|
|
}
|
|
|
|
open override func viewSafeAreaInsetsDidChange() {
|
|
super.viewSafeAreaInsetsDidChange()
|
|
maxHeightConstraint.constant = maximizedHeight
|
|
}
|
|
|
|
@objc
|
|
private func handlePan(_ sender: UIPanGestureRecognizer) {
|
|
let panningScrollView = interactiveScrollViews.first { $0.panGestureRecognizer == sender }
|
|
|
|
switch sender.state {
|
|
case .began, .changed:
|
|
guard beginInteractiveTransitionIfNecessary(sender),
|
|
let startingHeight = startingHeight,
|
|
let startingTranslation = 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 = 0
|
|
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
|
|
|
|
var newHeight = startingHeight - translation
|
|
if newHeight > maximizedHeight {
|
|
newHeight = maximizedHeight
|
|
}
|
|
|
|
// If the height is decreasing, adjust the relevant view's proporitionally
|
|
if newHeight < startingHeight {
|
|
backdropView?.alpha = 1 - (startingHeight - newHeight) / startingHeight
|
|
}
|
|
|
|
// Update our height to reflect the new position
|
|
heightConstraint.constant = newHeight
|
|
view.layoutIfNeeded()
|
|
case .ended, .cancelled, .failed:
|
|
guard let startingHeight = startingHeight else { break }
|
|
|
|
let dismissThreshold = startingHeight * 0.5
|
|
let growThreshold = startingHeight * 1.5
|
|
let velocityThreshold: CGFloat = 500
|
|
|
|
let currentHeight = contentContainerView.height
|
|
let currentVelocity = sender.velocity(in: view).y
|
|
|
|
enum CompletionState { case growing, dismissing, cancelling }
|
|
let completionState: CompletionState
|
|
|
|
if abs(currentVelocity) >= velocityThreshold {
|
|
if currentVelocity < 0 {
|
|
completionState = .growing
|
|
} else {
|
|
completionState = .dismissing
|
|
}
|
|
} else if currentHeight >= growThreshold {
|
|
completionState = .growing
|
|
} else if currentHeight <= dismissThreshold {
|
|
completionState = .dismissing
|
|
} else {
|
|
completionState = .cancelling
|
|
}
|
|
|
|
let finalHeight: CGFloat
|
|
switch completionState {
|
|
case .dismissing:
|
|
finalHeight = 0
|
|
case .growing:
|
|
finalHeight = maximizedHeight
|
|
case .cancelling:
|
|
finalHeight = startingHeight
|
|
}
|
|
|
|
let remainingDistance = finalHeight - currentHeight
|
|
|
|
// Calculate the time to complete the animation if we want to preserve
|
|
// the user's velocity. If this time is too slow (e.g. the user was scrolling
|
|
// very slowly) we'll default to `maxAnimationDuration`
|
|
let remainingTime = TimeInterval(abs(remainingDistance / currentVelocity))
|
|
|
|
UIView.animate(withDuration: min(remainingTime, maxAnimationDuration), delay: 0, options: .curveEaseOut, animations: {
|
|
if remainingDistance < 0 {
|
|
self.contentContainerView.frame.origin.y -= remainingDistance
|
|
switch self.handlePosition {
|
|
case .outside:
|
|
self.handle.frame.origin.y -= remainingDistance
|
|
case .inside:
|
|
break
|
|
}
|
|
} else {
|
|
self.heightConstraint.constant = finalHeight
|
|
self.view.layoutIfNeeded()
|
|
}
|
|
|
|
self.backdropView?.alpha = completionState == .dismissing ? 0 : 1
|
|
}) { _ in
|
|
self.heightConstraint.constant = finalHeight
|
|
self.view.layoutIfNeeded()
|
|
|
|
if completionState == .dismissing {
|
|
self.willDismissInteractively()
|
|
self.dismiss(animated: true, completion: nil)
|
|
}
|
|
}
|
|
|
|
resetInteractiveTransition(panningScrollView: panningScrollView)
|
|
default:
|
|
resetInteractiveTransition(panningScrollView: panningScrollView)
|
|
|
|
backdropView?.alpha = 1
|
|
|
|
guard let startingHeight = startingHeight else { break }
|
|
heightConstraint.constant = startingHeight
|
|
}
|
|
}
|
|
|
|
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.
|
|
guard (panningScrollView != nil && panningScrollView!.contentOffset.y <= 0)
|
|
|| contentContainerView.height < maxHeightConstraint.constant
|
|
|| panningScrollView == nil else { return false }
|
|
|
|
if startingTranslation == nil {
|
|
startingTranslation = sender.translation(in: view).y
|
|
}
|
|
|
|
if startingHeight == nil {
|
|
startingHeight = contentContainerView.height
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
private func resetInteractiveTransition(panningScrollView: UIScrollView?) {
|
|
startingTranslation = nil
|
|
startingHeight = nil
|
|
panningScrollView?.showsVerticalScrollIndicator = true
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
extension InteractiveSheetViewController: UIGestureRecognizerDelegate {
|
|
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
switch gestureRecognizer {
|
|
case is UITapGestureRecognizer:
|
|
let point = gestureRecognizer.location(in: view)
|
|
guard !contentContainerView.frame.contains(point) else { return false }
|
|
return true
|
|
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
|
|
}
|
|
}
|