UIView.animate 블록 안에서 프로퍼티를 바꾸고, 제스처는 UIPanGestureRecognizer의 상태를 switch로 나눠 처리했다. SwiftUI는 "무엇이 어떻게 변하나"를 직접 그리지 않는다. 상태를 바꾸면서 "이번 변화는 이 곡선으로"라고 트랜잭션에 적어 두면, 움직일 수 있는 속성(Animatable)이 알아서 보간한다. Day 14는 애니메이션·트랜잭션·제스처를 한 모델로 묶고 14일 전체를 한 장의 지도로 정리한다. 1부는 애니메이션이 어떻게 만들어지는지(트랜잭션·Animatable), identity가 움직임을 어떻게 바꾸는지(transition·matchedGeometryEffect), 여러 단계 모션(phase/keyframe)을 다룬다.1부 목표 · 24분
withAnimation과.animation(_:value:)중 상황에 맞는 쪽을 고르고, value 없는.animation을 찾아 고친다- "깜빡 바뀜 vs 부드럽게 이동"을 보고 identity 문제인지 transition 문제인지 가려 고친다
- 반복·이벤트형 다단계 모션을
phaseAnimator/keyframeAnimator로 만든다
1. 애니메이션의 해부 — 트랜잭션 · Animatable · Animation 🔥 핵심
탭 같은 이벤트가 오면 SwiftUI는 업데이트 트랜잭션을 연다. withAnimation은 이 트랜잭션에 "이번엔 이 애니메이션"이라고 적는다. 상태가 바뀌어 body가 새 값을 내면, scaleEffect 같은 animatable 속성이 트랜잭션의 애니메이션을 복사해 옛 값에서 새 값으로 프레임마다 보간한다(WWDC23 · Explore SwiftUI animation).
"Animatable attributes, like scaleEffect, determine the data being animated, while the Animation determines how that data changes over time." — WWDC23 · Explore SwiftUI animation
→ 무엇을 움직일지는 Animatable 속성이, 어떻게 움직일지는 Animation이 정한다. 두 축은 서로 독립이다.
두 가지 붙이는 법
struct PetAvatar: View {
@State private var selected = false
var body: some View {
Image(systemName: "cat.fill")
.font(.system(size: 60))
.scaleEffect(selected ? 1.5 : 1.0)
.onTapGesture {
withAnimation(.bouncy) { // 이번 트랜잭션에 .bouncy 기록
selected.toggle()
}
}
}
}struct PetAvatarBadge: View {
@Binding var selected: Bool // 밖에서 코드로 바뀌어도 애니메이션
var body: some View {
Image(systemName: "dog.fill")
.font(.system(size: 60))
.scaleEffect(selected ? 1.5 : 1.0)
.animation(.bouncy, value: selected) // selected가 바뀐 업데이트에만 적용
}
}struct AccidentalAnimation: View {
var isOn: Bool
var title: String
var body: some View {
Text(title)
.scaleEffect(isOn ? 1.2 : 1.0)
.animation(.easeInOut) // title이 바뀌어도, 부모 레이아웃이 바뀌어도 움직인다
}
}| 상황 | 고를 것 |
|---|---|
| 버튼·탭 핸들러에서 상태를 바꾼다 | withAnimation |
| 값이 밖에서(Binding, 네트워크 결과) 바뀐다 | .animation(_:value:) |
| 한 뷰 안에서 속성마다 다른 곡선 | animation(_:body:) — 2부 1절 |
곡선 고르기
Apple은 애니메이션을 세 묶음으로 나눈다: 타이밍 커브(linear, easeInOut + duration), 스프링(smooth·snappy·bouncy 프리셋, 또는 duration + bounce), 고차 애니메이션(delay·speed·repeat). 스프링은 속도를 보존하며 자연스럽게 멈춰서 권장되고, iOS 17부터 인자 없는 withAnimation의 기본값이 smooth 스프링이다(WWDC23 · Explore SwiftUI animation).
struct CurveCatalog: View {
@State private var on = false
var body: some View {
VStack(spacing: 12) {
Circle().frame(width: on ? 120 : 40)
Button("타이밍 커브") { withAnimation(.easeInOut(duration: 0.3)) { on.toggle() } }
Button("스프링 프리셋") { withAnimation(.snappy) { on.toggle() } }
Button("스프링 직접") { withAnimation(.spring(duration: 0.5, bounce: 0.3)) { on.toggle() } }
Button("지연") { withAnimation(.smooth.delay(0.2)) { on.toggle() } }
Button("끝나면 알림") {
withAnimation(.bouncy) {
on.toggle()
} completion: {
print("애니메이션 끝") // iOS 17
}
}
}
}
}내 뷰를 Animatable로 — 마지막 수단
SwiftUI는 Animatable을 채택한 뷰에 animatable 속성을 만든다. 요구 사항은 animatableData 하나이고, 타입은 VectorArithmetic(Double·CGFloat·CGPoint 등)이어야 한다.
struct CountingNumber: View, Animatable {
var value: Double
// 이 값을 프레임마다 보간해 body를 다시 부른다
// (Xcode 16+ · Swift 6 모드에서는 앞에 nonisolated를 붙인다 — View가 MainActor라서)
var animatableData: Double {
get { value }
set { value = newValue }
}
var body: some View {
Text("\(Int(value))")
.font(.largeTitle.monospacedDigit())
}
}
struct ScoreView: View {
@State private var score = 0.0
var body: some View {
CountingNumber(value: score)
.animation(.smooth(duration: 1), value: score)
.onTapGesture { score += 100 }
}
}내장 효과(scaleEffect 등)는 SwiftUI가 메인 스레드 밖에서, 내 뷰 코드를 부르지 않고 보간한다. 반면 커스텀 Animatable은 매 프레임 body를 실행하므로 훨씬 비싸다. Apple은 "내장 효과로 안 될 때만 쓰라"고 한다(WWDC23 · Explore SwiftUI animation).
2. transition과 matchedGeometryEffect — identity가 움직임을 정한다
Day 4의 규칙이 여기서 그대로 나온다. 같은 identity면 SwiftUI는 옛 값과 새 값을 보간한다(이동·색 변화). 다른 identity면 옛 뷰를 빼고 새 뷰를 넣는다 — 이때 쓰이는 것이 transition이다(WWDC21 · Demystify SwiftUI).
struct StatusDotBranch: View {
var isOn: Bool
var body: some View {
if isOn {
Circle().fill(.green).frame(width: 24, height: 24)
} else {
Circle().fill(.gray).frame(width: 24, height: 24)
}
}
}struct StatusDot: View {
var isOn: Bool
var body: some View {
Circle()
.fill(isOn ? Color.green : Color.gray)
.frame(width: 24, height: 24)
}
}진짜로 넣고 빼는 뷰에는 transition
struct ToastDemo: View {
@State private var showsToast = false
var body: some View {
VStack {
if showsToast {
Text("저장했다")
.padding()
.background(.thinMaterial, in: Capsule())
.transition(.move(edge: .top).combined(with: .opacity))
}
Spacer()
Button("저장") { showsToast.toggle() }
}
.animation(.snappy, value: showsToast) // 넣고 빼는 변화를 애니메이션 트랜잭션으로
}
}.transition은 "넣고 뺄 때 어떤 모양으로"만 정한다. 삽입/제거가 애니메이션 트랜잭션 안에서 일어나야 실제로 움직인다. 위 코드처럼 컨테이너에 .animation(_:value:)를 걸거나, 버튼에서 withAnimation { showsToast.toggle() }으로 바꾼다.
matchedGeometryEffect — 두 뷰를 하나처럼
작은 썸네일이 큰 카드로 커지는 "히어로" 효과는 사실 서로 다른 두 뷰다. @Namespace 안에서 같은 id를 주면 SwiftUI가 한쪽의 위치·크기를 다른 쪽으로 보간한다(Apple 문서 · matchedGeometryEffect).
struct HeroDemo: View {
@Namespace private var namespace
@State private var expanded = false
var body: some View {
VStack {
if expanded {
RoundedRectangle(cornerRadius: 24)
.fill(.orange)
.frame(height: 300)
.matchedGeometryEffect(id: "card", in: namespace)
} else {
RoundedRectangle(cornerRadius: 12)
.fill(.orange)
.frame(width: 80, height: 80)
.matchedGeometryEffect(id: "card", in: namespace)
}
Spacer()
}
.padding()
.onTapGesture {
withAnimation(.smooth) { expanded.toggle() }
}
}
}화면 전환 수준의 확대 효과는 iOS 18의 zoom 전환이 맡는다. 푸시될 뷰에 .navigationTransition(.zoom(sourceID:in:)), 탭한 셀에 .matchedTransitionSource(id:in:)를 같은 id·namespace로 붙인다. 전환 도중에도 끌어서 조작할 수 있고, sheet·fullScreenCover에도 쓸 수 있다. UIKit에도 같은 전환이 있다(WWDC24 · Enhance your UI animations and transitions, Apple 문서).
3. phaseAnimator · keyframeAnimator — 여러 단계 모션
지금까지는 "상태 A → 상태 B" 두 점 사이 애니메이션이었다. 반복되는 강조 효과나 "좋아요를 받으면 튀어 오르는" 모션은 여러 단계가 필요하다. iOS 17은 이를 위한 두 도구를 준다(WWDC23 · Wind your way through advanced animations).
struct OverdueBadge: View {
var body: some View {
Label("식사 시간 지남", systemImage: "fork.knife")
.font(.headline)
.phaseAnimator([false, true]) { content, highlighted in
content.foregroundStyle(highlighted ? Color.red : Color.primary)
} animation: { _ in
.easeInOut(duration: 1.0) // 기본은 스프링, 은은한 반복엔 느린 커브
}
}
}enum ReactionPhase: CaseIterable {
case initial, move, scale
var verticalOffset: Double {
switch self {
case .initial: 0
case .move, .scale: -40
}
}
var scale: Double { self == .scale ? 1.4 : 1.0 }
}
struct ReactionButton: View {
@State private var likes = 0
var body: some View {
Button {
likes += 1
} label: {
Label("\(likes)", systemImage: "heart.fill")
.phaseAnimator(ReactionPhase.allCases, trigger: likes) { content, phase in
content
.scaleEffect(phase.scale)
.offset(y: phase.verticalOffset)
} animation: { phase in
switch phase {
case .initial: .smooth
case .move: .spring(bounce: 0.5)
case .scale: .snappy
}
}
}
}
}struct HeartValues {
var scale = 1.0
var verticalStretch = 1.0
var verticalTranslation = 0.0
var angle = Angle.zero
}
struct KudosHeart: View {
@State private var kudos = 0
var body: some View {
Image(systemName: "heart.fill")
.font(.system(size: 60))
.foregroundStyle(.pink)
.keyframeAnimator(initialValue: HeartValues(), trigger: kudos) { content, value in
content
.rotationEffect(value.angle)
.scaleEffect(value.scale)
.scaleEffect(y: value.verticalStretch)
.offset(y: value.verticalTranslation)
} keyframes: { _ in
KeyframeTrack(\.scale) {
LinearKeyframe(1.0, duration: 0.36) // 잠깐 멈춤
SpringKeyframe(1.5, duration: 0.8, spring: .bouncy)
SpringKeyframe(1.0, spring: .bouncy)
}
KeyframeTrack(\.verticalTranslation) {
LinearKeyframe(0.0, duration: 0.1)
SpringKeyframe(20.0, duration: 0.15, spring: .bouncy) // 뛰기 전 움츠림
SpringKeyframe(-60.0, duration: 1.0, spring: .bouncy)
SpringKeyframe(0.0, spring: .bouncy)
}
KeyframeTrack(\.verticalStretch) {
CubicKeyframe(1.0, duration: 0.1)
CubicKeyframe(0.6, duration: 0.15)
CubicKeyframe(1.5, duration: 0.1)
CubicKeyframe(1.0, duration: 0.3)
}
KeyframeTrack(\.angle) {
CubicKeyframe(.zero, duration: 0.58)
CubicKeyframe(.degrees(16), duration: 0.125)
CubicKeyframe(.degrees(-16), duration: 0.125)
CubicKeyframe(.zero, duration: 0.125)
}
}
.onTapGesture { kudos += 1 }
}
}| phaseAnimator | keyframeAnimator | |
|---|---|---|
| 모델 | 이산 단계(Bool, enum) | 보간되는 값 struct + 트랙 |
| 타이밍 | 단계마다 Animation 하나 | 키프레임마다 시각·종류(Linear/Spring/Cubic/Move) |
| 도중에 목표 바뀌면 | 기존 애니메이션처럼 동작 | 재조준이 어색 — 도중에 바꾸지 않는다 |
| UIKit 대응 | UIView.animateKeyframes 연쇄 | CAKeyframeAnimation 여러 개 |
"think of keyframes like video clips that can be played." — WWDC23 · Wind your way through advanced animations
→ 키프레임은 미리 짜 둔 영상 클립이다. 사용자 조작에 따라 유연하게 따라가야 하는 곳(드래그 등)에는 스프링을 쓴다. 또 값이 매 프레임 갱신되므로 content 클로저 안에서 비싼 일을 하지 않는다.
✅ 복습 · 5분
withAnimation과 .animation(_:value:)는 트랜잭션 관점에서 무엇이 다른가?
withAnimation은 상태를 바꾸는 쪽에서 이번 업데이트 전체의 트랜잭션에 애니메이션을 넣는다. .animation(_:value:)는 그 뷰 위치에서, value가 바뀐 업데이트에만 트랜잭션의 애니메이션을 덮어쓴다. 그래서 값이 밖에서 바뀌는 컴포넌트에는 후자가 맞다.phaseAnimator와 keyframeAnimator 중 무엇을 고를지 한 줄 기준은?
phaseAnimator(단계 = Bool·enum). 크기·위치·각도가 각자 다른 시점에 움직여야 하면 keyframeAnimator(속성별 트랙). 키프레임은 미리 짠 클립이라 도중에 목표가 바뀌는 드래그 같은 곳에는 스프링을 쓴다.🔮 코드 예측: 2절 ToastDemo에서 .animation(.snappy, value: showsToast) 줄을 지우면 "저장"을 눌렀을 때 토스트는 어떻게 나타나나?
.transition은 "넣고 뺄 때의 모양"만 정할 뿐이고, 삽입·제거가 애니메이션 트랜잭션 안에서 일어나야 실제로 움직인다. 버튼을 withAnimation { showsToast.toggle() }로 바꿔도 다시 움직인다.🔁 지난 Day 복습: 뷰 안에서 context.fetch로 읽은 목록과 @Query 목록은 무엇이 다른가? (Day 13)
context.fetch는 한 번 읽고 끝이라 저장소가 바뀌어도 화면이 그대로다. @Query는 결과에 영향을 주는 저장소 변화를 계속 감시해 뷰를 다시 그린다. 뷰 안에서는 @Query, 뷰 밖(테스트·백그라운드)에서는 fetch를 쓴다.2부에서는 트랜잭션을 직접 손대(.transaction·withTransaction·animation(_:body:)) 원하는 곳만 움직이게 하고, DragGesture·@GestureState로 제스처를 붙인 뒤 14일 전체를 한 장의 지도로 묶는다.