PIXEL
DOCK

I like the smell of Swift in the morning…

Enable the Swipe Back Gesture (aka Interactive Pop Gesture) when using a UINavigationController with custom back button

Posted: | Author: | Filed under: iOS, Swift | Tags: , | 12 Comments »

Who doesn’t love the “Swipe Back” gesture to navigate back to the previous view controller? I rarely use the back button in the left upper corner of the screen anymore. Especially when you are holding your phone in one hand it is much more easy to just swipe back.

The best thing is that as a developer you don’t have to do anything to enable this swipe back functionality as it already part of UINavigationController.

On initialization UINavigationController installs a UIScreenEdgePanGestureRecognizer on its view to handle the interactive pop gesture (aka “Swipe Back” gesture). You can access the gesture recognizer via the interactivePopGestureRecognizer property.

Now, when you decide to replace the default back button with your own custom back button you will see that the “Swipe Back” Gesture is not working anymore. Apparently the UIScreenEdgePanGestureRecognizer’s delegate only allows the gesture to be recognized when it sees that the default back button is being used. Bummer!

To make the “Swipe Back” work again you have to bypass the delegate that disables the gesture. If found some suggestions that would simply set the delegate to nil. This seems to work at first. But when you start playing around with your app after doing that you will eventually see that the app freezes and does not recognize ANY touches anymore. This happens when you swipe back while the navigation controller is pushing a view controller. Not good!

So you have to set the delegate yourself and implement gestureRecognizerShouldBegin(_:) to disable the gesture whenever the navigation controller is pushing a view controller.

The easiest way to do this is to subclass UINavigationController:

class InteractivePopNavigationController: UINavigationController {
    
    // 1
    var isPushingViewController = false
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // 3
        delegate = self
        // 5
        interactivePopGestureRecognizer?.delegate = self
    }
    
    // 2
    override func pushViewController(_ viewController: UIViewController, animated: Bool) {
        isPushingViewController = true
        super.pushViewController(viewController, animated: animated)
    }
}

// 6
extension InteractivePopNavigationController: UIGestureRecognizerDelegate {
    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        guard gestureRecognizer is UIScreenEdgePanGestureRecognizer else { return true }
        return viewControllers.count > 1 && !isPushingViewController
    }
}

// 4
extension InteractivePopNavigationController: UINavigationControllerDelegate {
    func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        isPushingViewController = false
    }
}

To block the “Swipe Back” gesture there is a boolean property isPushingViewController that is true when the navigation controller is pushing a view controller. [// 1]

Setting the property to true is easy. We simply override pushViewController(_:animated:) and set isPushingViewController to true [// 2]

To be able to set it back to false when the view controller has been pushed we have to know when the push is completed. Luckily there is a UINavigationControllerDelegate method that we can use. So we set our UINavigationController subclass’ delegate to self [// 3] and implement navigationController(_:didShowViewController:animated:) to set isPushingViewController back to false [// 4]

Now all that is left is to also set the interactivePopGestureRecognizer‘s delegate to self [// 5] and to only allow the gesture when isPushingViewController is false. And while we are at it we also make sure that the swipe back makes sense. In other words we check if the navigation controller has more than one view controller on his stack. Otherwise we ignore the gesture. [// 6]

Yay, “Swipe Back” is back!

But wait a minute. What happens when someone is using this UINavigationController subclass and needs to set the delegate themselves:

let navigationController = InteractivePopNavigationController()
navigationController.delegate = self

This won’t work, because we are overwriting the delegate in our subclass’ viewDidLoad method.

To make this work, we need to keep a reference to the delegate before overwriting it and forward all delegate method calls to it:

class InteractivePopNavigationController: UINavigationController {
    
    var isPushingViewController = false
    weak var externalDelegate: UINavigationControllerDelegate?
    
    // 1
    override var delegate: UINavigationControllerDelegate? {
        didSet {
            if !(delegate is InteractivePopNavigationController) {
                externalDelegate = delegate
                super.delegate = oldValue
            }
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        delegate = self
        interactivePopGestureRecognizer?.delegate = self
    }
    
    override func pushViewController(_ viewController: UIViewController, animated: Bool) {
        isPushingViewController = true
        super.pushViewController(viewController, animated: animated)
    }
}

extension InteractivePopNavigationController: UIGestureRecognizerDelegate {
    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        guard gestureRecognizer is UIScreenEdgePanGestureRecognizer else { return true }
        return viewControllers.count > 1 && !isPushingViewController
    }
}

// 2
extension InteractivePopNavigationController: UINavigationControllerDelegate {
    func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        isPushingViewController = false
        externalDelegate?.navigationController?(navigationController, didShow: viewController, animated: animated)
    }
    
    func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
        externalDelegate?.navigationController?(navigationController, willShow: viewController, animated: animated)
    }
    
    func navigationControllerSupportedInterfaceOrientations(_ navigationController: UINavigationController) -> UIInterfaceOrientationMask {
        return externalDelegate?.navigationControllerSupportedInterfaceOrientations?(navigationController) ?? visibleViewController?.supportedInterfaceOrientations ?? .all
    }
    
    func navigationControllerPreferredInterfaceOrientationForPresentation(_ navigationController: UINavigationController) -> UIInterfaceOrientation {
        return externalDelegate?.navigationControllerPreferredInterfaceOrientationForPresentation?(navigationController) ?? self.preferredInterfaceOrientationForPresentation
    }
    
    func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return externalDelegate?.navigationController?(navigationController, animationControllerFor: operation, from: fromVC, to:toVC)
    }
    
    func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return externalDelegate?.navigationController?(navigationController, interactionControllerFor: animationController)
    }
}

We added two things:

// 1: We use the didSet property observer on the delegate property. Whenever the delegate is being set we check if our subclass itself is the delegate (we don’t do anything so the subclass will be set as its own delegate). If the delegate is of any other class we do not set it as delegate but keep a reference to it.

// 2: We implement the UINavigationControllerDelegate method that we use to see when the pushing is completing. In this method we set isPushingViewController back to false. We also forward the call to our external delegate.

Unfortunately we have to forward ALL methods that are defined in UINavigationControllerDelegate to out external delegate to make the external delegate fully functional. This is not an ideal solution. When Apple decides to add methods to UINavigationControllerDelegate in the future they will have to added here. Or they won’t be called when setting the delegate manually. In Objective-C you could use the NSInvocation API to handle this, but that API is not available in Swift. I have not found a way to forward all delegate methods to the external delegate in Swift. I you know a way to do this, please leave a comment!

And so, with a little bit of work, we can use the Interactive Pop Gesture (aka “Swipe Back”) even when we are not using the system default back button.