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 UIKit

class 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 UIKit

class 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 UIKit

class 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 UIKit

class 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!)
  }
  
  @objc 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!
        })
      }
    }
  }
}