In iOS, how to drag down to dismiss a modal?
I just created a tutorial for interactively dragging down a modal to dismiss it.
http://www.thorntech.com/2016/02/ios-tutorial-close-modal-dragging/
I found this topic to be confusing at first, so the tutorial builds this out step-by-step.
If you just want to run the code yourself, this is the repo:
https://github.com/ThornTechPublic/InteractiveModal
This is the approach I used:
View Controller
You override the dismiss animation with a custom one. If the user is dragging the modal, the interactor
kicks in.
import UIKitclass ViewController: UIViewController { let interactor = Interactor() override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { if let destinationViewController = segue.destinationViewController as? ModalViewController { destinationViewController.transitioningDelegate = self destinationViewController.interactor = interactor } }}extension ViewController: UIViewControllerTransitioningDelegate { func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { DismissAnimator() } func interactionControllerForDismissal(animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { interactor.hasStarted ? interactor : .none }}
Dismiss Animator
You create a custom animator. This is a custom animation that you package inside a UIViewControllerAnimatedTransitioning
protocol.
import UIKitclass DismissAnimator : NSObject { let transitionDuration = 0.6}extension DismissAnimator : UIViewControllerAnimatedTransitioning { func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval { transitionDuration } func animateTransition(transitionContext: UIViewControllerContextTransitioning) { guard let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey), let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey), let containerView = transitionContext.containerView() else { return } if transitionContext.transitionWasCancelled { containerView.insertSubview(toVC.view, belowSubview: fromVC.view) } let screenBounds = UIScreen.mainScreen().bounds let bottomLeftCorner = CGPoint(x: 0, y: screenBounds.height) let finalFrame = CGRect(origin: bottomLeftCorner, size: screenBounds.size) UIView.animateWithDuration( transitionDuration(transitionContext), animations: { fromVC.view.frame = finalFrame }, completion: { _ in transitionContext.completeTransition(!transitionContext.transitionWasCancelled()) } ) }}
Interactor
You subclass UIPercentDrivenInteractiveTransition
so that it can act as your state machine. Since the interactor object is accessed by both VCs, use it to keep track of the panning progress.
import UIKitclass Interactor: UIPercentDrivenInteractiveTransition { var hasStarted = false var shouldFinish = false}
Modal View Controller
This maps the pan gesture state to interactor method calls. The translationInView()
y
value determines whether the user crossed a threshold. When the pan gesture is .Ended
, the interactor either finishes or cancels.
import UIKitclass ModalViewController: UIViewController { var interactor:Interactor? = nil @IBAction func close(sender: UIButton) { dismiss(animated: true) } @IBAction func handleGesture(sender: UIPanGestureRecognizer) { let percentThreshold:CGFloat = 0.3 let translation = sender.translation(in: view) let verticalMovement = translation.y / view.bounds.height let downwardMovement = fmaxf(Float(verticalMovement), 0.0) let downwardMovementPercent = fminf(downwardMovement, 1.0) let progress = CGFloat(downwardMovementPercent) guard interactor = interactor else { return } switch sender.state { case .began: interactor.hasStarted = true dismiss(animated: true) case .changed: interactor.shouldFinish = progress > percentThreshold interactor.update(progress) case .cancelled: interactor.hasStarted = false interactor.cancel() case .ended: interactor.hasStarted = false interactor.shouldFinish ? interactor.finish() : interactor.cancel() default: break } } }
I'll share how I did it in Swift 3 :
Result
Implementation
class MainViewController: UIViewController { @IBAction func click() { performSegue(withIdentifier: "showModalOne", sender: nil) } }
class ModalOneViewController: ViewControllerPannable { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .yellow } @IBAction func click() { performSegue(withIdentifier: "showModalTwo", sender: nil) }}
class ModalTwoViewController: ViewControllerPannable { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .green }}
Where the Modals View Controllers inherit from a class
that I've built (ViewControllerPannable
) to make them draggable and dismissible when reach certain velocity.
ViewControllerPannable class
class ViewControllerPannable: UIViewController { var panGestureRecognizer: UIPanGestureRecognizer? var originalPosition: CGPoint? var currentPositionTouched: CGPoint? override func viewDidLoad() { super.viewDidLoad() panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureAction(_:))) view.addGestureRecognizer(panGestureRecognizer!) } func panGestureAction(_ panGesture: UIPanGestureRecognizer) { let translation = panGesture.translation(in: view) if panGesture.state == .began { originalPosition = view.center currentPositionTouched = panGesture.location(in: view) } else if panGesture.state == .changed { view.frame.origin = CGPoint( x: translation.x, y: translation.y ) } else if panGesture.state == .ended { let velocity = panGesture.velocity(in: view) if velocity.y >= 1500 { UIView.animate(withDuration: 0.2 , animations: { self.view.frame.origin = CGPoint( x: self.view.frame.origin.x, y: self.view.frame.size.height ) }, completion: { (isCompleted) in if isCompleted { self.dismiss(animated: false, completion: nil) } }) } else { UIView.animate(withDuration: 0.2, animations: { self.view.center = self.originalPosition! }) } } }}
Here is a one-file solution based on @wilson's answer (thanks đź‘Ť ) with the following improvements:
List of Improvements from previous solution
- Limit panning so that the view only goes down:
- Avoid horizontal translation by only updating the
y
coordinate ofview.frame.origin
- Avoid panning out of the screen when swiping up with
let y = max(0, translation.y)
- Avoid horizontal translation by only updating the
- Also dismiss the view controller based on where the finger is released (defaults to the bottom half of the screen) and not just based on the velocity of the swipe
- Show view controller as modal to ensure the previous viewcontroller appears behind and avoid a black background (should answer your question @nguyễn-anh-việt)
- Remove unneeded
currentPositionTouched
andoriginalPosition
- Expose the following parameters:
minimumVelocityToHide
: what speed is enough to hide (defaults to 1500)minimumScreenRatioToHide
: how low is enough to hide (defaults to 0.5)animationDuration
: how fast do we hide/show (defaults to 0.2s)
Solution
Swift 3 & Swift 4 :
//// PannableViewController.swift//import UIKitclass PannableViewController: UIViewController { public var minimumVelocityToHide: CGFloat = 1500 public var minimumScreenRatioToHide: CGFloat = 0.5 public var animationDuration: TimeInterval = 0.2 override func viewDidLoad() { super.viewDidLoad() // Listen for pan gesture let panGesture = UIPanGestureRecognizer(target: self, action: #selector(onPan(_:))) view.addGestureRecognizer(panGesture) } @objc func onPan(_ panGesture: UIPanGestureRecognizer) { func slideViewVerticallyTo(_ y: CGFloat) { self.view.frame.origin = CGPoint(x: 0, y: y) } switch panGesture.state { case .began, .changed: // If pan started or is ongoing then // slide the view to follow the finger let translation = panGesture.translation(in: view) let y = max(0, translation.y) slideViewVerticallyTo(y) case .ended: // If pan ended, decide it we should close or reset the view // based on the final position and the speed of the gesture let translation = panGesture.translation(in: view) let velocity = panGesture.velocity(in: view) let closing = (translation.y > self.view.frame.size.height * minimumScreenRatioToHide) || (velocity.y > minimumVelocityToHide) if closing { UIView.animate(withDuration: animationDuration, animations: { // If closing, animate to the bottom of the view self.slideViewVerticallyTo(self.view.frame.size.height) }, completion: { (isCompleted) in if isCompleted { // Dismiss the view when it dissapeared dismiss(animated: false, completion: nil) } }) } else { // If not closing, reset the view to the top UIView.animate(withDuration: animationDuration, animations: { slideViewVerticallyTo(0) }) } default: // If gesture state is undefined, reset the view to the top UIView.animate(withDuration: animationDuration, animations: { slideViewVerticallyTo(0) }) } } override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen; modalTransitionStyle = .coverVertical; } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) modalPresentationStyle = .overFullScreen; modalTransitionStyle = .coverVertical; }}