How to detect and take action when a button is being pressed in SwiftUI
Asked Answered
N

3

6

I want to execute an action when the button press begins and then when the button stops being pressed. I was looking for a simple solution, but got into more complicated configurations. One option that is pretty simple and close is the one I got from BlueSpud. The button action is not used so I tried:

    struct MyView: View {
    @State private var pressing = false

    var body: some View {

        Text("Button")
            .background(self.pressing ? Color.red : Color.blue)
            .gesture(DragGesture(minimumDistance: 0.0)
                .onChanged { _ in
                    self.pressing = true
                    print("Pressing started and/or ongoing")
            }
            .onEnded { _ in
                self.pressing = false
                print("Pressing ended")
            })
    }
}

The problem with this code is that if you drag your finger out of the button area while pressing, .onEnded never gets called, and without a reliable end to the event, the solution doesn't work.

I have also tried Apple's example for composing SwiftUI gestures. It provides a very consistent control over the pressed and unpressed states, but I can't seem to know where to insert my actions:

struct PressedButton: View {

    var startAction: ()->Void
    var endAction: ()->Void

    enum DragState {
        case inactive
        case pressing
        case dragging(translation: CGSize)

        var translation: CGSize {
            switch self {
            case .inactive, .pressing:
                return .zero
            case .dragging(let translation):
                return translation
            }
        }

        var isActive: Bool {
            switch self {
            case .inactive:
                print("DragState inactive but I can't add my action here")
                //self.endAction()
                return false
            case .pressing, .dragging:
                return true
            }
        }

        var isDragging: Bool {
            switch self {
            case .inactive, .pressing:
                return false
            case .dragging:
                return true
            }
        }
    }

    @GestureState var dragState = DragState.inactive

    var body: some View {
        let longPressDrag = LongPressGesture(minimumDuration: 0.1)
        .sequenced(before: DragGesture())
        .updating($dragState) { value, state, transaction in
            switch value {

            // Long press begins.
            case .first(true):
                print("Long press begins. I can add my action here")
                self.startAction()
                state = .pressing

            // Long press confirmed, dragging may begin.
            case .second(true, let drag):
                //print("Long press dragging")
                state = .dragging(translation: drag?.translation ?? .zero)

             // Dragging ended or the long press cancelled.
            default:
                print("Long press inactive but it doesn't get called")
                state = .inactive
            }
        }
        .onEnded { _ in
            print("Long press ended but it doesn't get called")
            }

        return Text("Button")
            .background(dragState.isActive ? Color.purple : Color.orange)
            .gesture(longPressDrag)
    }
}
Necrophobia answered 16/12, 2019 at 19:26 Comment(1)
See my solution on another question here. Looks to be exactly what you need.Workhouse
G
3

As soon as native SwiftUI does not allow now what you want to achieve, I'd recommend the following approach, which is valid and manageable and, so, reliable.

The demo shows simplified code based on using UIGestureRecongnizer/UIViewRepresentable, which can be easily extended (eg. if you want to intercept touchesCanceled, click count, etc.)

import SwiftUI
import UIKit

class MyTapGesture : UITapGestureRecognizer {

    var didBeginTouch: (()->Void)?
    var didEndTouch: (()->Void)?

    init(target: Any?, action: Selector?, didBeginTouch: (()->Void)? = nil, didEndTouch: (()->Void)? = nil) {
        super.init(target: target, action: action)
        self.didBeginTouch = didBeginTouch
        self.didEndTouch = didEndTouch
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesBegan(touches, with: event)
        self.didBeginTouch?()
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesEnded(touches, with: event)
        self.didEndTouch?()
    }
}

struct TouchesHandler: UIViewRepresentable {
    var didBeginTouch: (()->Void)?
    var didEndTouch: (()->Void)?

    func makeUIView(context: UIViewRepresentableContext<TouchesHandler>) -> UIView {
        let view = UIView(frame: .zero)
        view.isUserInteractionEnabled = true
        view.addGestureRecognizer(context.coordinator.makeGesture(didBegin: didBeginTouch, didEnd: didEndTouch))
        return view;
    }

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

    func makeCoordinator() -> Coordinator {
        return Coordinator()
    }

    class Coordinator {
        @objc
        func action(_ sender: Any?) {
            print("Tapped!")
        }

