是否可以在 iOS 14+ 中禁用后退导航菜单?

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

在 iOS 14+ 中,点击并按住 UINavigationItem 的 backBarButtonItem 将显示完整的导航堆栈。然后用户可以弹出到堆栈中的任何一点,而以前用户所能做的就是点击该项目以弹出堆栈中的一个项目。

是否可以禁用此功能? UIBarButtonItem 有一个名为 menu 的新 属性,但它似乎是 nil,尽管按住按钮时会显示菜单。这让我相信这可能是无法更改的特殊行为,但也许我忽略了一些东西。

可以通过子类化 UIBarButtonItem 来完成。在 UIBarButtonItem 上将菜单设置为 nil 不起作用,但您可以覆盖菜单 属性 并首先阻止设置它。

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
        }
    }
}

然后您可以按照自己喜欢的方式在视图控制器中配置后退按钮,但使用 BackBarButtonItem 而不是 UIBarButtonItem。

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

这是首选,因为您只在视图控制器的导航项中设置了一次 backBarButtonItem,然后无论它将推送什么视图控制器,推送的控制器都会自动在导航栏上显示后退按钮。如果使用 leftBarButtonItem 而不是 backBarButtonItem,则必须在将被推送的每个视图控制器上设置它。

编辑:

长按出现的后退导航菜单是一个属性的UIBarButtonItem。可以通过设置 navigationItem.backBarButtonItem 属性 来自定义视图控制器的后退按钮,这样我们就可以控制菜单。我看到这种方法的唯一问题是丢失了系统按钮具有的“后退”字符串的本地化(翻译)。

如果您希望禁用菜单成为默认行为,您可以在一个地方实现它,在符合 UINavigationControllerDelegate 的 UINavigationController 子类中:

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
  }
}

也在此处复制 https://whosebug.com/a/64386494/95309 的答案。

如果您看到“空”菜单是因为您当前将 backButtonTitle 设置为空字符串,或者将 backBarButtonItem 设置为空标题以删除后面的内容按钮标题,您应该从 iOS 14 及以后将 backButtonDisplayMode 设置为 minimal

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

A​​ndrei Marincas 的解决方案对我有用。然而,在每个根导航控制器上设置一个自定义的 UIBarButtonItem 是很麻烦的。在某些情况下,我发现设置自定义栏按钮项目不适用于所有 child 类(可能如果 child vc 对导航栏进行了一些修改故事板?)。所以我使用 swizzling 技术在每个 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
        }
    }
}

并在 application(_:didFinishLaunchingWithOptions:) 调用中

UIViewController.swizzle()

从这个答案中找到了使用 sizzling 的想法:

在 didFinishLaunchingWithOptions 中调用 UIBarButtonItem.fix_classInit()。 方法交换的目的是在菜单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's 的思路基本相同。

但是每次按下视图控制器时设置 backBarButtonItem 会导致后退按钮上出现烦人的过渡。

因此,我将 UIBarButtonItem.menu 的默认 setter 调整为一个什么都不做的代码块,这不会对 iOS 转换系统造成伤害。

只需复制此代码即可:

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

粘贴到任何地方。在你的 AppDelegate:

中调用它

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

        // ......

        Runtime.swizzle()
        return true
    }
}