Search code examples
iosbuttonswiftui

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


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

Solution

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