How do you detect a SwiftUI touchDown event with no movement or duration?
Asked Answered
J

10

36

I'm trying to detect when a finger first makes contact with a view in SwiftUI. I could do this very easily with UIKit Events but can't figure this out in SwiftUI.

I've tried a DragGesture with minimum movement of 0 but it still won't change until your finger moves.

TapGesture will only work when you lift your finger and LongPressGesture will not trigger fast enough no matter what I set the parameters to.

DragGesture(minimumDistance: 0, coordinateSpace: .local).onChanged({ _ in print("down")})

LongPressGesture(minimumDuration: 0.01, maximumDistance: 100).onEnded({_ in print("down")})

I want to detect a touchDown event as soon as a finger makes contact with a view. Apple's default gestures have restrictions to either distance or time.

Update: This is not an issue anymore as Apple has seemed to update how DragGesture works or maybe I was experiencing a specific contextual bug.

Jarib answered 10/11, 2019 at 0:6 Comment(2)
must be with the .updating modifier on the TapGesture, but can't figure out how to do it.Brita
Here is a very graceful solution FYI: serialcoder.dev/text-tutorials/swiftui/…Synaesthesia
F
10

If you combine the code from these two questions:

How to detect a tap gesture location in SwiftUI?

UITapGestureRecognizer - make it work on touch down, not touch up?

You can make something like this:

ZStack {
    Text("Test")
    TapView {
        print("Tapped")
    }
}
struct TapView: UIViewRepresentable {
    var tappedCallback: (() -> Void)

    func makeUIView(context: UIViewRepresentableContext<TapView>) -> TapView.UIViewType {
        let v = UIView(frame: .zero)
        let gesture = SingleTouchDownGestureRecognizer(target: context.coordinator,
                                                       action: #selector(Coordinator.tapped))
        v.addGestureRecognizer(gesture)
        return v
    }

    class Coordinator: NSObject {
        var tappedCallback: (() -> Void)

        init(tappedCallback: @escaping (() -> Void)) {
            self.tappedCallback = tappedCallback
        }

        @objc func tapped(gesture:UITapGestureRecognizer) {
            self.tappedCallback()
        }
    }

    func makeCoordinator() -> TapView.Coordinator {
        return Coordinator(tappedCallback:self.tappedCallback)
    }

    func updateUIView(_ uiView: UIView,
                      context: UIViewRepresentableContext<TapView>) {
    }
}

class SingleTouchDownGestureRecognizer: UIGestureRecognizer {
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        if self.state == .possible {
            self.state = .recognized
        }
    }
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
        self.state = .failed
    }
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
        self.state = .failed
    }
}

There's definitely some abstractions we can make so that the usage is more like the other SwiftUI Gestures, but this is a start. Hopefully Apple builds in support for this at some point.

Flugelhorn answered 10/11, 2019 at 6:9 Comment(0)
E
51

You can use the .updating modifier like this:

struct TapTestView: View {

    @GestureState private var isTapped = false

    var body: some View {

        let tap = DragGesture(minimumDistance: 0)
            .updating($isTapped) { (_, isTapped, _) in
                isTapped = true
            }

        return Text("Tap me!")
            .foregroundColor(isTapped ? .red: .black)
            .gesture(tap)
    }
}

Some notes:

  • The zero minimum distance makes sure the gesture is immediately recognised
  • The @GestureState property wrapper automatically resets its value to the original value when the gesture ends. This way you only have to worry about setting isTapped to true. It will automatically be false again when the interaction ends.
  • The updating modifier has this weird closure with three parameters. In this case we are only interested in the middle one. It's an inout parameter to the wrapped value of the GestureState, so we can set it here. The first parameter has the current value of the gesture; the third one is a Transaction containing some animation context.
