5

Apple has made it easier than ever this year to smoothly animate a view after performing a drag gesture, by automatically using the final velocity of the drag gesture as the initial velocity of the animation. Here is a simple example:

struct DraggableCircle: View {
    @State private var offset: CGSize = .zero
    
    var body: some View {
        let gesture = DragGesture(minimumDistance: 0)
            .onChanged { gesture in
                offset = gesture.translation
            }
            .onEnded { _ in
                withAnimation(.bouncy(duration: 2)) {
                    offset = .zero
                }
            }
        
        Circle()
            .frame(width: 200, height: 200)
            .offset(offset)
            .gesture(gesture)
    }
}

enter image description here

What this code is lacking, though, is the ability to smoothly interrupt the circle's animation by starting a new drag gesture before the animation finishes. Instead, the circle will instantly finish the animation, jumping away from your finger:

enter image description here

This makes complete sense because we are setting offset back to .zero when the gesture ends, without doing anything to figure out where on the screen the circle was positioned when the animation was interrupted.

What is the most straight-forward way to make this work without any sudden jumps?

One possible approach is to position the circle using the position of the gesture rather than its translation, which always puts the circle where the gesture is happening:

struct DraggableCircle_v2: View {
    @State private var position: CGPoint?
    
    var body: some View {
        let gesture = DragGesture(minimumDistance: 0)
            .onChanged { gesture in
                position = gesture.location
            }
            .onEnded { _ in
                withAnimation(.bouncy(duration: 2)) {
                    position = nil
                }
            }
        
        GeometryReader { geo in
            let center = CGPoint(
                x: geo.size.width / 2,
                y: geo.size.height / 2
            )
            
            Circle()
                .frame(width: 200, height: 200)
                .position(position ?? center)
                .gesture(gesture)
        }
    }
}

This will work for most use cases. However, this always puts the center of the circle where your finger is, not taking into account where on the circle the gesture started. In other words, we have not made any progress towards the goal of figuring out where the circle is positioned at the moment the animation is interrupted.

enter image description here

Can we interrupt our animation using a gesture and figure out where the circle is positioned at the time of the interruption?

Edit: Here's what I'm trying to achieve:

enter image description here

Tim Vermeulen
  • 12,352
  • 9
  • 44
  • 63
  • The animation takes a few seconds to end after you've stopped draggin the circle. You could use a DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) in the .onEnded closure and see what is the appropriate delay to avoid a non smooth interruption. – devdchaudhary Jul 22 '23 at 12:49
  • Would you be content with just adding a withAnimation(.bouncy) to the onChanged position solution? It would center the object on your finger yes, but it would also move smoothly instead of snapping. – Luck_Duracell Jul 24 '23 at 21:26
  • @Luck_Duracell You're totally right that that would get rid of any jumps, but the core of my curiosity here is really about making it work such that it doesn't move at all relative to my finger during the drag gesture. I could have been clearer about that! – Tim Vermeulen Jul 25 '23 at 12:10
  • I didn't get that. Do you need to: 1. grab it in the middle of the animation? or 2. summon it below your finger? or 3. Apply force even if you drag the black area? or 4. Apply sum fractions on a touch of the black area? or 5. finish the animation on interaction but with ease? or 6. Ignore new interactions until the current animation ends? or what? – Mojtaba Hosseini Jul 26 '23 at 18:14
  • 1
    @MojtabaHosseini 1, it should stop animating in place the moment a new drag gesture on it is initiated. I've added a gif to the question that shows the desired effect. – Tim Vermeulen Jul 26 '23 at 19:44

1 Answers1

0

Let's use a separate variable for tracking the circle's position while dragging (dragOffset) and a separate variable for animating the circle back to its initial position (animationOffset). We'll use these two variables separately to achieve smooth behavior.

struct ContentViewC: View {
    var body: some View {
        ZStack {
            Color.white.edgesIgnoringSafeArea(.all)
            DraggableCircleView()
        }
    }
}

struct DraggableCircleView: View {
@GestureState private var dragOffset: CGSize = .zero
@State private var animationOffset: CGSize = .zero

var body: some View {
    VStack {
        Circle()
            .frame(width: 200, height: 200)
            .foregroundColor(.blue)
            .offset(animationOffset) // Use animationOffset for smooth animation
            .offset(dragOffset) // Use dragOffset for smooth dragging
            .gesture(DragGesture()
                .updating($dragOffset, body: { (value, dragOffset, _) in
                    dragOffset = value.translation
                })
                .onEnded { value in
                    // Save the final drag position in animationOffset
                    animationOffset = value.translation
                    withAnimation(.easeOut(duration: 1.5)) {
                        // Animate the circle back to its initial position
                        animationOffset = .zero
                    }
                }
            )
    }
}}

With this code, we use dragOffset to update the position of the circle during dragging, and animationOffset for the smooth animation when the dragging ends. This separation should prevent the bouncing effect and give you a smoother experience when dragging and animating the circle back to the center.

When you have two .offset modifiers in the view hierarchy, SwiftUI combines them and applies the total offset to the view.

You can swap 2x offset for readability with:

.offset(CGSize(width: animationOffset.width + dragOffset.width,
                                         height: animationOffset.height + dragOffset.height))

But 2x offset looks more clean.

Hopsa
  • 425
  • 3
  • 10