15

How to change the state for an UIAction? The goal is to toggle a state checkmark next to an UIAction inside UIMenu.

enter image description here

Changing a UIAction's state via a reference stored in the view controller does not seem to change the state at all. Am I missing anything?

// View Controller
internal var menuAction: UIAction!

private func generatePullDownMenu() -> UIMenu {
    menuAction = UIAction(
        title: "Foo",
        image: UIImage(systemName: "chevron.down"),
        identifier: UIAction.Identifier("come.sample.action"),
        state:  .on
    ) { _ in self.menuAction.state = .off } // <--- THIS LINE


    let menu = UIMenu(
        title: "Sample Menu",
        image: nil,
        identifier: UIMenu.Identifier("com.sample.menu"),
        options: [],
        children: [menuAction]
    )

    return menu
}

// Inside UI setup code block
let buttonItem = UIBarButtonItem(
    title: "",
    image: UIImage(systemName: "chevron.down"),
    primaryAction: nil,
    menu: generatePullDownMenu()
)

Tried to change the action state from the closure directly and got the "Action is immutable because it is a child of a menu" error. Now I suspect an action object is always an immutable object.

menuAction = UIAction(
    title: "Foo",
    image: UIImage(systemName: "chevron.down"),
    identifier: UIAction.Identifier("come.sample.action"),
    state:  .on
) { action in action.state = .off } // <--- THIS LINE
XY L
  • 25,431
  • 14
  • 84
  • 143

4 Answers4

14

Replace the entire UIMenu object on state change would do the trick.

// view controller
internal var barButton: UIBarButtonItem!

// UI setup function
barButton = UIBarButtonItem(
    image: UIImage(systemName: "arrow.up.arrow.down.square"),
    primaryAction: nil,
    menu: generatePullDownMenu()
)

// On state change inside UIAction 
let actionNextSeen = UIAction(
    title: "foo",
    image: UIImage(systemName: "hourglass", )
    state: someVariable ? .off : .on
) { _ in
    someVariable = false
    self.barButton.menu = self.generatePullDownMenu()
}

REFERENCE

https://developer.apple.com/forums/thread/653862

XY L
  • 25,431
  • 14
  • 84
  • 143
  • That's basically exactly what I said. The data is used as an intermediary and you re-form the entire menu for next time. – matt Nov 08 '20 at 15:11
6

You will need to recreate the Menu. This example will also correctly select the item that was tapped on:

private func setupViews()
    timeFrameButton = UIBarButtonItem(
        image: UIImage(systemName: "calendar"),
        menu: createMenu()
    )
    navigationItem.leftBarButtonItem = timeFrameButton
}

private func createMenu(actionTitle: String? = nil) -> UIMenu {
    let menu = UIMenu(title: "Menu", children: [
        UIAction(title: "Yesterday") { [unowned self] action in
            self.timeFrameButton.menu = createMenu(actionTitle: action.title)
        },
        UIAction(title: "Last week") { [unowned self] action in
            self.timeFrameButton.menu = createMenu(actionTitle: action.title)
        },
        UIAction(title: "Last month") { [unowned self] action in
            self.timeFrameButton.menu = createMenu(actionTitle: action.title)
        }
    ])
    
    if let actionTitle = actionTitle {
        menu.children.forEach { action in
            guard let action = action as? UIAction else {
                return
            }
            if action.title == actionTitle {
                action.state = .on
            }
        }
    } else {
        let action = menu.children.first as? UIAction
        action?.state = .on
    }
    
    return menu
}
José
  • 3,112
  • 1
  • 29
  • 42
4

Do not attempt to change the menu while it is showing. Respond to the choice by changing your data. At the same time, the menu vanishes, because the user has chosen an action. But now you use that data to construct the menu the next time the user displays the menu.

matt
  • 515,959
  • 87
  • 875
  • 1,141
  • Example, very similar to what you're trying to do, in case 5 here: https://github.com/mattneub/Programming-iOS-Book-Examples/blob/66e806a9e05e7886cff64d4bd1b889104bae49fe/bk2ch12p595NewButtons/NewButtons/ViewController.swift – matt Nov 08 '20 at 13:23
  • 1
    `UIBarButtonItem` seems only trigger the menu generation function once – XY L Nov 08 '20 at 13:52
  • Sounds like using UIButton can be a work around, https://stackoverflow.com/questions/62970548/how-to-modify-uimenu-for-uibarbuttonitem-and-uibutton – XY L Nov 08 '20 at 14:05
1

When using iOS 15 or newer, you can use UIDeferredMenuElement.uncached to create dynamic UIAction objects:

let menu = UIMenu(options: .displayInline, children: [
    UIDeferredMenuElement.uncached { [weak self] completion in
        let actions = [
            UIAction(title: "My Dynamic Action 1", image: UIImage(systemName: "hourglass"), state: self?.myState1 == true ? .on : .off, handler: { (action) in
                // Do something
            }),
            UIAction(title: "My Dynamic Action 2", image: UIImage(systemName: "star"), state: self?.myState2 == true ? .on : .off, handler: { (action) in
                // Do something else
            })
        ] 
        completion(actions)
    }
])

Documentation: https://developer.apple.com/documentation/uikit/uideferredmenuelement/3857602-elementwithuncachedprovider

Ely
  • 8,259
  • 1
  • 54
  • 67
  • everyone is copying and pasting this block with no one have tried it actually – Omar N Shamali Sep 21 '22 at 16:17
  • 1
    @OmarNShamali Well that's not really true – Ely Sep 22 '22 at 17:38
  • 1
    Although I didn't paste this exact block, this example provided exactly what I needed. I preferred to create my actions separately then add them as an array to the completion call. But that's just a style difference. Anyhow, it worked great. I now have a checkmark that jumps between my menu items as user chooses one then activates the menu again. Thanks @Ely! – Smartcat Oct 24 '22 at 17:34