← 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 that scales up and then settles back down needs two animations in sequence. Getting there used to mean chaining withAnimation blocks with a manually timed delay in between, hoping the durations stayed in sync.

#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 the animation curve per phase, live next to the view that uses them — no state variable dedicated to tracking "which step am I on".
  • Each phase can carry its own Animation, so a fast snap into the bump and a springy settle out of it are both expressed declaratively.
  • No DispatchQueue.asyncAfter guesswork — SwiftUI drives the phase transitions itself, so 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 doesn't pick up mid-cycle from wherever the last run left off. If the trigger changes again while a cycle is still animating, you'll see a visible snap back to phase one before the new cycle begins. For rapid-fire triggers (double-tapping the like button, for instance), debounce the trigger value or accept that overlapping taps restart the sequence.

swiftuiios17animation

Related shorts