PhaseAnimator in SwiftUI

  • Oct 27, 2025

PhaseAnimator in SwiftUI

SwiftUI includes powerful animation features that make apps feel more polished and interactive. These animations are smooth, can be interrupted, and follow natural physics to create realistic movement.

Starting in iOS 17, SwiftUI introduced new animation APIs that give developers even more control. One of the most exciting additions is PhaseAnimator, which we’ll explore in this article.

Before we dive in, let’s quickly review how animations work in SwiftUI. Adding animation is simple — you can wrap changes inside a withAnimation block or apply the animation modifier to a view. Whenever the state of your view updates, SwiftUI will automatically animate the change for you.

Let’s start with an example to see this in action. We will create a view with circle that can be animated to scale up or down by tapping on the circle.

struct PhaseAnimationExample: View {
    @State private var animate = false
    
    var body: some View {
        NavigationStack {
            VStack {
                Circle()
                    .fill(.red.gradient)
            }
            .scaleEffect(animate ? 1.0 : 0.5)
            .navigationTitle("DevTechie Courses")
            .onTapGesture {
                animate.toggle()
            }
        }
    }
}

In code above, we created a Boolean state variable named “animate” declared using the @State property wrapper. This variable is used to control the animation behavior.

State in SwiftUI is a local source of truth that stores dynamic values for a view. When a @State property changes, SwiftUI automatically redraws the affected UI to match the new data. It helps keep the interface reactive, synchronized, and easy to update without managing manual UI refreshes.

The view uses a NavigationStack with a VStack inside it. In the stack, there is a red circle created with the Circle view and filled using a red gradient.

The VStack has a scaleEffect that depends on the animate state variable. When animate is true, the circle appears at its normal size (scaleEffect(1.0)). When animate is false, the circle shrinks to half its size (scaleEffect(0.5)). 

The view also uses an onTapGesture modifier. When the user taps anywhere on the view, it toggles the animate state between true and false. This state change updates the scaleEffect, making the circle grow or shrink based on the new value.

However, when you run the code, the size changes happen instantly with no animation. The view scales immediately because no animation has been applied to the change in state.

Let’s add animation to this scale change using withAnimation block

struct PhaseAnimationExample: View {
    @State private var animate = false
    
    var body: some View {
        NavigationStack {
            VStack {
                Circle()
                    .fill(.red.gradient)
            }
            .scaleEffect(animate ? 1.0 : 0.5)
            .navigationTitle("DevTechie Courses")
            .onTapGesture {
                withAnimation {
                    animate.toggle()
                }
            }
        }
    }
}

This simple approach works well for basic animations, but what if we need more control over how a view transitions between multiple states? That’s where the new PhaseAnimator comes in.

PhaseAnimator cycles through a set of phases whenever a trigger value changes. Each phase defines a different visual state, and SwiftUI smoothly animates from one phase to another as the trigger updates.

Next, we’ll update our example to use the phaseAnimator modifier, since we will be applying the scale effect inside the phaseAnimator modifier, we will remove that from the code.

struct PhaseAnimationExample: View {
    @State private var animate = false
    
    var body: some View {
        NavigationStack {
            VStack {
                Circle()
                    .fill(.red.gradient)
                    
            }
            .phaseAnimator([1.0, 0.5, 0.1], trigger: animate) { view, phase in
                view
                    .scaleEffect(phase)
                    .opacity(phase)
            }
            .navigationTitle("DevTechie Courses")
            .onTapGesture {
                withAnimation {
                    animate.toggle()
                }
            }
        }
    }
}

In this code, we define three phases for the view: 1.0, 0.5, and 0.1. These phases represent the different states the view will cycle through and must be a non-empty sequence.

The @State property animate acts as a trigger. Whenever its value changes, the phaseAnimator updates the view.

The trailing closure is a view builder that takes two parameters:

  1. proxy view — the current view that can be modified with animatable changes

  2. phase — the current value from the phase sequence

Inside the closure, we apply scaleEffect and opacity modifiers so the view smoothly transitions through the defined phases whenever it is tapped.

Let’s build a pendulum animation using phaseAnimator.

struct PhaseAnimationExample: View {
    @State private var animate = false
    
    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                Image(systemName: "clock.fill")
                    .resizable()
                    .foregroundStyle(.orange.gradient)
                    .frame(width: 200, height: 200)
                VStack(spacing: 0) {
                    Rectangle()
                        .frame(width: 1, height: 150)
                    Circle()
                        .fill(.brown.gradient)
                        .frame(height: 20)
                }
                .phaseAnimator([45.0, -45.0]) { view, phase in
                    view.rotationEffect(.degrees(phase), anchor: .top)
                }
            }
            .padding(25)
            .background(Color.indigo, in: RoundedRectangle(cornerRadius: 10).stroke(lineWidth: 5))
            .navigationTitle("DevTechie Courses")
            
        }
    }
}

In the code above, we have a VStack with two main components. The first component is an image created using the clock.fillsystem symbol from SF Symbols to represent a clock face. The second component is another VStack with Rectangle and Circle views to make a pendulum. The phaseAnimator modifier adds the phase values of 45 and -45 degrees and rotationEffectmodifier is used to add rotation effect from the top of the VStack view. 

Our pendulum is really fast and it will be great to slow it down a bit.

Another overload of phaseAnimator gives us ability to control animation for different phases. For this case, let’s use the closure to slow the animation down a bit.