Entoil answered 24/1, 2020 at 14:1 Comment(4)
@Jarib this answer should be accepted as the best answer imhoDicarlo
Same, the zero minimum distance was the most important thing i was missing while searching for a solution.Unwinking
This works perfectly. But not sure why I still need to call .onChanged and .onEnded with doing nothing, otherwise the updating method is only called once and sets isTapped to false immediately. Tested on Xcode 14 with iOS 16.0.Deadhead
Note that because it's a drag gesture, the updating closure will keep firing as the finger moves, not only on the initial touch. That's fine if all you're doing is setting isTapped to update UI state, but if you want to run any other code only at the moment of touch, either use .onChange(of: isTapped) {…} or, in the updating closure, add guard !isTapped else { return } before any other statements.Gentilis
F
14

You can create a view modifier this way:

extension View {
    func onTouchDownGesture(callback: @escaping () -> Void) -> some View {
        modifier(OnTouchDownGestureModifier(callback: callback))
    }
}

private struct OnTouchDownGestureModifier: ViewModifier {
    @State private var tapped = false
    let callback: () -> Void

    func body(content: Content) -> some View {
        content
            .simultaneousGesture(DragGesture(minimumDistance: 0)
                .onChanged { _ in
                    if !self.tapped {
                        self.tapped = true
                        self.callback()
                    }
                }
                .onEnded { _ in
                    self.tapped = false
                })
    }
}

Now you can use it like:

struct MyView: View {
    var body: some View {
        Text("Hello World")
            .onTouchDownGesture {
                print("View did tap!")
            }
    }
}
Fico answered 21/3, 2020 at 12:39 Comment(2)
This works perfectly, and I like the code. But it feels the same as an onTapGesture.Finegrained
simultaneousGesture is the only thing that worked for me for both press down and lifting up. Thank you!Effectuate
F
10

If you combine the code from these two questions:

How to detect a tap gesture location in SwiftUI?

UITapGestureRecognizer - make it work on touch down, not touch up?

You can make something like this:

ZStack {
    Text("Test")
    TapView {
        print("Tapped")
    }
}
struct TapView: UIViewRepresentable {
    var tappedCallback: (() -> Void)

    func makeUIView(context: UIViewRepresentableContext<TapView>) -> TapView.UIViewType {
        let v = UIView(frame: .zero)
        let gesture = SingleTouchDownGestureRecognizer(target: context.coordinator,
                                                       action: #selector(Coordinator.tapped))
        v.addGestureRecognizer(gesture)
        return v
    }

    class Coordinator: NSObject {
        var tappedCallback: (() -> Void)

        init(tappedCallback: @escaping (() -> Void)) {
            self.tappedCallback = tappedCallback
        }

        @objc func tapped(gesture:UITapGestureRecognizer) {
            self.tappedCallback()
        }
    }

    func makeCoordinator() -> TapView.Coordinator {
        return Coordinator(tappedCallback:self.tappedCallback)
    }

    func updateUIView(_ uiView: UIView,
                      context: UIViewRepresentableContext<TapView>) {
    }
}

class SingleTouchDownGestureRecognizer: UIGestureRecognizer {
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        if self.state == .possible {
            self.state = .recognized
        }
    }
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
        self.state = .failed
    }
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
        self.state = .failed
    }
}

There's definitely some abstractions we can make so that the usage is more like the other SwiftUI Gestures, but this is a start. Hopefully Apple builds in support for this at some point.

Flugelhorn answered 10/11, 2019 at 6:9 Comment(0)
P
9

All you need is this:

LongPressGesture().onChanged {_ in print("down") }

😀

Here's a demonstration app:

App Demo

And here's the code for it:

struct ContentView: View {
    @State var isRed = false

    var body: some View {
        Circle()
            .fill(isRed ? Color.red : Color.black)
            .frame(width: 150, height: 150)
            .gesture(LongPressGesture().onChanged { _ in self.isRed.toggle()})
    }
}
Persinger answered 13/5, 2020 at 20:32 Comment(2)
Simple and effective, great answer!Cathexis
Not a proper solution. When the user lifts his finger from the view, the state does not changeRabbinical
N
7

None of the above worked perfectly, like the DragGesture made it impossible for the table to scroll

after long searching i found this holy grail!

Note: it is public available public, but the _ prefix means it may not be suited for production

but still works great

