In iOS, how to drag down to dismiss a modal?
Asked Answered
S

16

107

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.

Sisterly answered 26/3, 2015 at 22:52 Comment(6)
Take a look at custom interactive transitions. This is the way you can implement it. developer.apple.com/library/prerelease/ios/documentation/UIKit/…Infantile
Referred to github.com/ThornTechPublic/InteractiveModal repo by Robert Chen and wrote a wrapper/handler class to handle everything. No more boilerplate code supports four basic transitions (top to bottom, bottom to top, left to right and right to left) with dismissing gestures github.com/chamira/ProjSetup/blob/master/AppProject/_BasicSetup/…Halflength
@ChamiraFernando , looked at your code and it helps a lot. Is there a way to make it so that multiple directions are included instead of one?Geomorphology
I'll do. Time is huge constrain these days :(Halflength
github.com/satishVekariya/DraggableViewControllerEndure
It worked for me: github.com/ModernProgrammer/DragDismissDemoFrechette
B
102

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
       }
    }
    
}
Barbaresi answered 4/2, 2016 at 15:55 Comment(10)
Hey Robert, great work. Do you have an idea how we could modify this to allow it to work with table views? That is, being able to pull down to dismiss when the tableview is at top? ThanksIronware
Ross, I created a new branch which has a working example: github.com/ThornTechPublic/InteractiveModal/tree/Ross. If you want to see what it looks like first, check out this GIF: raw.githubusercontent.com/ThornTechPublic/InteractiveModal/…. The table view has a built-in panGestureRecognizer that can be wired to the existing handleGesture(_:) method via target-action. In order to avoid conflict with normal table scrolling, the pull-down dismissal only kicks in when the table is scrolled to the top. I used a snapshot, and added lots of comments as well.Barbaresi
Robert, more great work. I did make my own implementation that uses the existing tableView panning methods like scrollViewDidScroll, scrollViewWillBeginDragging. It requires the tableView to have bounces and bouncesVertically both set to true - that way we can measure the ContentOffset of the tableview items. The advantage to this method is that it seems to allow the the tableview to be swiped off the screen in one gesture if there is enough velocity (due to the bouncing). I'll probably send you a pull request sometime this week, both options seem valid.Ironware
Good job @RossBarbish, I can't wait to see how you pulled that off. It'll be pretty sweet to be able to scroll up, and then enter interactive transition mode, all in one fluid motion.Barbaresi
set presentation property of segue to over current context to avoid black screen at the back when you pull down the viewControllerJehu
@RobertChen, thanks for great answer. Could you show how to solve the conflict in a scroll view (the solution for table view doesn't for scroll view in Swift 4)Thermograph
thank you so much for your solution. I converted to Objective-C and it is working fabulouslyPuebla
After slide down. It shows a black screen. even though i provide "overCurrentContext" as segue property. any help?Chromolithograph
Just add this in dismissAnimator Class before transitionContext.containerView.insertSubview @Jehu if transitionContext.transitionWasCancelled { transitionContext.containerView.insertSubview(toVC.view, belowSubview: fromVC.view) }Calvert
Is it only me that gets a very glitchy animation the first few moments when panning very slowly? The dismiss is stuttering in that case.Butyraldehyde
D
71

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!
        })
      }
    }
  }
}
Dvandva answered 10/12, 2016 at 15:20 Comment(7)
I copied your code and it worked. Bu background of model view when pull down is black, not transparent as yoursEclecticism
In the storyboard from Attributes Inspector panel of Storyboard segue of MainViewController to ModalViewController: set the Presentation property to Over Current ContextDvandva
This seems easier that the accepted answer, but I'm getting an error in the ViewControllerPannable class. The error is "cannot call value of non-function type UIPanGestureRecognizer". It's on the line "panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureAction(_:)))" Any ideas?Highpitched
Fixed the error I mentioned by changing it to UIPanGestureRecognizer. "panGestureRecognizer = panGestureRecognizer(target..." changed to: "panGestureRecognizer = UIPanGestureRecognizer(target..."Highpitched
Since i am presenting the VC not modally presenting it, how can i remove the black background while dismissing?Urial
It works without segue also. For those who want to use it without segue simply present it and add this property : vc.modalPresentationStyle = .overCurrentContext vc is the object created instantiating bla blaaa...Nasturtium
@NguyễnAnhViệt set your viewController's presentationStyle to overFullScreen.Depravity
L
26

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 UIKit

