Search code examples
iosswiftuiuikit

BarButtonItem with closure stops working for no reason


The following issue happens in Xcode 16.2, iOS18 SDK with build target iOS17

I have an extension on UIBarButtonItem to support closures instead of target/action:


extension UIBarButtonItem {
    typealias Closure = () -> Void

    private class UIBarButtonItemClosureWrapper: NSObject {
        let closure: Closure
        init(_ closure: @escaping Closure) {
            self.closure = closure
        }
        
        deinit {
            print("DEINIT")
        }
    }
    
    private enum AssociatedKeys {
        static var targetClosure = 1
    }
    
    convenience init(title: String?, style: UIBarButtonItem.Style, closure: @escaping Closure) {
        self.init(title: title, style: style, target: nil, action: #selector(closureAction))
        objc_setAssociatedObject(self, &AssociatedKeys.targetClosure, UIBarButtonItemClosureWrapper(closure), objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    }

    convenience init(image: UIImage?, style: UIBarButtonItem.Style, closure: @escaping Closure) {
        self.init(image: image, style: style, target: nil, action: #selector(closureAction))
        objc_setAssociatedObject(self, &AssociatedKeys.targetClosure, UIBarButtonItemClosureWrapper(closure), objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    }

    @objc
    func closureAction() {
        guard let closureWrapper = objc_getAssociatedObject(self, &AssociatedKeys.targetClosure) as? UIBarButtonItemClosureWrapper else { return }
        closureWrapper.closure()
    }
}

However something really strange is happening and I don't know why.

I use this in a UIViewController in navigationItem.rightBarButtonItems, and that viewcontroller is the root viewcontroller of a UINavigationController. It all works fine, until I add a TextField to the SwiftUI View that's contained in that viewcontroller's UIHostingController child viewcontroller.

So in summary: UINavigationController -> UIViewController -> UIHostingController -> TextField

As soon as I give the focus to the TextField, the button no longer works. You can see it become translucent as you tap it (so it is registering the tap), but the closure is no longer called. If I put a button next to it that uses the standard target/action system, that one keeps on working.

This is the UIViewController code:

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let item = UIBarButtonItem(title: "TargetAction", style: .done, target: self, action: #selector(buttonTapped))
        let item2 = UIBarButtonItem(title: "ClosureAction", style: .done) {
            print("CLOSURE TEST")
        }
        self.navigationItem.rightBarButtonItems = [item, item2]
    }
    
    @objc func buttonTapped() {
        print("TARGET TEST")
    }

and this is the View code:

struct MyView: View {

    var body: some View {
        Form {
            TextField("name", text: .constant("text"))
        }
    }

Target Test is printed every time I tap on the button. Closure Test is printed when I tap the other button, until I focus the TextField by tapping on it. From then on Target Test still works, but Closure Test no longer prints.

Is the focus of the TextField and the keyboard appearance doing something with the navigation bar behind the scenes that I am unaware of, thereby destroying the associated objects somehow? Deinit is never printed, and putting a breakpoint in ClosureAction shows the function isn't called either.


Solution

  • You used nil as the target: parameter when creating a UIBarButtonItem. This means when the button is pressed, the selector will be sent down the responder chain, until some object responds to the selector.

    When the text field is focused, it will be the first responder, and the front of the responder chain likely contains a lot of SwiftUI-machinary. It is likely that one of those SwiftUI things along the responder chain "swallowed" the selector, preventing it from reaching the UIBarButtonItem.

    If you use self as the target:, the selector will be directly sent to the UIBarButtonItem and this works as expected.

    convenience init(title: String?, style: UIBarButtonItem.Style, closure: @escaping Closure) {
        self.init(title: title, style: style, target: nil, action: #selector(closureAction))
        self.target = self // here!
        objc_setAssociatedObject(self, &AssociatedKeys.targetClosure, UIBarButtonItemClosureWrapper(closure), objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    }
    

    That said, UIBarButtonItems support closure-based actions since iOS 14. You don't need your extension at all. Just do:

    let item2 = UIBarButtonItem(title: "ClosureAction", primaryAction: UIAction {_ in
        print("Closure action")
    })