< How to disable taps for a view using allowsHitTesting() | What’s the difference between @ObservedObject, @State, and @EnvironmentObject? > |
Updated for Xcode 13.3
If you’re looking to detect the coordinate where a tap occurred inside a view, I’m afraid you’re partly out of luck. I say “partly” because SwiftUI does not contain a built-in way to detect the location of taps, however we can build one ourselves 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:
UIViewRepresentable
that can wrap a custom UIView
subclass.UIView
subclass will implement touchesBegan()
, touchesMoved()
, touchesEnded()
, and touchesCancelled()
, so we can track the user’s touches as they happen.UIView
methods will find the touch’s location in the view, then send it upwards as a CGPoint
if appropriate.touchesBegan()
for example), and whether they want us to carry on tracking events after their finger has left the view.ViewModifier
, using the overlay()
modifier to place our UIViewRepresentable
over any other view. Overlays automatically resize themselves to be the same as their parent view, which is perfect here.View
extension to add an onTouch()
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)
}
}
Download this as an Xcode project
Let me know if you can find a simpler way to accomplish this – all feedback welcome!
SPONSORED Fernando's book will guide you in fixing bugs in three real, open-source, downloadable apps from the App Store. Learn applied programming fundamentals by refactoring real code from published apps. Hacking with Swift readers get a $10 discount!
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.