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

Viewed 5747

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.

5 Answers

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?) {
        }
}

Andrei Marincas' solution worked for me. However setting a custom UIBarButtonItem on every root navigation controller is tiresome. And on some cases, I found setting the custom bar button item was not working for all the child classes(probably if the child vc has some modification on the Navbar from storyboard?). So I used the swizzling technique to add the custom back bar button item on every ViewDidLoad.

import UIKit

private let swizzling: (UIViewController.Type, Selector, Selector) -> Void = { forClass, originalSelector, swizzledSelector in
    if let originalMethod = class_getInstanceMethod(forClass, originalSelector), let swizzledMethod = class_getInstanceMethod(forClass, swizzledSelector) {
        let didAddMethod = class_addMethod(forClass, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod))
        if didAddMethod {
            class_replaceMethod(forClass, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod))
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod)
        }
    }
}

extension UIViewController {
    
    static func swizzle() {
        let originalSelector1 = #selector(viewDidLoad)
        let swizzledSelector1 = #selector(swizzled_viewDidLoad)
        swizzling(UIViewController.self, originalSelector1, swizzledSelector1)
    }
    
    @objc open func swizzled_viewDidLoad() {
        if let _ = navigationController {
            let backButton = BackBarButtonItem(title: "      ", style: .plain, target: nil, action: nil) // Set any title you'd like, I needed to show only the back icon.
            navigationItem.backBarButtonItem = backButton
        }
        swizzled_viewDidLoad()
    }
} 
// From Andrei's answer
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
        }
    }
}

And in application(_:didFinishLaunchingWithOptions:) call

UIViewController.swizzle()

Found the idea to use sizzling from this answer: https://stackoverflow.com/a/64713022/8817327

Related