class 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;
    }
}
Laing answered 16/11, 2017 at 21:45 Comment(8)
In your code there is either typo or a missing variable "minimumHeightRatioToHide"Histo
Thanks @shokaveli, fixed (was minimumScreenRatioToHide)Laing
This is a very nice solution. However, I have a small issue and not quite sure what the cause is: dropbox.com/s/57abkl9vh2goif8/pannable.gif?dl=0 The red background is part of the modal VC, the blue background is part of the VC that presented the modal. There is this glitchy behavior when the pan gesture recognizer is starting that I can't seem to fix.Schoening
Hi @Knolraap. Maybe look at the value of self.view.frame.origin before you call sliceViewVerticallyTo the first time: it seems the offset we see is the same as the status bar height, so maybe your initial origin isn't 0?Laing
Wow, this is awesome :) Just a question, im presenting my view controller embedded inside a UINavgiationController, is it possible to somehow make the navigation controller be dismissable? Currently, the navigation bar stays at the top, and there is a black screen behind the VC being pulled down. Any pointers would be appreciated. Thanks :)Jackknife
Just a quick update, after trying many different things out, i finally found out that this code doesn't act nicely with the top safe area. If anything is attached to the top safe area by say using constraints, it will resize and move erratically when being pulled down... I think what can be done is to make a custom uinavigationbar and provide it a default frame extending beneath the safe area, but im unsure if thats the best approach...Jackknife
It's better to use slideViewVerticallyTo as a nested function in onPan.Thermit
Indeed @nik-kov. Updated.Laing
D
22

I figured out super simple way to do this. Just put the following code into your view controller:

Swift 4

override func viewDidLoad() {
    super.viewDidLoad()
    let gestureRecognizer = UIPanGestureRecognizer(target: self,
                                                   action: #selector(panGestureRecognizerHandler(_:)))
    view.addGestureRecognizer(gestureRecognizer)
}

@IBAction func panGestureRecognizerHandler(_ sender: UIPanGestureRecognizer) {
    let touchPoint = sender.location(in: view?.window)
    var initialTouchPoint = CGPoint.zero

    switch sender.state {
    case .began:
        initialTouchPoint = touchPoint
    case .changed:
        if touchPoint.y > initialTouchPoint.y {
            view.frame.origin.y = touchPoint.y - initialTouchPoint.y
        }
    case .ended, .cancelled:
        if touchPoint.y - initialTouchPoint.y > 200 {
            dismiss(animated: true, completion: nil)
        } else {
            UIView.animate(withDuration: 0.2, animations: {
                self.view.frame = CGRect(x: 0,
                                         y: 0,
                                         width: self.view.frame.size.width,
                                         height: self.view.frame.size.height)
            })
        }
    case .failed, .possible:
        break
    }
}
Dolmen answered 27/5, 2018 at 16:54 Comment(5)
Thanks, works perfectly! Just drop a Pan Gesture Recogniser to the view in interface builder and connect with the above @IBAction.Theseus
Works in Swift 5 too. Just follow the instructions @Theseus gave.Gene
I think this is the best way.Patricapatrice
@Alex Shubin ,How to dismiss when drag from ViewController to TabbarController ?Moxley
I've declared "var initialTouchPoint = CGPoint.zero" outside of the method and (for my needs) it performs better. Before my change, if you start the pan swipe from the middle of the screen, the animation will jump/start from that point and from my perspective it did not look good, after my change it will efficiently use the drag diff to animate the view controller to the bottom.Jarrettjarrid
E
18

Swift 4.x, Using Pangesture

Simple way

Horizontal

class ViewConrtoller: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(onDrage(_:))))
    }

    @objc func onDrage(_ sender:UIPanGestureRecognizer) {
        let percentThreshold:CGFloat = 0.3
        let translation = sender.translation(in: view)
    
        let newX = ensureRange(value: view.frame.minX + translation.x, minimum: 0, maximum: view.frame.maxX)
        let progress = progressAlongAxis(newX, view.bounds.width)
    
        view.frame.origin.x = newX //Move view to new position
    
        if sender.state == .ended {
            let velocity = sender.velocity(in: view)
           if velocity.x >= 300 || progress > percentThreshold {
               self.dismiss(animated: true) //Perform dismiss
           } else {
               UIView.animate(withDuration: 0.2, animations: {
                   self.view.frame.origin.x = 0 // Revert animation
               })
          }
       }
    
       sender.setTranslation(.zero, in: view)
    }
}

