Pause and Resume Animation in SwiftUI
Reading time: 4 min
This recipe shows how to pause and resume animations in SwiftUI.
The end result looks like this:
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:
- What was the original target value? In this case, it's 1 as opacity was moving from 0 to 1.
- 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. - 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
}
}
}
}