866 lines
29 KiB
Swift
866 lines
29 KiB
Swift
//
|
||
// PanModalPresentationController.swift
|
||
// PanModal
|
||
//
|
||
// Copyright © 2019 Tiny Speck, Inc. All rights reserved.
|
||
//
|
||
|
||
import UIKit
|
||
|
||
/**
|
||
The PanModalPresentationController is the middle layer between the presentingViewController
|
||
and the presentedViewController.
|
||
|
||
It controls the coordination between the individual transition classes as well as
|
||
provides an abstraction over how the presented view is presented & displayed.
|
||
|
||
For example, we add a drag indicator view above the presented view and
|
||
a background overlay between the presenting & presented view.
|
||
|
||
The presented view's layout configuration & presentation is defined using the PanModalPresentable.
|
||
|
||
By conforming to the PanModalPresentable protocol & overriding values
|
||
the presented view can define its layout configuration & presentation.
|
||
*/
|
||
public class PanModalPresentationController: UIPresentationController {
|
||
|
||
/**
|
||
Enum representing the possible presentation states
|
||
*/
|
||
public enum PresentationState {
|
||
case shortForm
|
||
case longForm
|
||
}
|
||
|
||
/**
|
||
Constants
|
||
*/
|
||
struct Constants {
|
||
static let indicatorYOffset = CGFloat(8.0)
|
||
static let snapMovementSensitivity = CGFloat(0.7)
|
||
static let dragIndicatorSize = CGSize(width: 36.0, height: 5.0)
|
||
}
|
||
|
||
// MARK: - Properties
|
||
|
||
/**
|
||
A flag to track if the presented view is animating
|
||
*/
|
||
private var isPresentedViewAnimating = false
|
||
|
||
/**
|
||
A flag to determine if scrolling should seamlessly transition
|
||
from the pan modal container view to the scroll view
|
||
once the scroll limit has been reached.
|
||
*/
|
||
private var extendsPanScrolling = true
|
||
|
||
/**
|
||
A flag to determine if scrolling should be limited to the longFormHeight.
|
||
Return false to cap scrolling at .max height.
|
||
*/
|
||
private var anchorModalToLongForm = true
|
||
|
||
/**
|
||
The y content offset value of the embedded scroll view
|
||
*/
|
||
private var scrollViewYOffset: CGFloat = 0.0
|
||
|
||
/**
|
||
An observer for the scroll view content offset
|
||
*/
|
||
private var scrollObserver: NSKeyValueObservation?
|
||
|
||
// store the y positions so we don't have to keep re-calculating
|
||
|
||
/**
|
||
The y value for the short form presentation state
|
||
*/
|
||
private var shortFormYPosition: CGFloat = 0
|
||
|
||
/**
|
||
The y value for the long form presentation state
|
||
*/
|
||
private var longFormYPosition: CGFloat = 0
|
||
|
||
/**
|
||
Determine anchored Y postion based on the `anchorModalToLongForm` flag
|
||
*/
|
||
private var anchoredYPosition: CGFloat {
|
||
let defaultTopOffset = presentable?.topOffset ?? 0
|
||
return anchorModalToLongForm ? longFormYPosition : defaultTopOffset
|
||
}
|
||
|
||
/**
|
||
Configuration object for PanModalPresentationController
|
||
*/
|
||
private var presentable: PanModalPresentable? {
|
||
return presentedViewController as? PanModalPresentable
|
||
}
|
||
|
||
// MARK: - Views
|
||
|
||
/**
|
||
Background view used as an overlay over the presenting view
|
||
*/
|
||
private lazy var backgroundView: DimmedView = {
|
||
let view: DimmedView
|
||
if let alpha = presentable?.backgroundAlpha {
|
||
view = DimmedView(dimAlpha: alpha)
|
||
} else {
|
||
view = DimmedView()
|
||
}
|
||
view.didTap = { [weak self] _ in
|
||
self?.dismissPresentedViewController()
|
||
}
|
||
return view
|
||
}()
|
||
|
||
/**
|
||
A wrapper around the presented view so that we can modify
|
||
the presented view apperance without changing
|
||
the presented view's properties
|
||
*/
|
||
private lazy var panContainerView: PanContainerView = {
|
||
let frame = containerView?.frame ?? .zero
|
||
return PanContainerView(presentedView: presentedViewController.view, frame: frame)
|
||
}()
|
||
|
||
/**
|
||
Drag Indicator View
|
||
*/
|
||
private lazy var dragIndicatorView: UIView = {
|
||
let view = UIView()
|
||
view.backgroundColor = .lightGray
|
||
view.layer.cornerRadius = Constants.dragIndicatorSize.height / 2.0
|
||
return view
|
||
}()
|
||
|
||
/**
|
||
Override presented view to return the pan container wrapper
|
||
*/
|
||
public override var presentedView: UIView {
|
||
return panContainerView
|
||
}
|
||
|
||
// MARK: - Gesture Recognizers
|
||
|
||
/**
|
||
Gesture recognizer to detect & track pan gestures
|
||
*/
|
||
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
|
||
let gesture = UIPanGestureRecognizer(target: self, action: #selector(didPanOnPresentedView(_ :)))
|
||
gesture.minimumNumberOfTouches = 1
|
||
gesture.maximumNumberOfTouches = 1
|
||
gesture.delegate = self
|
||
return gesture
|
||
}()
|
||
|
||
// MARK: - Deinitializers
|
||
|
||
deinit {
|
||
scrollObserver?.invalidate()
|
||
}
|
||
|
||
// MARK: - Lifecycle
|
||
|
||
override public func containerViewWillLayoutSubviews() {
|
||
super.containerViewWillLayoutSubviews()
|
||
configureViewLayout()
|
||
}
|
||
|
||
override public func presentationTransitionWillBegin() {
|
||
|
||
guard let containerView = containerView
|
||
else { return }
|
||
|
||
layoutBackgroundView(in: containerView)
|
||
layoutPresentedView(in: containerView)
|
||
configureScrollViewInsets()
|
||
|
||
guard let coordinator = presentedViewController.transitionCoordinator else {
|
||
backgroundView.dimState = .max
|
||
return
|
||
}
|
||
|
||
coordinator.animate(alongsideTransition: { [weak self] _ in
|
||
self?.backgroundView.dimState = .max
|
||
self?.presentedViewController.setNeedsStatusBarAppearanceUpdate()
|
||
})
|
||
}
|
||
|
||
override public func dismissalTransitionWillBegin() {
|
||
|
||
guard let coordinator = presentedViewController.transitionCoordinator else {
|
||
backgroundView.dimState = .off
|
||
return
|
||
}
|
||
|
||
/**
|
||
Drag indicator is drawn outside of view bounds
|
||
so hiding it on view dismiss means avoids visual bugs
|
||
*/
|
||
coordinator.animate(alongsideTransition: { [weak self] _ in
|
||
self?.dragIndicatorView.alpha = 0.0
|
||
self?.backgroundView.dimState = .off
|
||
self?.presentingViewController.setNeedsStatusBarAppearanceUpdate()
|
||
})
|
||
}
|
||
|
||
override public func presentationTransitionDidEnd(_ completed: Bool) {
|
||
if completed { return }
|
||
|
||
backgroundView.removeFromSuperview()
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - Public Methods
|
||
|
||
public extension PanModalPresentationController {
|
||
|
||
/**
|
||
Tranisition the PanModalPresentationController
|
||
to the given presentation state
|
||
*/
|
||
public func transition(to state: PresentationState) {
|
||
|
||
guard presentable?.shouldTransition(to: state) == true
|
||
else { return }
|
||
|
||
presentable?.willTransition(to: state)
|
||
|
||
switch state {
|
||
case .shortForm:
|
||
snap(toYPosition: shortFormYPosition)
|
||
case .longForm:
|
||
snap(toYPosition: longFormYPosition)
|
||
}
|
||
}
|
||
|
||
/**
|
||
Set the content offset of the scroll view
|
||
|
||
Due to content offset observation, its not possible to programmatically
|
||
set the content offset directly on the scroll view while in the short form.
|
||
|
||
This method pauses the content offset KVO, performs the content offset chnage
|
||
and then resumes content offset observation.
|
||
*/
|
||
public func setContentOffset(offset: CGPoint) {
|
||
|
||
guard let scrollView = presentable?.panScrollable
|
||
else { return }
|
||
|
||
/**
|
||
Invalidate scroll view observer
|
||
to prevent its overriding the content offset change
|
||
*/
|
||
scrollObserver?.invalidate()
|
||
scrollObserver = nil
|
||
|
||
/**
|
||
Set scroll view offset & track scrolling
|
||
*/
|
||
scrollView.setContentOffset(offset, animated:false)
|
||
trackScrolling(scrollView)
|
||
|
||
/**
|
||
Add the scroll view observer
|
||
*/
|
||
observe(scrollView: scrollView)
|
||
}
|
||
|
||
/**
|
||
Updates the PanModalPresentationController layout
|
||
based on values in the PanModalPresentabls
|
||
|
||
- Note: This should be called whenever any
|
||
pan modal presentable value changes after the initial presentation
|
||
*/
|
||
public func setNeedsLayoutUpdate() {
|
||
configureViewLayout()
|
||
adjustPresentedViewFrame()
|
||
observe(scrollView: presentable?.panScrollable)
|
||
configureScrollViewInsets()
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - Presented View Layout Configuration
|
||
|
||
private extension PanModalPresentationController {
|
||
|
||
/**
|
||
Boolean flag to determine if the presented view is anchored
|
||
*/
|
||
var isPresentedViewAnchored: Bool {
|
||
if !isPresentedViewAnimating
|
||
&& extendsPanScrolling
|
||
&& presentedView.frame.minY <= anchoredYPosition {
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
/**
|
||
Adds the presented view to the given container view
|
||
& configures the view elements such as drag indicator, rounded corners
|
||
based on the pan modal presentable.
|
||
*/
|
||
func layoutPresentedView(in containerView: UIView) {
|
||
|
||
/**
|
||
If the presented view controller does not conform to pan modal presentable
|
||
don't configure
|
||
*/
|
||
guard let presentable = presentable
|
||
else { return }
|
||
|
||
/**
|
||
⚠️ If this class is NOT used in conjunction with the PanModalPresentationAnimator
|
||
& PanModalPresentable, the presented view should be added to the container view
|
||
in the presentation animator instead of here
|
||
*/
|
||
containerView.addSubview(presentedView)
|
||
containerView.addGestureRecognizer(panGestureRecognizer)
|
||
|
||
if presentable.showDragIndicator {
|
||
addDragIndicatorView(to: presentedView)
|
||
}
|
||
|
||
if presentable.shouldRoundTopCorners {
|
||
addRoundedCorners(to: presentedView)
|
||
}
|
||
|
||
setNeedsLayoutUpdate()
|
||
adjustPanContainerBackgroundColor()
|
||
}
|
||
|
||
/**
|
||
Reduce height of presentedView so that it sits at the bottom of the screen
|
||
*/
|
||
func adjustPresentedViewFrame() {
|
||
let frame = containerView?.frame ?? .zero
|
||
let size = CGSize(width: frame.size.width, height: frame.height - anchoredYPosition)
|
||
presentedViewController.view.frame = CGRect(origin: .zero, size: size)
|
||
}
|
||
|
||
/**
|
||
Adds a background color to the pan container view
|
||
in order to avoid a gap at the bottom
|
||
during initial view presentation in longForm (when view bounces)
|
||
*/
|
||
func adjustPanContainerBackgroundColor() {
|
||
panContainerView.backgroundColor = presentedViewController.view.backgroundColor
|
||
?? presentable?.panScrollable?.backgroundColor
|
||
}
|
||
|
||
/**
|
||
Adds the background view to the view hierarchy
|
||
& configures its layout constraints.
|
||
*/
|
||
func layoutBackgroundView(in containerView: UIView) {
|
||
containerView.addSubview(backgroundView)
|
||
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||
backgroundView.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true
|
||
backgroundView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor).isActive = true
|
||
backgroundView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor).isActive = true
|
||
backgroundView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true
|
||
}
|
||
|
||
/**
|
||
Adds the drag indicator view to the view hierarchy
|
||
& configures its layout constraints.
|
||
*/
|
||
func addDragIndicatorView(to view: UIView) {
|
||
view.addSubview(dragIndicatorView)
|
||
dragIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||
dragIndicatorView.bottomAnchor.constraint(equalTo: view.topAnchor, constant: -Constants.indicatorYOffset).isActive = true
|
||
dragIndicatorView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
|
||
dragIndicatorView.widthAnchor.constraint(equalToConstant: Constants.dragIndicatorSize.width).isActive = true
|
||
dragIndicatorView.heightAnchor.constraint(equalToConstant: Constants.dragIndicatorSize.height).isActive = true
|
||
}
|
||
|
||
/**
|
||
Caluclates & stores the layout anchor points & options
|
||
*/
|
||
func configureViewLayout() {
|
||
|
||
guard let layoutPresentable = presentedViewController as? PanModalPresentable.LayoutType
|
||
else { return }
|
||
|
||
shortFormYPosition = layoutPresentable.shortFormYPos
|
||
longFormYPosition = layoutPresentable.longFormYPos
|
||
anchorModalToLongForm = layoutPresentable.anchorModalToLongForm
|
||
extendsPanScrolling = layoutPresentable.allowsExtendedPanScrolling
|
||
|
||
containerView?.isUserInteractionEnabled = layoutPresentable.isUserInteractionEnabled
|
||
}
|
||
|
||
/**
|
||
Configures the scroll view insets
|
||
*/
|
||
func configureScrollViewInsets() {
|
||
|
||
guard
|
||
let scrollView = presentable?.panScrollable,
|
||
!scrollView.isScrolling
|
||
else { return }
|
||
|
||
/**
|
||
Disable vertical scroll indicator until we start to scroll
|
||
to avoid visual bugs
|
||
*/
|
||
scrollView.showsVerticalScrollIndicator = false
|
||
scrollView.scrollIndicatorInsets = presentable?.scrollIndicatorInsets ?? .zero
|
||
|
||
/**
|
||
Set the appropriate contentInset as the configuration within this class
|
||
offsets it
|
||
*/
|
||
scrollView.contentInset.bottom = presentingViewController.bottomLayoutGuide.length
|
||
|
||
/**
|
||
As we adjust the bounds during `handleScrollViewTopBounce`
|
||
we should assume that contentInsetAdjustmentBehavior will not be correct
|
||
*/
|
||
if #available(iOS 11.0, *) {
|
||
scrollView.contentInsetAdjustmentBehavior = .never
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - Pan Gesture Event Handler
|
||
|
||
private extension PanModalPresentationController {
|
||
|
||
/**
|
||
The designated function for handling pan gesture events
|
||
*/
|
||
@objc func didPanOnPresentedView(_ recognizer: UIPanGestureRecognizer) {
|
||
|
||
guard
|
||
shouldRespond(to: panGestureRecognizer),
|
||
let containerView = containerView
|
||
else {
|
||
recognizer.setTranslation(.zero, in: recognizer.view)
|
||
return
|
||
}
|
||
|
||
switch recognizer.state {
|
||
case .began, .changed:
|
||
|
||
/**
|
||
Respond accordingly to pan gesture translation
|
||
*/
|
||
respond(to: recognizer)
|
||
|
||
/**
|
||
If presentedView is translated above the longForm threshold, treat as transition
|
||
*/
|
||
if presentedView.frame.origin.y == anchoredYPosition && extendsPanScrolling {
|
||
presentable?.willTransition(to: .longForm)
|
||
}
|
||
|
||
default:
|
||
|
||
/**
|
||
Use velocity sensitivity value to restrict snapping
|
||
*/
|
||
let velocity = recognizer.velocity(in: presentedView)
|
||
|
||
if isVelocityWithinSensitivityRange(velocity.y) {
|
||
|
||
/**
|
||
If velocity is within the sensitivity range,
|
||
transition to a presentation state or dismiss entirely.
|
||
|
||
This allows the user to dismiss directly from long form
|
||
instead of going to the short form state first.
|
||
*/
|
||
if velocity.y < 0 {
|
||
transition(to: .longForm)
|
||
|
||
} else if (nearestDistance(to: presentedView.frame.minY, inDistances: [longFormYPosition, containerView.bounds.height]) == longFormYPosition
|
||
&& presentedView.frame.minY < shortFormYPosition) || presentable?.allowsDragToDismiss == false {
|
||
transition(to: .shortForm)
|
||
|
||
} else {
|
||
dismissPresentedViewController()
|
||
}
|
||
|
||
} else {
|
||
|
||
/**
|
||
The `containerView.bounds.height` is used to determine
|
||
how close the presented view is to the bottom of the screen
|
||
*/
|
||
let position = nearestDistance(to: presentedView.frame.minY, inDistances: [containerView.bounds.height, shortFormYPosition, longFormYPosition])
|
||
|
||
if position == longFormYPosition {
|
||
transition(to: .longForm)
|
||
|
||
} else if position == shortFormYPosition || presentable?.allowsDragToDismiss == false {
|
||
transition(to: .shortForm)
|
||
|
||
} else {
|
||
dismissPresentedViewController()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
Determine if the pan modal should respond to the gesture recognizer.
|
||
|
||
If the pan modal is already being dragged & the delegate returns false, ignore until
|
||
the recognizer is back to it's original state (.began)
|
||
|
||
⚠️ This is the only time we should be cancelling the pan modal gesture recognizer
|
||
*/
|
||
func shouldRespond(to panGestureRecognizer: UIPanGestureRecognizer) -> Bool {
|
||
guard
|
||
presentable?.shouldRespond(to: panGestureRecognizer) == true ||
|
||
!(panGestureRecognizer.state == .began || panGestureRecognizer.state == .cancelled)
|
||
else {
|
||
panGestureRecognizer.isEnabled = false
|
||
panGestureRecognizer.isEnabled = true
|
||
return false
|
||
}
|
||
return !shouldFail(panGestureRecognizer: panGestureRecognizer)
|
||
}
|
||
|
||
/**
|
||
Communicate intentions to presentable and adjust subviews in containerView
|
||
*/
|
||
func respond(to panGestureRecognizer: UIPanGestureRecognizer) {
|
||
presentable?.willRespond(to: panGestureRecognizer)
|
||
|
||
var yDisplacement = panGestureRecognizer.translation(in: presentedView).y
|
||
|
||
/**
|
||
If the presentedView is not anchored to long form, reduce the rate of movement
|
||
above the threshold
|
||
*/
|
||
if presentedView.frame.origin.y < longFormYPosition {
|
||
yDisplacement /= 2.0
|
||
}
|
||
adjust(toYPosition: presentedView.frame.origin.y + yDisplacement)
|
||
|
||
panGestureRecognizer.setTranslation(.zero, in: presentedView)
|
||
}
|
||
|
||
/**
|
||
Determines if we should fail the gesture recognizer based on certain conditions
|
||
|
||
We fail the presented view's pan gesture recognizer if we are actively scrolling on the scroll view.
|
||
This allows the user to drag whole view controller from outside scrollView touch area.
|
||
|
||
Unfortunately, cancelling a gestureRecognizer means that we lose the effect of transition scrolling
|
||
from one view to another in the same pan gesture so don't cancel
|
||
*/
|
||
func shouldFail(panGestureRecognizer: UIPanGestureRecognizer) -> Bool {
|
||
|
||
/**
|
||
Allow api consumers to override the internal conditions &
|
||
decide if the pan gesture recognizer should be prioritized.
|
||
|
||
⚠️ This is the only time we should be cancelling the panScrollable recognizer,
|
||
for the purpose of ensuring we're no longer tracking the scrollView
|
||
*/
|
||
guard !shouldPrioritize(panGestureRecognizer: panGestureRecognizer) else {
|
||
presentable?.panScrollable?.panGestureRecognizer.isEnabled = false
|
||
presentable?.panScrollable?.panGestureRecognizer.isEnabled = true
|
||
return false
|
||
}
|
||
|
||
guard
|
||
isPresentedViewAnchored,
|
||
let scrollView = presentable?.panScrollable,
|
||
scrollView.contentOffset.y > 0
|
||
else {
|
||
return false
|
||
}
|
||
|
||
let loc = panGestureRecognizer.location(in: presentedView)
|
||
return (scrollView.frame.contains(loc) || scrollView.isScrolling)
|
||
}
|
||
|
||
/**
|
||
Determine if the presented view's panGestureRecognizer should be prioritized over
|
||
embedded scrollView's panGestureRecognizer.
|
||
*/
|
||
func shouldPrioritize(panGestureRecognizer: UIPanGestureRecognizer) -> Bool {
|
||
return panGestureRecognizer.state == .began &&
|
||
presentable?.shouldPrioritize(panModalGestureRecognizer: panGestureRecognizer) == true
|
||
}
|
||
|
||
/**
|
||
Check if the given velocity is within the sensitivity range
|
||
*/
|
||
func isVelocityWithinSensitivityRange(_ velocity: CGFloat) -> Bool {
|
||
return (abs(velocity) - (1000 * (1 - Constants.snapMovementSensitivity))) > 0
|
||
}
|
||
|
||
func snap(toYPosition yPos: CGFloat) {
|
||
PanModalAnimator.animate({ [weak self] in
|
||
self?.adjust(toYPosition: yPos)
|
||
self?.isPresentedViewAnimating = true
|
||
}, config: presentable) { [weak self] didComplete in
|
||
self?.isPresentedViewAnimating = !didComplete
|
||
}
|
||
}
|
||
|
||
/**
|
||
Sets the y position of the presentedView & adjusts the backgroundView.
|
||
*/
|
||
func adjust(toYPosition yPos: CGFloat) {
|
||
presentedView.frame.origin.y = max(yPos, anchoredYPosition)
|
||
|
||
guard presentedView.frame.origin.y > shortFormYPosition else {
|
||
backgroundView.dimState = .max
|
||
return
|
||
}
|
||
|
||
let yDisplacementFromShortForm = presentedView.frame.origin.y - shortFormYPosition
|
||
|
||
/**
|
||
Once presentedView is translated below shortForm, calculate yPos relative to bottom of screen
|
||
and apply percentage to backgroundView alpha
|
||
*/
|
||
backgroundView.dimState = .percent(1.0 - (yDisplacementFromShortForm / presentedView.frame.height))
|
||
}
|
||
|
||
/**
|
||
Finds the nearest distance to a given position out of a given array of distance values
|
||
|
||
- Parameters:
|
||
- position: reference postion we are trying to find the closest distance to
|
||
- distances: array of positions we would like to compare against
|
||
*/
|
||
func nearestDistance(to position: CGFloat, inDistances distances: [CGFloat]) -> CGFloat {
|
||
guard let nearestDistance = distances.min(by: { abs(position - $0) < abs(position - $1) })
|
||
else { return position }
|
||
return nearestDistance
|
||
}
|
||
|
||
/**
|
||
Dismiss presented view
|
||
*/
|
||
func dismissPresentedViewController() {
|
||
presentable?.panModalWillDismiss()
|
||
presentedViewController.dismiss(animated: true, completion: nil)
|
||
}
|
||
}
|
||
|
||
// MARK: - UIScrollView Observer
|
||
|
||
private extension PanModalPresentationController {
|
||
|
||
/**
|
||
Creates & stores an observer on the given scroll view's content offset.
|
||
This allows us to track scrolling without overriding the scrollView delegate
|
||
*/
|
||
func observe(scrollView: UIScrollView?) {
|
||
scrollObserver?.invalidate()
|
||
scrollObserver = scrollView?.observe(\.contentOffset, options: .old) { [weak self] scrollView, change in
|
||
|
||
/**
|
||
Incase we have a situation where we have two containerViews in the same presentation
|
||
*/
|
||
guard self?.containerView != nil
|
||
else { return }
|
||
|
||
self?.didPanOnScrollView(scrollView, change: change)
|
||
}
|
||
}
|
||
|
||
/**
|
||
Scroll view content offset change event handler
|
||
|
||
Also when scrollView is scrolled to the top, we disable the scroll indicator
|
||
otherwise glitchy behaviour occurs
|
||
|
||
This is also shown in Apple Maps (reverse engineering)
|
||
which allows us to seamlessly transition scrolling from the panContainerView to the scrollView
|
||
*/
|
||
func didPanOnScrollView(_ scrollView: UIScrollView, change: NSKeyValueObservedChange<CGPoint>) {
|
||
|
||
guard
|
||
!presentedViewController.isBeingDismissed,
|
||
!presentedViewController.isBeingPresented
|
||
else { return }
|
||
|
||
if !isPresentedViewAnchored && scrollView.contentOffset.y > 0 {
|
||
|
||
/**
|
||
Hold the scrollView in place if we're actively scrolling and not handling top bounce
|
||
*/
|
||
haltScrolling(scrollView)
|
||
|
||
} else if scrollView.isScrolling || isPresentedViewAnimating {
|
||
|
||
if isPresentedViewAnchored {
|
||
/**
|
||
While we're scrolling upwards on the scrollView,
|
||
store the last content offset position
|
||
*/
|
||
trackScrolling(scrollView)
|
||
} else {
|
||
/**
|
||
Keep scroll view in place while we're panning on main view
|
||
*/
|
||
haltScrolling(scrollView)
|
||
}
|
||
|
||
} else if presentedViewController.view.isKind(of: UIScrollView.self)
|
||
&& !isPresentedViewAnimating && scrollView.contentOffset.y <= 0 {
|
||
|
||
/**
|
||
In the case where we drag down quickly on the scroll view and let go,
|
||
`handleScrollViewTopBounce` adds a nice elegant touch.
|
||
*/
|
||
handleScrollViewTopBounce(scrollView: scrollView, change: change)
|
||
} else {
|
||
trackScrolling(scrollView)
|
||
}
|
||
}
|
||
|
||
/**
|
||
Halts the scroll of a given scroll view & anchors it at the `scrollViewYOffset`
|
||
*/
|
||
func haltScrolling(_ scrollView: UIScrollView) {
|
||
scrollView.setContentOffset(CGPoint(x: 0, y: scrollViewYOffset), animated: false)
|
||
scrollView.showsVerticalScrollIndicator = false
|
||
}
|
||
|
||
/**
|
||
As the user scrolls, track & save the scroll view y offset.
|
||
This helps halt scrolling when we want to hold the scroll view in place.
|
||
*/
|
||
func trackScrolling(_ scrollView: UIScrollView) {
|
||
scrollViewYOffset = max(scrollView.contentOffset.y, 0)
|
||
scrollView.showsVerticalScrollIndicator = true
|
||
}
|
||
|
||
/**
|
||
To ensure that the scroll transition between the scrollView & the modal
|
||
is completely seamless, we need to handle the case where content offset is negative.
|
||
|
||
In this case, we follow the curve of the decelerating scroll view.
|
||
This gives the effect that the modal view and the scroll view are one view entirely.
|
||
|
||
- Note: This works best where the view behind view controller is a UIScrollView.
|
||
So, for example, a UITableViewController.
|
||
*/
|
||
func handleScrollViewTopBounce(scrollView: UIScrollView, change: NSKeyValueObservedChange<CGPoint>) {
|
||
|
||
guard let oldYValue = change.oldValue?.y
|
||
else { return }
|
||
|
||
let yOffset = scrollView.contentOffset.y
|
||
let presentedSize = containerView?.frame.size ?? .zero
|
||
|
||
/**
|
||
Decrease the view bounds by the y offset so the scroll view stays in place
|
||
and we can still get updates on its content offset
|
||
*/
|
||
presentedView.bounds.size = CGSize(width: presentedSize.width, height: presentedSize.height + yOffset)
|
||
|
||
if oldYValue > yOffset {
|
||
/**
|
||
Move the view in the opposite direction to the decreasing bounds
|
||
until half way through the deceleration so that it appears
|
||
as if we're transferring the scrollView drag momentum to the entire view
|
||
*/
|
||
presentedView.frame.origin.y = longFormYPosition - yOffset
|
||
} else {
|
||
scrollViewYOffset = 0
|
||
snap(toYPosition: longFormYPosition)
|
||
}
|
||
|
||
scrollView.showsVerticalScrollIndicator = false
|
||
}
|
||
}
|
||
|
||
// MARK: - UIGestureRecognizerDelegate
|
||
|
||
extension PanModalPresentationController: UIGestureRecognizerDelegate {
|
||
|
||
/**
|
||
Do not require any other gesture recognizers to fail
|
||
*/
|
||
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||
return false
|
||
}
|
||
|
||
/**
|
||
Allow simultaneous gesture recognizers only when the other gesture recognizer
|
||
is a pan gesture recognizer
|
||
*/
|
||
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||
return otherGestureRecognizer.isKind(of: UIPanGestureRecognizer.self)
|
||
}
|
||
}
|
||
|
||
// MARK: - UIBezierPath
|
||
|
||
private extension PanModalPresentationController {
|
||
|
||
/**
|
||
Draws top rounded corners on a given view
|
||
We have to set a custom path for corner rounding
|
||
because we render the dragIndicator outside of view bounds
|
||
*/
|
||
func addRoundedCorners(to view: UIView) {
|
||
let radius = presentable?.cornerRadius ?? 0
|
||
let path = UIBezierPath(roundedRect: view.bounds,
|
||
byRoundingCorners: [.topLeft, .topRight],
|
||
cornerRadii: CGSize(width: radius, height: radius))
|
||
|
||
// Draw around the drag indicator view, if displayed
|
||
if presentable?.showDragIndicator == true {
|
||
let indicatorLeftEdgeXPos = view.bounds.width/2.0 - Constants.dragIndicatorSize.width/2.0
|
||
drawAroundDragIndicator(currentPath: path, indicatorLeftEdgeXPos: indicatorLeftEdgeXPos)
|
||
}
|
||
|
||
// Set path as a mask to display optional drag indicator view & rounded corners
|
||
let mask = CAShapeLayer()
|
||
mask.path = path.cgPath
|
||
view.layer.mask = mask
|
||
|
||
// Improve performance by rasterizing the layer
|
||
view.layer.shouldRasterize = true
|
||
view.layer.rasterizationScale = UIScreen.main.scale
|
||
}
|
||
|
||
/**
|
||
Draws a path around the drag indicator view
|
||
*/
|
||
func drawAroundDragIndicator(currentPath path: UIBezierPath, indicatorLeftEdgeXPos: CGFloat) {
|
||
|
||
let totalIndicatorOffset = Constants.indicatorYOffset + Constants.dragIndicatorSize.height
|
||
|
||
// Draw around drag indicator starting from the left
|
||
path.addLine(to: CGPoint(x: indicatorLeftEdgeXPos, y: path.currentPoint.y))
|
||
path.addLine(to: CGPoint(x: path.currentPoint.x, y: path.currentPoint.y - totalIndicatorOffset))
|
||
path.addLine(to: CGPoint(x: path.currentPoint.x + Constants.dragIndicatorSize.width, y: path.currentPoint.y))
|
||
path.addLine(to: CGPoint(x: path.currentPoint.x, y: path.currentPoint.y + totalIndicatorOffset))
|
||
}
|
||
}
|
||
|
||
// MARK: - Helper Extensions
|
||
|
||
private extension UIScrollView {
|
||
|
||
/**
|
||
A flag to determine if a scroll view is scrolling
|
||
*/
|
||
var isScrolling: Bool {
|
||
return isDragging && !isDecelerating || isTracking
|
||
}
|
||
}
|