iosswiftswiftui

How to acheive continuous animation when switching State in SwiftUI?


I have following UI:

enter image description here

Red Rectangle spin speed is base from slider's value. I'm acheiving this using .linear(duration:).repeatForever(autoreverses: false) (duration base on slider's value) and .id modifer to stop current animation whenever slider's value changed.

struct ContentView: View {
    @State private var level: Double = 0
    @State private var rotation: Double = 0
    
    var body: some View {
        VStack(spacing: 16) {
            Rectangle().fill(.red)
                .frame(width: 100, height: 100)
                .id(level)
                .rotationEffect(Angle(degrees: rotation))
                .animation(level > 0 ? .linear(duration: 2 / level).repeatForever(autoreverses: false) : .linear(duration: 2), value: rotation)
            
            Spacer().frame(height: 12)
            Slider(value: $level, in: 0...3, step: 1)
            HStack {
                Text("0")
                    .fontWeight(.semibold)
                Spacer()
                Text("1")
                    .fontWeight(.semibold)
                Spacer()
                Text("2")
                    .fontWeight(.semibold)
                Spacer()
                Text("3")
                    .fontWeight(.semibold)
            }
        }
        .padding(16)
        .onChange(of: level, perform: { _ in
            rotation += 360
        })
    }
}

My current issue is when user change slider's value, Rectangle is reset to initial state because of .id so it create a glitch.

Here is video link of how my code currently work: https://imgur.com/a/6bsUJzm

My question is how to get smooth transition between state when user change slider's value? Any insights or suggestions would be appreciated!


Solution

  • One way to fix is to avoid using .repeatForever for the animation. Instead:

    Since iOS 17, an animation that is triggered using withAnimation can have a completion callback. So this would be one way of triggering the follow-on animation. However, doing it this way gives a rather stuttering animation.

    An alternative technique is to use an Animatable ViewModifier to track the animation and perform an action when it completes. Doing it this way works more smoothly. The answer to SwiftUI Animation finish animation cycle provides a generic modifier that can be used for this purpose (it was my answer). That post was also concerned with a continuous rotation, so it is quite a similar issue.

    Here is the updated example. Some more notes:

    var body: some View {
        VStack(spacing: 16) {
            Rectangle()
                .fill(.red)
                .frame(width: 100, height: 100)
                .rotationEffect(Angle(degrees: rotation))
    
                // See https://stackoverflow.com/a/76969841/20386264
                .modifier(
                    AnimationCompletionCallback(animatedValue: rotation) {
                        if level > 0 {
                            rotation += 90
                        }
                    }
                )
                .animation(.linear(duration: 2 / max(1, level)), value: rotation)
            Spacer().frame(height: 12)
            Slider(value: $level, in: 0...3, step: 1)
            HStack {
                Text("0")
                Spacer()
                Text("1")
                Spacer()
                Text("2")
                Spacer()
                Text("3")
            }
            .fontWeight(.semibold)
        }
        .padding(16)
        // Pre iOS 17: .onChange(of: level) { [oldVal = level] newVal in
        .onChange(of: level) { oldVal, newVal in
            if oldVal == 0 {
                rotation += 90
            }
        }
    }
    

    Animation