Animations
SwiftUI animations are a single API surface: you change state, you wrap the change in `withAnimation`, the framework interpolates. Pair with `.matchedGeometryEffect` for hero transitions, `.phaseAnimator` for sequenced animations, and explicit transitions for enter/exit.
withAnimation, transitions, matchedGeometry, phases
EXAMPLE
import SwiftUI
// 1) Implicit animation — same state, declarative transition
struct Counter: View {
@State private var n = 0
var body: some View {
VStack(spacing: 24) {
Text("\(n)")
.font(.system(size: 96, weight: .bold))
.contentTransition(.numericText()) // animate digit changes
.animation(.bouncy, value: n)
Button("Increment") {
withAnimation { n += 1 } // wrap state changes
}
}
.padding()
}
}
// 2) Enter / exit transitions
struct ToggleCard: View {
@State private var show = false
var body: some View {
VStack {
Button(show ? "Hide" : "Show") {
withAnimation(.spring) { show.toggle() }
}
if show {
Text("Hello")
.padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
.transition(.scale.combined(with: .opacity))
}
}
}
}
// 3) Hero transition with matchedGeometryEffect
struct HeroDemo: View {
@Namespace private var ns
@State private var selected: Int? = nil
let items = [1, 2, 3]
var body: some View {
ZStack {
if let s = selected {
VStack {
Color.blue
.matchedGeometryEffect(id: s, in: ns)
.frame(width: 200, height: 200)
.onTapGesture { withAnimation(.spring) { selected = nil } }
Text("Item \(s)").font(.title)
}
} else {
HStack {
ForEach(items, id: \.self) { i in
Color.blue
.matchedGeometryEffect(id: i, in: ns)
.frame(width: 60, height: 60)
.onTapGesture { withAnimation(.spring) { selected = i } }
}
}
}
}
}
}
// 4) Phase animator — keyframed without the math
struct PhaseLogo: View {
var body: some View {
Image(systemName: "sparkles")
.font(.largeTitle)
.phaseAnimator([0, 1]) { content, phase in
content
.scaleEffect(phase == 0 ? 1 : 1.2)
.rotationEffect(.degrees(phase == 0 ? 0 : 12))
.opacity(phase == 0 ? 1 : 0.8)
} animation: { phase in
.easeInOut(duration: 0.6)
}
}
}
// 5) Animation curves
// .linear, .easeIn, .easeOut, .easeInOut, .spring(), .bouncy, .smooth, .snappy
// .spring(duration: 0.4, bounce: 0.3)
// custom Animation(.timingCurve(0.2, 0, 0.2, 1, duration: 0.4))
// 6) Animation modifier — declarative trigger
struct Card: View {
@State private var pressed = false
var body: some View {
RoundedRectangle(cornerRadius: 16)
.fill(.indigo)
.frame(width: 160, height: 200)
.scaleEffect(pressed ? 0.98 : 1.0)
.animation(.easeOut(duration: 0.15), value: pressed)
.onTapGesture { withAnimation { /* something */ } }
.onLongPressGesture(minimumDuration: 0.0,
pressing: { pressed = $0 },
perform: {})
}
}
// 7) Accessibility — respect Reduce Motion
struct RespectMotion: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var on = false
var body: some View {
Image(systemName: on ? "heart.fill" : "heart")
.scaleEffect(on ? 1.2 : 1.0)
.animation(reduceMotion ? nil : .bouncy, value: on)
.onTapGesture { on.toggle() }
}
}
// 8) Pitfalls
// - Mixing implicit .animation() AND withAnimation() can double-apply
// - Forgetting `value:` on .animation -> animates everything that changes
// - animationdriving on state that resets every frame -> jitter
// - Heavy work inside the animated body -> drops to CPU rendering
// - Skipping reduceMotion -> some users hate motion (and Apple guidelines)
Why it matters
`withAnimation { state = next }` is the right default — declarative, scoped, and composes with every transition modifier. Reach for `phaseAnimator` and `matchedGeometryEffect` when the animation has more than one stage or moves between containers; for simple state-driven motion, the implicit API is enough.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Discussion
Loading…