| < How to run a completion callback when an animation finishes | How to create animatable views, modifiers, and more > |
Updated for Xcode 16.4
New in iOS 17
SwiftUI’s PhaseAnimator view and phaseAnimator modifier allow us to perform multi-step animation by cycling through animation phases of our choosing, either constantly or when triggered.
Creating these multi-phase animations takes three steps:
CaseIterable enum.For example, this next example creates a simple animation that makes some text start small and invisible, scale up to natural size and be fully opaque, then scale up to be very large and invisible. It uses an array of the numbers 0, 1, and 3 to represent the various scaling sizes we’ll be using (0%, 100%, and 300%), and it makes the text opaque when the size is 1:
Text("Hello, world!")
.font(.largeTitle)
.phaseAnimator([0, 1, 3]) { view, phase in
view
.scaleEffect(phase)
.opacity(phase == 1 ? 1 : 0)
}
Download this as an Xcode project

Because we haven’t provided a trigger for the animation, it will run forever.
If you prefer, you can write that using a wrapping PhaseAnimator view, which has the advantage that multiple views can move between phases together:
VStack(spacing: 50) {
PhaseAnimator([0, 1, 3]) { value in
Text("Hello, world!")
.font(.largeTitle)
.scaleEffect(value)
.opacity(value == 1 ? 1 : 0)
Text("Goodbye, world!")
.font(.largeTitle)
.scaleEffect(3 - value)
.opacity(value == 1 ? 1 : 0)
}
}
Download this as an Xcode project

Like I said, you might prefer to use an enum with your various phases. This might have meaningful raw values attached, but it doesn’t need to. Here’s the same thing rewritten using an enum:
enum AnimationPhase: Double, CaseIterable {
case fadingIn = 0
case middle = 1
case zoomingOut = 3
}
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.font(.largeTitle)
.phaseAnimator(AnimationPhase.allCases) { view, phase in
view
.scaleEffect(phase.rawValue)
.opacity(phase.rawValue == 1 ? 1 : 0)
}
}
}
Download this as an Xcode project
Rather than have the phase animators repeat endlessly, you can make it trigger the animation sequence on your command. To do this, attach a trigger value for SwiftUI to watch, such as a random UUID or an incrementing number. Whenever that value changes, SwiftUI will reset your animator and play it back in full.
In this next example, tapping the button triggers a three-step animation using enum cases. First, we define the various animation phases we want, then we move through them whenever a property changes:
enum AnimationPhase: CaseIterable {
case start, middle, end
}
struct ContentView: View {
@State private var animationStep = 0
var body: some View {
Button("Tap Me!") {
animationStep += 1
}
.font(.largeTitle)
.phaseAnimator(AnimationPhase.allCases, trigger: animationStep) { content, phase in
content
.blur(radius: phase == .start ? 0 : 10)
.scaleEffect(phase == .middle ? 3 : 1)
}
}
}
Download this as an Xcode project

For even more control, you can specify exactly which animation to use for each phase. For example, this moves between quick .bouncy and a slow .easeInOut animations to get a more varied movement:
enum AnimationPhase: CaseIterable {
case start, middle, end
}
struct ContentView: View {
@State private var animationStep = 0
var body: some View {
Button("Tap Me!") {
animationStep += 1
}
.font(.largeTitle)
.phaseAnimator(AnimationPhase.allCases, trigger: animationStep) { content, phase in
content
.blur(radius: phase == .start ? 0 : 10)
.scaleEffect(phase == .middle ? 3 : 1)
} animation: { phase in
switch phase {
case .start, .end: .bouncy
case .middle: .easeInOut(duration: 2)
}
}
}
}
Download this as an Xcode project

One approach I’ve found useful is to add extra computed properties to the animation phases to make the rest of the code easier to read, like this:
enum AnimationPhase: CaseIterable {
case fadingIn, middle, zoomingOut
var scale: Double {
switch self {
case .fadingIn: 0
case .middle: 1
case .zoomingOut: 3
}
}
var opacity: Double {
switch self {
case .fadingIn: 0
case .middle: 1
case .zoomingOut: 0
}
}
}
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.font(.largeTitle)
.phaseAnimator(AnimationPhase.allCases) { content, phase in
content
.scaleEffect(phase.scale)
.opacity(phase.opacity)
}
}
}
Download this as an Xcode project

SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further for less! Get my all-new book Everything but the Code to make more money with apps, get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn Swift Testing, design patterns, and more.