        func makeGesture(didBegin: (()->Void)?, didEnd: (()->Void)?) -> MyTapGesture {
            MyTapGesture(target: self, action: #selector(self.action(_:)), didBeginTouch: didBegin, didEndTouch: didEnd)
        }
    }
    typealias UIViewType = UIView
}

struct TestCustomTapGesture: View {
    var body: some View {
        Text("Hello, World!")
            .padding()
            .background(Color.yellow)
            .overlay(TouchesHandler(didBeginTouch: {
                print(">> did begin")
            }, didEndTouch: {
                print("<< did end")
            }))
    }
}

struct TestCustomTapGesture_Previews: PreviewProvider {
    static var previews: some View {
        TestCustomTapGesture()
    }
}
Gerik answered 17/12, 2019 at 9:26 Comment(1)
For me it failed working when long pressing (imagine Push-To-Talk). I had to do a simple change and use UILongPressGestureRecognizer instead of UITapGestureRecognizerGeranium
F
8

there is no need for UIView and Representable,

you could use the built-in SwiftUI isPressed attribute for Button's configuration like this:

import SwiftUI

struct MyButtonStyle: ButtonStyle
{
    func makeBody(configuration: Configuration) -> some View
    {
        if(configuration.isPressed)
        {
            // call your action here but don't change @State of current view
            print("Button is pressed")
        }
        else
        {
            // call your stop-action here but don't change @State of current view
            print("Button released")
        }
        
        return configuration.label
            .padding()
            .background(Color.blue)
            .foregroundColor(.white)
            .cornerRadius(20)
            .scaleEffect(configuration.isPressed ? 0.8 : 1)
            .animation(.easeOut(duration: 0.2), value: configuration.isPressed)
    }
}

struct ButtonTestView: View
{
    var body: some View
    {
        Button("Press me")
        {
            print("Button action")
        }
        .buttonStyle(MyButtonStyle())
    }
}

@main
struct TestApp: App
{
    var body: some Scene
    {
        WindowGroup
        {
            ButtonTestView()
        }
    }
}

Fleecy answered 11/9, 2021 at 10:22 Comment(3)
I'm not sure this works. MyButtonStyle() appears to manufacture both pressed and unpressed styles before the button is actually pressed (at least in Swift 5), causing both print statements to fire. Attempting to modify any state at that time -- during view manufacture -- is illegal, so I don't think you can do anything useful here.Mitchum
not sure what do you mean by "illegal", this is a fully working example even in Swift 5, i have seen no evidence that developing a UI behavior is illegal inside a buttonStyle struct, and for me, please note that this is not a State, rather a UI look and feel decision during View manufacture, apps are approved on the app store using this solution, so i would love it if you could share the link in the docs / policy that you rely on that this is illegalFleecy
Sorry for "illegal;" I should've said "undefined." Specifically, any state-affecting code to the "Button is Pressed/Released" clauses above causes Xcode to issue warnings of the form "Modifying state during view update; this will cause undefined behavior." You are likely correct that apps can be approved despite this, and of course it can be eliminated by only using this code in buttons that have no effect on program state. For me I want a solution more like Button()'s regular action proc, which occurs outside of the view remanufacture cycle and therefore permits state modification.Mitchum
G
3

As soon as native SwiftUI does not allow now what you want to achieve, I'd recommend the following approach, which is valid and manageable and, so, reliable.

The demo shows simplified code based on using UIGestureRecongnizer/UIViewRepresentable, which can be easily extended (eg. if you want to intercept touchesCanceled, click count, etc.)

import SwiftUI
import UIKit

class MyTapGesture : UITapGestureRecognizer {

    var didBeginTouch: (()->Void)?
    var didEndTouch: (()->Void)?

    init(target: Any?, action: Selector?, didBeginTouch: (()->Void)? = nil, didEndTouch: (()->Void)? = nil) {
        super.init(target: target, action: action)
        self.didBeginTouch = didBeginTouch
        self.didEndTouch = didEndTouch
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesBegan(touches, with: event)
        self.didBeginTouch?()
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesEnded(touches, with: event)
        self.didEndTouch?()
    }
}

struct TouchesHandler: UIViewRepresentable {
    var didBeginTouch: (()->Void)?
    var didEndTouch: (()->Void)?

    func makeUIView(context: UIViewRepresentableContext<TouchesHandler>) -> UIView {
        let view = UIView(frame: .zero)
        view.isUserInteractionEnabled = true
        view.addGestureRecognizer(context.coordinator.makeGesture(didBegin: didBeginTouch, didEnd: didEndTouch))
        return view;
    }

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

    func makeCoordinator() -> Coordinator {
        return Coordinator()
    }

    class Coordinator {
        @objc
        func action(_ sender: Any?) {
            print("Tapped!")
        }

        func makeGesture(didBegin: (()->Void)?, didEnd: (()->Void)?) -> MyTapGesture {
            MyTapGesture(target: self, action: #selector(self.action(_:)), didBeginTouch: didBegin, didEndTouch: didEnd)
        }
    }
    typealias UIViewType = UIView
}

struct TestCustomTapGesture: View {
    var body: some View {
        Text("Hello, World!")
            .padding()
            .background(Color.yellow)
            .overlay(TouchesHandler(didBeginTouch: {
                print(">> did begin")
            }, didEndTouch: {
                print("<< did end")
            }))
    }
}

struct TestCustomTapGesture_Previews: PreviewProvider {
    static var previews: some View {
        TestCustomTapGesture()
    }
}
Gerik answered 17/12, 2019 at 9:26 Comment(1)
For me it failed working when long pressing (imagine Push-To-Talk). I had to do a simple change and use UILongPressGestureRecognizer instead of UITapGestureRecognizerGeranium
T
0

you can add this gesture to your view:

.onLongPressGesture(minimumDuration: 100.0, maximumDistance: .infinity, pressing: { pressing in
                if pressing {
                    print("My long pressed starts")
                } else {
                    print("My long pressed ends")
                }
            }, perform: { })
Torr answered 10/5 at 22:11 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.