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
scalestate 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.bouncysettle out of it are both declarative. - No
DispatchQueue.asyncAfterguesswork. 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.