No Swipe Back when hiding Navigation Bar in UINavigationController

47,360

Solution 1

A hack that is working is to set the interactivePopGestureRecognizer's delegate of the UINavigationController to nil like this:

[self.navigationController.interactivePopGestureRecognizer setDelegate:nil];

But in some situations it could create strange effects.

Solution 2

Problems with Other Methods

Setting the interactivePopGestureRecognizer.delegate = nil has unintended side-effects.

Setting navigationController?.navigationBar.hidden = true does work, but does not allow your change in navigation bar to be hidden.

Lastly, it's generally better practice to create a model object that is the UIGestureRecognizerDelegate for your navigation controller. Setting it to a controller in the UINavigationController stack is what is causing the EXC_BAD_ACCESS errors.

Full Solution

First, add this class to your project:

class InteractivePopRecognizer: NSObject, UIGestureRecognizerDelegate {

    var navigationController: UINavigationController

    init(controller: UINavigationController) {
        self.navigationController = controller
    }

    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return navigationController.viewControllers.count > 1
    }

    // This is necessary because without it, subviews of your top controller can
    // cancel out your gesture recognizer on the edge.
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
}

Then, set your navigation controller's interactivePopGestureRecognizer.delegate to an instance of your new InteractivePopRecognizer class.

var popRecognizer: InteractivePopRecognizer?

override func viewDidLoad() {
    super.viewDidLoad()
    setInteractiveRecognizer()
}

private func setInteractiveRecognizer() {
    guard let controller = navigationController else { return }
    popRecognizer = InteractivePopRecognizer(controller: controller)
    controller.interactivePopGestureRecognizer?.delegate = popRecognizer
}

Enjoy a hidden navigation bar with no side effects, that works even if your top controller has table, collection, or scroll view subviews.

Solution 3

In my case, to prevent strange effects

Root view controller

override func viewDidLoad() {
    super.viewDidLoad()

    // Enable swipe back when no navigation bar
    navigationController?.interactivePopGestureRecognizer?.delegate = self
}

// UIGestureRecognizerDelegate
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
    if let navVc = navigationController {
      return navVc.viewControllers.count > 1
    }
    return false
}

Solution 4

Updated for iOS 13.4

iOS 13.4 broke the previous solution, so things are gonna get ugly. It looks like in iOS 13.4 this behavior is now controlled by a private method _gestureRecognizer:shouldReceiveEvent: (not to be confused with the new public shouldReceive method added in iOS 13.4).


I found that other posted solutions overriding the delegate, or setting it to nil caused some unexpected behavior.

In my case, when I was on the top of the navigation stack and tried to use the gesture to pop one more, it would fail (as expected), but subsequent attempts to push onto the stack would start to cause weird graphical glitches in the navigation bar. This makes sense, because the delegate is being used to handle more than just whether or not to block the gesture from being recognized when the navigation bar is hidden, and all that other behavior was being thrown out.

From my testing, it appears that gestureRecognizer(_:, shouldReceiveTouch:) is the method that the original delegate is implementing to block the gesture from being recognized when the navigation bar is hidden, not gestureRecognizerShouldBegin(_:). Other solutions that implement gestureRecognizerShouldBegin(_:) in their delegate work because the lack of an implementation of gestureRecognizer(_:, shouldReceiveTouch:) will cause the default behavior of receiving all touches.

@Nathan Perry's solution gets close, but without an implementation of respondsToSelector(_:), the UIKit code that sends messages to the delegate will believe there is no implementation for any of the other delegate methods, and forwardingTargetForSelector(_:) will never get called.

So, we take control of `gestureRecognizer(_:, shouldReceiveTouch:) in the one specific scenario we want to modify the behavior, and otherwise forward everything else to the delegate.

class AlwaysPoppableNavigationController : UINavigationController {

    private var alwaysPoppableDelegate: AlwaysPoppableDelegate!

    override func viewDidLoad() {
        super.viewDidLoad()

        self.alwaysPoppableDelegate = AlwaysPoppableDelegate(navigationController: self, originalDelegate: self.interactivePopGestureRecognizer!.delegate!)
        self.interactivePopGestureRecognizer!.delegate = self.alwaysPoppableDelegate
    }
}

private class AlwaysPoppableDelegate : NSObject, UIGestureRecognizerDelegate {

    weak var navigationController: AlwaysPoppableNavigationController?
    weak var originalDelegate: UIGestureRecognizerDelegate?

    init(navigationController: AlwaysPoppableNavigationController, originalDelegate: UIGestureRecognizerDelegate) {
        self.navigationController = navigationController
        self.originalDelegate = originalDelegate
    }

    // For handling iOS before 13.4
    @objc func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
        if let navigationController = navigationController, navigationController.isNavigationBarHidden && navigationController.viewControllers.count > 1 {
            return true
        }
        else if let originalDelegate = originalDelegate {
            return originalDelegate.gestureRecognizer!(gestureRecognizer, shouldReceive: touch)
        }
        else {
            return false
        }
    }

    // For handling iOS 13.4+
    @objc func _gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceiveEvent event: UIEvent) -> Bool {
        if let navigationController = navigationController, navigationController.isNavigationBarHidden && navigationController.viewControllers.count > 1 {
            return true
        }
        else if let originalDelegate = originalDelegate {
            let selector = #selector(_gestureRecognizer(_:shouldReceiveEvent:))
            if originalDelegate.responds(to: selector) {
                let result = originalDelegate.perform(selector, with: gestureRecognizer, with: event)
                return result != nil
            }
        }

        return false
    }

    override func responds(to aSelector: Selector) -> Bool {
        if #available(iOS 13.4, *) {
            // iOS 13.4+ does not need to override responds(to:) behavior, it only uses forwardingTarget
            return originalDelegate?.responds(to: aSelector) ?? false
        }
        else {
            if aSelector == #selector(gestureRecognizer(_:shouldReceive:)) {
                return true
            }
            else {
                return originalDelegate?.responds(to: aSelector) ?? false
            }
        }
    }

    override func forwardingTarget(for aSelector: Selector) -> Any? {
        if #available(iOS 13.4, *), aSelector == #selector(_gestureRecognizer(_:shouldReceiveEvent:)) {
            return nil
        }
        else {
            return self.originalDelegate
        }
    }
}

Solution 5

You can subclass UINavigationController as following:

@interface CustomNavigationController : UINavigationController<UIGestureRecognizerDelegate>

@end

Implementation:

@implementation CustomNavigationController

- (void)setNavigationBarHidden:(BOOL)hidden animated:(BOOL)animated {
    [super setNavigationBarHidden:hidden animated:animated];
    self.interactivePopGestureRecognizer.delegate = self;
}

- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer {
    if (self.viewControllers.count > 1) {
        return YES;
    }
    return NO;
}

@end
Share:
47,360
mihai
Author by

mihai

Updated on July 08, 2022

Comments

  • mihai
    mihai almost 2 years

    I love the swipe pack thats inherited from embedding your views in a UINavigationController. Unfortunately i cannot seem to find a way to hide the NavigationBar but still have the touch pan swipe back gesture. I can write custom gestures but I prefer not to and to rely on the UINavigationController back swipe gesture instead.

    if I uncheck it in the storyboard, the back swipe doesn't work

    enter image description here

    alternatively if I programmatically hide it, the same scenario.

    - (void)viewDidLoad
    {
        [super viewDidLoad];
        [self.navigationController setNavigationBarHidden:YES animated:NO]; // and animated:YES
    }
    

    Is there no way to hide the top NavigationBar and still have the swipe?