NEW: My new book Pro SwiftUI is out now – level up your SwiftUI skills today! >>

How to detect the location of a tap inside a view

Paul Hudson    @twostraws   

Updated for Xcode 14.0 beta 1

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:

  1. We need to create a UIViewRepresentable that can wrap a custom UIView subclass.
  2. This UIView subclass will implement touchesBegan(), touchesMoved(), touchesEnded(), and touchesCancelled(), so we can track the user’s touches as they happen.
  3. Each of those UIView methods will find the touch’s location in the view, then send it upwards as a CGPoint if appropriate.
  4. 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.
  5. We’ll create a new struct that conforms to 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.
  6. Create a 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

Hacking with Swift is sponsored by Stream

SPONSORED Build Chat messaging quickly with Stream Chat. The Stream iOS Chat SDK is highly flexible, customizable, and crazy optimized for performance. Take advantage of this top-notch developer experience, get started for free today!

Click here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Similar solutions…

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 5.0/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.