42

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

Epaga
  • 36,890
  • 58
  • 154
  • 243

8 Answers8

28

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

}
Epaga
  • 36,890
  • 58
  • 154
  • 243
  • 1
    Best approach but I'll add `Background` with a `.background` or better yet `.overlay` modifier instead of using an extra `ZStack`. – Rivera Nov 18 '19 at 19:19
  • 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! – andrewcar Jan 09 '21 at 17:23
  • This works but does not handle gesture priority well when there is also SwiftUI gestures in the same view. – Louis Lac Apr 23 '21 at 07:44
  • 1
    @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) {}` – robinst Sep 18 '21 at 07:33
23

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.

A. Lucas
  • 221
  • 3
  • 11
tall-adam
  • 231
  • 1
  • 3
  • 11
    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. – Rivera Nov 18 '19 at 19:16
  • 1
    @Rivera you can work around the problem by adding this code to the scrollable superview: `.highPriorityGesture(DragGesture(minimumDistance: 10))` – Alexander Poleschuk Feb 10 '22 at 09:49
12

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.

przemekblasiak
  • 455
  • 4
  • 9
8

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)
    }
}
Moose
  • 2,444
  • 22
  • 23
8

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))
            }
        }
    }
}
Louis Lac
  • 3,132
  • 18
  • 26
  • 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. – Shengchalover May 05 '21 at 08:23
  • Yes, unfortunately it still has drawbacks, but at least less than the `UIViewRepresentable` solution. – Louis Lac May 05 '21 at 08:41
7

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 */
  }
}
Moose
  • 2,444
  • 22
  • 23
Yiannis
  • 71
  • 1
  • 1
  • This worked perfectly for me. I needed a global value so I just changed `CoordinateSpace = .local` to `CoordinateSpace = .global`. – DEVIGNR Jan 10 '21 at 10:39
4

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)")
    }
Logan
  • 50,758
  • 20
  • 93
  • 126
1

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)
}))
Arun Reddy
  • 2,883
  • 1
  • 19
  • 16