285 lines
6.7 KiB
Markdown
285 lines
6.7 KiB
Markdown
# SwiftUI Animation Basics
|
|
|
|
Core animation concepts, implicit vs explicit animations, timing curves, and performance patterns.
|
|
|
|
## Table of Contents
|
|
- [Core Concepts](#core-concepts)
|
|
- [Implicit Animations](#implicit-animations)
|
|
- [Explicit Animations](#explicit-animations)
|
|
- [Animation Placement](#animation-placement)
|
|
- [Selective Animation](#selective-animation)
|
|
- [Timing Curves](#timing-curves)
|
|
- [Animation Performance](#animation-performance)
|
|
- [Disabling Animations](#disabling-animations)
|
|
- [Debugging](#debugging)
|
|
|
|
---
|
|
|
|
## Core Concepts
|
|
|
|
State changes trigger view updates. SwiftUI provides mechanisms to animate these changes.
|
|
|
|
**Animation Process:**
|
|
1. State change triggers view tree re-evaluation
|
|
2. SwiftUI compares new tree to current render tree
|
|
3. Animatable properties are identified and interpolated (~60 fps)
|
|
|
|
**Key Characteristics:**
|
|
- Animations are additive and cancelable
|
|
- Always start from current render tree state
|
|
- Blend smoothly when interrupted
|
|
|
|
---
|
|
|
|
## Implicit Animations
|
|
|
|
Use `.animation(_:value:)` to animate when a specific value changes.
|
|
|
|
```swift
|
|
// 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!
|
|
```
|
|
|
|
---
|
|
|
|
## Explicit Animations
|
|
|
|
Use `withAnimation` for event-driven state changes.
|
|
|
|
```swift
|
|
// GOOD - explicit animation
|
|
Button("Toggle") {
|
|
withAnimation(.spring) {
|
|
isExpanded.toggle()
|
|
}
|
|
}
|
|
|
|
// BAD - no animation context
|
|
Button("Toggle") {
|
|
isExpanded.toggle() // Abrupt change
|
|
}
|
|
```
|
|
|
|
**When to use which:**
|
|
- **Implicit**: Animations tied to specific value changes, precise view tree scope
|
|
- **Explicit**: Event-driven animations (button taps, gestures)
|
|
|
|
---
|
|
|
|
## Animation Placement
|
|
|
|
Place animation modifiers after the properties they should animate.
|
|
|
|
```swift
|
|
// 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)
|
|
```
|
|
|
|
---
|
|
|
|
## Selective Animation
|
|
|
|
Animate only specific properties using multiple animation modifiers or scoped animations.
|
|
|
|
```swift
|
|
// 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
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Timing Curves
|
|
|
|
### Built-in Curves
|
|
|
|
| Curve | Use Case |
|
|
|-------|----------|
|
|
| `.spring` | Interactive elements, most UI |
|
|
| `.easeInOut` | Appearance changes |
|
|
| `.bouncy` | Playful feedback (iOS 17+) |
|
|
| `.linear` | Progress indicators only |
|
|
|
|
### Modifiers
|
|
|
|
```swift
|
|
.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 vs Bad Timing
|
|
|
|
```swift
|
|
// 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
|
|
```
|
|
|
|
---
|
|
|
|
## Animation Performance
|
|
|
|
### Prefer Transforms Over Layout
|
|
|
|
```swift
|
|
// 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
|
|
```
|
|
|
|
### Narrow Animation Scope
|
|
|
|
```swift
|
|
// 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
|
|
```
|
|
|
|
### Avoid Animation in Hot Paths
|
|
|
|
```swift
|
|
// 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
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Disabling Animations
|
|
|
|
```swift
|
|
// 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
|
|
```
|
|
|
|
---
|
|
|
|
## Debugging
|
|
|
|
```swift
|
|
// 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)
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Quick Reference
|
|
|
|
### Do
|
|
- Use `.animation(_:value:)` with value parameter
|
|
- Use `withAnimation` for event-driven animations
|
|
- Prefer transforms over layout changes
|
|
- Scope animations narrowly
|
|
- Choose appropriate timing curves
|
|
|
|
### Don't
|
|
- Use deprecated `.animation(_:)` without value
|
|
- Animate layout properties in hot paths
|
|
- Apply broad animations at root level
|
|
- Use linear timing for UI (feels robotic)
|
|
- Animate on every frame in scroll handlers
|