._onButtonGesture(pressing: { pressing in
                    
}, perform: {

})
Nubianubian answered 16/5, 2023 at 13:41 Comment(7)
Something that works without having to reinvent the wheel. I like it. Thank you!Effectuate
Just released a watchOS app with this code included and it got approved for the App Store.Maneuver
yes, have several apps released and approved as well using this... it's a public methodNubianubian
perform is called on successful click, while pressing is called while pressedNubianubian
Good note, thanks. Here's another: If you put this above .onTapGesture(perform:), it will block that one from getting called. However, if you put onTapGesture above _onButtonGesture, they both get called. I'm loath to rely on this undocumented one, but... might as well. It's pretty inexcusable that SwiftUI lacks this basic interaction tool.Caliche
This is broken in iOS 18 :( If someone finds a solution, no matter how "late", please say it.Sillimanite
lets not panic and wait for the final iOS18... in the meantime you can file Apple a reportNubianubian
C
5

This is a solution to detect changes between states and also the coordinates of the touch (within the Text View in this case):

I added an enum to manage the states (using began, moved and ended for those UIKit-nostalgic)

import SwiftUI

struct ContentView: View {
    @State var touchPoint = CGPoint(x: 0, y: 0)
    @State var touchState = TouchState.none
    var body: some View {
        Text("\(touchState.name): \(Int(self.touchPoint.x)), \(Int(self.touchPoint.y))")
            .border(Color.red).font(.largeTitle)
            .gesture(
                DragGesture(minimumDistance: 0)
                    .onChanged({ (touch) in
                        self.touchState = (self.touchState == .none || self.touchState == .ended) ? .began : .moved
                        self.touchPoint = touch.location
                    })
                    .onEnded({ (touch) in
                        self.touchPoint = touch.location
                        self.touchState = .ended
                    })
            )
    }
}
enum TouchState {
    case none, began, moved, ended
    var name: String {
        return "\(self)"
    }
}
Chiquitachirico answered 14/2, 2020 at 3:59 Comment(4)
This is nice code too. But it has the same issue. I detects a touchdown, but doesn't report it until I remove my touch, which is the same as a tap.Finegrained
It does detect the touch at the moment, and reports it immediately. I may be missing something in your comment.... I am testing in an iPhone XS with iOS 13Chiquitachirico
I'll check again. I seemed to me it didn't work until I took my finger off the screen.Finegrained
Yeah, onChanged and onEnded aren't being called when I press down in the button. This solution doesn't work for me.Effectuate
K
3

For iOS, here's a solution that detects all gesture states. Using code from Joe's answer under this question.

import SwiftUI
import UIKit

struct TouchDownView: UIViewRepresentable {
    typealias TouchDownCallback = ((_ state: UIGestureRecognizer.State) -> Void)

    var callback: TouchDownCallback

    func makeUIView(context: UIViewRepresentableContext<TouchDownView>) -> TouchDownView.UIViewType {
        let view = UIView(frame: .zero)

        let gesture = UILongPressGestureRecognizer(
            target: context.coordinator,
            action: #selector(Coordinator.gestureRecognized)
        )

        gesture.minimumPressDuration = 0
        view.addGestureRecognizer(gesture)

        return view
    }

    class Coordinator: NSObject {
        var callback: TouchDownCallback

        init(callback: @escaping TouchDownCallback) {
            self.callback = callback
        }

        @objc fileprivate func gestureRecognized(gesture: UILongPressGestureRecognizer) {
            callback(gesture.state)
        }
    }

    func makeCoordinator() -> TouchDownView.Coordinator {
        return Coordinator(callback: callback)
    }

    func updateUIView(_ uiView: UIView,
                      context: UIViewRepresentableContext<TouchDownView>) {
    }
}

Usage

TouchDownView { state in
    switch state {
        case .began: print("gesture began")
        case .ended: print("gesture ended")
        case .cancelled, .failed: print("gesture cancelled/failed")
        default: break
    }
}
Kwa answered 6/3, 2021 at 9:37 Comment(0)
S
3

Warning: this currently appears to be broken in iOS 18 (2024-06-11).


I already answered this here, but worth posting on this more popular question too.


You can use a hidden _onButtonGesture method on View, which is public. It doesn't even need to be attached to the Button, but it looks better since you see that pressing down effect.

Code:

struct ContentView: View {
    @State private var counter = 0
    @State private var pressing = false

    var body: some View {
        VStack(spacing: 30) {
            Text("Count: \(counter)")

            Button("Increment") {
                counter += 1
            }
            ._onButtonGesture { pressing in
                self.pressing = pressing
            } perform: {}

            Text("Pressing button: \(pressing ? "yes" : "no")")
        }
    }
}

Result:

Result

Doesn't look great as a GIF due to the frame rate, but whenever you press down pressing goes to true, then false on release.

Sillimanite answered 10/10, 2021 at 3:15 Comment(2)
Thanks! This works exactly as expected. No idea why Apple is yet to make it public. However, I'm concerned about Apple rejecting the app because of using this. :/Analgesia
@RevanthKausikan Although yes it is undefined behaviour and the API could be removed at any point - it's unlikely in my opinion you'll get rejected for this. It is likely public for inlining purposes (which would mean this function is called in a regular app which doesn't use it directly). I don't know the inner workings so this is speculative.Sillimanite
K
1

Update: Don't use this solution if you want to support watchOS 10 or newer! It's broken.

This is a watchOS-specific answer, because the stuff that's inside the definition of isTouchedDown gets called multiple times in a weird way on iOS. There are better solutions for iOS if you want to run actions when your view is touched up/down, such as my other answer.


Note: The isTouchedDown definition will still fire a couple of times when the container screen appears. A hacky solution to prevent that is the boolean shouldCallIsTouchedDownCode that turns false after 0.3 seconds in the onAppear.

@GestureState var longPressGestureState = false

@State var shouldCallIsTouchedDownCode = false

var isTouchedDown: Bool {
    guard shouldCallIsTouchedDownCode else {
        return
    }

    // use this place to call functions when the value changes

    return longPressGestureState
}

var body: View {
    Color(isTouchedDown ? .red : .black)
        .gesture(
             LongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity)
                 .updating($longPressGestureState) { value, state, _ in
                     state = value
                 }
        )
        .onAppear {
             shouldCallIsTouchedDownCode = false

             DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                 shouldCallIsTouchedDownCode = true
             }
        }
}
Kwa answered 1/10, 2020 at 17:42 Comment(0)
C
1