Helper function

func progressAlongAxis(_ pointOnAxis: CGFloat, _ axisLength: CGFloat) -> CGFloat {
        let movementOnAxis = pointOnAxis / axisLength
        let positiveMovementOnAxis = fmaxf(Float(movementOnAxis), 0.0)
        let positiveMovementOnAxisPercent = fminf(positiveMovementOnAxis, 1.0)
        return CGFloat(positiveMovementOnAxisPercent)
    }
    
    func ensureRange<T>(value: T, minimum: T, maximum: T) -> T where T : Comparable {
        return min(max(value, minimum), maximum)
    }

#Hard way

Refer this -> https://github.com/satishVekariya/DraggableViewController

Endure answered 7/2, 2018 at 10:10 Comment(12)
I tried to use your code. but I want to do a slight change were my subview is at bottom and when the user drags the view the height of the subview should also increase with respect to tapped position. Note:- the gesture event it been put on subviewBischoff
Sure, you can do itEndure
Hi @Endure Any idea on how to modify this code to use for drag on the left side of the x axis, ie.e, negative movements along the x axis?Kirkwall
@NikhilPandey Just change view.frame.minY -> view.frame.minX translation.y -> translation.x view.frame.maxY -> view.frame.maxX view.bounds.height -> view.bounds.width view.frame.origin.y -> view.frame.origin.x velocity.y -> velocity.x self.view.frame.origin.y -> self.view.frame.origin.xEndure
@NikhilPandey I updated my answer plz check and let me know if any kind of problemEndure
@Endure I tried yesterday that and even today I saw your replies but problem is that they are shifting it from left to right (as positive x movement is in that direction), while I was trying something like right to left (as in Google inbox or Microsoft outlook app) and thats in negative direction in which the logic and key values will change.Kirkwall
@Endure You also need to change the answer's heading as vertical is showing x axis movements and horizontal is showing y axis movements.Kirkwall
@Endure Can you convert this to Object-C?Tristan
@Tristan sorry man i don't know obj-C But you can use swift code with objcEndure
Remeber to set modalPresentationStyle = UIModalPresentationOverFullScreen to avoid back screen behind the view.Varia
Thanks!! the simple way works great! Though your sample code is for dismissing horizontally, not vertically.Esplanade
@Esplanade nice oneEndure
C
17

created a demo for interactively dragging down to dismiss view controller like snapchat's discover mode. Check this github for sample project.

enter image description here

Cho answered 26/4, 2016 at 11:26 Comment(1)
Great, but it's really outdated. Does anyone know another sample project like this?Togoland
L
12

Massively updates the repo for Swift 4.

For Swift 3, I have created the following to present a UIViewController from right to left and dismiss it by pan gesture. I have uploaded this as a GitHub repository.

enter image description here

DismissOnPanGesture.swift file:

//  Created by David Seek on 11/21/16.
//  Copyright © 2016 David Seek. All rights reserved.

import UIKit

class DismissAnimator : NSObject {
}

extension DismissAnimator : UIViewControllerAnimatedTransitioning {
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.6
    }
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        
        let screenBounds = UIScreen.main.bounds
        let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)
        let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)
        var x:CGFloat      = toVC!.view.bounds.origin.x - screenBounds.width
        let y:CGFloat      = toVC!.view.bounds.origin.y
        let width:CGFloat  = toVC!.view.bounds.width
        let height:CGFloat = toVC!.view.bounds.height
        var frame:CGRect   = CGRect(x: x, y: y, width: width, height: height)

        toVC?.view.alpha = 0.2
        
        toVC?.view.frame = frame
        let containerView = transitionContext.containerView
        
        containerView.insertSubview(toVC!.view, belowSubview: fromVC!.view)

        
        let bottomLeftCorner = CGPoint(x: screenBounds.width, y: 0)
        let finalFrame = CGRect(origin: bottomLeftCorner, size: screenBounds.size)
        
        UIView.animate(
            withDuration: transitionDuration(using: transitionContext),
            animations: {
                fromVC!.view.frame = finalFrame
                toVC?.view.alpha = 1
                
                x = toVC!.view.bounds.origin.x
                frame = CGRect(x: x, y: y, width: width, height: height)

                toVC?.view.frame = frame
            },
            completion: { _ in
                transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
            }
        )
    }
}

class Interactor: UIPercentDrivenInteractiveTransition {
    var hasStarted = false
    var shouldFinish = false
}

