In iOS, how to drag down to dismiss a modal? In iOS, how to drag down to dismiss a modal? ios ios

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.

enter image description here

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 of view.frame.origin
    • Avoid panning out of the screen when swiping up with let y = max(0, translation.y)
  • 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 and originalPosition
  • 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;    }}