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.asyncAfterguesswork — 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.