How can I detect a right-click in SwiftUI?
Asked Answered
T

6

18

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))
Termless answered 11/12, 2019 at 6:53 Comment(6)
Your first link is broken. Private repo?Xenophobe
.onTapGesture() check it out.Hamamelidaceous
Whoops, you're right @GilBirman! Fixed; sorry about thatTermless
@Raymond I tried that first. Unless I'm missing something big, it seems to behave identically to .gesture(TapGesture().onEnded(.......))Termless
Apple now has the ContextMenu feature for this. developer.apple.com/documentation/swiftui/contextmenuTrutko
@Trutko I think you didn't read the question, nor see the answersTermless
I
12

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.

Incomer answered 11/1, 2020 at 6:14 Comment(6)
Thank you. This is basically what I was doing for now. Really sad that this is their first impression with the framework. I'll mark this as accepted until (hopefully 🤞🏼) a better solution comes alongTermless
Welp; looks like this is the best answer today. The bounty is yours! Hopefully someday we'll have an official solution.Termless
@Incomer is that still the recommended solution or did SwiftUI 2.0 bring here some improvements?Emanuele
@Emanuele Looking at the official documentation, it does not appear that they've added native support for any gesture that is expressed in terms of mouse-style interactions. They are all expressed in terms of "taps" and finger-style interactions, which means it'll be hard to get to a right-click from this philosophy. Docs are here: developer.apple.com/documentation/swiftui/gestures and here: developer.apple.com/documentation/swiftui/eventmodifiersIncomer
@Incomer How di you apply RightClickableSwiftUIView to a Text() to some other SwiftUI-View?Emanuele
You might try using a ZStack, and placing your right-clickable view "on top" of the Text() view.Incomer
M
6

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

Metamorphose answered 17/12, 2021 at 22:33 Comment(0)
E
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")
                        }
                )
Etka answered 20/3, 2021 at 6:31 Comment(1)
Thanks. BTW - I learned the hard way that if you have multiple .gesture calls attached to the same View, you want to put the ones with modifiers first. I had one that included an unmodified gesture first, and it was the one that was always called. Swapping the order fixed it.Etka
S
1

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.

Scruff answered 23/2, 2021 at 20:21 Comment(0)
E
1

I modified the answer by BT and made it a ViewModifier that can be easily implemented in cross-platform SwiftUI apps (this code will only work on macOS for now, hence the #if os(macOS) blocks).

Implementation:

.rightClickable {
    print("right clicked! yay!")
}

Code:

#if os(macOS)
struct RightClickableSwiftUIView: NSViewRepresentable {
    
    var function: () -> Void
    
    @Binding var onRightClick: Bool
    
    func updateNSView(_ nsView: RightClickableView, context: NSViewRepresentableContext<RightClickableSwiftUIView>) {}
    
    func makeNSView(context: Context) -> RightClickableView {
        RightClickableView(onRightClick: $onRightClick)
    }
}

class RightClickableView: NSView {
    
    required init?(coder: NSCoder) {
        fatalError()
    }
    
    init(onRightClick: Binding<Bool>) {
        _onRightClick = onRightClick
        super.init(frame: NSRect())
    }
    
    @Binding var onRightClick: Bool
    
    override func mouseDown(with theEvent: NSEvent) {
        /// Execute Left Click Code
    }
    
    override func rightMouseDown(with theEvent: NSEvent) {
        /// Execute Right Click Code
        onRightClick.toggle()
    }
}
#endif

struct RightClickableModifier: ViewModifier {
    
    var function: () -> Void
    @State private var onRightClick = false
    
    func body(content: Content) -> some View {
        content
        #if os(macOS)
            .overlay {
                RightClickableSwiftUIView(function: function, onRightClick: $onRightClick)
                    .onChange(of: onRightClick) { _, _ in
                        self.function()
                    }
            }
        #endif
    }
}

extension View {
    func rightClickable(_ function: @escaping () -> Void) -> some View {
        modifier(RightClickableModifier(function: function))
    }
}

This could very easily be adapted to both support executing code for left-clicking as well as supporting long-presses on iOS.

Eudemonics answered 25/3 at 16:33 Comment(2)
Very well done! I appreciate how this fits in so well with SwiftUI's syntax. Thank you!Termless
Thanks! I hope it works well for you.Eudemonics
E
0

add this to your view. works on macos

.contextMenu {
    Button("Remove") {
        print("remove this view")
    }
}
Ebersole answered 24/4, 2022 at 0:48 Comment(2)
Thank you for this interesting solution, but it doesn't fit my problem and seems to just restate Colin's answerTermless
This is the correct solutionBoccioni

© 2022 - 2024 — McMap. All rights reserved.