< How to disable taps for a view using allowsHitTesting() | What’s the difference between @ObservedObject, @State, and @EnvironmentObject? > |
Updated for Xcode 14.2
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)")
}
Download this as an Xcode project
“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)")
}
Download this as an Xcode project
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:
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
SPONSORED Play is the first native iOS design tool created for designers and engineers. You can install Play for iOS and iPad today and sign up to check out the Beta of our macOS app with SwiftUI code export. We're also hiring engineers!
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.