.agents/skills/swiftui-expert-skill/references/animation-basics.md
Core animation concepts, implicit vs explicit animations, timing curves, and performance patterns.
State changes trigger view updates. SwiftUI provides mechanisms to animate these changes.
Animation Process:
Key Characteristics:
Use .animation(_:value:) to animate when a specific value changes.
// GOOD - uses value parameter
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.animation(.spring, value: isExpanded)
.onTapGesture { isExpanded.toggle() }
// BAD - deprecated, animates all changes unexpectedly
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.animation(.spring) // Deprecated!
Use withAnimation for event-driven state changes.
// GOOD - explicit animation
Button("Toggle") {
withAnimation(.spring) {
isExpanded.toggle()
}
}
// BAD - no animation context
Button("Toggle") {
isExpanded.toggle() // Abrupt change
}
When to use which:
Place animation modifiers after the properties they should animate.
// GOOD - animation after properties
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.foregroundStyle(isExpanded ? .blue : .red)
.animation(.default, value: isExpanded) // Animates both
// BAD - animation before properties
Rectangle()
.animation(.default, value: isExpanded) // Too early!
.frame(width: isExpanded ? 200 : 100, height: 50)
Animate only specific properties using multiple animation modifiers or scoped animations.
// GOOD - selective animation
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.animation(.spring, value: isExpanded) // Animate size
.foregroundStyle(isExpanded ? .blue : .red)
.animation(nil, value: isExpanded) // Don't animate color
// iOS 17+ scoped animation
Rectangle()
.foregroundStyle(isExpanded ? .blue : .red) // Not animated
.animation(.spring) {
$0.frame(width: isExpanded ? 200 : 100, height: 50) // Animated
}
| Curve | Use Case |
|---|---|
.spring | Interactive elements, most UI |
.easeInOut | Appearance changes |
.bouncy | Playful feedback (iOS 17+) |
.linear | Progress indicators only |
.animation(.default.speed(2.0), value: flag) // 2x faster
.animation(.default.delay(0.5), value: flag) // Delayed start
.animation(.default.repeatCount(3, autoreverses: true), value: flag)
// GOOD - appropriate timing for interaction type
Button("Tap") {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
isActive.toggle()
}
}
.scaleEffect(isActive ? 0.95 : 1.0)
// BAD - too slow for button feedback
Button("Tap") {
withAnimation(.easeInOut(duration: 1.0)) { // Way too slow!
isActive.toggle()
}
}
// BAD - linear feels robotic
Rectangle()
.animation(.linear(duration: 0.5), value: isActive) // Mechanical
// GOOD - GPU accelerated transforms
Rectangle()
.frame(width: 100, height: 100)
.scaleEffect(isActive ? 1.5 : 1.0) // Fast
.offset(x: isActive ? 50 : 0) // Fast
.rotationEffect(.degrees(isActive ? 45 : 0)) // Fast
.animation(.spring, value: isActive)
// BAD - layout changes are expensive
Rectangle()
.frame(width: isActive ? 150 : 100, height: isActive ? 150 : 100) // Expensive
.padding(isActive ? 50 : 0) // Expensive
// GOOD - animation scoped to specific subview
VStack {
HeaderView() // Not affected
ExpandableContent(isExpanded: isExpanded)
.animation(.spring, value: isExpanded) // Only this
FooterView() // Not affected
}
// BAD - animation at root
VStack {
HeaderView()
ExpandableContent(isExpanded: isExpanded)
FooterView()
}
.animation(.spring, value: isExpanded) // Animates everything
// GOOD - gate by threshold
.onPreferenceChange(ScrollOffsetKey.self) { offset in
let shouldShow = offset.y < -50
if shouldShow != showTitle { // Only when crossing threshold
withAnimation(.easeOut(duration: 0.2)) {
showTitle = shouldShow
}
}
}
// BAD - animating every scroll change
.onPreferenceChange(ScrollOffsetKey.self) { offset in
withAnimation { // Fires constantly!
self.offset = offset.y
}
}
// GOOD - disable with transaction
Text("Count: \(count)")
.transaction { $0.animation = nil }
// GOOD - disable from parent context
DataView()
.transaction { $0.disablesAnimations = true }
// BAD - hacky zero duration
Text("Count: \(count)")
.animation(.linear(duration: 0), value: count) // Hacky
// Slow down for inspection
#if DEBUG
.animation(.linear(duration: 3.0).speed(0.2), value: isExpanded)
#else
.animation(.spring, value: isExpanded)
#endif
// Debug modifier to log values
struct AnimationDebugModifier: ViewModifier, Animatable {
var value: Double
var animatableData: Double {
get { value }
set {
value = newValue
print("Animation: \(newValue)")
}
}
func body(content: Content) -> some View {
content.opacity(value)
}
}
.animation(_:value:) with value parameterwithAnimation for event-driven animations.animation(_:) without value