Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save adenisonafifi/b3d31f75a7a6225963ffb5738b9b76a4 to your computer and use it in GitHub Desktop.
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.
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