Reading time: 4 min

This recipe shows how to pause and resume animations in SwiftUI.

The end result looks like this:

Preview

This component is available as a Swift Package in this repo.

Pausing and stopping animations

Stopping an animation in SwiftUI, while not available out of the box, is fairly easy. What you'll do is to use an AnimatableModifier that tracks current animation progress. Then, when the isPaused binding is set to true, update the animated property's final value to the current animation progress.

It is important to set the animated property using withAnimation, but we want for it to happen as fast as possible so that user doesn't notice the interruption. To do this, we'll add an animation type that emulates instantaneous transition:

public extension Animation {
  static let instant = Animation.linear(duration: 0.0001)
}

After that, coding up the animation stopper modifier goes as follows:

public struct StoppableAnimationModifier<Value: VectorArithmetic>: AnimatableModifier {
  @Binding var binding: Value
  @Binding var paused: Bool

  public var animatableData: Value

  public init(binding: Binding<Value>,
              paused: Binding<Bool>) {
    _binding = binding
    _paused = paused
    animatableData = binding.wrappedValue
  }

  public func body(content: Content) -> some View {
    content
      .onChange(of: paused) { isPaused in
        if isPaused {
          withAnimation(.instant) {
            binding = animatableData // the magic happens here
          }
        }
      }
  }
}
Resuming animations

Resuming paused animations is where things get tricky. Basically, there are three problems to overcome.

Suppose your animation changes opacity from 0 to 1 over 5 seconds and you pause the animation at the 2 second mark. This leaves the animatableData value at 0.4. Since SwiftUI is a declarative framework, this is also the only data we can get out of AnimatableModifier - whatever other data we need to describe the animation needs to be provided in the initializer.

Now, to resume this animation, you need to know the following:

  1. What was the original target value? In this case, it's 1 as opacity was moving from 0 to 1.
  2. How long did the animation last up until now and how long does it still have to go? In the case above, animatableData value of 0.4 translates to 2 seconds, meaning 3 more seconds are needed to complete the animation.
  3. What kind of animation was all of this about? There has to be a way to create such animation, whose duration is computed in step #2.

This might seem like a lot, but in reality it isn't that bad. First, let's extend the modifier to include those three new properties and use them to trigger a resume. Resuming boils down to pointing the animated property to its target value again:

public typealias RemainingDurationProvider<Value: VectorArithmetic> = (Value) -> TimeInterval
public typealias AnimationWithDurationProvider = (TimeInterval) -> Animation

public struct PausableAnimationModifier<Value: VectorArithmetic>: AnimatableModifier {
  @Binding var binding: Value
  @Binding var paused: Bool

  private let targetValue: Value
  private let remainingDuration: RemainingDurationProvider<Value>
  private let animation: AnimationWithDurationProvider

  public var animatableData: Value

  public init(binding: Binding<Value>,
              targetValue: Value,
              remainingDuration: @escaping RemainingDurationProvider<Value>,
              animation: @escaping AnimationWithDurationProvider,
              paused: Binding<Bool>) {
    _binding = binding
    self.targetValue = targetValue
    self.remainingDuration = remainingDuration
    self.animation = animation
    _paused = paused
    animatableData = binding.wrappedValue
  }

  public func body(content: Content) -> some View {
    content
      .onChange(of: paused) { isPaused in
        if isPaused {
          withAnimation(.instant) {
            binding = animatableData
          }
        } else { // resuming
          withAnimation(animation(remainingDuration(animatableData))) {
            binding = targetValue
          }
        }
      }
  }
}

Here's a helpful extension to wrap the modifier in a method:

public extension View {
  func pausableAnimation<Value: VectorArithmetic>(binding: Binding<Value>,
                                                  targetValue: Value,
                                                  remainingDuration: @escaping RemainingDurationProvider<Value>,
                                                  animation: @escaping AnimationWithDurationProvider,
                                                  paused: Binding<Bool>) -> some View {
    self.modifier(PausableAnimationModifier(binding: binding,
                                            targetValue: targetValue,
                                            remainingDuration: remainingDuration,
                                            animation: animation,
                                            paused: paused))
  }
}

Finally, here's an example of the modifier in action:

struct PausableAnimationTest: View {
  @State private var angle = 0.0
  @State private var isPaused = false

  private let duration: TimeInterval = 6
  private let startAngle = 0.0
  private let endAngle = 360.0

  // this formula will generally always be the same
  private var remainingDuration: RemainingDurationProvider<Double> {
    { currentAngle in
      duration * (1 - (currentAngle - startAngle) / (endAngle - startAngle))
    }
  }

  private let animation: AnimationWithDurationProvider = { duration in
    .linear(duration: duration)
  }

  var body: some View {
    VStack {
      ZStack {
        Text("I'm slowly rotating!")
          .rotationEffect(.degrees(angle))
          .pausableAnimation(binding: $angle,
                             targetValue: endAngle,
                             remainingDuration: remainingDuration,
                             animation: animation,
                             paused: $isPaused)
      }
      .frame(height: 300)
      Button(isPaused ? "Resume" : "Pause") {
        isPaused = !isPaused
      }
    }
    .onAppear {
      angle = startAngle
      withAnimation(animation(duration)) {
        angle = endAngle
      }
    }
  }
}

Next Post Previous Post