This is a copy-paste. Sorry. But it worked well (as a start) for me!

Coming from https://betterprogramming.pub/implement-touch-events-in-swiftui-b3a2b0700fd4

struct ContentView: View {
    @State var buttonPressed = false
    var body: some View {
        Text("Press to accelerate!")
            .foregroundColor(Color.white)
            .padding(10)
            .background(Color.gray)
            .cornerRadius(6)
            .padding(10)
            .scaleEffect(buttonPressed ? 0.8 : 1)
            .animation(.spring(), value: buttonPressed)

            /// Apply the method.
            .onTouchDownUp { pressed in
                self.buttonPressed = pressed
            }
    }
}

extension View {
    /// A convenience method for applying `TouchDownUpEventModifier.`
    func onTouchDownUp(pressed: @escaping ((Bool) -> Void)) -> some View {
        self.modifier(TouchDownUpEventModifier(pressed: pressed))
    }
}

struct TouchDownUpEventModifier: ViewModifier {
    /// Keep track of the current dragging state. To avoid using `onChange`, we won't use `GestureState`
    @State var dragged = false

    /// A closure to call when the dragging state changes.
    var pressed: (Bool) -> Void
    func body(content: Content) -> some View {
        content
            .gesture(
                DragGesture(minimumDistance: 0)
                    .onChanged { _ in
                        if !dragged {
                            dragged = true
                            pressed(true)
                        }
                    }
                    .onEnded { _ in
                        dragged = false
                        pressed(false)
                    }
            )
    }
}

What I needed to do was change .gesture( into .simultaneousGesture( because I had another DragGesture on the same view.

Calfee answered 29/8, 2023 at 8:32 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.