let transition: CATransition = CATransition()

func presentVCRightToLeft(_ fromVC: UIViewController, _ toVC: UIViewController) {
    transition.duration = 0.5
    transition.type = kCATransitionPush
    transition.subtype = kCATransitionFromRight
    fromVC.view.window!.layer.add(transition, forKey: kCATransition)
    fromVC.present(toVC, animated: false, completion: nil)
}

func dismissVCLeftToRight(_ vc: UIViewController) {
    transition.duration = 0.5
    transition.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
    transition.type = kCATransitionPush
    transition.subtype = kCATransitionFromLeft
    vc.view.window!.layer.add(transition, forKey: nil)
    vc.dismiss(animated: false, completion: nil)
}

func instantiatePanGestureRecognizer(_ vc: UIViewController, _ selector: Selector) {
    var edgeRecognizer: UIScreenEdgePanGestureRecognizer!
    edgeRecognizer = UIScreenEdgePanGestureRecognizer(target: vc, action: selector)
    edgeRecognizer.edges = .left
    vc.view.addGestureRecognizer(edgeRecognizer)
}

func dismissVCOnPanGesture(_ vc: UIViewController, _ sender: UIScreenEdgePanGestureRecognizer, _ interactor: Interactor) {
    let percentThreshold:CGFloat = 0.3
    let translation = sender.translation(in: vc.view)
    let fingerMovement = translation.x / vc.view.bounds.width
    let rightMovement = fmaxf(Float(fingerMovement), 0.0)
    let rightMovementPercent = fminf(rightMovement, 1.0)
    let progress = CGFloat(rightMovementPercent)
    
    switch sender.state {
    case .began:
        interactor.hasStarted = true
        vc.dismiss(animated: true, completion: nil)
    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
    }
}

Easy usage:

import UIKit

class VC1: UIViewController, UIViewControllerTransitioningDelegate {
    
    let interactor = Interactor()
    
    @IBAction func present(_ sender: Any) {
        let vc = self.storyboard?.instantiateViewController(withIdentifier: "VC2") as! VC2
        vc.transitioningDelegate = self
        vc.interactor = interactor
        
        presentVCRightToLeft(self, vc)
    }
    
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return DismissAnimator()
    }
    
    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return interactor.hasStarted ? interactor : nil
    }
}

class VC2: UIViewController {
    
    var interactor:Interactor? = nil
    
    override func viewDidLoad() {
        super.viewDidLoad()
        instantiatePanGestureRecognizer(self, #selector(gesture))
    }
    
    @IBAction func dismiss(_ sender: Any) {
        dismissVCLeftToRight(self)
    }
    
    @objc func gesture(_ sender: UIScreenEdgePanGestureRecognizer) {
        dismissVCOnPanGesture(self, sender, interactor!)
    }
}
Lapointe answered 21/11, 2016 at 16:34 Comment(1)
Hi, How can i present using Pan Gesture Recognizer please help me thanksProfessorship
F
7

Only vertical dismiss

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:  view.frame.origin.x,
            y:  view.frame.origin.y + translation.y
        )
        panGesture.setTranslation(CGPoint.zero, in: self.view)
    } else if panGesture.state == .ended {
        let velocity = panGesture.velocity(in: view)
        if velocity.y >= 150 {
            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!
            })
        }
    }
Floranceflore answered 21/12, 2016 at 10:49 Comment(0)
B
6

What you're describing is an interactive custom transition animation. You are customizing both the animation and the driving gesture of a transition, i.e. the dismissal (or not) of a presented view controller. The easiest way to implement it is by combining a UIPanGestureRecognizer with a UIPercentDrivenInteractiveTransition.

My book explains how to do this, and I have posted examples (from the book). This particular example is a different situation - the transition is sideways, not down, and it is for a tab bar controller, not a presented controller - but the basic idea is exactly the same:

https://github.com/mattneub/Programming-iOS-Book-Examples/blob/master/bk2ch06p300customAnimation3/ch19p620customAnimation1/Animator.swift

If you download that project and run it, you will see that what is happening is exactly what you are describing, except that it is sideways: if the drag is more than half, we transition, but if not, we cancel and snap back into place.

Borchardt answered 26/3, 2015 at 23:44 Comment(1)
404 Page not found.Sinasinai
A
6

I've created an easy to use extension.

Just inherent Your UIViewController with InteractiveViewController and you are done InteractiveViewController

