3

Currently we can easily track the pointer position inside a view during a drag gesture thanks to DragGesture in SwiftUI because this gesture exposes the start position and the translation relative to a given coordinate space:

struct Example: View {
    @GestureState private var startPosition: CGPoint = .zero
    @GestureState private var translation: CGSize = .zero
    
    var body: some View {
        Rectangle()
            .fill(Color.black)
            .frame(width: 600, height: 400)
            .coordinateSpace(name: "rectangle")
            .overlay(coordinates)
            .gesture(dragGesture)
    }
    
    var dragGesture: some Gesture {
        DragGesture(minimumDistance: 5, coordinateSpace: .named("rectangle"))
            .updating($startPosition) { (value, state, _) in
                state = value.startLocation
            }
            .updating($translation) { (value, state, _) in
                state = value.translation
            }
    }
    
    var coordinates: some View {
        Text("\(startPosition.x) \(startPosition.y) \(translation.width) \(translation.height)")
            .foregroundColor(.white)
            .font(Font.body.monospacedDigit())
    }
}

On the opposite TapGesture does not give any information about the position of the tap it only signals when the tap occurred:

var tapGesture: some Gesture {
    TapGesture()
        .onEnded { _ in
            print("A tap occurred")
        }
}

Is there a simple way to get the pointer location when a tap gesture occurs?

I found little solutions around the web but they usually require a lot of boilerplate or are not handy.

A wrapper around some UIKit think could do the trick, any idea?

Louis Lac
  • 5,298
  • 1
  • 21
  • 36

1 Answers1

2

Update iOS 16

Starting form iOS 16 / macOS 13, the new onTapGesture modifier makes available the location of the tap/click in the action closure as a CGPoint:

struct ContentView: View {
  var body: some View {
    Rectangle()
      .frame(width: 200, height: 200)
      .onTapGesture { location in 
        print("Tapped at \(location)")
      }
  }
}

Original Answer

Edit

I found a better solution by experimenting with simultaneous gestures. This solution integrated perfectly with SwiftUI gestures and works similarly to other existing gestures:

import SwiftUI

struct ClickGesture: Gesture {
    let count: Int
    let coordinateSpace: CoordinateSpace
    
    typealias Value = SimultaneousGesture<TapGesture, DragGesture>.Value
    
    init(count: Int = 1, coordinateSpace: CoordinateSpace = .local) {
        precondition(count > 0, "Count must be greater than or equal to 1.")
        self.count = count
        self.coordinateSpace = coordinateSpace
    }
    
    var body: SimultaneousGesture<TapGesture, DragGesture> {
        SimultaneousGesture(
            TapGesture(count: count),
            DragGesture(minimumDistance: 0, coordinateSpace: coordinateSpace)
        )
    }
    
    func onEnded(perform action: @escaping (CGPoint) -> Void) -> _EndedGesture<ClickGesture> {
        ClickGesture(count: count, coordinateSpace: coordinateSpace)
            .onEnded { (value: Value) -> Void in
                guard value.first != nil else { return }
                guard let location = value.second?.startLocation else { return }
                guard let endLocation = value.second?.location else { return }
                guard ((location.x-1)...(location.x+1)).contains(endLocation.x),
                      ((location.y-1)...(location.y+1)).contains(endLocation.y) else {
                    return
                }
                
                action(location)
            }
    }
}

extension View {
    func onClickGesture(
        count: Int,
        coordinateSpace: CoordinateSpace = .local,
        perform action: @escaping (CGPoint) -> Void
    ) -> some View {
        gesture(ClickGesture(count: count, coordinateSpace: coordinateSpace)
            .onEnded(perform: action)
        )
    }
    
    func onClickGesture(
        count: Int,
        perform action: @escaping (CGPoint) -> Void
    ) -> some View {
        onClickGesture(count: count, coordinateSpace: .local, perform: action)
    }
    
    func onClickGesture(
        perform action: @escaping (CGPoint) -> Void
    ) -> some View {
        onClickGesture(count: 1, coordinateSpace: .local, perform: action)
    }
}

Previous Workaround

Based on this SO question linked in a comment, I adapted the UIKit solution suitable for macOS.

Apart from changing the types I added a computation to compute the position in the macOS coordinate system style. There is still an ugly force unwrap but I do not know enough AppKit to remove it safely.

You should use this modifier before other gestures modifiers if you want multiple simultaneous gestures on one view.

Currently the CoordinateSpace argument only works for .local and .global but is not implemented correctly for .named(Hashable).

If you have solutions for some of those issues I'll update the answer.

Here is the code:

public extension View {
    func onTapWithLocation(count: Int = 1, coordinateSpace: CoordinateSpace = .local, _ tapHandler: @escaping (CGPoint) -> Void) -> some View {
        modifier(TapLocationViewModifier(tapHandler: tapHandler, coordinateSpace: coordinateSpace, count: count))
    }
}

fileprivate struct TapLocationViewModifier: ViewModifier {
    let tapHandler: (CGPoint) -> Void
    let coordinateSpace: CoordinateSpace
    let count: Int
    
    func body(content: Content) -> some View {
        content.overlay(
            TapLocationBackground(tapHandler: tapHandler, coordinateSpace: coordinateSpace, numberOfClicks: count)
        )
    }
}

fileprivate struct TapLocationBackground: NSViewRepresentable {
    let tapHandler: (CGPoint) -> Void
    let coordinateSpace: CoordinateSpace
    let numberOfClicks: Int
    
    func makeNSView(context: NSViewRepresentableContext<TapLocationBackground>) -> NSView {
        let v = NSView(frame: .zero)
        let gesture = NSClickGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.tapped))
        gesture.numberOfClicksRequired = numberOfClicks
        v.addGestureRecognizer(gesture)
        return v
    }
    
    final class Coordinator: NSObject {
        let tapHandler: (CGPoint) -> Void
        let coordinateSpace: CoordinateSpace
        
        init(handler: @escaping ((CGPoint) -> Void), coordinateSpace: CoordinateSpace) {
            self.tapHandler = handler
            self.coordinateSpace = coordinateSpace
        }
        
        @objc func tapped(gesture: NSClickGestureRecognizer) {
            let height = gesture.view!.bounds.height
            var point = coordinateSpace == .local
                ? gesture.location(in: gesture.view)
                : gesture.location(in: nil)
            point.y = height - point.y
            tapHandler(point)
        }
    }
    
    func makeCoordinator() -> TapLocationBackground.Coordinator {
        Coordinator(handler: tapHandler, coordinateSpace: coordinateSpace)
    }
    
    func updateNSView(_: NSView, context _: NSViewRepresentableContext<TapLocationBackground>) { }
}
Louis Lac
  • 5,298
  • 1
  • 21
  • 36