How to animate a shape (or button) as long as it's touched in SwiftUI?

Viewed 65

I want a circle to slowly scale down as long as the user keeps a finger on it. The scale should stop and reset to its default value of 1 when

  • either a LongPressGesture is detected (finished), i.e. the user pressed long enough for it to fire
  • or the gesture ended, i.e. the user lifted the finger

I implemented this as follows. When I keep the finger on the circle, it works as expected. But when I do a normal tap on the circle, it scales down continuously and never stops. Why is that and how can I fix it?

struct ContentView: View {
    
    @State private var longPressTimer: Timer?
    @State private var longPressDuration: TimeInterval = 0
    
    private var gesture: some Gesture {
        LongPressGesture()
            .onChanged { isPressing in
                print("changed")
                if isPressing {
                    longPressTimer = Timer.scheduledTimer(withTimeInterval: 1/25, repeats: true) { timer in
                        longPressDuration += timer.timeInterval
                    }
                } else {
                    reset()
                }
            }
            .onEnded { _ in
                print("ended")
                reset()
            }
    }
    
    var body: some View {
        Circle()
            .foregroundColor(.red)
            .scaleEffect(1 - longPressDuration/5)
            .gesture(gesture)
    }
    
    private func reset() {
        longPressDuration = 0
        longPressTimer?.invalidate()
        longPressTimer = nil
    }
}

When I only tap and immediately lift the finger, ended is never printed to the console and changed is only printed once (when the gesture begins). When I long-press, it prints

changed
ended

as expected.

1 Answers

Figured out a way to do this:

Instead of a LongPressGesture, I use a DragGesture with a minimumDistance of 0.

So when I simply modify the code for the gesture property as follows, both changed and ended are called (and printed) as expected.

private var gesture: some Gesture {
    DragGesture(minimumDistance: 0)
        .onChanged { value in
            print("changed")
            if longPressTimer == nil {
                longPressTimer = Timer.scheduledTimer(withTimeInterval: 1/25, repeats: true) { timer in
                    longPressDuration += timer.timeInterval
                }
            }
        }
        .onEnded { _ in
            print("ended")
            reset()
        }
}

A DragGesture always calls onEnded when the user lifts the finger as it doesn't have a defined minimum requirement to fire (like 1 or 2 seconds for the LongPressGesture). Thus, each DragGesture is a "success" in a way and so the onEnded closure is called.

While this works reliably and has no downsides that I can see, I feel like it's a huge gap in the SwiftUI API. Gestures should have a way to communicate back when they are canceled – no matter what type of gesture we use. Otherwise, we cannot update our state accordingly and state is what SwiftUI is all about.

Related