How to detect a tap gesture location in SwiftUI?
Asked Answered
A

12

58

(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?

Arduous answered 9/6, 2019 at 10:49 Comment(0)
F
31

Update iOS 16

Starting form iOS 16 / macOS 13, 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))
            }
        }
    }
}
Frozen answered 6/3, 2021 at 9:32 Comment(8)
While looks very slick and resembles a simple tap gesture, I recommend using it with caution cause it is still a simultaneous gesture composition under the hood, which may cause undesirable side effects. As an example, it's possible to put a Tap Gesture (and Tap before Long Press sequence) onto a List row without breaking a scroll, while Drag in the composition above totally breaks it. || For simple view hierarchies the solution should work pretty nice.Wisniewski
Yes, unfortunately it still has drawbacks, but at least less than the UIViewRepresentable solution.Frozen
How can I do this using onLongPressGesture ?? is there an easy way in ios16 ?Gelding
It’s currently not possible. Try the workaround I explained in the original answer using simultaneous gestures.Frozen
Hi, is it possible to use 2 DragGestures simultaneously to implement two-finger pan?Ariellearies
Probably not since it only handles a single digit gesture, though I've not tested it. Use a MagnificationGesture instead.Frozen
it doesn't have a translation attribute in Value, so how can I calculate it?Ariellearies
@Ariellearies Ask a new question please, comments are not appropriated for this purpose.Frozen
A
30

Well, after some tinkering around and thanks to this answer to a different question of mine, I've figured out a way to do it using a UIViewRepresentable (but by all means, let me know if there's an easier way!) This code works for me!

struct ContentView : View {
    @State var points:[CGPoint] = [CGPoint(x:0,y:0), CGPoint(x:50,y:50)]
    var body: some View {
        return ZStack(alignment: .topLeading) {
            Background {
                   // tappedCallback
                   location in
                    self.points.append(location)
                }
                .background(Color.white)
            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))
            }
        }
    }
}

struct Background:UIViewRepresentable {
    var tappedCallback: ((CGPoint) -> Void)

    func makeUIView(context: UIViewRepresentableContext<Background>) -> UIView {
        let v = UIView(frame: .zero)
        let gesture = UITapGestureRecognizer(target: context.coordinator,
                                             action: #selector(Coordinator.tapped))
        v.addGestureRecognizer(gesture)
        return v
    }

    class Coordinator: NSObject {
        var tappedCallback: ((CGPoint) -> Void)
        init(tappedCallback: @escaping ((CGPoint) -> Void)) {
            self.tappedCallback = tappedCallback
        }
        @objc func tapped(gesture:UITapGestureRecognizer) {
            let point = gesture.location(in: gesture.view)
            self.tappedCallback(point)
        }
    }

    func makeCoordinator() -> Background.Coordinator {
        return Coordinator(tappedCallback:self.tappedCallback)
    }

    func updateUIView(_ uiView: UIView,
                       context: UIViewRepresentableContext<Background>) {
    }

}
Arduous answered 9/6, 2019 at 20:51 Comment(4)
Best approach but I'll add Background with a .background or better yet .overlay modifier instead of using an extra ZStack.Zagreus
This worked perfectly and was very clean. I also went with throwing this in a .background rather than stacking it below in a ZStack. Thanks!Stalagmite
This works but does not handle gesture priority well when there is also SwiftUI gestures in the same view.Frozen
@LouisLac I've had a problem where my UITapGestureRecognizer with numberOfTapsRequired = 2 stopped working when one of the parent SwiftUI views had a onTapGesture (for a single tap). What fixed it was wrapping the view with the recognizer in a SwiftUI view that had a no-op .onTapGesture(count: 2) {}Minoru
T
30

I was able to do this with a DragGesture(minimumDistance: 0). Then use the startLocation from the Value on onEnded to find the tap's first location.

Trinidadtrinitarian answered 12/6, 2019 at 17:37 Comment(2)
The issue with this approach is when inside a scrollable superview. Then scrolling will be imposible if started from the view as it will be detected as a "tap" instead.Zagreus
@Rivera you can work around the problem by adding this code to the scrollable superview: .highPriorityGesture(DragGesture(minimumDistance: 10))Laird
H
15

An easy solution is to use the DragGesture and set minimumDistance parameter to 0 so that it resembles the tap gesture:

Color.gray
    .gesture(DragGesture(minimumDistance: 0).onEnded({ (value) in
        print(value.location) // Location of the tap, as a CGPoint.
    }))

In case of a tap gesture it will return the location of this tap. However, it will also return the end location for a drag gesture – what's also referred to as a "touch up event". Might not be the desired behavior, so keep it in mind.

Handout answered 24/2, 2021 at 21:23 Comment(2)
This works but not a good solution to use in listProven
How can I do this using onLongPressGesture ?? is there an easy way in ios16 ??Gelding
U
10

It is also possible to use gestures.

There is a few more work to cancel the tap if a drag occurred or trigger action on tap down or tap up..

struct ContentView: View {
    
