Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

A common way to dismiss a modal is to swipe down - How do we allows the user to drag the modal down, if it's far enough, the modal's dismissed, otherwise it animates back to the original position?

For example, we can find this used on the Twitter app's photo views, or Snapchat's "discover" mode.

Similar threads point out that we can use a UISwipeGestureRecognizer and [self dismissViewControllerAnimated...] to dismiss a modal VC when a user swipes down. But this only handles a single swipe, not letting the user drag the modal around.

like image 721
foobar Avatar asked Mar 26 '15 22:03

foobar


People also ask

How do you dismiss a modal view controller?

According to the View Controller Programming guide for iPhone OS, this is incorrect when it comes to dismissing modal view controllers you should use delegation. So before presenting your modal view make yourself the delegate and then call the delegate from the modal view controller to dismiss.

How do I drag a view in Swift?

In order to make our view draggable, we have to add a gesture recognizer called UIPanGestureRecognizer . Apple's explanation of UIPanGestureRecognizer is quite straightforward: “A discrete gesture recognizer that interprets panning gestures.

How do you use swipe gestures in Swift 4?

A swipe gesture recognizer detects swipes in one of four directions, up , down , left , and right . We set the direction property of the swipe gesture recognizer to down . If the user swipes from the top of the blue view to the bottom of the blue view, the swipe gesture recognizer invokes the didSwipe(_:)


2 Answers

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        }     }      } 
like image 107
Robert Chen Avatar answered Sep 18 '22 16:09

Robert Chen


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!         })       }     }   } } 
like image 39
Wilson Avatar answered Sep 21 '22 16:09

Wilson