← SwiftUI 14일 마스터 14 / 14 · 1부

Day 14 · 1부 · 애니메이션 해부와 여러 단계 모션

withAnimation과 .animation(value:)의 차이, identity가 정하는 transition·matchedGeometryEffect, phaseAnimator·keyframeAnimator로 여러 단계 모션을 만든다.
진행률
0 / 0 완료
UIKit에서는 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).

① 탭 트랜잭션 열림 ② withAnimation 트랜잭션에 기록 ③ 상태 변경 의존 속성 무효화 ④ body 호출 새 값 1.5 ⑤ scaleEffect 애니메이션 복사 1.0 1.5 시간 → model 값: 즉시 1.5 (점선) presentation 값: 프레임마다 보간 (실선) 내부적으로는 0 → 0.5 델타를 보간한다 탭 시점
상태(model 값)는 즉시 바뀌고, 화면에 보이는 값(presentation 값)만 시간에 걸쳐 따라간다. 트랜잭션은 이 업데이트가 끝나면 버려진다.
📝 Apple 원문

"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이 정한다. 두 축은 서로 독립이다.

두 가지 붙이는 법

✅ ① withAnimation — 상태를 바꾸는 쪽(이벤트 핸들러)에서
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()
                }
            }
    }
}
✅ ② .animation(_:value:) — 값이 바뀔 때만, 이 뷰 쪽에서
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가 바뀐 업데이트에만 적용
    }
}
❌ value 없는 .animation — iOS 15에서 deprecated, 관계없는 변화까지 움직인다
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 등)이어야 한다.

✅ 숫자가 1씩 올라가며 세어지는 텍스트
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).

같은 identity → 값 보간 다른 identity → transition Circle().fill(isOn ? .green : .gray) if isOn { Circle() } else { Circle() } gray green 한 뷰가 색만 바뀐다 상태(@State)도 그대로 유지 옛 뷰 ≠ 새 뷰 제거: fade out 삽입: fade in 두 뷰가 교대한다 · 상태는 새로 시작 "깜빡 바뀐다"면 값이 아니라 identity가 바뀐 것이다
보간은 같은 뷰에서만 가능하다. 다른 뷰로 갈아 끼우면 SwiftUI는 transition(기본 fade)으로 교대시킨다.
❌ 불필요한 분기 — 색만 바꾸고 싶었는데 뷰가 교대한다
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만 달면 아무 일도 안 일어난다

.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() }
        }
    }
}
🆕 최신 API iOS 18+

화면 전환 수준의 확대 효과는 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).

phaseAnimator — 단계를 차례로 keyframeAnimator — 속성별 트랙 initial move scale y 0 · ×1.0 y −40 · ×1.0 y −40 · ×1.4 반복형은 처음으로 돌아가 순환 전환마다 모든 속성이 함께 움직인다 단계 = 이산적인 상태 (enum 추천) scale offsetY angle 시간 점 = 키프레임 · 트랙마다 타이밍이 독립
phase는 "상태를 차례로 넘기는 것", keyframe은 "속성마다 시간표를 따로 짜는 것"이다.
✅ phaseAnimator ① — 보이는 동안 계속 반복되는 강조
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)       // 기본은 스프링, 은은한 반복엔 느린 커브
            }
    }
}
✅ phaseAnimator ② — trigger가 바뀔 때 한 바퀴만
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
                    }
                }
        }
    }
}
✅ keyframeAnimator — 속성마다 트랙을 따로
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 }
    }
}
phaseAnimatorkeyframeAnimator
모델이산 단계(Bool, enum)보간되는 값 struct + 트랙
타이밍단계마다 Animation 하나키프레임마다 시각·종류(Linear/Spring/Cubic/Move)
도중에 목표 바뀌면기존 애니메이션처럼 동작재조준이 어색 — 도중에 바꾸지 않는다
UIKit 대응UIView.animateKeyframes 연쇄CAKeyframeAnimation 여러 개
📝 Apple 원문

"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가 바뀐 업데이트에만 트랜잭션의 애니메이션을 덮어쓴다. 그래서 값이 밖에서 바뀌는 컴포넌트에는 후자가 맞다.
쉽게 말하면 withAnimation은 "지금부터 모두 천천히!"라고 외치는 선생님, .animation(value:)은 "내 번호가 불리면 나만 천천히"라고 정해 둔 학생이다.
phaseAnimator와 keyframeAnimator 중 무엇을 고를지 한 줄 기준은?
모든 속성이 같은 단계에서 함께 움직이면 phaseAnimator(단계 = Bool·enum). 크기·위치·각도가 각자 다른 시점에 움직여야 하면 keyframeAnimator(속성별 트랙). 키프레임은 미리 짠 클립이라 도중에 목표가 바뀌는 드래그 같은 곳에는 스프링을 쓴다.
쉽게 말하면 phase는 "하나, 둘, 셋!" 구령에 맞춘 단체 체조, keyframe은 악기마다 따로 적힌 악보다.
🔮 코드 예측: 2절 ToastDemo에서 .animation(.snappy, value: showsToast) 줄을 지우면 "저장"을 눌렀을 때 토스트는 어떻게 나타나나?
위에서 미끄러져 내려오지 않고 즉시 나타난다(사라질 때도 즉시 사라진다). .transition은 "넣고 뺄 때의 모양"만 정할 뿐이고, 삽입·제거가 애니메이션 트랜잭션 안에서 일어나야 실제로 움직인다. 버튼을 withAnimation { showsToast.toggle() }로 바꿔도 다시 움직인다.
쉽게 말하면 무대 입장 동작(transition)은 짜 놨지만 음악(애니메이션)을 안 틀면 배우가 그냥 툭 나타난다.
🔁 지난 Day 복습: 뷰 안에서 context.fetch로 읽은 목록과 @Query 목록은 무엇이 다른가? (Day 13)
context.fetch는 한 번 읽고 끝이라 저장소가 바뀌어도 화면이 그대로다. @Query는 결과에 영향을 주는 저장소 변화를 계속 감시해 뷰를 다시 그린다. 뷰 안에서는 @Query, 뷰 밖(테스트·백그라운드)에서는 fetch를 쓴다.
쉽게 말하면 fetch는 사진 한 장, @Query는 생중계 카메라다.
➡️ 2부 예고

2부에서는 트랜잭션을 직접 손대(.transaction·withTransaction·animation(_:body:)) 원하는 곳만 움직이게 하고, DragGesture·@GestureState로 제스처를 붙인 뒤 14일 전체를 한 장의 지도로 묶는다.

출처 · 참고자료