25

By default, if you drag right from the left edge of the screen, it will drag away the ViewController and take it off the stack.

I want to extend this functionality to the entire screen. When the user drags right anywhere, I'd like the same to happen.

I know that I can implement a swipe right gesture and simply call self.navigationController?.popViewControllerAnimated(true)

However, there is no "dragging" motion. I want the user to be able to right-drag the view controller as if it's an object, revealing what's underneath. And, if it's dragged past 50%, dismiss it. (Check out instagram to see what I mean.)

TIMEX
  • 259,804
  • 351
  • 777
  • 1,080
  • I think this would be helpful for you https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIScreenEdgePanGestureRecognizer_class/index.html#//apple_ref/occ/cl/UIScreenEdgePanGestureRecognizer – Coldsteel48 Feb 14 '16 at 07:05
  • Does Instagram dismiss it past 33%? I just tried, but it seems to me at 50% to dismiss the view. – Blaszard Feb 18 '16 at 20:05
  • @Blaszard just updated . – TIMEX Feb 19 '16 at 07:01
  • If there was no bounty, I would close this question as too broad. There are multiple tutorials (Apple, blogs) and multiple github libraries. – Sulthan Feb 19 '16 at 17:12
  • These libraries do exactly what you are asking for: [fastred/SloppySwiper](https://github.com/fastred/SloppySwiper) and [jaredsinclair/JTSSloppySwiping](https://github.com/jaredsinclair/JTSSloppySwiping) – ElectroBuddha Jul 08 '17 at 10:19
  • Added a much simpler solution see my answer below. – Kugutsumen Sep 03 '19 at 04:35

8 Answers8

36

enter image description here

Made a demo project in Github
https://github.com/rishi420/SwipeRightToPopController

I've used UIViewControllerAnimatedTransitioning protocol

From the doc:

// This is used for percent driven interactive transitions, as well as for container controllers ...

Added a UIPanGestureRecognizer to the controller's view. This is the action of the gesture:

func handlePanGesture(panGesture: UIPanGestureRecognizer) {

    let percent = max(panGesture.translationInView(view).x, 0) / view.frame.width

    switch panGesture.state {

    case .Began:
        navigationController?.delegate = self
        navigationController?.popViewControllerAnimated(true)

    case .Changed:
        percentDrivenInteractiveTransition.updateInteractiveTransition(percent)

    case .Ended:
        let velocity = panGesture.velocityInView(view).x

        // Continue if drag more than 50% of screen width or velocity is higher than 1000
        if percent > 0.5 || velocity > 1000 {
            percentDrivenInteractiveTransition.finishInteractiveTransition()
        } else {
            percentDrivenInteractiveTransition.cancelInteractiveTransition()
        }

    case .Cancelled, .Failed:
        percentDrivenInteractiveTransition.cancelInteractiveTransition()

    default:
        break
    }
}

Steps:

  1. Calculate the percentage of drag on the view
  2. .Begin: Specify which segue to perform and assign UINavigationController delegate. delegate will be needed for InteractiveTransitioning
  3. .Changed: UpdateInteractiveTransition with percentage
  4. .Ended: Continue remaining transitioning if drag 50% or more or higher velocity else cancel
  5. .Cancelled, .Failed: cancel transitioning


References:

  1. UIPercentDrivenInteractiveTransition
  2. https://github.com/visnup/swipe-left
  3. https://github.com/robertmryan/ScreenEdgeGestureNavigationController
  4. https://github.com/groomsy/custom-navigation-animation-transition-demo
Warif Akhand Rishi
  • 23,920
  • 8
  • 80
  • 107
  • Is there any way to do this without having to recreate the default pop transition, that you know of? – Tim Vermeulen Feb 23 '16 at 11:06
  • The step 2 `case .Begin:` where you specify the `segue`. it could be any segue of your choice. – Warif Akhand Rishi Feb 23 '16 at 11:47
  • That's not what I meant. In your sample project, you make a `SlideAnimatedTransitioning` class that mimics the default transition animation, right? I was wondering if I can simply use the default animation, rather than having to mimic it... – Tim Vermeulen Feb 23 '16 at 11:53
  • How can i use this example to SwipeLeftToPushController ? – Mridul Gupta Apr 24 '17 at 10:23
  • This is overkill... you can just copy the action over to a pan gesture recogniser. See my answer below. – Kugutsumen Sep 03 '19 at 04:35
  • Hello @WarifAkhandRishi, is there a way i can implement this to dismiss a UIViewController that was presented rather than pushed? – Israel Meshileya Nov 14 '19 at 08:01
  • @IsraelMeshileya by default iOS 13 does this on presented VC. You can check out [PanSlip](https://github.com/k-lpmg/PanSlip) or similar open source repo. – Warif Akhand Rishi Nov 23 '19 at 02:35
13

Create a pan gesture recogniser and move the interactive pop gesture recogniser's targets across.

Add your recogniser to the pushed view controller's viewDidLoad and voila!

Edit: Updated the code with more detailed solution.

import os
import UIKit

public extension UINavigationController {
  func fixInteractivePopGestureRecognizer(delegate: UIGestureRecognizerDelegate) {
    guard
      let popGestureRecognizer = interactivePopGestureRecognizer,
      let targets = popGestureRecognizer.value(forKey: "targets") as? NSMutableArray,
      let gestureRecognizers = view.gestureRecognizers,
      // swiftlint:disable empty_count
      targets.count > 0
    else { return }

    if viewControllers.count == 1 {
      for recognizer in gestureRecognizers where recognizer is PanDirectionGestureRecognizer {
        view.removeGestureRecognizer(recognizer)
        popGestureRecognizer.isEnabled = false
        recognizer.delegate = nil
      }
    } else {
      if gestureRecognizers.count == 1 {
        let gestureRecognizer = PanDirectionGestureRecognizer(axis: .horizontal, direction: .right)
        gestureRecognizer.cancelsTouchesInView = false
        gestureRecognizer.setValue(targets, forKey: "targets")
        gestureRecognizer.require(toFail: popGestureRecognizer)
        gestureRecognizer.delegate = delegate
        popGestureRecognizer.isEnabled = true

        view.addGestureRecognizer(gestureRecognizer)
      }
    }
  }
}

public enum PanAxis {
  case vertical
  case horizontal
}

public enum PanDirection {
  case left
  case right
  case up
  case down
  case normal
}

public class PanDirectionGestureRecognizer: UIPanGestureRecognizer {
  let axis: PanAxis
  let direction: PanDirection

  public init(axis: PanAxis, direction: PanDirection = .normal, target: AnyObject? = nil, action: Selector? = nil) {
    self.axis = axis
    self.direction = direction
    super.init(target: target, action: action)
  }

  override public func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
    super.touchesMoved(touches, with: event)

    if state == .began {
      let vel = velocity(in: view)
      switch axis {
      case .horizontal where abs(vel.y) > abs(vel.x):
        state = .cancelled
      case .vertical where abs(vel.x) > abs(vel.y):
        state = .cancelled
      default:
        break
      }

      let isIncrement = axis == .horizontal ? vel.x > 0 : vel.y > 0

      switch direction {
      case .left where isIncrement:
        state = .cancelled
      case .right where !isIncrement:
        state = .cancelled
      case .up where isIncrement:
        state = .cancelled
      case .down where !isIncrement:
        state = .cancelled
      default:
        break
      }
    }
  }
}

In your collection view for example:

  open override func didMove(toParent parent: UIViewController?) {
    navigationController?.fixInteractivePopGestureRecognizer(delegate: self)
  }

// MARK: - UIGestureRecognizerDelegate
extension BaseCollection: UIGestureRecognizerDelegate {
  public func gestureRecognizer(
    _ gestureRecognizer: UIGestureRecognizer,
    shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer
  ) -> Bool {
    otherGestureRecognizer is PanDirectionGestureRecognizer
  }
}
Kugutsumen
  • 878
  • 8
  • 18
2

Swift 4 version of the accepted answer by @Warif Akhand Rishi

Even though this answer does work there are 2 quirks that I found out about it.

  1. if you swipe left it also dismisses just as if you were swiping right.
  2. it's also very delicate because if even a slight swipe is directed in either direction it will dismiss the vc.

Other then that it definitely works and you can swipe either right or left to dismiss.

class ViewController: UIGestureRecognizerDelegate, UINavigationControllerDelegate {

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationController?.interactivePopGestureRecognizer?.delegate = self
        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
        view.addGestureRecognizer(panGesture)
    }

    @objc func handlePanGesture(_ gesture: UIPanGestureRecognizer){

        let interactiveTransition = UIPercentDrivenInteractiveTransition()

        let percent = max(gesture.translation(in: view).x, 0) / view.frame.width

        switch gesture.state {

        case .began:
            navigationController?.delegate = self

            // *** use this if the vc is PUSHED on the stack **
            navigationController?.popViewController(animated: true)

            // *** use this if the vc is PRESENTED **
            //navigationController?.dismiss(animated: true, completion: nil)

        case .changed:
            interactiveTransition.update(percent)

        case .ended:
            let velocity = gesture.velocity(in: view).x

            // Continue if drag more than 50% of screen width or velocity is higher than 1000
            if percent > 0.5 || velocity > 1000 {
                interactiveTransition.finish()
            } else {
                interactiveTransition.cancel()
            }

        case .cancelled, .failed:
            interactiveTransition.cancel()

        default:break
        }
    }
}
Lance Samaria
  • 17,576
  • 18
  • 108
  • 256
  • You solution is poping viewcontroller very fast – Dhruv Narayan Singh Aug 29 '18 at 10:14
  • Hello @LanceSamaria is there a way i can implement this to dismiss a UIViewController that was presented rather than pushed? – Israel Meshileya Nov 14 '19 at 08:03
  • @IsraelMeshileya I haven’t tried it but maybe change the line that says **navigationController?.popViewController(animated(: true)** to **dismiss...** – Lance Samaria Nov 14 '19 at 08:18
  • oh, not at all. I am still trying out some other tweaks but will let you know once I am through. – Israel Meshileya Nov 15 '19 at 07:38
  • @IsraelMeshileya you must be doing something wrong. I just tried the code myself and it works fine. I present the vc like this let myVC = MyVC(); let navVC = UINav...(root...: myVC); present(navVC....). I add the above code from my answer in MyVC and when I swipe left or right it dismisses. In **case: .began** you have to swap this line: **navigationController?.popViewController(animated(: true)** to use this line instead: **navigationController?.dismiss(animated: true, completion: nil)**. Look at the code, I added it in there for you. I updated the **case: .began** with it – Lance Samaria Nov 15 '19 at 10:20
  • 1
    @IsraelMeshileya I added the project to GitHub. Just download it, run it, tap the button and after myVC is presented, swipe to dismiss and it works fine. There is a cancelButton in the upper left hand corner, you do not need to tap it to cancel. Here: https://github.com/lsamaria/SwipeToDismiss .Please let me know if it works for you – Lance Samaria Nov 15 '19 at 10:47
1

The cleanest way is to subclass your navigation controller and add a directional pan gesture recognizer to its view that borrows its target/action properties from the default interaction pan gesture recognizer.

First, create a directional pan gesture recognizer that simply puts itself into a failed state if the initial gesture is not in the desired direction.

class DirectionalPanGestureRecognizer: UIPanGestureRecognizer {
    enum Direction {
        case up
        case down
        case left
        case right
    }
    
    private var firstTouch: CGPoint?
    var direction: Direction
    
    init(direction: Direction, target: Any? = nil, action: Selector? = nil) {
        self.direction = direction
        super.init(target: target, action: action)
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        firstTouch = touches.first?.location(in: view)
        super.touchesBegan(touches, with: event)
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
        switch state {
        case .possible:
            if let firstTouch = firstTouch,
               let thisTouch = touches.first?.location(in: view) {
                let deltaX = thisTouch.x - firstTouch.x
                let deltaY = thisTouch.y - firstTouch.y
                
                switch direction {
                case .up:
                    if abs(deltaY) > abs(deltaX),
                       deltaY < 0 {
                        break
                    } else {
                        state = .failed
                    }
                    
                case .down:
                    if abs(deltaY) > abs(deltaX),
                       deltaY > 0 {
                        break
                    } else {
                        state = .failed
                    }
                    
                case .left:
                    if abs(deltaX) > abs(deltaY),
                       deltaX < 0 {
                        break
                    } else {
                        state = .failed
                    }
                    
                case .right:
                    if abs(deltaX) > abs(deltaY),
                       deltaX > 0 {
                        break
                    } else {
                        state = .failed
                    }
                }
            }
        default:
            break
        }
        super.touchesMoved(touches, with: event)
    }
    
    override func reset() {
        firstTouch = nil
        super.reset()
    }
}

Then subclass UINavigationController and perform all of the logic in there.

class CustomNavigationController: UINavigationController {
    let popGestureRecognizer = DirectionalPanGestureRecognizer(direction: .right)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        replaceInteractivePopGestureRecognizer()
    }
    
    private func replaceInteractivePopGestureRecognizer() {
        guard let targets = interactivePopGestureRecognizer?.value(forKey: "targets") else {
            return
        }
        popGestureRecognizer.setValue(targets, forKey: "targets")
        popGestureRecognizer.delegate = self
        view.addGestureRecognizer(popGestureRecognizer)
        interactivePopGestureRecognizer?.isEnabled = false // this is optional; it just disables the default recognizer
    }
}

And then conform to the delegate. We only need the first method, gestureRecognizerShouldBegin. The other two methods are optional.

Most apps that have this feature enabled won't work if the user is in a scroll view and it's still scrolling; the scroll view must come to a complete stop before the swipe-to-pop gesture is recognized. This is not how it works with the default recognizer so the last two methods of this delegate (1) allow simultaneous gesturing with scroll views but (2) force the pop recognizer to fail when competing with the scroll view.

// MARK: - Gesture recognizer delegate
extension CustomNavigationController: UIGestureRecognizerDelegate {
    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return viewControllers.count > 1
    }
    
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
                           shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        if otherGestureRecognizer.view is UIScrollView {
            return true
        }
        return false
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
                           shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        if otherGestureRecognizer.view is UIScrollView {
            return true
        }
        return false
    }
}
trndjc
  • 11,654
  • 3
  • 38
  • 51
