18

I have set up an UIButton as the rightBarButtonItem in an UIViewController inside an UINavigationController and associated an iOS13 context menu to it.

Long pressing the button shows the context menu as expected.

Is there a way to show the context menu also by tapping on the button (e.g. by adding a target for the .touchUpInside event)?

The button/barButtonItem is set up as follows:

let button = UIButton(type: .system)
button.setImage(UIImage(systemName: "plus"), for: .normal)

let barButton = UIBarButtonItem(customView: button)
self.navigationItem.rightBarButtonItem = barButton

let interaction = UIContextMenuInteraction(delegate: self)
button.addInteraction(interaction)

The context menu is defined as follows:

extension ViewController: UIContextMenuInteractionDelegate {
    func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
        return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { suggestedActions in
            let importAction = UIAction(title: "Import", image: UIImage(systemName: "folder")) { action in }
            let createAction = UIAction(title: "Create", image: UIImage(systemName: "square.and.pencil")) { action in }
            return UIMenu(title: "", children: [importAction, createAction])
        }
    }
}
mmklug
  • 2,252
  • 2
  • 16
  • 31
  • @IvanCantarino any luck? I'm aware that UIKit manages all interaction with the Context Menu, but there are other examples of UIKit-managed elementes that can be triggered programmatically. You can probably (programmatically) simulate the long press, but I'm looking for a more elegant solution (ie. simply calling where on the screen to present it). – JDev May 05 '20 at 19:43
  • Well, since I was looking for something like this for a Mac Catalyst App, I managed to use a `UIMenuController` with the library `PSMenuItem`, though the simulation of an `UIContextMenu` would be preferable tbh. – Ivan Cantarino May 05 '20 at 19:45
  • I came to update my answer to mention the new addition in iOS 14 to find that you have already updated your question :). I would just suggest adding that as a new answer to your own question, and [that's totally fine](https://stackoverflow.com/help/self-answer). – Hejazi Jul 10 '20 at 16:35

4 Answers4

10

iOS 14

iOS 14 (first Beta) now supports the desired functionality. With the following code tapping on the UIBarButtonItem will display the menu immediately (also avoiding the blurred background that results from calling UIContextMenuInteraction):

override func viewDidLoad() {
    super.viewDidLoad()
    
    let importAction = UIAction(title: "Import", image: UIImage(systemName: "folder")) { action in }
    let createAction = UIAction(title: "Create", image: UIImage(systemName: "square.and.pencil")) { action in }
    
    let menuBarButton = UIBarButtonItem(
        title: "Add",
        image: UIImage(systemName:"plus"),
        primaryAction: nil,
        menu: UIMenu(title: "", children: [importAction, createAction])
    )
    
    self.navigationItem.rightBarButtonItem = menuBarButton
}

The functionality is achieved by not providing the primaryAction.

You can achieve the same effect using an UIButton. In that case you will need to set

button.showsMenuAsPrimaryAction = true

The full code for an UIButton could look like this:

override func viewDidLoad() {
    super.viewDidLoad()
   
    let button = UIButton(type: .system)
    button.setImage(UIImage(systemName: "plus"), for: .normal)
    button.translatesAutoresizingMaskIntoConstraints = false
    view.addSubview(button)
    
    NSLayoutConstraint.activate([
        button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
    ])
    
    let importAction = UIAction(title: "Import", image: UIImage(systemName: "folder")) { action in }
    let createAction = UIAction(title: "Create", image: UIImage(systemName: "square.and.pencil")) { action in }
    
    let items = [importAction, createAction]
    
    button.menu = UIMenu(title: "Add", children: items)
    button.showsMenuAsPrimaryAction = true
}

mmklug
  • 2,252
  • 2
  • 16
  • 31
  • Hi, is there a way to programmatically select the button and then show the menu? As of now. i can only trigger the UIMenu by manually press the button. I want to show the menu directly when the user enter a view controller. Thanks in advance... – Foster Jul 20 '20 at 15:27
  • 2
    I try to do the same thing but triggered events doesn't show the menu at all. Do you figures out something ? – guiltance Aug 24 '20 at 07:20
  • Upvoted for `button.showsMenuAsPrimaryAction` – Ayan Sengupta Jul 01 '21 at 19:08
  • @Foster: Programmatically pushing buttons is reserved for tests. Apple doesn't want malicious apps writing code pushing buttons to reduce the risk of some code operating app and do something a user doesn't want. – clearlight Jun 27 '22 at 23:37
8

The context menu is, by design, automatically shown by the system when an appropriate gesture (a force touch or a long press) occurs. You can't manually show it.

From docs:

A context menu interaction object tracks Force Touch gestures on devices that support 3D Touch, and long-press gestures on devices that don't support it.

UIKit manages all menu-related interactions and reports the selected action, if any, back to your app.

UPDATE:

Although it's still not possible to manually show the context menu in iOS 14, it's now possible to show the UIMenu we create for the context menu as a pull-down menu. Check @Lobo's answer for how to do that on iOS 14.

matt
  • 515,959
  • 87
  • 875
  • 1,141
Hejazi
  • 16,587
  • 9
  • 52
  • 67
6

Using a private API can do this job.

@objc
func buttonTapped() {
    // _presentMenuAtLocation:
    guard let interaction = imageView.interactions.first,
          let data = Data(base64Encoded: "X3ByZXNlbnRNZW51QXRMb2NhdGlvbjo="),
          let str = String(data: data, encoding: .utf8)
    else {
        return
    }
    let selector = NSSelectorFromString(str)
    guard interaction.responds(to: selector) else {
        return
    }
    interaction.perform(selector, with: CGPoint.zero)
}
Lane
  • 61
  • 1
  • 2
  • Private API is forbidden... – Medhi May 06 '22 at 15:36
  • If you want your app rejected from the App store, go ahead and use undocumented APIs. Also use hacks and undocumented APIs if you want your app to break when Apple makes some arbitrary change for some area they aren't obligated to publicly support. – clearlight Jun 27 '22 at 23:33
2

I found a way to show UIMenu WITHOUT CLICK and without using of private api.

The key is realizing that the system shows the menu by corresponding gesture recoginzers and we can do the same.

Below is an example of showing a menu of the UIButton after 1.0sec without click the button.

It works even if the UIButton is hidden.

This approch can be extended to other views. However there's some restrictions. For example when applying to UIView, because it's the long press recognizer that shows the menu, we need to pass a real UIEvent with timestamp info. However, we can't change UIEvent.timestamp without help of private api. In this case, we will need real interactions and pass the recorded real touchBegan UIEvent to the long press recoginzer(the event can be reused).

So in order to show UIMenu without user interactions for other views that require long press, we can declare a hidden UIButton(need to add to some view) and set showsMenuAsPrimaryAction to true and actually show UIButton's menu.

class TestViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let btn = UIButton(frame: CGRect(x: 100, y: 100, width: 100, height: 50))
        btn.backgroundColor = .lightGray
        self.view.addSubview(btn)
        
        // these two lines actually create the recognizers for showing the menu
        btn.menu = getMenu()
        btn.showsMenuAsPrimaryAction = true
        
        for r in btn.gestureRecognizers! {
            print(type(of: r))
        }
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            print("show context menu without click")
            // This is the gesture recognizer that shows the menu when it receive any touch began event
            let r = btn.gestureRecognizers![2]
            r.touchesBegan([], with: UIEvent())
        }
    }
    
    private func getMenu() -> UIMenu {
        let inspectAction =
            UIAction(title: NSLocalizedString("InspectTitle", comment: ""),
                     image: UIImage(systemName: "arrow.up.square")) { action in
            }
            
        let deleteAction =
            UIAction(title: NSLocalizedString("DeleteTitle", comment: ""),
                     image: UIImage(systemName: "trash"),
                     attributes: .destructive) { action in
            }
                                        
        return UIMenu(title: "", children: [inspectAction, deleteAction])
    }
}

In practice, better find the actual recognizer by its type instead of using absoulte index if you're not sure.

Showing hidden button's menu:

showing hidden button's menu

laishere
  • 95
  • 7