12

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.

mmd1080
  • 1,812
  • 2
  • 17
  • 29
  • https://stackoverflow.com/a/49267846/4759154 seems to confirm my suspicion that this is being controlled by private API but again maybe there is a way... – mmd1080 Jul 13 '20 at 20:22
  • Did you acheive it? – Jalil Sep 28 '20 at 17:50
  • Did you see/try https://stackoverflow.com/questions/62158125/swiftui-hide-back-button-and-navigation-bar-appears-for-a-fraction-of-secon? I just added this code to my project and it worked as I wanted.... – Zonker.in.Geneva May 02 '21 at 19:23

5 Answers5

15

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
  }
}
Andrei Marincas
  • 446
  • 5
  • 13
  • Clever, but you’d have to do this on every view controller too. – matt Sep 19 '20 at 01:21
  • @matt There are ways to setup a backBarButtonItem on the navigationItem of every view controller in the stack in one place, for instance by implementing the navigationController:didShow: delegate method of UINavigationControllerDelegate. You don't have to do it in every view controller. But to rely on leftBarButtonItem to do the popViewController by every view controller that might be pushed, it's risky. There is a more complete answer here if you're interested: https://developer.apple.com/forums/thread/653913?answerId=634040022#634040022 – Andrei Marincas Sep 19 '20 at 03:21
  • @matt Yes, you would have to set a custom back button on every UIViewController object that will be pushed, in order to have control over the menu. But programatically, you can implement it in one place. The only problem with this that I see is that you lose the "Back" string localization. I don't see any other orthodox way of disabling the menu. The system back button is not public. – Andrei Marincas Sep 19 '20 at 04:20
  • What a useless feature though, I hate the fact it empty confusing bubble to users – Yaroslav Dukal Oct 02 '20 at 05:44
  • Can this 3D touch be completely disable in the app? – Yaroslav Dukal Oct 02 '20 at 06:13
5

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
    }
}
iMoeNya
  • 672
  • 4
  • 11
2

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

Claus Jørgensen
  • 25,882
  • 9
  • 87
  • 150
  • 2
    It doesn't disable but it prevents it from being all empty, while keeping clean back buttons. – Medhi May 25 '21 at 15:54
1

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?) {
        }
}
  • 2
    Generally, answers are much more helpful if they include an explanation of what the code is intended to do, and why that solves the problem without introducing others. – DCCoder May 18 '21 at 16:58
-1

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