Reading time: 2 min
This recipe shows how to track SwiftUI animation progress and completion via callbacks. For an animated value (offset, opacity, etc.), get its current value as the animation progresses and then get notified when the animation is completed.
The end result looks like this:
This component is available as a Swift Package in this repo.
To accomplish this, we'll use an AnimatableModifier
, which is similar to a ViewModifier
, except it also exposes the animatableData
property. SwiftUI uses this property to gradually change the original animated value into its new state as the animation progresses. Observing it allows us to track animation progress and know when it ends. Here's the code of the observer modifier:
public struct AnimationObserverModifier<Value: VectorArithmetic>: AnimatableModifier {
// this is the view property that drives the animation - offset, opacity, etc.
private let observedValue: Value
private let onChange: ((Value) -> Void)?
private let onComplete: (() -> Void)?
// SwiftUI implicity sets this value as the animation progresses
public var animatableData: Value {
didSet {
notifyProgress()
}
}
public init(for observedValue: Value,
onChange: ((Value) -> Void)?,
onComplete: (() -> Void)?) {
self.observedValue = observedValue
self.onChange = onChange
self.onComplete = onComplete
animatableData = observedValue
}
public func body(content: Content) -> some View {
content
}
private func notifyProgress() {
DispatchQueue.main.async {
onChange?(animatableData)
if animatableData == observedValue {
onComplete?()
}
}
}
}
Lastly, add this helpful extension:
public extension View {
func animationObserver<Value: VectorArithmetic>(for value: Value,
onChange: ((Value) -> Void)? = nil,
onComplete: (() -> Void)? = nil) -> some View {
self.modifier(AnimationObserverModifier(for: value,
onChange: onChange,
onComplete: onComplete))
}
}
Here's a sample view that makes use of the animation observer to update the UI as the animation progresses:
struct AnimationObserverTest: View {
@State private var offset = 0.0
@State private var offsetSpan: ClosedRange<Double> = 0...1
@State private var progressPercentage = 0.0
@State private var isDone = false
var body: some View {
GeometryReader { geo in
VStack {
Text("Loading: \(progressPercentage)%")
Rectangle()
.foregroundColor(.blue)
.frame(height: 50)
.offset(x: offset)
.animationObserver(for: offset) { progress in // HERE
progressPercentage = 100 * abs(progress - offsetSpan.lowerBound)
/ (offsetSpan.upperBound - offsetSpan.lowerBound)
} onComplete: {
isDone = true
}
if isDone {
Text("Done!")
} else if progressPercentage >= 50 {
Text("Woooooah, we're half way there...")
}
Button("Reload") {
isDone = false
offset = -geo.size.width
offsetSpan = offset...0
withAnimation(.easeIn(duration: 5)) {
offset = 0
}
}
}
}
}
}