Search code examples
iosanimationswiftuiswiftui-animation

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?


Solution

  • 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: AnimatableModifier {
    
        var targetValue: Double
        var completion: () -> ()
    
        init(animatedValue: Double, completion: @escaping () -> ()) {
            self.targetValue = animatedValue
            self.completion = completion
            self.animatableData = animatedValue
        }
    
        var animatableData: Double {
            didSet {
                checkIfFinished()
            }
        }
    
        func checkIfFinished() -> () {
            if (animatableData == targetValue) {
                DispatchQueue.main.async {
                    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