skip to Main Content

I had no trouble implementing a pop-up button that lets a user select from a mutually exclusive list of options. This is covered in the Pop-up buttons section of the HIG.

Now I want something similar but to allow the user to select any number of options from the list. The "Pop-up buttons" page in the HIG states:

Use a pull-down button instead if you need to: […] Let people select multiple items

But the Pull-down buttons page of the HIG makes no mention of how to support multiple selection.

Here’s what I tried so far. I start with the pop-up button code (copy and paste into an iOS Swift Playground to play along):

import UIKit
import PlaygroundSupport

class MyVC: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let items = [ "Option 1", "Option 2", "Option 3", "Option 4" ]
        let actions: [UIAction] = items.map {
            let action = UIAction(title: $0) { action in
                print("Selected (action.title)")
            }

            return action
        }
        let menu = UIMenu(children: actions)

        var buttonConfig = UIButton.Configuration.gray()
        let button = UIButton(configuration: buttonConfig)
        button.menu = menu
        button.showsMenuAsPrimaryAction = true
        button.changesSelectionAsPrimaryAction = true

        button.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(button)
        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
            button.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
        ])
    }
}

PlaygroundPage.current.liveView = MyVC()

Then update the code to make it a pull-down button. First, disable the changesSelectionAsPrimaryAction property of the button.

button.changesSelectionAsPrimaryAction = false

Then give the button a title so it appears as more than a tiny little square.

buttonConfig.title = "Select Items"

Now we have a button that shows a menu when it’s tapped. But now there are no checkmarks and selecting a menu doesn’t result in any checkmark. So here I thought I would update the handler block of the UIAction to toggle the action’s state.

let action = UIAction(title: $0) { action in
    print("Selected (action.title)")
    action.state = action.state == .on ? .off : .on
}

But now when you tap on a menu item the code crashes with an exception. When running in a real iOS app (not a Playground), the error is:

2023-05-21 10:40:56.038217-0600 ButtonMenu[63482:10716279] *** Assertion failure in -[_UIImmutableAction setState:], UIAction.m:387
2023-05-21 10:40:56.063676-0600 ButtonMenu[63482:10716279] *** Terminating app due to uncaught exception ‘NSInternalInconsistencyException’, reason: ‘Action is immutable because it is a child of a menu’

Is it possible to implement a multiple select menu using UIButton and UIMenu?

If so, what piece am I missing?

If not, what component should be used for multiple selection? Ideally it would be great if the user could tap the button to bring up the menu, select multiple items in the menu, then tap the button again to dismiss the menu.

2

Answers


  1. Chosen as BEST ANSWER

    I found a work-around. Instead of trying to modify the action provided in the UIAction handler, you need to modify the original action in the button's menu.

    The problem is that you can't get the UIMenu from the UIAction. And you can't declare the UIMenu before creating the array of UIAction. So you need to create the UIButton first, then you can access the button in the action handler which then lets you access the button's menu and update the matching action.

    Here's updated code that allows you to select multiple items in a pull-down menu:

    import UIKit
    import PlaygroundSupport
    
    class MyVC: UIViewController {
        override func viewDidLoad() {
            super.viewDidLoad()
    
            var buttonConfig = UIButton.Configuration.gray()
            buttonConfig.title = "Select Stuff"
            let button = UIButton(configuration: buttonConfig)
    
            let items = [ "Option 1", "Option 2", "Option 3", "Option 4" ]
            let actions: [UIAction] = items.map {
                let action = UIAction(title: $0) { action in
                    print("Selected (action.title)")
    
                    // The following line causes a crash
                    //action.state = action.state == .on ? .off : .on
    
                    // The following updates the original UIAction without crashing
                    if let act = button.menu?.children.first(where: { $0.title == action.title }), let act = act as? UIAction {
                        act.state = act.state == .on ? .off : .on
                    }
                }
    
                return action
            }
    
            let menu = UIMenu(children: actions)
            button.menu = menu
            button.showsMenuAsPrimaryAction = true
    
            button.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(button)
            NSLayoutConstraint.activate([
                button.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
                button.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
            ])
        }
    }
    
    PlaygroundPage.current.liveView = MyVC()
    

    Now that I finally figured out how to make this work, I realize it has two big user experience issues. 1) The button doesn't reflect the current selection state like the pop-up button does. 2) If the user wants to select more than one item, the user needs to tap the button to present the menu for each desired selection.

    Issue 1 can be solved by adding the following line inside the action handler, after the code that updates the action's state:

    button.configuration?.title = button.menu?.selectedElements.map { $0.title }.joined(separator: ", ") ?? "None"
    

    I don't know a good way to solve issue 2. I'll leave that for another question.


    The original question and this answer don't address one other real-world part of this task - having some of the menu items selected initially. I leave that as an exercise for the reader.


  2. It does sound like the stuff in the pop-up menu section of the HIG about using the pull-down menu for multiple selection is just blowing chunks. Not the first time I’ve seen that happen in Apple’s docs, WWDC videos, etc.

    I would suggest just using a different interface, i.e. don’t try to do this with the built-in button -> UIMenu interface.

    Instead, when the user does whatever the user should do (tap the button), you present a presented view controller; now you’re in charge of the interface, and you can use a multiple-selection table view, a table view with switches, whatever. (You can see some rather old discussion about some of the options here.) The presented view can be at any size and location; I like to use a popover presentation for this sort of thing.

    I think that’s better than "fighting the framework".

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search