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:

Preview

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
           }
         }
       }
     }
   }
}

Next Post Previous Post