Is it possible to disable the back navigation menu in iOS 14+?

In iOS 14+, tapping and holding on the backBarButtonItem of a UINavigationItem will present the full navigation stack. Then a user may pop to any point in the stack, whereas previously all a user could do was tap this item to pop one item in the stack.

Is it possible to disable this? UIBarButtonItem has a new property named menu, but it appears to be nil in spite of showing a menu when holding on the button. This leads me to believe this may be special behavior that cannot be changed, but perhaps I'm overlooking something.


It can be done by subclassing UIBarButtonItem. Setting the menu to nil on a UIBarButtonItem doesn't work, but you can override the menu property and prevent setting it in the first place.

class BackBarButtonItem: UIBarButtonItem {
    @available(iOS 14.0, *)
    override var menu: UIMenu? {
        set {
            // Don't set the menu here
            // super.menu = menu
        }
        get {
            return super.menu
        }
    }
}

Then you can configure the back button in your view controller the way you like, but using BackBarButtonItem instead of UIBarButtonItem.

let backButton = BackBarButtonItem(title: "BACK", style: .plain, target: nil, action: nil)
navigationItem.backBarButtonItem = backButton

This is preferred because you set the backBarButtonItem only once in your view controller's navigation item, and then whatever view controller it will be pushing, the pushed controller will show the back button automatically on the nav bar. If using leftBarButtonItem instead of backBarButtonItem, you will have to set it on every view controller that will be pushed.

Edit:

The back navigation menu that appears on long press is a property of UIBarButtonItem. The back button of a view controller can be customized by setting the navigationItem.backBarButtonItem property and by doing so we can control the menu. The only problem with this approach that I see is losing the localization (translation) of the "Back" string which the system button has.

If you want the disabled menu to be the default behaviour you can implement this in one place, in a UINavigationController subclass conforming to UINavigationControllerDelegate:

class NavigationController: UINavigationController, UINavigationControllerDelegate {
  init() {
    super.init(rootViewController: ViewController())
    delegate = self
  }
   
  func navigationController(_ navigationController: UINavigationController,
                            willShow viewController: UIViewController, animated: Bool) {
    let backButton = BackBarButtonItem(title: "Back", style: .plain, target: nil, action: nil)
    viewController.navigationItem.backBarButtonItem = backButton
  }
}

Runtime swizzling is the final solution.

It's basically the same idea as Andrei Marincas's subclass and set solution.

But setting the backBarButtonItem every time a view controller is pushed results in an annoying transition on the back button.

Therefore I swizzle the default setter of UIBarButtonItem.menu to a do-nothing code block, which does no harm to the iOS transition system.

Simply just copy this code:

enum Runtime {
    static func swizzle() {
        if #available(iOS 14.0, *) {
            exchange(
                #selector(setter: UIBarButtonItem.menu),
                with: #selector(setter: UIBarButtonItem.swizzledMenu),
                in: UIBarButtonItem.self
            )
        }
    }
    
    private static func exchange(
        _ selector1: Selector,
        with selector2: Selector,
        in cls: AnyClass
    ) {
        guard
            let method = class_getInstanceMethod(
                cls,
                selector1
            ),
            let swizzled = class_getInstanceMethod(
                cls,
                selector2
            )
        else {
            return
        }
        method_exchangeImplementations(method, swizzled)
    }
}

@available(iOS 14.0, *)
private extension UIBarButtonItem {
    @objc dynamic var swizzledMenu: UIMenu? {
        get {
            nil
        }
        set {
            
        }
    }
}

Paste anywhere. Call it in your AppDelegate:


@main
class AppDelegate: UIResponder {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {

        // ......

        Runtime.swizzle()
        return true
    }
}

Copying an answer from https://stackoverflow.com/a/64386494/95309 here as well.

If you're seeing a "empty" menu because you're currently setting the backButtonTitle to a empty string, or setting the backBarButtonItem with a empty title in order to remove the back button title, you should instead set the backButtonDisplayMode to minimal from iOS 14 and onwards.

if #available(iOS 14.0, *) {
    navigationItem.backButtonDisplayMode = .minimal
} else {
    navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
}

https://developer.apple.com/documentation/uikit/uinavigationitem/3656350-backbuttondisplaymode


Call UIBarButtonItem.fix_classInit() in didFinishLaunchingWithOptions. The purpose of method exchange is to do nothing in menu setter.

func swizzlingClass(_ forClass: AnyClass, originalSelector: Selector, swizzledSelector: Selector) {
        guard let originalMethod = class_getInstanceMethod(forClass, originalSelector), let swizzledMethod = class_getInstanceMethod(forClass, swizzledSelector) else {
            return
        }
        if class_addMethod(forClass, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod)) {
            class_replaceMethod(forClass, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod))
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod)
        }
}
    
extension UIBarButtonItem {
        public static func fix_classInit() {
            if #available(iOS 14.0, *) {
                swizzlingClass(UIBarButtonItem.self, originalSelector: #selector(setter: UIBarButtonItem.menu), swizzledSelector: #selector(fix_setMenu(menu:)))
            }
        }
        
        @available(iOS 14.0, *)
        @objc func fix_setMenu(menu: UIMenu?) {
        }
}