Niklas
Niklas

Reputation: 25443

SwiftUI Animation finish animation cycle

I want to finish the loading animation nicely when changing the refreshing boolean in the following scenario:

public struct RefreshingView : View {
    @State private var rotation = Angle(degrees: 0.0)
    @Binding private var refreshing: Bool

    public init(refreshing: Binding<Bool>) {
        _refreshing = refreshing
    }

    public var body: some View {
        if refreshing {
            Image(systemName: "arrow.2.circlepath")
                .foregroundColor(Color.red)
                .rotationEffect(rotation)
                .animateForever(using: .linear(duration: 1), autoreverses: false) {
                    rotation = Angle(degrees: -180)
                }
        } else {
            Image(systemName: "arrow.2.circlepath")
                .foregroundColor(Color.red)
                .onAppear {
                    rotation = Angle(degrees: 0)
                }
        }
    }
}

public extension View {
  func animateForever(
      using animation: Animation = Animation.easeInOut(duration: 1),
      autoreverses: Bool = false,
      _ action: @escaping () -> Void
  ) -> some View {
      let repeated = animation.repeatForever(autoreverses: autoreverses)

      return onAppear {
          // That main.async is really important to change the animation from being explicit to implicit.
          // https://stackoverflow.com/a/64566746/1979703
          DispatchQueue.main.async {
              withAnimation(repeated) {
                  action()
              }
          }
      }
  }
}

Currently, the problem is that when I change the refreshing value the animation stops correctly, but it does not finsh the rotation so it looks like it is cut off. Is there a way to mitigate this by always finishing the current animation?

Upvotes: 3

Views: 482

Answers (1)

Benzy Neez
Benzy Neez

Reputation: 21730

To get this working, there needs to be more of a disconnect between the refreshing flag and the animation:

  • when the flag is turned on then the animation should begin
  • after every 180 degrees rotation it should repeat if the flag is still on
  • otherwise it should stop.

This means, instead of using an animation with .repeatForever, you need to perform a single animation and then launch it again when it completes if the flag is still on. In iOS 17 you can add a completion callback to withAnimation, which would be ideal for this purpose. Until then, you can achieve something similar using an AnimatableModifier, see SwiftUI withAnimation completion callback.

This shows it working:

// Credit to Centurion for the AnimatableModifier solution:
// https://stackoverflow.com/a/62855108/20386264
struct AnimationCompletionCallback<V: VectorArithmetic>: ViewModifier, Animatable {
    private let targetValue: V
    var completion: () -> ()

    init(animatedValue: V, completion: @escaping () -> ()) {
        self.targetValue = animatedValue
        self.completion = completion
        self.animatableData = animatedValue
    }

    var animatableData: V {
        didSet {
            checkIfFinished()
        }
    }

    func checkIfFinished() -> () {
        if (animatableData == targetValue) {
            Task { @MainActor in
                self.completion()
            }
        }
    }

    func body(content: Content) -> some View {
        content
    }
}

public struct RefreshingView : View {

    let refreshing: Bool
    @State private var rotation = 0.0

    private func nextTurn() {
        withAnimation(.linear(duration: 1)) {
            rotation += 180
        }
    }

    public var body: some View {
        Image(systemName: "arrow.2.circlepath")
            .resizable()
            .scaledToFill()
            .frame(width: 100, height: 100)
            .foregroundColor(Color.red)
            .rotationEffect(Angle(degrees: rotation))
            .onChange(of: refreshing) { newValue in
                if newValue {
                    nextTurn()
                }
            }
            .modifier(AnimationCompletionCallback(animatedValue: rotation) {
                if refreshing {
                    nextTurn()
                }
            })
    }
}

struct ContentView: View {

    @State private var refreshing = false

    var body: some View {
        VStack(spacing: 50) {
            Toggle("Refreshing", isOn: $refreshing).fixedSize()
            RefreshingView(refreshing: refreshing)
        }
    }
}

Animation

Upvotes: 4

Related Questions