Signal-iOS/SignalUI/ViewControllers/InteractiveSheetViewController.swift
Igor Solomennikov 77d1f2b5ab
Fix regression caused OWSTableSheetViewController not use all available height.
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.
2022-07-21 09:43:30 -07:00

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