4

Can anyone think how to call an action when double clicking a NavigationLink in a List in MacOS? I've tried adding onTapGesture(count:2) but it does not have the desired effect and overrides the ability of the link to be selected reliably.

var body: some View {
    NavigationView {
        List {
            ForEach(items) { item in
                NavigationLink(destination: Item(itemDetail: item)) {
                    ItemRow(itemRow: item) //<-my row view
                }.buttonStyle(PlainButtonStyle())
                 .simultaneousGesture(TapGesture(count:2)
                 .onEnded {
                     print("double tap")
                })
            }
        }
    }
}

EDIT:

I've set up a tag/selection in the NavigationLink and can now double or single click the content of the row. The only trouble is, although the itemDetail view is shown, the "active" state with the accent does not appear on the link. Is there a way to either set the active state (highlighted state) or extend the NavigationLink functionality to accept double tap as well as a single?

@State var selection:String?
var body: some View {
    NavigationView {
        List {
            ForEach(items) { item in
                NavigationLink(destination: Item(itemDetail: item), tag: item.id, selection: self.$selection) {
                    ItemRow(itemRow: item) //<-my row view
                }.onTapGesture(count:2) { //<- Needed to be first!
                    print("doubletap")
                }.onTapGesture(count:1) {
                    self.selection = item.id
                }
            }
        }
    }
}
mousebat
  • 474
  • 7
  • 25

4 Answers4

4

Here's another solution that seems to work the best for me. It's a modifier that adds an NSView which does the actual handling. Works in List even with selection:

extension View {
    /// Adds a double click handler this view (macOS only)
    ///
    /// Example
    /// ```
    /// Text("Hello")
    ///     .onDoubleClick { print("Double click detected") }
    /// ```
    /// - Parameters:
    ///   - handler: Block invoked when a double click is detected
    func onDoubleClick(handler: @escaping () -> Void) -> some View {
        modifier(DoubleClickHandler(handler: handler))
    }
}

struct DoubleClickHandler: ViewModifier {
    let handler: () -> Void
    func body(content: Content) -> some View {
        content.background {
            DoubleClickListeningViewRepresentable(handler: handler)
        }
    }
}

struct DoubleClickListeningViewRepresentable: NSViewRepresentable {
    let handler: () -> Void
    func makeNSView(context: Context) -> DoubleClickListeningView {
        DoubleClickListeningView(handler: handler)
    }
    func updateNSView(_ nsView: DoubleClickListeningView, context: Context) {}
}

class DoubleClickListeningView: NSView {
    let handler: () -> Void

    init(handler: @escaping () -> Void) {
        self.handler = handler
        super.init(frame: .zero)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)
        if event.clickCount == 2 {
            handler()
        }
    }
}

https://gist.github.com/joelekstrom/91dad79ebdba409556dce663d28e8297

Accatyyc
  • 5,798
  • 4
  • 36
  • 51
  • 1
    Really great solution that works well even with List items and do not conflict with List's selection. Really apreciated! Absolutely sure the most powerful solution here! – Andrew_STOP_RU_WAR_IN_UA Nov 29 '22 at 02:41
1

I've tried all these solutions but the main issue is using gesture or simultaneousGesture overrides the default single tap gesture on the List view which selects the item in the list. As such, here's a simple method I thought of to retain the default single tap gesture (select row) and handle a double tap separately.

struct ContentView: View {
    @State private var monitor: Any? = nil
    @State private var hovering = false
    @State private var selection = Set<String>()
    
    let fruits = ["apple", "banana", "plum", "grape"]

    var body: some View {
        List(fruits, id: \.self, selection: $selection) { fruit in
            VStack {
                Text(fruit)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .clipShape(Rectangle()) // Allows the hitbox to be the entire word not the if you perfectly press the text  
            }
            .onHover {
                hovering = $0
            }
        }
        .onAppear {
            monitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) {
                if $0.clickCount == 2 && hovering { // Checks if mouse is actually hovering over the button or element
                    print("Double Tap!") // Run action
                }
                return $0
            }
        }
        .onDisappear {
            if let monitor = monitor {
                NSEvent.removeMonitor(monitor)
            }
        }
    }
}

This works if you just need to single tap to select and item, but only do something if the user double taps. If you want to handle a single tap and a double tap, there still remains the problem of single tap running when its a double tap. A potential work around would be to capture and delay the single tap action by a few hundred ms and cancel it if it was a double tap action

Denny L.
  • 219
  • 1
  • 12
  • This is the only one that seems to work as expected for me. Thanks – Accatyyc Apr 04 '22 at 09:25
  • I just added my latest edit to the code with a new `hovering` variable to make sure the action only runs when you want it to. Before it would run anytime you double clicked, even if it was outside of the text. Small oversight on my end, but fixed :) take a look – Denny L. Apr 04 '22 at 21:07
  • 1
    Thanks! Actually, I had some issues with this one (hover doesn't always seems to work correctly for me). Inspired by this I went with another solution which uses an actual NSView, which seems to do the trick. Posted as a separate answer. – Accatyyc Apr 05 '22 at 12:55
  • 1
    Wow that is an awesome solution! +1 thanks so much for sharing. Replaced in my project with yours – Denny L. Apr 05 '22 at 23:50
0

Use simultaneous gesture, like below (tested with Xcode 11.4 / macOS 10.15.5)

NavigationLink(destination: Text("View One")) {
    Text("ONE")
}
.buttonStyle(PlainButtonStyle())        // << required !!
.simultaneousGesture(TapGesture(count: 2)
    .onEnded { print(">> double tap")})

or .highPriorityGesture(... if you need double-tap has higher priority

Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Unfortunately this seems to have an either/or effect for me. The views inside the link become double tappable but in order for the active selection of the list to update I have to select an unpopulated part of the row. I'll edit my question to show code. – mousebat Jul 22 '20 at 21:59
  • It would be great if there was a way to override the NavigationLink's in-built gesture so it can take a double tap action in the entire row without disturbing the original functionality of displaying the view. – mousebat Jul 24 '20 at 11:37
0

Looking for a similar solution I tried @asperi answer, but had the same issue with tappable areas as the original poster. After trying many variations the following is working for me:

@State var selection: String?
...
NavigationLink(destination: HistoryListView(branch: string), tag: string, selection: self.$selection) {
  Text(string)
    .gesture(
      TapGesture(count:1)
        .onEnded({
          print("Tap Single")
          selection = string
        })
    )
    .highPriorityGesture(
      TapGesture(count:2)
        .onEnded({
          print("Tap Double")
        })
    )
}
Cory Loken
  • 1,395
  • 8
  • 8