HangarRash
HangarRash

Reputation: 14975

How to implement a pull-down button that supports multiple selection?

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.

Upvotes: 0

Views: 2400

Answers (2)

HangarRash
HangarRash

Reputation: 14975

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

            // This keeps the menu presented allowing for multiple selection
            // but the checkmark state doesn't update until the menu
            // is dismissed and displayed again.
            // action.attributes = [ .keepsMenuPresented ]

            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"

Issue 2 can be solved, sort of, by adding the .keepsMenuPresented attribute to each of the UIAction instances. While doing this allows the user to tap on multiple actions while the menu stays presented, the checkmark state of the actions does not change until the menu is dismissed and presented again.


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.

Upvotes: 1

matt
matt

Reputation: 534885

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".

Upvotes: 0

Related Questions