简体   繁体   中英

How to fire event handler when the user STOPS a Long Press Gesture in SwiftUI?

Based on the documentation, the .OnEnded event handler will fire when the LongPressGesture has been successfully detected. How can I fire an event when the user stops pressing after the gesture has been detected?

Here is an example:

  • User presses for eg 2 seconds.

  • ** Something appears **

  • User releases after another 2 seconds

  • ** That something disappears **

I managed to solve it, although if anyone has an easier solution I would gladly accept.

Basically I need to chain 2 LongPressGesture -s together.

The first one will take effect after a 2 second long press - this is when the something should appear.

The second one will take effect after Double.infinity time, meaning that it will never complete, so the user can press as long as they want. For this effect, we only care about the event when it is cancelled - meaning that the user stopped pressing.

@GestureState private var isPressingDown: Bool = false

[...]

aView.gesture(LongPressGesture(minimumDuration: 2.0)
    .sequenced(before: LongPressGesture(minimumDuration: .infinity))
    .updating($isPressingDown) { value, state, transaction in
        switch value {            
            case .second(true, nil): //This means the first Gesture completed
                state = true //Update the GestureState
            default: break
        }
    })
    .
[...]

something.opacity(isPressingDown ? 1 : 0)

When sequencing two LongPressGesture -s by calling the .sequenced(before:) method, you get a

SequenceGesture<LongPressGesture, LongPressGesture> as return value

which has a .first(Bool) and a .second(Bool, Bool?) case in its Value enum.

The .first(Bool) case is when the first LongPressGesture hasn't ended yet.

The .second(Bool, Bool?) case is when the first LongPressGesture has ended.

So when the SequenceGesture 's value is .second(true, nil) , that means the first Gesture has completed and the second is yet undefined - this is when that something should be shown - this is why we set the state variable to true inside that case (The state variable encapsulates the isPressingDown variable because it was given as first parameter to the .updating(_:body:) method) .

And we don't have to do anything about setting the state back to false because when using the .updating(_:body:) method the state returns to its initial value - which was false - if the user cancels the Gesture. Which will result in the disappearance of "something". (Here cancelling means we lift our finger before the minimum required seconds for the Gesture to end - which is infinity seconds for the second gesture.)

So it is important to note that the .updating(_:body:) method's callback is not called when the Gesture is cancelled , as per this documentation's Update Transient UI State section.


EDIT 03/24/2021:

I ran into the problem of updating an @Published property of an @ObservedObject in my view. Since the .updating() method closure is not called when resetting the GestureState you need another way to reset the @Published property. The way to solve that issue is adding another View Modifier called .onChange(of:perform:) :

Model.swift:

class Model: ObservableObject {
    @Published isPressedDown: Bool = false
    
    private var cancellableSet = Set<AnyCancellable>()

    init() {
        //Do Something with isPressedDown
        $isPressedDown
            .sink { ... }
            .store(in: &cancellableSet)
    }
}

View.swift:

@GestureState private var _isPressingDown: Bool = false
@ObservedObject var model: Model

[...]

aView.gesture(LongPressGesture(minimumDuration: 2.0)
    .sequenced(before: LongPressGesture(minimumDuration: .infinity))
    .updating($_isPressingDown) { value, state, transaction in
        switch value {            
            case .second(true, nil): //This means the first Gesture completed
                state = true //Update the GestureState
                model.isPressedDown = true //Update the @ObservedObject property
            default: break
        }
    })
    .onChange(of: _isPressingDown) { value in
        if !value {
            model.isPressedDown = false //Reset the @ObservedObject property
        }
    })

It seems like there may be a more elegant solution here, although it's not apparent right away.

Instead of using a LongPressGesture, you can use a DragGesture with a minimum distance of 0

@State var pressing = false
Text("Press Me").gesture(
    DragGesture(minimumDistance: 0)
        .onChanged({ _ in
            pressing = true
        })
        .onEnded({ _ in
            doThingWhenReleased()
        })
)

In this example, pressing will be true when you are pressed down, and will turn to false when it's released. The onEnded closure will be called when the item is released. Here is this feature bundled into a ViewModifier to use, although it may be better as a standalone Gesture() if you want it to have a similar api as other gestures:

struct PressAndReleaseModifier: ViewModifier {
    @Binding var pressing: Bool
    var onRelease: () -> Void

    func body(content: Content) -> some View {
        content
            .simultaneousGesture(
                DragGesture(minimumDistance: 0)
                    .onChanged{ state in
                        
                        pressing = true
                    }
                    .onEnded{ _ in
                        pressing = false
                        onRelease()
                    }
            )
    }
}

extension View {
    func pressAndReleaseAction(pressing: Binding<Bool>, onRelease: @escaping (() -> Void)) -> some View {
        modifier(PressAndReleaseModifier(pressing: pressing, onRelease: onRelease))
    }
}

You can use it like this

struct SomeView: View {
    @State var pressing
    var body: some View {
        Text(pressing ? "Press Me!" : "Pressed")
            .pressAndReleaseAction(pressing: $pressing, onRelease: {
                print("Released")
            })
    }
}

The only downside to this is that if the user drags outside of the button and releases, onRelease() is still fired. This could probably be updated inside the ViewModifier to get the expected outcome, where the user can drag outside to cancel the action, but you might have to use a GeometryReader.

I know it's kinda late, but I found another way to handle long press gesture. It also handle simple tap gesture.

Sorry for the bad writing you might encounter in this post.

It goes down in 2 parts:

First, we want to be able to detect touch down events on any view. For this we use a custom ViewModifier found this thread

Then, see the code below.

// 1.
@State var longPress = false
@State var longPressTimer: Timer?

[...]

// 2.
Button(action: { 
    if !longPress { 
        // 5.
    }

    // 6. 
    longPress = false
    longPressTimer?.invalidate()
    longPressTimer = nil
}) {
    Color.clear
}
.onTouchDownGesture { // 3.
    // 4.
    longPressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in
        longPress = true
    }
}
.frame(maxWidth: .infinity, maxHeight: .infinity)

[...]
    
  1. We will need a boolean to hold the long press state (pressed or not). And a Timer.

  2. Add a standard SwiftUI Button. A Button action is only triggered when the button is released (touch up event), so very useful in our case.

  3. Apply the custom touch down modifier to the button, we are now able to tell when the button is pressed & when it is released.

  4. Init & fire the Timer at touch down. The time interval is the time you want to wait before recognising a touch down event as a long press event. When the timer ends, it means we are now in a "long press" state and we set longPress to true.

  5. (optional) At button touch up, if the button was not tapped "long enough", we execute the work we want for the button default action, if desired.

  6. Finally, still at touch up, cancel long press event & empty the timer.

With this method you achieve a long press like effect. My advice here is to listen to the didSet event on the @State var longPress: Bool , but what do I know ?

Also, I don't know if it's perf friendly, didn't check. But at least it works.

All improvements and ideas you guys might have are of course welcome. Cheers.

Here is a solution if you want to fire action after release button

struct ContentView: View {

    var body: some View {
        Text("Tap me long")
        .onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: handlePressing) {}
    }

    func handlePressing(_ isPressed: Bool) {
        guard !isPressed else { return }
        //MARK: - handle if unpressed
        print("Unpressed")
    }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM