'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.
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:
- Create your listening component as an
NSView
. - Wrap it with a SwiftUI view that implements
NSViewRepresentable
. - 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 |