Search code examples
swiftswiftui

How to detect a tap gesture location in SwiftUI?


(For SwiftUI, not vanilla UIKit) Very simple example code to, say, display red boxes on a gray background:

struct ContentView : View {
    @State var points:[CGPoint] = [CGPoint(x:0,y:0), CGPoint(x:50,y:50)]
    var body: some View {
        return ZStack {
            Color.gray
                .tapAction {
                   // TODO: add an entry to self.points of the location of the tap
                }
            ForEach(self.points.identified(by: \.debugDescription)) {
                point in
                Color.red
                    .frame(width:50, height:50, alignment: .center)
                    .offset(CGSize(width: point.x, height: point.y))
            }
        }
    }
}

I'm assuming instead of tapAction, I need to have a TapGesture or something? But even there I don't see any way to get information on the location of the tap. How would I go about this?


Solution

  • Update iOS 17

    Starting form iOS 17 / macOS 14, the onTapGesture modifier makes available the location of the tap/click in the action closure:

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

    Original Answer

    The most correct and SwiftUI-compatible implementation I come up with is this one. You can use it like any regular SwiftUI gesture and even combine it with other gestures, manage gesture priority, etc...

    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> {
            self.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)
            }
        }
    }
    

    The above code defines a struct conforming to SwiftUI Gesture protocol. This gesture is a combinaison of a TapGesture and a DragGesture. This is required to ensure that the gesture was a tap and to retrieve the tap location at the same time.

    The onEnded method checks that both gestures occurred and returns the location as a CGPoint through the escaping closure passed as parameter. The two last guard statements are here to handle multiple tap gestures, as the user can tap slightly different locations, those lines introduce a tolerance of 1 point, this can be changed if ones want more flexibility.

    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)
        }
    }
    

    Finally View extensions are defined to offer the same API as onDragGesture and other native gestures.

    Use it like any SwiftUI gesture:

    struct ContentView : View {
        @State var points:[CGPoint] = [CGPoint(x:0,y:0), CGPoint(x:50,y:50)]
        var body: some View {
            return ZStack {
                Color.gray
                    .onClickGesture { point in
                        points.append(point)
                    }
                ForEach(self.points.identified(by: \.debugDescription)) {
                    point in
                    Color.red
                        .frame(width:50, height:50, alignment: .center)
                        .offset(CGSize(width: point.x, height: point.y))
                }
            }
        }
    }