call method showInteractive() from your controller to show as Interactive.

enter image description here

Ario answered 21/11, 2016 at 5:55 Comment(0)
S
4

In Objective C : Here's the code

inviewDidLoad

UISwipeGestureRecognizer *swipeRecognizer = [[UISwipeGestureRecognizer alloc]
                                             initWithTarget:self action:@selector(swipeDown:)];
swipeRecognizer.direction = UISwipeGestureRecognizerDirectionDown;
[self.view addGestureRecognizer:swipeRecognizer];

//Swipe Down Method

- (void)swipeDown:(UIGestureRecognizer *)sender{
[self dismissViewControllerAnimated:YES completion:nil];
}
Steinway answered 4/11, 2016 at 12:29 Comment(1)
how to control the time for swipe down before it is dismissed?Hobnailed
T
4

For those who really wanna dive a little deeper into Custom UIViewController Transition, I recommend this great tutorial from raywenderlich.com.

The original final sample project contains bug. So I fixed it and upload it to Github repo. The proj is in Swift 5, so you can easily run and play it.

Here is a preview:

And it's interactive too!

Happy hacking!

Tractate answered 31/3, 2020 at 3:0 Comment(0)
B
3

This my simple class for Drag ViewController from axis. Just herited your class from DraggableViewController.

MyCustomClass: DraggableViewController

Work only for presented ViewController.

// MARK: - DraggableViewController

public class DraggableViewController: UIViewController {

    public let percentThresholdDismiss: CGFloat = 0.3
    public var velocityDismiss: CGFloat = 300
    public var axis: NSLayoutConstraint.Axis = .horizontal
    public var backgroundDismissColor: UIColor = .black {
        didSet {
            navigationController?.view.backgroundColor = backgroundDismissColor
        }
    }

    // MARK: LifeCycle

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(onDrag(_:))))
    }

    // MARK: Private methods

    @objc fileprivate func onDrag(_ sender: UIPanGestureRecognizer) {

        let translation = sender.translation(in: view)

        // Movement indication index
        let movementOnAxis: CGFloat

        // Move view to new position
        switch axis {
        case .vertical:
            let newY = min(max(view.frame.minY + translation.y, 0), view.frame.maxY)
            movementOnAxis = newY / view.bounds.height
            view.frame.origin.y = newY

        case .horizontal:
            let newX = min(max(view.frame.minX + translation.x, 0), view.frame.maxX)
            movementOnAxis = newX / view.bounds.width
            view.frame.origin.x = newX
        }

        let positiveMovementOnAxis = fmaxf(Float(movementOnAxis), 0.0)
        let positiveMovementOnAxisPercent = fminf(positiveMovementOnAxis, 1.0)
        let progress = CGFloat(positiveMovementOnAxisPercent)
        navigationController?.view.backgroundColor = UIColor.black.withAlphaComponent(1 - progress)

        switch sender.state {
        case .ended where sender.velocity(in: view).y >= velocityDismiss || progress > percentThresholdDismiss:
            // After animate, user made the conditions to leave
            UIView.animate(withDuration: 0.2, animations: {
                switch self.axis {
                case .vertical:
                    self.view.frame.origin.y = self.view.bounds.height

                case .horizontal:
                    self.view.frame.origin.x = self.view.bounds.width
                }
                self.navigationController?.view.backgroundColor = UIColor.black.withAlphaComponent(0)

            }, completion: { finish in
                self.dismiss(animated: true) //Perform dismiss
            })
        case .ended:
            // Revert animation
            UIView.animate(withDuration: 0.2, animations: {
                switch self.axis {
                case .vertical:
                    self.view.frame.origin.y = 0

                case .horizontal:
                    self.view.frame.origin.x = 0
                }
            })
        default:
            break
        }
        sender.setTranslation(.zero, in: view)
    }
}
Blackett answered 3/4, 2019 at 17:50 Comment(0)
N
2

Here is an extension I made based on @Wilson answer :

// MARK: IMPORT STATEMENTS
import UIKit

// MARK: EXTENSION
extension UIViewController {

    // MARK: IS SWIPABLE - FUNCTION
    func isSwipable() {
        let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
        self.view.addGestureRecognizer(panGestureRecognizer)
    }

