[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
---
|
||||
id: ios-swiftui-animation-deep
|
||||
title: SwiftUI Animation — implicit / explicit / matchedGeometry
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [ios, swiftui, animation, vibe-coding]
|
||||
tech_stack: { language: "Swift", applicable_to: ["iOS"] }
|
||||
applied_in: []
|
||||
aliases: [SwiftUI animation, withAnimation, matchedGeometryEffect, transition, PhaseAnimator, KeyframeAnimator]
|
||||
---
|
||||
|
||||
# SwiftUI Animation
|
||||
|
||||
> SwiftUI 의 animation 가 declarative. **`.animation()`, `withAnimation`, transition, matchedGeometry**. iOS 17+ 의 PhaseAnimator / KeyframeAnimator.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- Implicit: state 변경 = 자동.
|
||||
- Explicit: `withAnimation` block.
|
||||
- Transition: appear / disappear.
|
||||
- matchedGeometry: 두 view 간 morph.
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### Implicit animation
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State var scale = 1.0
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.scaleEffect(scale)
|
||||
.animation(.spring(), value: scale)
|
||||
.onTapGesture {
|
||||
scale = scale == 1.0 ? 2.0 : 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ `.animation(_, value:)` 가 modern API. Value 변경 = animate.
|
||||
|
||||
### Explicit (withAnimation)
|
||||
```swift
|
||||
@State var isExpanded = false
|
||||
|
||||
Button("Toggle") {
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ Block 안 변경 가 animate.
|
||||
|
||||
### 모든 animation type
|
||||
```swift
|
||||
.animation(.linear(duration: 0.3), value: x)
|
||||
.animation(.easeInOut(duration: 0.5), value: x)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7), value: x)
|
||||
.animation(.bouncy, value: x) // iOS 17
|
||||
.animation(.smooth, value: x) // iOS 17
|
||||
.animation(.snappy, value: x) // iOS 17
|
||||
.animation(.interactiveSpring(), value: x)
|
||||
```
|
||||
|
||||
### Transition
|
||||
```swift
|
||||
struct Toast: View {
|
||||
@State var show = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if show {
|
||||
Text("Hello")
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(.spring(), value: show)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ Appear / disappear 의 animation.
|
||||
|
||||
### Built-in transitions
|
||||
```swift
|
||||
.transition(.opacity)
|
||||
.transition(.scale)
|
||||
.transition(.slide)
|
||||
.transition(.move(edge: .leading))
|
||||
.transition(.push(from: .top)) // iOS 17
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
```
|
||||
|
||||
### Custom transition (iOS 17 movePhase)
|
||||
```swift
|
||||
struct BlurTransition: Transition {
|
||||
func body(content: Content, phase: TransitionPhase) -> some View {
|
||||
content
|
||||
.blur(radius: phase.isIdentity ? 0 : 20)
|
||||
.opacity(phase.isIdentity ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 사용
|
||||
.transition(BlurTransition())
|
||||
```
|
||||
|
||||
### matchedGeometryEffect (morph)
|
||||
```swift
|
||||
@Namespace var namespace
|
||||
@State var expanded = false
|
||||
|
||||
VStack {
|
||||
if expanded {
|
||||
Image("hero")
|
||||
.matchedGeometryEffect(id: "hero", in: namespace)
|
||||
.frame(width: 300, height: 300)
|
||||
} else {
|
||||
Image("hero")
|
||||
.matchedGeometryEffect(id: "hero", in: namespace)
|
||||
.frame(width: 100, height: 100)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
withAnimation(.spring()) { expanded.toggle() }
|
||||
}
|
||||
```
|
||||
|
||||
→ 같은 ID = morph. Hero animation 의 답.
|
||||
|
||||
### PhaseAnimator (iOS 17+)
|
||||
```swift
|
||||
struct PulseView: View {
|
||||
var body: some View {
|
||||
Circle()
|
||||
.phaseAnimator([1.0, 1.5, 1.0]) { content, phase in
|
||||
content
|
||||
.scaleEffect(phase)
|
||||
.opacity(phase == 1.5 ? 0.5 : 1.0)
|
||||
} animation: { phase in
|
||||
.easeInOut(duration: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ 자동 cycle. Loop 식 animation.
|
||||
|
||||
### KeyframeAnimator (iOS 17+)
|
||||
```swift
|
||||
struct WaveView: View {
|
||||
@State var trigger = false
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.keyframeAnimator(initialValue: AnimationValues(), trigger: trigger) { content, value in
|
||||
content
|
||||
.scaleEffect(value.scale)
|
||||
.rotationEffect(value.rotation)
|
||||
} keyframes: { _ in
|
||||
KeyframeTrack(\.scale) {
|
||||
LinearKeyframe(1.5, duration: 0.5)
|
||||
SpringKeyframe(1.0, duration: 0.5)
|
||||
}
|
||||
KeyframeTrack(\.rotation) {
|
||||
CubicKeyframe(.degrees(360), duration: 1.0)
|
||||
}
|
||||
}
|
||||
.onTapGesture { trigger.toggle() }
|
||||
}
|
||||
}
|
||||
|
||||
struct AnimationValues {
|
||||
var scale = 1.0
|
||||
var rotation = Angle.zero
|
||||
}
|
||||
```
|
||||
|
||||
→ 복잡 multi-property animation.
|
||||
|
||||
### Animatable protocol (custom)
|
||||
```swift
|
||||
struct CounterShape: Shape, Animatable {
|
||||
var value: Double
|
||||
|
||||
var animatableData: Double {
|
||||
get { value }
|
||||
set { value = newValue }
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
// value 가 변경 시 매 frame draw
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ Custom shape 도 animate.
|
||||
|
||||
### AnimatableModifier
|
||||
```swift
|
||||
struct ShakeEffect: GeometryEffect {
|
||||
var amount: CGFloat = 10
|
||||
var animatableData: CGFloat
|
||||
|
||||
func effectValue(size: CGSize) -> ProjectionTransform {
|
||||
ProjectionTransform(CGAffineTransform(translationX:
|
||||
amount * sin(animatableData * .pi * 5), y: 0))
|
||||
}
|
||||
}
|
||||
|
||||
// 사용
|
||||
.modifier(ShakeEffect(animatableData: CGFloat(attempts)))
|
||||
```
|
||||
|
||||
### Spring (커스텀)
|
||||
```swift
|
||||
.animation(.spring(
|
||||
response: 0.5, // duration approximation
|
||||
dampingFraction: 0.7, // overshoot (0 = bounce, 1 = no bounce)
|
||||
blendDuration: 0
|
||||
), value: x)
|
||||
```
|
||||
|
||||
→ `response` + `dampingFraction` 가 일반.
|
||||
|
||||
### Animation interruption
|
||||
```swift
|
||||
// 진행 중 animation 가 다른 변경 시:
|
||||
withAnimation(.easeInOut) { x = 100 }
|
||||
// 0.2 sec 후
|
||||
withAnimation(.spring()) { x = 50 }
|
||||
|
||||
// → 2번째 가 1번째 의 현재 값 부터 시작 (smooth).
|
||||
```
|
||||
|
||||
→ SwiftUI 가 자동.
|
||||
|
||||
### Animation curve
|
||||
```swift
|
||||
extension Animation {
|
||||
static var customCurve: Animation {
|
||||
.timingCurve(0.2, 0.8, 0.2, 1.0, duration: 0.5)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ `.timingCurve(c1x, c1y, c2x, c2y)` = bezier.
|
||||
|
||||
### Disable animation (specific)
|
||||
```swift
|
||||
.transaction { tx in
|
||||
tx.animation = nil
|
||||
}
|
||||
|
||||
// Or
|
||||
withTransaction(Transaction(animation: nil)) {
|
||||
x = 100
|
||||
}
|
||||
```
|
||||
|
||||
→ 일부 변경 가 animate X.
|
||||
|
||||
### List / scroll animation
|
||||
```swift
|
||||
List(items, id: \.id) { item in
|
||||
Row(item)
|
||||
}
|
||||
.animation(.spring(), value: items)
|
||||
|
||||
// onDelete + animation
|
||||
.swipeActions {
|
||||
Button("Delete") {
|
||||
withAnimation { items.removeAll { $0.id == item.id } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Scroll 의 visual effect (iOS 17)
|
||||
```swift
|
||||
ScrollView {
|
||||
ForEach(items) { item in
|
||||
Card(item)
|
||||
.scrollTransition { content, phase in
|
||||
content
|
||||
.opacity(phase.isIdentity ? 1 : 0.3)
|
||||
.scaleEffect(phase.isIdentity ? 1 : 0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ Scroll 위치 따라 modify.
|
||||
|
||||
### Repeat animation
|
||||
```swift
|
||||
.animation(.linear(duration: 1).repeatForever(autoreverses: true), value: x)
|
||||
|
||||
// Or
|
||||
withAnimation(.linear(duration: 1).repeatForever()) {
|
||||
rotation = 360
|
||||
}
|
||||
```
|
||||
|
||||
### Performance
|
||||
```
|
||||
- 매 frame draw 안 비싸게.
|
||||
- Complex shape = lazy.
|
||||
- Background work + animation 가 main thread X.
|
||||
- Instruments > Animation Hitches.
|
||||
```
|
||||
|
||||
### Geometry effect (transform)
|
||||
```swift
|
||||
struct RotationEffect: GeometryEffect {
|
||||
var angle: Double
|
||||
var animatableData: Double {
|
||||
get { angle }
|
||||
set { angle = newValue }
|
||||
}
|
||||
|
||||
func effectValue(size: CGSize) -> ProjectionTransform {
|
||||
ProjectionTransform(CGAffineTransform(rotationAngle: angle))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 함정
|
||||
```
|
||||
- @State 변경 + animation 없음: 즉시.
|
||||
- ObservableObject 변경: 자동 X (withAnimation 명시).
|
||||
- async work 후 변경: main thread + withAnimation.
|
||||
- 큰 list animation: 성능.
|
||||
- transition 없이 conditional: 즉시 fade.
|
||||
```
|
||||
|
||||
### Reduce motion (a11y)
|
||||
```swift
|
||||
@Environment(\.accessibilityReduceMotion) var reduce
|
||||
|
||||
withAnimation(reduce ? nil : .spring()) {
|
||||
x.toggle()
|
||||
}
|
||||
```
|
||||
|
||||
→ User 가 motion ↓ 옵션 — 존중.
|
||||
|
||||
### iOS 17 추가 (modern)
|
||||
```
|
||||
- ContainerRelativeShape
|
||||
- Shader Library (Metal 통합)
|
||||
- Symbol effects
|
||||
- Gradient animatable
|
||||
|
||||
→ SwiftUI 매년 발전.
|
||||
```
|
||||
|
||||
### Symbol effect
|
||||
```swift
|
||||
Image(systemName: "heart.fill")
|
||||
.symbolEffect(.bounce, value: trigger)
|
||||
.symbolEffect(.pulse)
|
||||
.symbolEffect(.variableColor.iterative)
|
||||
```
|
||||
|
||||
→ SF Symbol 가 자체 animate.
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 작업 | 추천 |
|
||||
|---|---|
|
||||
| State 변경 | Implicit `.animation(_, value:)` |
|
||||
| 명시 control | `withAnimation` |
|
||||
| Appear/disappear | `.transition()` |
|
||||
| Hero / morph | `matchedGeometryEffect` |
|
||||
| Loop | `phaseAnimator` |
|
||||
| 복잡 timeline | `keyframeAnimator` |
|
||||
| Custom geometry | `Animatable` protocol |
|
||||
| Symbol | `symbolEffect` |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **animation(:value:) 없는 implicit**: 모든 거 animate.
|
||||
- **withAnimation 안 의 async work**: race.
|
||||
- **Complex shape + 매 frame redraw**: 성능.
|
||||
- **Transition 없이 conditional**: 갑작 사라짐.
|
||||
- **Reduce motion 무시**: a11y 위반.
|
||||
- **Spring response 가 너무 작음**: jittery.
|
||||
- **Stack 의 모든 child 가 animate**: 폭발.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- `.animation(_, value:)` 가 modern (iOS 16+).
|
||||
- matchedGeometryEffect 가 Hero 답.
|
||||
- iOS 17 의 phaseAnimator / keyframe 가 powerful.
|
||||
- Reduce motion 항상 존중.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[iOS_SwiftUI_State_Property_Wrappers]]
|
||||
- [[Frontend_Animation_Motion]]
|
||||
- [[React_Animation_Performance]]
|
||||
Reference in New Issue
Block a user