19

For SwiftUI the default button behavior is equivalent to UIKit's "touch up inside", which activates when your finger touches the button then raises while within the bounds of the button.

Is there any way to change this to "touch down" so the action closure is run immediately when your finger touches the button?

RogerTheShrubber
  • 986
  • 8
  • 19

6 Answers6

17

You can use a DragGesture with a minimumDistance of zero and define a closure for DOWN (onChanged()) or UP (onEnded()):

struct ContentView: View {
    @State private var idx = 0

    var body: some View {
        let g = DragGesture(minimumDistance: 0, coordinateSpace: .local).onChanged({
            print("DOWN: \($0)")
        }).onEnded({
            print("UP: \($0)")
        })

        return Rectangle().frame(width: 100, height: 50).gesture(g)
    }
}
kontiki
  • 37,663
  • 13
  • 111
  • 125
11

You can create a custom view modifier:

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
                })
    }
}

struct MyView: View {
    var body: some View {
        Text("Hello World")
            .onTouchDownGesture {
                print("View did tap!")
            }
    }
}
superpuccio
  • 11,674
  • 8
  • 65
  • 93
9

While DragGesture works well in many scenarios, it can have some unwanted side effects such as when used within a scroll view, it will take precedence over the scroll view's built-in gesture handing.

In SwiftUI, Buttons already keep track of the pressed state, so a solution to this problem is to use a custom ButtonStyle to allow for hooking into changes in the isPressed state.

Here's a working solution:

Define a custom ButtonStyle:

struct CustomButtonStyle: ButtonStyle {
    
    var onPressed: () -> Void
    
    var onReleased: () -> Void
    
    // Wrapper for isPressed where we can run custom logic via didSet (or willSet)
    @State private var isPressedWrapper: Bool = false {
        didSet {
            // new value is pressed, old value is not pressed -> switching to pressed state
            if (isPressedWrapper && !oldValue) {
                onPressed()
            } 
            // new value is not pressed, old value is pressed -> switching to unpressed state
            else if (oldValue && !isPressedWrapper) {
                onReleased()
            }
        }
    }
    
    // return the label unaltered, but add a hook to watch changes in configuration.isPressed
    func makeBody(configuration: Self.Configuration) -> some View {
        return configuration.label
            .onChange(of: configuration.isPressed, perform: { newValue in isPressedWrapper = newValue })
    }
}

You could also write the didSet logic directly in the perform block of the onChange modifier, but I think this keeps it looking clean.

Wrap your clickable view with Button


struct ExampleView: View {

    @State private var text: String = "Unpressed"

    var body: some View {
        Text(self.text)
        Button(action: { ... }, label: {
            // your content here
        }).buttonStyle(CustomButtonStyle(onPressed: { 
            self.text = "Pressed!"
        }, onReleased: { 
            self.text = "Unpressed" 
        }))
    }
}

Then you can pass whatever logic you want to the onPressed and onReleased parameters to CustomButtonStyle.

I've used this to allow for custom onPressed handling of clickable rows in a ScrollView.

akerra
  • 1,017
  • 8
  • 18
  • This is fantastic, thanks for providing. Using your impl https://gist.github.com/drosenstark/c839c309346903c9d4c4d30023aa5163 Though I'll probably update and change that to something better. Thanks again. – Dan Rosenstark May 01 '23 at 11:56
  • I would say that this is the most correct solution, but unfortunately there is a bug in iOS 16 and 17 where there is a slight delay from when holding start until `configuration.isPressed ` changes to true. Makes it feel way less responsive. – Pointblaster Aug 16 '23 at 11:38
8

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.

George
  • 25,988
  • 10
  • 79
  • 133
1

Using onLongPressGesture:

Button("Test") {
    // touch up
}.onLongPressGesture(minimumDuration: 0, perform: {}) { pressing in
    if pressing {
        // touch down
    }
}
Oleg Barinov
  • 279
  • 5
  • 5
-2

I managed to achieve that with a simple button modifier:

struct TouchedButtonStyle: PrimitiveButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration
            .label
            .onTapGesture(perform: configuration.trigger)
    }
}

Now you have to just assign the modifier to your button:

YourButton()
    .buttonStyle(TouchedButtonStyle())
Vitalii Vashchenko
  • 1,777
  • 1
  • 13
  • 23