Last active
May 28, 2024 19:05
-
-
Save adenisonafifi/b3d31f75a7a6225963ffb5738b9b76a4 to your computer and use it in GitHub Desktop.
A UIViewController which is set up to be dismissed via a vertical pan (swipe down). Works even with a UIScrollView in the view.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
protocol VerticalScrollInteractionViewDelegate: class { | |
var canScroll: Bool { get set } | |
var headerHeight: CGFloat { get set } | |
} | |
class CustomViewController: UIViewController, VerticalScrollInteractionViewDelegate { | |
// VerticalScrollInteractionViewDelegate | |
var canScroll: Bool = false | |
var headerHeight: CGFloat = 0.0 | |
var dismissalInteractiveTransition: ScrollDismissalInteractiveTransition? = nil | |
var isInteractivelyDismissing: Bool { | |
return dismissalInteractiveTransition != nil | |
} | |
init() { | |
super.init(nibName: nil, bundle: nil) | |
} | |
override func loadView() { | |
view = CustomView() | |
} | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
// Add the gesture recognizer which will detect the interactive dismissal | |
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handleTransitionPan)) | |
panGesture.delegate = self | |
view.addGestureRecognizer(panGesture) | |
} | |
override func viewDidAppear(_ animated: Bool) { | |
super.viewDidAppear(animated) | |
// Take control of transition delegate to interactively dismiss | |
transitioningDelegate = self | |
} | |
override func viewDidLayoutSubviews() { | |
// Set headerHeight to the `maxY` of your header (if you have one) | |
super.viewDidLayoutSubviews() | |
} | |
required init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
} | |
extension CustomViewController: UIViewControllerTransitioningDelegate { | |
@objc func handleTransitionPan(recognizer: UIPanGestureRecognizer) { | |
switch(recognizer.state) { | |
case .began: | |
attemptDismissalTransition(recognizer: recognizer) | |
case .changed: | |
if let interactive = dismissalInteractiveTransition { | |
interactive.changed(recognizer: recognizer) | |
} else { | |
attemptDismissalTransition(recognizer: recognizer) | |
} | |
case .ended: | |
dismissalInteractiveTransition?.ended(recognizer: recognizer) | |
dismissalInteractiveTransition = nil | |
default: | |
break | |
} | |
} | |
fileprivate func attemptDismissalTransition(recognizer: UIPanGestureRecognizer) { | |
dismissalInteractiveTransition = ScrollDismissalInteractiveTransition(recognizer: recognizer, canScroll: canScroll, headerHeight: headerHeight) | |
if(dismissalInteractiveTransition != nil) { | |
// dismiss this view controller (preferably via delegation) | |
dismiss(animated: true, completion: nil) | |
} | |
} | |
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { | |
return nil | |
} | |
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { | |
// Create a custom UIViewControllerAnimatedTransitioning to return here. | |
// A simple one that just translates the view would be sufficient to experiment. | |
return AnimatedTransitionSubclass() | |
} | |
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { | |
return dismissalInteractiveTransition | |
} | |
} | |
extension CustomViewController: UIGestureRecognizerDelegate { | |
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { | |
// This allows the UICollectionView ScrollView to interact with the interactive dismissal UIPanGestureRecognizer | |
let otherGestureIsDescendent = otherGestureRecognizer.view?.isDescendant(of: self.view) | |
return otherGestureIsDescendent == true | |
} | |
} | |
class ScrollDismissalInteractiveTransition: UIPercentDrivenInteractiveTransition { | |
var initialTouchLocation: CGPoint | |
var containerBounds: CGRect | |
init?(recognizer: UIPanGestureRecognizer, canScroll: Bool, headerHeight: CGFloat) { | |
guard let view = recognizer.view, let superview = view.superview else { return nil } | |
let location = recognizer.location(in: superview) | |
// canScroll will be true if the UIScrollViewDelegate detected the desired content offset | |
// headerHeight can be used if there is a vertical gap at the top of your view which should always initiate the interactive dismissal when panned | |
guard canScroll || location.y <= headerHeight else { return nil } | |
initialTouchLocation = location | |
containerBounds = view.bounds | |
} | |
func changed(recognizer: UIPanGestureRecognizer) { | |
guard let view = recognizer.view, let superview = view.superview else { return } | |
let touchLocation = recognizer.location(in: superview) | |
// Progress direction is dragging down (positive y direction) | |
let deltaY = touchLocation.y - initialTouchLocation.y | |
guard deltaY > 0 else { update(0); return } | |
let percentageComplete = deltaY / containerBounds.height | |
update(percentageComplete) | |
} | |
func ended(recognizer: UIPanGestureRecognizer) { | |
guard let view = recognizer.view, let superview = view.superview else { return } | |
let velocity = recognizer.velocity(in: superview) | |
guard velocity.y > 100.0 else { cancel(); return } | |
finish() | |
} | |
} | |
extension CustomView: UIScrollViewDelegate { | |
// My CustomView would have a weak delegate to the CustomViewController to update `canScroll` | |
// weak var scrollInteractionViewDelegate: VerticalScrollInteractionViewDelegate? | |
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { | |
scrollInteractionViewDelegate?.canScroll = false | |
} | |
func scrollViewDidScroll(_ scrollView: UIScrollView) { | |
// The value `-150` here determines that dismissal begins at content offset -150 | |
scrollInteractionViewDelegate?.canScroll = scrollView.contentOffset.y < -150 | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment