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))
6
  • 1
    Your first link is broken. Private repo?
    – Gil Birman
    Commented Dec 11, 2019 at 7:58
  • .onTapGesture() check it out.
    – ABC
    Commented Dec 11, 2019 at 9:00
  • Whoops, you're right @GilBirman! Fixed; sorry about that
    – Ky -
    Commented Dec 11, 2019 at 16:37
  • @Raymond I tried that first. Unless I'm missing something big, it seems to behave identically to .gesture(TapGesture().onEnded(.......))
    – Ky -
    Commented Dec 11, 2019 at 16:41
  • Apple now has the ContextMenu feature for this. developer.apple.com/documentation/swiftui/contextmenu
    – adamek
    Commented Feb 8 at 12:51

6 Answers 6

12
+150

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.

6
  • 1
    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 along
    – Ky -
    Commented Jan 11, 2020 at 6:40
  • 3
    Welp; looks like this is the best answer today. The bounty is yours! Hopefully someday we'll have an official solution.
    – Ky -
    Commented Jan 17, 2020 at 19:18
  • 1
    @B.T. is that still the recommended solution or did SwiftUI 2.0 bring here some improvements?
    – mica
    Commented Jan 7, 2021 at 10:23
  • 1
    @mica 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/eventmodifiers
    – B.T.
    Commented Jan 12, 2021 at 5:17
  • 1
    @B.T. How di you apply RightClickableSwiftUIView to a Text() to some other SwiftUI-View?
    – mica
    Commented Feb 7, 2021 at 15:10
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

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")
                        }
                )
1
  • 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. Commented Mar 22, 2021 at 22:31
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.

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.

2
  • Very well done! I appreciate how this fits in so well with SwiftUI's syntax. Thank you!
    – Ky -
    Commented Apr 13 at 22:37
  • Thanks! I hope it works well for you.
    – Gryzle
    Commented Apr 15 at 16:02
0

add this to your view. works on macos

.contextMenu {
    Button("Remove") {
        print("remove this view")
    }
}
2
  • 2
    Thank you for this interesting solution, but it doesn't fit my problem and seems to just restate Colin's answer
    – Ky -
    Commented Apr 25, 2022 at 19:51
  • This is the correct solution Commented May 12, 2022 at 14:46

Not the answer you're looking for? Browse other questions tagged or ask your own question.