struct PhaseAnimationExample: View {
    @State private var animate = false
    
    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                Image(systemName: "clock.fill")
                    .resizable()
                    .foregroundStyle(.orange.gradient)
                    .frame(width: 200, height: 200)
                VStack(spacing: 0) {
                    Rectangle()
                        .frame(width: 1, height: 150)
                    Circle()
                        .fill(.brown.gradient)
                        .frame(height: 20)
                }
                .phaseAnimator([45.0, -45.0]) { view, phase in
                    view.rotationEffect(.degrees(phase), anchor: .top)
                } animation: { _ in
                    .easeInOut(duration: 0.5)
                }
            }
            .padding(25)
            .background(Color.indigo, in: RoundedRectangle(cornerRadius: 10).stroke(lineWidth: 5))
            .navigationTitle("DevTechie Courses")
            
        }
    }
}

We can make this animation more dramatic by adding different animations for various phases for example:

struct PhaseAnimationExample: View {
    @State private var animate = false
    
    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                Image(systemName: "clock.fill")
                    .resizable()
                    .foregroundStyle(.orange.gradient)
                    .frame(width: 200, height: 200)
                VStack(spacing: 0) {
                    Rectangle()
                        .frame(width: 1, height: 150)
                    Circle()
                        .fill(.brown.gradient)
                        .frame(height: 20)
                }
                .phaseAnimator([45.0, -45.0]) { view, phase in
                    view.rotationEffect(.degrees(phase), anchor: .top)
                } animation: { phase in
                    switch phase {
                    case 1: .easeInOut(duration: 0.5)
                    case 2: .spring(duration: 0.2, bounce: 0.4)
                    case 3: .snappy(duration: 2.0)
                    default: .smooth(duration: 1.0)
                    }
                }
            }
            .padding(25)
            .background(Color.indigo, in: RoundedRectangle(cornerRadius: 10).stroke(lineWidth: 5))
            .navigationTitle("DevTechie Courses")
            
        }
    }
}

Since the phaseAnimator expects a sequence and operates on the values of the sequence, we can create some really interesting animation flows using that.

For our next example we will create a view to display a set of characters with different animation effects. Tapping on the view will initiates the animation, showcasing each character with its assigned animation effect.

struct PhaseAnimationExample: View {
    @State private var startSwitching: Bool = false
    var dtArray = ["d","e", "v", "t", "e", "c", "h", "i", "e"]
    
    var body: some View {
        PhaseAnimator(dtArray, trigger: startSwitching) { char in
            ZStack {
                Circle()
                    .fill(.orange.gradient.opacity(0.5))
                    .frame(width: 200)
                
                Image(systemName: char.lowercased())
                    .symbolVariant(.circle)
                    .font(.system(size: 200))
                    .foregroundStyle(.indigo.gradient)
            }
        } animation: { char in
            switch char {
            case "d": .bouncy.speed(0.8)
            case "e": .easeIn.speed(0.8)
            case "v": .easeInOut.speed(0.8)
            case "t": .easeOut.speed(0.8)
            case "c": .spring.speed(0.8)
            case "h": .linear.speed(0.8)
            case "i": .smooth.speed(0.8)
            default:
                    .bouncy
            }
        }
        .onTapGesture {
            startSwitching.toggle()
        }
    }
}

Let’s try one last example in which each letter from “devtechie” is displayed inside a colorful, bubble, arranged around a central point. Each bubble uses a PhaseAnimator, which animates through a series of phases represented by an array of radian values. These phases change the rotation and movement of each bubble, making the entire display come alive with animated orbits, scaling effects, and subtle rotations. The bubble’s position is calculated so the letters are roughly distributed in a circular layout, while their radius and size subtly change based on both the phase and the letter’s index, creating a lively, organic feel. Each letter also gets its own color and animation curve, adding more visual variety to the animation. When the user taps anywhere in the view, the character order is shuffled and the animation sequence is triggered again, resulting in a new, unique, and engaging arrangement each time. 

struct MorphingCharacterBubbles: View {
    @State private var startSwitching = false
    @State private var charArray = ["d","e", "v", "t", "e", "c", "h", "i", "e"].shuffled()
    
    let phases: [Double] = [0, .pi/6, .pi/3, .pi/2, .pi, 3*Double.pi/2]
    
    var body: some View {
        ZStack {
            ForEach(charArray.indices, id: \.self) { idx in
                PhaseAnimator(phases, trigger: startSwitching) { phase in
                    let angle = (Double(idx) / Double(charArray.count)) * 2 * Double.pi + phase
                    let radius: CGFloat = 90 + CGFloat(sin(phase + Double(idx))) * 24
                    let color = Color(hue: Double(idx) / Double(charArray.count), saturation: 0.9, brightness: 0.95)
                    
                    return ZStack {
                        Circle()
                            .fill(color.shadow(.drop(radius: 5)))
                            .frame(width: 60 + CGFloat(cos(angle+phase)) * 10)
                        Text(charArray[idx].uppercased())
                            .font(.system(size: 30, weight: .bold))
                            .foregroundStyle(.white)
                            .shadow(radius: 2)
                    }
                    .offset(
                        x: cos(angle + phase) * radius,
                        y: sin(angle + phase) * radius
                    )
                    .scaleEffect(1 + 0.13 * sin(phase*2 + Double(idx)))
                    .rotationEffect(.degrees(5 * sin(phase + Double(idx))))
                } animation: { phase in
                    switch idx % 7 {
                    case 0: .bouncy.speed(0.9)
                    case 1: .spring.speed(1)
                    case 2: .snappy.speed(1)
                    case 3: .smooth.speed(0.7)
                    case 4: .easeInOut.speed(0.9)
                    case 5: .easeOut.speed(0.8)
                    default: .easeIn.speed(1)
                    }
                }
            }
        }
        .frame(width: 320, height: 320)
        
        .onTapGesture {
            charArray.shuffle()
            startSwitching.toggle()
        }
    }
}