    // MARK: HANDLE PAN GESTURE - FUNCTION
    @objc func handlePanGesture(_ panGesture: UIPanGestureRecognizer) {
        let translation = panGesture.translation(in: view)
        let minX = view.frame.width * 0.135
        var originalPosition = CGPoint.zero

        if panGesture.state == .began {
            originalPosition = view.center
        } else if panGesture.state == .changed {
            view.frame.origin = CGPoint(x: translation.x, y: 0.0)

            if panGesture.location(in: view).x > minX {
                view.frame.origin = originalPosition
            }

            if view.frame.origin.x <= 0.0 {
                view.frame.origin.x = 0.0
            }
        } else if panGesture.state == .ended {
            if view.frame.origin.x >= view.frame.width * 0.5 {
                UIView.animate(withDuration: 0.2
                     , animations: {
                        self.view.frame.origin = CGPoint(
                            x: self.view.frame.size.width,
                            y: self.view.frame.origin.y
                        )
                }, completion: { (isCompleted) in
                    if isCompleted {
                        self.dismiss(animated: false, completion: nil)
                    }
                })
            } else {
                UIView.animate(withDuration: 0.2, animations: {
                    self.view.frame.origin = originalPosition
                })
            }
        }
    }

}

USAGE

Inside your view controller you want to be swipable :

override func viewDidLoad() {
    super.viewDidLoad()

    self.isSwipable()
}

and it will be dismissible by swiping from the extreme left side of the view controller, as a navigation controller.

Nne answered 8/11, 2017 at 20:25 Comment(1)
Hey, I have used you code and it works perfectly for right swipe but I want to dismiss on Swipe down , How can I do this? Please help!Saltpeter
E
2

For Swift 4 + Swift 5, using UIPanGestureRecognizer. Based on @SPatel 's answer above.

Add these two helper functions:

func progressAlongAxis(_ pointOnAxis: CGFloat, _ axisLength: CGFloat) -> CGFloat {
    let movementOnAxis = pointOnAxis / axisLength
    let positiveMovementOnAxis = fmaxf(Float(movementOnAxis), 0.0)
    let positiveMovementOnAxisPercent = fminf(positiveMovementOnAxis, 1.0)
    return CGFloat(positiveMovementOnAxisPercent)
}

func ensureRange<T>(value: T, minimum: T, maximum: T) -> T where T: Comparable {
    return min(max(value, minimum), maximum)
}

To dismiss by dragging down:


class SwipeDownViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        // dismiss dragging vertically:
        view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(onDragY(_:))))
    }

    @objc func onDragY(_ sender: UIPanGestureRecognizer) {
        let percentThreshold: CGFloat = 0.3
        let translation = sender.translation(in: view)

        let newY = ensureRange(value: view.frame.minY + translation.y, minimum: 0, maximum: view.frame.maxY)
        let progress = progressAlongAxis(newY, view.bounds.height)

        view.frame.origin.y = newY // Move view to new position

        if sender.state == .ended {
            let velocity = sender.velocity(in: view)
            if velocity.y >= 300 || progress > percentThreshold {
                dismiss(animated: true) // Perform dismiss
            } else {
                UIView.animate(withDuration: 0.2, animations: {
                    self.view.frame.origin.y = 0 // Revert animation
                })
            }
        }

        sender.setTranslation(.zero, in: view)
    }
}

To dismiss by dragging right:


class SwipeRightViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        // dismiss dragging horizontally:
        view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(onDragX(_:))))
    }

    @objc func onDragX(_ sender: UIPanGestureRecognizer) {
        let percentThreshold: CGFloat = 0.3
        let translation = sender.translation(in: view)

        let newX = ensureRange(value: view.frame.minX + translation.x, minimum: 0, maximum: view.frame.maxX)
        let progress = progressAlongAxis(newX, view.bounds.width)

        view.frame.origin.x = newX // Move view to new position

        if sender.state == .ended {
            let velocity = sender.velocity(in: view)
            if velocity.x >= 300 || progress > percentThreshold {
                dismiss(animated: true) // Perform dismiss
            } else {
                UIView.animate(withDuration: 0.2, animations: {
                    self.view.frame.origin.x = 0 // Revert animation
                })
            }
        }

        sender.setTranslation(.zero, in: view)
    }
}


Esplanade answered 7/1, 2022 at 11:12 Comment(0)
D
0

You can use a UIPanGestureRecognizer to detect the user's drag and move the modal view with it. If the ending position is far enough down, the view can be dismissed, or otherwise animated back to its original position.

Check out this answer for more information on how to implement something like this.

Diazo answered 26/3, 2015 at 23:2 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.