    @State var tapLocation: CGPoint?
    
    @State var dragLocation: CGPoint?

    var locString : String {
        guard let loc = tapLocation else { return "Tap" }
        return "\(Int(loc.x)), \(Int(loc.y))"
    }
    
    var body: some View {
        
        let tap = TapGesture().onEnded { tapLocation = dragLocation }
        let drag = DragGesture(minimumDistance: 0).onChanged { value in
            dragLocation = value.location
        }.sequenced(before: tap)
        
        Text(locString)
        .frame(width: 200, height: 200)
        .background(Color.gray)
        .gesture(drag)
    }
}
Urbannai answered 6/1, 2021 at 22:16 Comment(1)
Thank you! I tried so many combinations of gestures!Novelist
I
9

Just in case someone needs it, I have converted the above answer into a view modifier which also takes a CoordinateSpace as an optional parameter

import SwiftUI
import UIKit

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

fileprivate struct TapLocationViewModifier: ViewModifier {
  let tapHandler: (CGPoint) -> Void
  let coordinateSpace: CoordinateSpace

  func body(content: Content) -> some View {
    content.overlay(
      TapLocationBackground(tapHandler: tapHandler, coordinateSpace: coordinateSpace)
    )
  }
}

fileprivate struct TapLocationBackground: UIViewRepresentable {
  var tapHandler: (CGPoint) -> Void
  let coordinateSpace: CoordinateSpace

  func makeUIView(context: UIViewRepresentableContext<TapLocationBackground>) -> UIView {
    let v = UIView(frame: .zero)
    let gesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.tapped))
    v.addGestureRecognizer(gesture)
    return v
  }

  class Coordinator: NSObject {
    var tapHandler: (CGPoint) -> Void
    let coordinateSpace: CoordinateSpace

    init(handler: @escaping ((CGPoint) -> Void), coordinateSpace: CoordinateSpace) {
      self.tapHandler = handler
      self.coordinateSpace = coordinateSpace
    }

    @objc func tapped(gesture: UITapGestureRecognizer) {
      let point = coordinateSpace == .local
        ? gesture.location(in: gesture.view)
        : gesture.location(in: nil)
      tapHandler(point)
    }
  }

  func makeCoordinator() -> TapLocationBackground.Coordinator {
    Coordinator(handler: tapHandler, coordinateSpace: coordinateSpace)
  }

  func updateUIView(_: UIView, context _: UIViewRepresentableContext<TapLocationBackground>) {
    /* nothing */
  }
}
Illampu answered 21/10, 2020 at 14:41 Comment(2)
This worked perfectly for me. I needed a global value so I just changed CoordinateSpace = .local to CoordinateSpace = .global.Halfhearted
This is the only thing that works correctly when wrapped in a scroll view, if you can't use the new onTapGesture(perform:) available in iOS 17.Frampton
I
9

Using some of the answers above, I made a ViewModifier that is maybe useful:

