← All shorts
11iOS 17+2 min read

phaseAnimator: Multi-Step Animation in One Modifier

Sequence a bump, settle, and rest animation without chaining withAnimation calls or DispatchQueue delays.

A "like" button scales up, then settles back down. Two animations, in sequence.

Getting that used to mean two withAnimation blocks with a hand-timed delay between them: a 0.15 in the animation curve, a matching 0.15 in the asyncAfter deadline, and you keeping the two numbers equal by hand.

#The old way

struct LikeButton: View {
    @State private var liked = false
    @State private var scale: CGFloat = 1.0

    var body: some View {
        Button {
            liked.toggle()
            withAnimation(.snappy(duration: 0.15)) {
                scale = 1.4
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
                withAnimation(.bouncy) {
                    scale = 1.0
                }
            }
        } label: {
            Image(systemName: liked ? "heart.fill" : "heart")
                .font(.system(size: 32))
                .foregroundStyle(liked ? .red : .gray)
                .scaleEffect(scale)
        }
    }
}

#The new way

import SwiftUI

struct LikeButton: View {
    @State private var liked = false

    enum Phase: CaseIterable {
        case initial
        case scaleUp
        case settle
    }

    var body: some View {
        Button {
            liked.toggle()
        } label: {
            Image(systemName: liked ? "heart.fill" : "heart")
                .font(.system(size: 32))
                .foregroundStyle(liked ? .red : .gray)
                .phaseAnimator(Phase.allCases, trigger: liked) { content, phase in
                    content
                        .scaleEffect(phase == .scaleUp ? 1.4 : 1.0)
                } animation: { phase in
                    switch phase {
                    case .initial: .smooth
                    case .scaleUp: .snappy(duration: 0.15)
                    case .settle: .bouncy
                    }
                }
        }
    }
}

#Why it matters

  • The phase sequence and its per-phase curve sit right on the view that uses them. The scale state variable is gone, and so is the job of tracking which step the button is on.
  • Each phase carries its own Animation. A .snappy(duration: 0.15) into the bump and a .bouncy settle out of it are both declarative.
  • No DispatchQueue.asyncAfter guesswork. SwiftUI drives the phase transitions itself, so the timing can't drift out of sync with the animation curve.

#Gotcha

phaseAnimator(_:trigger:content:animation:) always restarts from the first element of the phases sequence when the trigger value changes. It never picks up mid-cycle from wherever the last run left off.

So change the trigger again while a cycle is still animating and you get a visible snap back to .initial before the new cycle begins. For rapid-fire triggers such as double-tapping the like button, debounce the trigger value. The alternative is accepting that overlapping taps restart the sequence.

swiftuiios17animation

Related shorts