Sheet/Sheet/Bottom Sheet/Presentation/SheetContainerViewControlle...

556 lines
18 KiB
Swift

//
// SheetContainerViewController.swift
// Sheet
//
// Created by Guilherme Rambo on 05/08/19.
// Copyright © 2019 Guilherme Rambo. All rights reserved.
//
import UIKit
public struct SheetMetrics {
public static let `default` = SheetMetrics()
public let bufferHeight: CGFloat = 400
public let cornerRadius: CGFloat = 10
public let shadowRadius: CGFloat = 10
public let shadowOpacity: CGFloat = 0.12
public var trueSheetHeight: CGFloat {
return UIScreen.main.bounds.height + bufferHeight
}
}
/// Defines snapping positions for the sheet.
public enum SheetDetent: String, CaseIterable {
/// A detent where the sheet will have its maximum height and have
/// its top edge close to the top edge of the screen.
case maximum
/// A detent where the sheet's height will be about half the height
/// of the screen, with its top edge close to the middle of the screen.
case middle
/// A detent at which the sheet's contents are effectively hidden,
/// but the sheet's header still peek's through the bottom of the screen,
/// allowing the user to expand it.
case minimum
/// The velocity at which the sheet will ignore the middle detent and transition directly
/// from the maximum detent to the minimum detent when swiping down.
static let thresholdVelocityForSkippingMiddleDetent: CGFloat = 2000
/// The velocity at which the sheet will be dismissed instead of snapping
/// to a detent when flung down.
static let thresholdVelocityForFlingDismissal: CGFloat = 4000
/// The velocity at which the sheet will snap to the middle detent when flung upwards from
/// the minimum detent, ignoring the distance between the current position and the minimum detent.
static let thresholdVelocityForEnforcingMinimumToMiddleTransition: CGFloat = 900
}
extension SheetDetent: CustomDebugStringConvertible {
public var debugDescription: String {
switch self {
case .maximum: return "<Maximum Detent>"
case .middle: return "<Middle Detent>"
case .minimum: return "<Minimum Detent>"
}
}
}
class SheetContainerViewController: UIViewController {
var transitionToMaximumDetentProgressDidChange: ((CGFloat) -> Void)?
var performSnapCompanionAnimations: ((SheetDetent) -> Void)?
let sheetContentController: UIViewController
let initialDetent: SheetDetent
let metrics: SheetMetrics
let allowedDetents: [SheetDetent]
let dismissWhenFlungDown: Bool
weak var presentingSheetPresenter: SheetPresenter?
init(sheetContentController: UIViewController,
presentingSheetPresenter: SheetPresenter?,
initialDetent: SheetDetent = .middle,
allowedDetents: [SheetDetent] = SheetDetent.allCases,
metrics: SheetMetrics = .default,
dismissWhenFlungDown: Bool = false)
{
self.sheetContentController = sheetContentController
self.presentingSheetPresenter = presentingSheetPresenter
self.initialDetent = initialDetent
self.metrics = metrics
self.allowedDetents = allowedDetents
self.dismissWhenFlungDown = dismissWhenFlungDown
super.init(nibName: nil, bundle: nil)
}
private var overrideStatusBarStyle: UIStatusBarStyle? {
didSet {
setNeedsStatusBarAppearanceUpdate()
}
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return overrideStatusBarStyle ?? super.preferredStatusBarStyle
}
override var childForStatusBarStyle: UIViewController? {
return overrideStatusBarStyle != nil ? nil : sheetContentController
}
override var childForStatusBarHidden: UIViewController? {
return sheetContentController
}
required init?(coder: NSCoder) {
fatalError()
}
#warning("TODO: Rubber band and limit maximum sheet height while interactively moving")
private var maximumSheetHeight: CGFloat {
return value(for: .maximum) - view.safeAreaInsets.top
}
#warning("TODO: Rubber band and limit minimum sheet height while interactively moving")
private var minimumSheetHeight: CGFloat {
return metrics.shadowRadius + view.safeAreaInsets.bottom
}
private func heightToBottom(constant: CGFloat) -> CGFloat {
return metrics.trueSheetHeight - constant
}
private func normalize(_ value: CGFloat, range: ClosedRange<CGFloat>) -> CGFloat {
return (value - range.lowerBound) / (range.upperBound - range.lowerBound)
}
private lazy var availableHeightOnMiddleDetent = abs(value(for: .middle) - metrics.trueSheetHeight)
private lazy var availableHeightOnMaximumDetent = abs(value(for: .maximum) - metrics.trueSheetHeight)
var maximumDetentAnimationProgress: CGFloat {
guard sheetBottomConstraint.constant < value(for: .middle) else { return 0 }
let currentAvailableHeight = sheetController.availableHeight
let rawMin = availableHeightOnMiddleDetent / availableHeightOnMaximumDetent
let rawValue = currentAvailableHeight / availableHeightOnMaximumDetent
return normalize(rawValue, range: rawMin...1)
}
private func value(for detent: SheetDetent) -> CGFloat {
switch detent {
case .maximum: return heightToBottom(constant: UIScreen.main.bounds.height * 0.92)
case .middle: return heightToBottom(constant: UIScreen.main.bounds.height * 0.54)
case .minimum: return heightToBottom(constant: UIScreen.main.bounds.height * 0.16)
}
}
private var flingDownDismissVelocity: CGFloat?
private var snappingCancelled = false {
didSet {
if snappingCancelled { view.isUserInteractionEnabled = false }
}
}
private func closestSnappingDetent(for height: CGFloat, velocity: CGPoint) -> SheetDetent {
// print("SNAP velocity = \(velocity)")
var winner: SheetDetent = .maximum
let validDetents: [SheetDetent]
if dismissWhenFlungDown, velocity.y > 0, abs(velocity.y) > SheetDetent.thresholdVelocityForFlingDismissal {
snappingCancelled = true
flingDownDismissVelocity = velocity.y
presentingSheetPresenter?.dismiss()
return .minimum
}
if abs(velocity.y) > SheetDetent.thresholdVelocityForSkippingMiddleDetent {
if velocity.y < 0 {
// Swiping up really hard, force maximum detent
validDetents = [.maximum]
} else {
// Swiping hard in any direction, ignore middle detent
validDetents = allowedDetents.filter({ $0 != .middle })
}
} else if velocity.y < 0,
abs(velocity.y) > SheetDetent.thresholdVelocityForEnforcingMinimumToMiddleTransition,
sheetBottomConstraint.constant > value(for: .middle)
{
// Swiping up hard in between minimum and medium detent, force middle detent
validDetents = [.middle]
} else {
validDetents = allowedDetents
}
for detent in validDetents {
if abs(height - value(for: detent)) < abs(height - value(for: winner)) {
winner = detent
}
}
return winner
}
private weak var currentAnimator: UIViewPropertyAnimator?
private func timingCurve(with velocity: CGFloat) -> FluidTimingCurve {
let damping: CGFloat = velocity.isZero ? 100 : 30
return FluidTimingCurve(
velocity: CGVector(dx: velocity, dy: velocity),
stiffness: 400,
damping: damping
)
}
private func estimateTargetDetent(with velocity: CGFloat) -> SheetDetent {
return .maximum
}
private func snap(to detent: SheetDetent, with velocity: CGPoint = .zero, completion: (() -> Void)? = nil) {
guard !snappingCancelled else { return }
if currentAnimator?.state == .some(.active) {
currentAnimator?.stopAnimation(true)
}
let targetValue = value(for: detent)
// the 0.5 is to ensure there's always some distance for the gesture to work with
let distanceY = (sheetBottomConstraint.constant - 0.5) - targetValue
let effectiveVelocity = velocity.y.isInfinite || velocity.y.isNaN ? 2000 : velocity.y
let initialVelocityY = distanceY.isZero ? 0 : effectiveVelocity/distanceY * -1
let timing = timingCurve(with: initialVelocityY)
let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: timing)
animator.isUserInteractionEnabled = true
self.sheetBottomConstraint.constant = targetValue
animator.addAnimations {
self.performSnapCompanionAnimations?(detent)
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
self.sheetController.updateContentInsets()
if detent == .maximum {
self.dimmingView.alpha = self.maximumDimmingAlpha
self.overrideStatusBarStyle = .lightContent
} else {
if detent == .minimum {
self.dimmingView.alpha = 0
} else {
self.dimmingView.alpha = self.minimumDimmingAlpha
}
self.overrideStatusBarStyle = nil
}
}
animator.addCompletion { pos in
guard pos == .end else { return }
completion?()
}
currentAnimator = animator
animator.startAnimation()
}
func dismissSheet(coordinator: UIViewControllerTransitionCoordinator? = nil, duration: TimeInterval = 0.3, completion: (() -> Void)? = nil) {
let targetValue = metrics.trueSheetHeight
let animationBlock = {
self.dimmingView.alpha = 0
self.performSnapCompanionAnimations?(.minimum)
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
self.sheetController.updateContentInsets()
self.overrideStatusBarStyle = nil
}
if let coordinator = coordinator {
coordinator.animate(alongsideTransition: { _ in
animationBlock()
}, completion: { _ in
completion?()
})
} else {
let distanceY = sheetBottomConstraint.constant - targetValue
let effectiveVelocity: CGFloat
if let flingVelocity = flingDownDismissVelocity {
effectiveVelocity = flingVelocity.isInfinite || flingVelocity.isNaN ? 2000 : flingVelocity
} else {
effectiveVelocity = 0
}
let initialVelocityY = distanceY.isZero ? 0 : effectiveVelocity/distanceY * -1
let timing = timingCurve(with: initialVelocityY)
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: timing)
animator.isUserInteractionEnabled = true
self.sheetBottomConstraint.constant = targetValue
animator.addAnimations {
animationBlock()
}
animator.addCompletion { pos in
guard pos == .end else { return }
completion?()
}
animator.startAnimation()
}
}
private lazy var sheetBottomConstraint: NSLayoutConstraint = {
return sheetController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: metrics.trueSheetHeight)
}()
private(set) lazy var sheetController: SheetViewController = {
let v = SheetViewController(metrics: self.metrics)
v.rubberBandingStartHandler = { [weak self] in
self?.registerRubberBandingStart()
}
v.rubberBandingUpdateHandler = { [weak self] offset in
self?.followSheetScrollViewRubberBanding(with: offset)
}
v.rubberBandingFinishedHandler = { [weak self] in
self?.rubberBandingFinished()
}
return v
}()
private lazy var dimmingView: UIView = {
let v = UIView()
v.backgroundColor = .black
v.alpha = 0
v.autoresizingMask = [.flexibleWidth, .flexibleHeight]
v.frame = view.bounds
return v
}()
private lazy var panGesture: UIPanGestureRecognizer = {
let g = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
g.delegate = self
return g
}()
override func loadView() {
view = SheetContainerView(metrics: metrics)
view.addSubview(dimmingView)
addChild(sheetController)
view.addSubview(sheetController.view)
sheetController.didMove(toParent: self)
NSLayoutConstraint.activate([
sheetController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
sheetController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
sheetController.view.heightAnchor.constraint(equalToConstant: metrics.trueSheetHeight),
sheetBottomConstraint
])
sheetController.installContent(sheetContentController)
sheetController.view.addGestureRecognizer(panGesture)
}
private var snappedToInitialDetent = false
private func snapToInitialDetent() {
NSObject.cancelPreviousPerformRequests(withTarget: self)
perform(#selector(doSnapToInitialDetent), with: nil, afterDelay: 0)
}
@objc private func doSnapToInitialDetent() {
snap(to: initialDetent)
}
override func viewDidLoad() {
super.viewDidLoad()
snapToInitialDetent()
}
private var isDraggingSheet = false
private var lastTranslationY: CGFloat = 0
private var initialSheetHeightConstant: CGFloat = 0
private var minimumDimmingAlpha: CGFloat = 0.1
private var maximumDimmingAlpha: CGFloat = 0.5
private func snapToClosestDetent(with velocity: CGPoint) {
let target = closestSnappingDetent(for: sheetBottomConstraint.constant, velocity: velocity)
snap(to: target, with: velocity)
}
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: view)
let velocity = recognizer.velocity(in: view)
switch recognizer.state {
case .began:
isDraggingSheet = true
initialSheetHeightConstant = sheetBottomConstraint.constant
case .ended, .cancelled, .failed:
isDraggingSheet = false
if !sheetController.isScrollingEnabled {
snapToClosestDetent(with: velocity)
}
lastTranslationY = 0
case .changed:
let newConstant = sheetBottomConstraint.constant + (translation.y - lastTranslationY)
if sheetController.isScrollViewAtTheTop {
if newConstant > value(for: .maximum) || translation.y > 0 {
sheetBottomConstraint.constant = newConstant
sheetController.updateContentInsets()
sheetController.isScrollingEnabled = false
progressMaximumDetentInteractiveAnimation()
} else {
sheetController.isScrollingEnabled = true
}
} else {
sheetController.isScrollingEnabled = true
}
lastTranslationY = translation.y
default:
break
}
}
private var sheetBottomConstantAtRubberBandingStart: CGFloat = 0
private func registerRubberBandingStart() {
sheetBottomConstantAtRubberBandingStart = sheetBottomConstraint.constant
}
private func rubberBandingFinished() {
guard currentAnimator?.state != .active else { return }
snapToClosestDetent(with: .zero)
}
private func followSheetScrollViewRubberBanding(with offset: CGFloat) {
guard offset < 0 else { return } // only follow rubber banding when at the top
sheetBottomConstraint.constant = sheetBottomConstantAtRubberBandingStart - offset
progressMaximumDetentInteractiveAnimation()
}
private func progressMaximumDetentInteractiveAnimation() {
let progressToMaxDetent = maximumDetentAnimationProgress
if progressToMaxDetent >= 0.5 {
overrideStatusBarStyle = .lightContent
} else {
overrideStatusBarStyle = nil
}
if sheetBottomConstraint.constant < value(for: .minimum) {
let duration: TimeInterval = dimmingView.alpha == 0 ? 0.3 : 0
UIView.animate(withDuration: duration) {
self.dimmingView.alpha = self.minimumDimmingAlpha + self.maximumDimmingAlpha * progressToMaxDetent
}
} else {
dimmingView.alpha = 0
}
transitionToMaximumDetentProgressDidChange?(progressToMaxDetent)
}
deinit {
print("\(String(describing: type(of: self))) DEINIT")
}
}
private final class SheetContainerView: UIView {
let metrics: SheetMetrics
init(metrics: SheetMetrics) {
self.metrics = metrics
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let result = super.hitTest(point, with: event) else { return nil }
return result.isSheetDescendant ? result : nil
}
}
extension UIView {
var isSheetDescendant: Bool {
var currentView: UIView? = self
repeat {
if currentView is SheetContentView { return true }
currentView = currentView?.superview
} while currentView != nil
return false
}
}
extension SheetContainerViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}