FREE TRIAL: Accelerate your app development career with Hacking with Swift+! >>

How to detect the location of a tap inside a view

Paul Hudson    @twostraws   

Updated for Xcode 13.0

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:

  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 closer 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!

Hacking with Swift is sponsored by Essential Developer

SPONSORED Join a FREE crash course for iOS devs who want to become complete senior developers — from October 18th to 24th. Learn how to apply iOS app architecture patterns through a series of lectures and practical coding sessions.

Learn more

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

Similar solutions…

BUY OUR BOOKS
Buy Pro Swift 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 (Vapor Edition) 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 Server-Side Swift (Kitura Edition) 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.