How to detect the location of a tap inside a view
How to detect the location of a tap inside a view êŽë š
Updated for Xcode 15
Updated for iOS 16
SwiftUI has an onTapGesture()
variant that lets us detect the exact location of a tap, either relative to a view's bounds or globally on the whole screen.
As an example, this displays a red circle, and prints the relative location of any taps it receives:
Circle()
.fill(.red)
.frame(width: 100, height: 100)
.onTapGesture { location in
print("Tapped at \(location)")
}
âRelative locationâ means relative to the circle's bounds â as the circle is 100x100 in size, if you tap the exact center it would print 50x50 regardless of where the circle was placed on the screen.
If you want the global location â i.e., where your tap took place relative to the top-left corner of the entire screen â you should add the coordinateSpace
parameter like this:
Circle()
.fill(.red)
.frame(width: 100, height: 100)
.onTapGesture(coordinateSpace: .global) { location in
print("Tapped at \(location)")
}
This onTapGesture()
variant is available from iOS 16 and later. If you're looking to do something similar on earlier versions of iOS, we can build something similar by wrapping UIKit and sprinkling some SwiftUI sugar on top to make it easy to use.
This takes quite a bit of code, so I want to list the exact steps we'll be following first, then provide all the code with extra comments afterwards. The end result is a reusable view modifier you can attach to any view â text, image, etc â that can detect touches starting, ending, and moving, depending on what you want.
First, the steps:
- We need to create a
UIViewRepresentable
that can wrap a customUIView
subclass. - This
UIView
subclass will implementtouchesBegan()
,touchesMoved()
,touchesEnded()
, andtouchesCancelled()
, so we can track the user's touches as they happen. - Each of those
UIView
methods will find the touch's location in the view, then send it upwards as aCGPoint
if appropriate. - We'll decide the âif appropriateâ part based on two things: which touch events the user has asked us to track (perhaps they just want
touchesBegan()
for example), and whether they want us to carry on tracking events after their finger has left the view. - We'll create a new struct that conforms to
ViewModifier
, using theoverlay()
modifier to place ourUIViewRepresentable
over any other view. Overlays automatically resize themselves to be the same as their parent view, which is perfect here. - Create a
View
extension to add anonTouch()
modifier, which makes the finished API pleasant to use. It's a lot, I know, but it does work really well as you'll see. Here's the code:
// Our UIKit to SwiftUI wrapper view
struct TouchLocatingView: UIViewRepresentable {
// The types of touches users want to be notified about
struct TouchType: OptionSet {
let rawValue: Int
static let started = TouchType(rawValue: 1 << 0)
static let moved = TouchType(rawValue: 1 << 1)
static let ended = TouchType(rawValue: 1 << 2)
static let all: TouchType = [.started, .moved, .ended]
}
// A closure to call when touch data has arrived
var onUpdate: (CGPoint) -> Void
// The list of touch types to be notified of
var types = TouchType.all
// Whether touch information should continue after the user's finger has left the view
var limitToBounds = true
func makeUIView(context: Context) -> TouchLocatingUIView {
// Create the underlying UIView, passing in our configuration
let view = TouchLocatingUIView()
view.onUpdate = onUpdate
view.touchTypes = types
view.limitToBounds = limitToBounds
return view
}
func updateUIView(_ uiView: TouchLocatingUIView, context: Context) {
}
// The internal UIView responsible for catching taps
class TouchLocatingUIView: UIView {
// Internal copies of our settings
var onUpdate: ((CGPoint) -> Void)?
var touchTypes: TouchLocatingView.TouchType = .all
var limitToBounds = true
// Our main initializer, making sure interaction is enabled.
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = true
}
// Just in case you're using storyboards!
required init?(coder: NSCoder) {
super.init(coder: coder)
isUserInteractionEnabled = true
}
// Triggered when a touch starts.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
send(location, forEvent: .started)
}
// Triggered when an existing touch moves.
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
send(location, forEvent: .moved)
}
// Triggered when the user lifts a finger.
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
send(location, forEvent: .ended)
}
// Triggered when the user's touch is interrupted, e.g. by a low battery alert.
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
send(location, forEvent: .ended)
}
// Send a touch location only if the user asked for it
func send(_ location: CGPoint, forEvent event: TouchLocatingView.TouchType) {
guard touchTypes.contains(event) else {
return
}
if limitToBounds == false || bounds.contains(location) {
onUpdate?(CGPoint(x: round(location.x), y: round(location.y)))
}
}
}
}
// A custom SwiftUI view modifier that overlays a view with our UIView subclass.
struct TouchLocater: ViewModifier {
var type: TouchLocatingView.TouchType = .all
var limitToBounds = true
let perform: (CGPoint) -> Void
func body(content: Content) -> some View {
content
.overlay(
TouchLocatingView(onUpdate: perform, types: type, limitToBounds: limitToBounds)
)
}
}
// A new method on View that makes it easier to apply our touch locater view.
extension View {
func onTouch(type: TouchLocatingView.TouchType = .all, limitToBounds: Bool = true, perform: @escaping (CGPoint) -> Void) -> some View {
self.modifier(TouchLocater(type: type, limitToBounds: limitToBounds, perform: perform))
}
}
// Finally, here's some example code you can try out.
struct ContentView: View {
var body: some View {
VStack {
Text("This will track all touches, inside bounds only.")
.padding()
.background(.red)
.onTouch(perform: updateLocation)
Text("This will track all touches, ignoring bounds â you can start a touch inside, then carry on moving it outside.")
.padding()
.background(.blue)
.onTouch(limitToBounds: false, perform: updateLocation)
Text("This will track only starting touches, inside bounds only.")
.padding()
.background(.green)
.onTouch(type: .started, perform: updateLocation)
}
}
func updateLocation(_ location: CGPoint) {
print(location)
}
}