'How can I detect a right-click in SwiftUI?

I'm writing a simple Mines app to help me get to know SwiftUI. As such, I want primary click (usually LMB) to "dig" (reveal whether there's a mine there), and secondary click (usually RMB) to place a flag.

I have the digging working! But I can't figure out how to place a flag, because I can't figure out how to detect a secondary click.

Here's what I'm trying:

BoardSquareView(
    style: self.style(for: square),
    model: square
)
.gesture(TapGesture().modifiers(.control).onEnded(self.handleUserDidAltTap(square)))
.gesture(TapGesture().onEnded(self.handleUserDidTap(square)))

As I implied earlier, the function returned by handleUserDidTap is called properly on click, but the one returned by handleUserDidAltTap is only called when I hold down the Control key. That makes sense because that's what the code says... but I don't see any API which could make it register secondary clicks, so I don't know what else to do.

I also tried this, but the behavior seemed identical:

BoardSquareView(
    style: self.style(for: square),
    model: square
)
.gesture(TapGesture().modifiers(.control).onEnded(self.handleUserDidAltTap(square)))
.onTapGesture(self.handleUserDidTap(square))


Solution 1:[1]

As things stand with SwiftUI right now, this isn't directly possible. I am sure it will be in the future, but at the moment, the TapGesture is clearly focused mainly on the iOS use cases which don't have a concept of a "right click" so I think that is why this was ignored. Notice the "long press" concept is a first-class citizen in the form of the LongPressGesture, and that is almost exclusively used in an iOS context, which supports this theory.

That said, I did figure out a way to make this work. What you have to do is fall back on the older technology, and embed it into your SwiftUI view.

struct RightClickableSwiftUIView: NSViewRepresentable {
    func updateNSView(_ nsView: RightClickableView, context: NSViewRepresentableContext<RightClickableSwiftUIView>) {
        print("Update")
    }
    
    func makeNSView(context: Context) -> RightClickableView {
        RightClickableView()
    }
}

class RightClickableView: NSView {
    override func mouseDown(with theEvent: NSEvent) {
        print("left mouse")
    }
    
    override func rightMouseDown(with theEvent: NSEvent) {
        print("right mouse")
    }
}

I tested this, and it worked for me inside a fairly complex SwiftUI application. The basic approach here is:

  1. Create your listening component as an NSView.
  2. Wrap it with a SwiftUI view that implements NSViewRepresentable.
  3. Plop your implementation into the UI where you want it, just like you would do with any other SwiftUI view.

Not an ideal solution, but it might be good enough for right now. I hope this solves your problem until Apple expands SwiftUI's capabilities further.

Solution 2:[2]

Although I like (and up-voted) the accepted answer, I found a way for a view to respond to right-mouse button events without having to conform to NSViewRepresentable. This approach integrated more cleanly into my app, so it may be worth considering as one possibility for someone else facing this problem.

My solution involves first being willing to accept the convention that right-clicking and control-left-clicking are traditionally treated as equivalent in macOS. This solution doesn't allow for handling control-right-clicking differently from control-left-clicking. But any other modifiers are handled, since it only adds .control to them and converts it to a left-click.

This might break SwiftUI contextual menus, if you use them. I haven't tested that.

So idea is to translate right mouse button events into left mouse button events with a control-key modifier.

To accomplish this I subclassed NSHostingView, and provided a convenience extension on NSEvent

// -------------------------------------
fileprivate extension NSEvent
{
    // -------------------------------------
    var translateRightMouseButtonEvent: NSEvent
    {
        guard let cgEvent = self.cgEvent else { return self }
        
        switch type
        {
            case .rightMouseDown: cgEvent.type = .leftMouseDown
            case .rightMouseUp: cgEvent.type = .leftMouseUp
            case .rightMouseDragged: cgEvent.type = .leftMouseDragged
                
            default: return self
        }
        
        cgEvent.flags.formUnion(.maskControl)
        
        guard let nsEvent = NSEvent(cgEvent: cgEvent) else { return self }
        
        return nsEvent
    }
}

// -------------------------------------
class MyHostingView<Content: View>: NSHostingView<Content>
{
    // -------------------------------------
    @objc public override func rightMouseDown(with event: NSEvent) {
        super.mouseDown(with: event.translateRightMouseButtonEvent)
    }
    
    // -------------------------------------
    @objc public override func rightMouseUp(with event: NSEvent) {
        super.mouseUp(with: event.translateRightMouseButtonEvent)
    }
    
    // -------------------------------------
    @objc public override func rightMouseDragged(with event: NSEvent) {
        super.mouseDragged(with: event.translateRightMouseButtonEvent)
    }
}

Then in AppDelegate.didFinishLaunching I changed

        window.contentView = NSHostingView(rootView: contentView)

to

        window.contentView = MyHostingView(rootView: contentView)

Of course one would have to make similar changes in any other code that might refer to NSHostingView. Often the reference in AppDelegate is the only one, but in a significant project there might be others.

The right mouse button events then appear in SwiftUI code as a TapGesture with a .control modifier.

            Text("Right-clickable Text")
                .gesture(
                    TapGesture().modifiers(.control)
                        .onEnded
                        { _ in
                            print("Control-Clicked")
                        }
                )

Solution 3:[3]

This doesn't precisely solve the minesweeper use case, but contextMenu is one way to handle right clicks in SwiftUI macOS apps.

For example:

.contextMenu {
    Button(action: {}, label: { Label("Menu title", systemImage: "icon") })
}

will respond to a right click on macOS and a long press on iOS

Solution 4:[4]

Unfortunately accepted solution is not suitable for me because there is no visual feedback when click on NSStatusItem.button. What worked for me is to detect right click in button's touch handler:

func setupStatusBarItem() {
        statusBarItem.button?.action = #selector(didPressBarItem(_:))
        statusBarItem.button?.target = self
        statusBarItem.button?.sendAction(on: [.leftMouseDown, .rightMouseDown])
}

@objc func didPressBarItem(_ sender: AnyObject?) {
        if let event = NSApp.currentEvent, event.isRightClick {
            print("right")
        } else {
            print("left")
        }
}

extension NSEvent {
    var isRightClick: Bool {
        let rightClick = (self.type == .rightMouseDown)
        let controlClick = self.modifierFlags.contains(.control)
        return rightClick || controlClick
    }
}

Inspired by article.

Solution 5:[5]

add this to your view. works on macos

.contextMenu {
    Button("Remove") {
        print("remove this view")
    }
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Lukas Würzburger
Solution 2
Solution 3 Colin Tremblay
Solution 4 alex1704
Solution 5 Luke Price