struct OnTap: ViewModifier {
    let response: (CGPoint) -> Void
    
    @State private var location: CGPoint = .zero
    func body(content: Content) -> some View {
        content
            .onTapGesture {
                response(location)
            }
            .simultaneousGesture(
                DragGesture(minimumDistance: 0)
                    .onEnded { location = $0.location }
            )
    }
}

extension View {
    func onTapGesture(_ handler: @escaping (CGPoint) -> Void) -> some View {
        self.modifier(OnTap(response: handler))
    }
}

Then use like so:

Rectangle()
    .fill(.green)
    .frame(width: 200, height: 200)
    .onTapGesture { location in 
        print("tapped: \(location)")
    }
Implicate answered 28/10, 2021 at 19:12 Comment(0)
T
5

Using DragGesture with minimumDistance broke scroll gestures on all the views that are stacked under. Using simultaneousGesture did not help. What ultimately did it for me was using sequencing the DragGesture to a TapGesture inside simultaneousGesture, like so:

.simultaneousGesture(TapGesture().onEnded {
    // Do something                                    
}.sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .global).onEnded { value in
    print(value.startLocation)
}))
Tailored answered 15/4, 2022 at 11:29 Comment(0)
S
2

Starting iOS 17 and macOS 14 we have MapProxy and can use it get the coordinate based on the location of the gesture.

Example taken from Xcode (but not available on documentation nor on the WWDC sessions).

struct ContentView: View {

    @State private var markerCoordinate: CLLocationCoordinate2D = .office

    var body: some View {
        MapReader { proxy in
            Map {
                Marker("Marker", coordinate: markerCoordinate)
            }
            .onTapGesture { location in
                if let coordinate = proxy.convert(location, from: .local) {
                    markerCoordinate = coordinate
                }
            }
        }
    }
}
Squib answered 12/9, 2023 at 8:22 Comment(2)
Great feature, thanks for sharing it with us. It would be even better if we can use it with .onLongPressGesture. Looks like for the moment is not possible because the latter one doesn't take arguments. Do you know any workarounds?Cima
@Cima I did investigate for a while but couldn't find a workaround to enable long press gesture. I ended up with a different solution for those cases.Squib
L
0

In iOS 16 and MacOS 13 there are better solutions, but to stay compatible with older os versions, I use this rather simple gesture, which also has the advantage of distinguish between single- and double-click.

 var combinedClickGesture: some Gesture {
    SimultaneousGesture(ExclusiveGesture(TapGesture(count: 2),TapGesture(count: 1)), DragGesture(minimumDistance: 0) )
        .onEnded { value in
            if let v1 = value.first {
                var count: Int
                switch v1 {
                case .first():  count = 2
                case .second(): count = 1
                }
                if let v2 = value.second {
                    print("combinedClickGesture couunt = \(count) location = \(v2.location)")
                }
            }
        }
}

As pointed out several times before it´s a problem when the view already is using DragGesture, but often it is fixed when using the modifier: .simultaneousGesture(combinedClickGesture) instead of .gesture(combinedClickGesture)

Leukemia answered 16/12, 2022 at 22:24 Comment(0)
S
0

Since macOS 13 and iOS 16 there is available SpatialTapGesture, which detects a location of a tap

Stolon answered 26/3, 2023 at 14:9 Comment(0)
C
-1

Posting this for others who still have to support iOS 15.

It's also possible using GeometryReader and CoordinateSpace. The only downside is depending on your use case you might have to specify the size of the geometry reader.

VStack {
    Spacer()

    GeometryReader { proxy in
        Button {
            print("Global tap location: \(proxy.frame(in: .global).center)")
            print("Custom coordinate space tap location: \(proxy.frame(in: .named("StackOverflow")))")
        } label: {
            Text("Tap me I know you want it")
        }
        .frame(width: 42, height: 42)
    }
    .frame(width: 42, height: 42)

    Spacer()
}
.coordinateSpace(name: "StackOverflow")
Corpuz answered 15/1, 2023 at 11:6 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.