iosuikitios14uimenu

How to modify UIMenu before it's shown to support dynamic actions


iOS 14 adds the ability to display menus upon tapping or long pressing a UIBarButtonItem or UIButton, like so:

let menu = UIMenu(children: [UIAction(title: "Action", image: nil) { action in
    //do something
}])
button.menu = menu
barButtonItem = UIBarButtonItem(title: "Show Menu", image: nil, primaryAction: nil, menu: menu)

This most often replaces action sheets (UIAlertController with actionSheet style). It's really common to have a dynamic action sheet where actions are only included or may be disabled based on some state at the time the user taps the button. But with this API, the menu is created at the time the button is created. How can you modify the menu prior to it being presented or otherwise make it dynamic to ensure the appropriate actions are available and in the proper state when it will appear?


Solution

  • You can store a reference to your bar button item or button and recreate the menu each time any state changes that affects the available actions in the menu. menu is a settable property so it can be changed any time after the button is created. You can also get the current menu and replace its children like so: button.menu = button.menu?.replacingChildren([])

    For scenarios where you are not informed when the state changes for example, you really need to be able to update the menu right before it appears. There is a UIDeferredMenuElement API which allows the action(s) to be generated dynamically. It's a block where you call a completion handler providing an array of UIMenuElement. A placeholder with loading UI is added by the system and is replaced once you call the completion handler, so it supports asynchronous determination of menu items. However, this block is only called once and then it is cached and reused so this doesn't do what we need for this scenario. iOS 15 added a new uncached provider API which behaves the same way except the block is invoked every time the element is displayed, which is exactly what we need for this scenario.

    barButtonItem.menu = UIMenu(children: [
        UIDeferredMenuElement.uncached { [weak self] completion in
            var actions = [UIMenuElement]()
            if self?.includeTestAction == true {
                actions.append(UIAction(title: "Test Action") { [weak self] action in
                    self?.performTestAction()
                })
            }
            completion(actions)
        }
    ])
    

    Before this API existed, I did find for UIButton you can change the menu when the user touches down via target/action like so: button.addTarget(self, action: #selector(buttonTouchedDown(_:)), for: .touchDown). This worked only if showsMenuAsPrimaryAction was false so they had to long press to open the menu. I didn't find a solution for UIBarButtonItem, but you could use a UIButton as a custom view.