0

You need to investigate the interactivePopGestureRecognizer property of your UINavigationController.

Here is a similar question with example code to hook this up.

UINavigationController interactivePopGestureRecognizer working abnormal in iOS7

Community
  • 1
  • 1
TomSwift
  • 39,369
  • 12
  • 121
  • 149
-2

Swipe Right to dismiss the View Controller

Swift 5 Version - (Also removed the gesture recognition when swiping from right - to - left)

Important - In ‘Attributes inspector’ of VC2, set the ‘Presentation’ value from ‘Full Screen’ to ‘Over Full Screen’. This will allow VC1 to be visible during dismissing VC2 via gesture — without it, there will be black screen behind VC2 instead of VC1.

class ViewController: UIGestureRecognizerDelegate, UINavigationControllerDelegate {
    
    var initialTouchPoint: CGPoint = CGPoint(x: 0, y: 0)
    
    override func viewDidLoad() {
        super.viewDidLoad()

        navigationController?.interactivePopGestureRecognizer?.delegate = self
        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
        view.addGestureRecognizer(panGesture)
    }

    @objc func handlePanGesture(_ sender: UIPanGestureRecognizer) {
        let touchPoint = sender.location(in: self.view?.window)
        let percent = max(sender.translation(in: view).x, 0) / view.frame.width
        let velocity = sender.velocity(in: view).x
        
        if sender.state == UIGestureRecognizer.State.began {
            initialTouchPoint = touchPoint
        } else if sender.state == UIGestureRecognizer.State.changed {
            if touchPoint.x - initialTouchPoint.x > 0 {
                self.view.frame = CGRect(x: touchPoint.x - initialTouchPoint.x, y: 0, width: self.view.frame.size.width, height: self.view.frame.size.height)
            }
        } else if sender.state == UIGestureRecognizer.State.ended || sender.state == UIGestureRecognizer.State.cancelled {
            
            if percent > 0.5 || velocity > 1000 {
                navigationController?.popViewController(animated: true)
            } else {
                UIView.animate(withDuration: 0.3, animations: {
                    self.view.frame = CGRect(x: 0, y: 0, width: self.view.frame.size.width, height: self.view.frame.size.height)
                })
            }
        }
    }
}
Nimantha
  • 6,405
  • 6
  • 28
  • 69
Fury2K
  • 1
-2

I think this is easier than the suggested solution and also works for all viewControllers inside that navigation and also for nested scrollviews.

https://stackoverflow.com/a/58779146/8517882

Just install the pod and then use EZNavigationController instead of UINavigationController to have this behavior on all view controllers inside that navigation controller.

Enricoza
  • 1,101
  • 6
  • 18
-2

Answers are too complicated. There is a simple solution. Add next line to your base navigation controller, or navigation controller that you want to have this ability:

  self.interactivePopGestureRecognizer?.delegate = nil
Nimantha
  • 6,405
  • 6
  • 28
  • 69