Advanced Animation Techniques
While withAnimation is the powerful workhorse for most animations, SwiftUI also provides specialized tools for more complex, narrative-style animations where you need greater control over the sequence and timing.
PhaseAnimator
PhaseAnimator is the modern, declarative replacement for older, more manual techniques like using a Timer to cycle through states. It is designed for animations that happen in a clear sequence of distinct steps.
The recipe for using it is simple:
-
Define your steps, usually as an
enum. This makes your code very readable. -
Create the
PhaseAnimatorview, giving it your sequence of phases and a trigger to start it. -
Write your view code inside the animator, making its appearance change based on the current
phase. -
Optionally, provide an
animationblock to customize the transition between phases.
This example builds a progress bar that animates to full in stages every time you tap it. The enum for the phases includes a computed property (widthMultiplier), which makes the code inside the animator incredibly clean and readable.
Here is the view code for the PhaseAnimator example.
// MARK: - Simpler PhaseAnimator Example
struct ProgressAnimatorView: View {
// 1. Define the phases with a clear name and a computed property
enum ProgressPhase: CaseIterable {
case initial, quarter, half, threeQuarters, full
var widthMultiplier: Double {
switch self {
case .initial: 0.0
case .quarter: 0.25
case .half: 0.5
case .threeQuarters: 0.75
case .full: 1.0
}
}
}
// 2. Use a simple integer trigger to replay the animation on each tap.
@State private var progressTrigger = 0
var body: some View {
VStack(spacing: 20) {
Text("PhaseAnimator")
.font(.title)
.fontWeight(.bold)
// 3. The animator runs its sequence every time the trigger value changes.
PhaseAnimator(ProgressPhase.allCases, trigger: progressTrigger) { phase in
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(.gray.opacity(0.3))
Capsule()
.fill(.green)
.frame(width: geo.size.width * phase.widthMultiplier)
}
}
} animation: { _ in
.spring(duration: 0.6, bounce: 0.4)
}
.frame(height: 20)
Text("Tap the progress bar to animate it.")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
.onTapGesture {
progressTrigger += 1
}
}
}
KeyframeAnimator
While PhaseAnimator is for a sequence of states, KeyframeAnimator is for controlling the value of a property over time within a single animation. If PhaseAnimator is like a flipbook with a few distinct pages, KeyframeAnimator is like being a puppeteer, precisely controlling the puppet’s strings at specific moments to create a single, seamless motion.
This gives you precise, granular control for creating custom bounces, jiggles, and other complex effects that a standard animation curve can’t achieve. This example creates a complex, multi-part animation on a piece of text.
Here is the view code for the KeyframeAnimator example.
// MARK: - KeyframeAnimator Example
struct KeyframeAnimatorView: View {
struct AnimationValues {
var scale: Double = 1.0
var verticalOffset: Double = 0.0
var opacity: Double = 1.0
}
@State private var animationTrigger = 0
var body: some View {
VStack {
Text("KeyframeAnimator")
.font(.title)
.fontWeight(.bold)
Text("Hello, Keyframes!")
.keyframeAnimator(
initialValue: AnimationValues(),
trigger: animationTrigger
) { content, value in
content
.scaleEffect(value.scale)
.offset(y: value.verticalOffset)
.opacity(value.opacity)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.2, duration: 0.3, spring: .bouncy)
SpringKeyframe(1.0, spring: .bouncy)
}
KeyframeTrack(\.verticalOffset) {
LinearKeyframe(-20, duration: 0.2)
LinearKeyframe(0, duration: 0.5)
}
KeyframeTrack(\.opacity) {
LinearKeyframe(1.0, duration: 0.6)
LinearKeyframe(0.0, duration: 0.2)
}
}
.onTapGesture {
animationTrigger += 1
}
Text("Tap the text!")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
}
}
SwiftUI provides four types of keyframes for the KeyframeAnimator: Linear, Cubic, Spring, and Move.
LinearKeyframe
A LinearKeyframe creates a direct, constant-speed transition between the previous value and the new target value. Think of it as drawing a straight line between two points. The animation proceeds at a steady rate without any acceleration or deceleration.
Use Case: Best for uniform motion, like a loading bar filling up or an object moving at a constant velocity.
// Moves from the previous scale to 1.5 at a constant speed over 0.2 seconds
LinearKeyframe(1.5, duration: 0.2)
CubicKeyframe
A CubicKeyframe animates a value along a cubic Bézier curve. This allows for smooth acceleration and deceleration, often called “easing.” It gives animations a more natural and polished feel compared to the mechanical movement of a linear keyframe. This is the most common type of timing curve used in UI animation.
Use Case: Ideal for most UI transitions, like an element sliding into view (“ease-out”), a modal disappearing (“ease-in”), or a button changing state (“ease-in-out”).
// Smoothly eases from the previous offset to 20 over 0.2 seconds
CubicKeyframe(20, duration: 0.2)
SpringKeyframe
A SpringKeyframe animates a value using spring physics to create a bouncy, dynamic, and realistic effect. Instead of just moving to a target, the value will overshoot it and oscillate back and forth before settling. You can customize the spring’s behavior by providing parameters like stiffness and damping.
Use Case: Perfect for animations that should feel physical and playful, like a “like” button bouncing when tapped, a notification wiggling for attention, or an object reacting to being dropped.
// "Bounces" towards a rotation of -30 degrees
SpringKeyframe(Angle.degrees(-30), duration: 0.2)
MoveKeyFrame
The MoveKeyframe is a keyframe that immediately moves to the given value without interpolation. Unlike LinearKeyframe, CubicKeyframe, and SpringKeyframe which smoothly transition over time, MoveKeyframe simply jumps to the target value.
Use Case: Use when you need to switch between distinct states without any visual transition.
// image immediately appears
MoveKeyframe("image")
// immediately switch to image2
MoveKeyframe(at: 1.0, "image2")
Let’s put all the animations into a runnable app.