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?
To get this working, there needs to be more of a disconnect between the refreshing
flag and the animation:
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)
}
}
}