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

Day 14 · 2부 · Transaction·제스처 + 14일 총정리

.transaction·withTransaction·animation(_:body:)로 애니메이션 범위를 정하고, DragGesture·탭 횟수·길게 누르기와 제스처 조합(동시·순서·배타)을 붙인 뒤 14일 지도와 증상별 복습표로 마무리한다.
진행률
0 / 0 완료
1부에서 애니메이션이 트랜잭션에 실려 animatable 속성으로 전달되고, identity가 "보간이냐 교대냐"를 정하며, phase/keyframe으로 여러 단계 모션을 만든다는 것을 봤다. 2부는 그 트랜잭션을 직접 다뤄 원하는 곳만 움직이게 하고, 제스처를 붙인 뒤 14일을 한 장의 지도로 묶는다. UIKit에서 UIView.performWithoutAnimation과 UIPanGestureRecognizer로 하던 일이 트랜잭션 하나의 모델 위에서 풀린다.

2부 목표 · 24분

  • 의도치 않은 애니메이션을 .transaction/withTransaction/animation(_:body:)로 막는다
  • DragGesture + @GestureState로 손가락을 따라가고 놓으면 스프링으로 돌아오는 카드를 만든다
  • 더블 탭·길게 누르기를 붙이고, simultaneously/sequenced/exclusively로 "길게 누른 뒤 드래그" 같은 조합을 만든다
  • "탭 = 버튼"을 가려 Button으로 바꾸고 동작 줄이기 설정을 존중한다
  • 버그 증상을 보고 14일 중 어느 Day를 다시 읽을지 고른다

1. Transaction 다루기 🔥 핵심

1부의 해부 도해에서 본 트랜잭션은 이번 업데이트의 문맥(무엇보다 애니메이션)을 뷰 트리 아래로 암묵적으로 전달하는 딕셔너리다. Environment가 아래로, Preference가 위로 흐르듯 Transaction도 아래로 흐르되, 업데이트가 끝나면 버려진다(WWDC23 · Explore SwiftUI animation).

withAnimation(.bouncy) { score += 1 } Transaction { animation: .bouncy } VStack Text(score) .transaction { … = nil } Circle 받은 트랜잭션 그대로 Image .animation(.smooth) { … } 즉시 바뀜 bouncy로 움직임 클로저 안 속성만 smooth 복사본만 바꾸고 원본은 그대로 트랜잭션은 이번 업데이트에만 쓰이고 버려진다
같은 트랜잭션이라도 가지마다 끄거나(.transaction), 좁은 범위만 바꿀(animation(_:body:)) 수 있다.
✅ 일부만 애니메이션에서 빼기 — .transaction
struct ScoreBoard: View {
    @State private var score = 0

    var body: some View {
        VStack(spacing: 16) {
            Text("\(score)")
                .font(.largeTitle)
                .transaction { $0.animation = nil }   // 숫자는 즉시
            Circle()
                .frame(width: 40 + CGFloat(score) * 4)  // 원은 bouncy
            Button("+1") {
                withAnimation(.bouncy) { score += 1 }
            }
            Button("리셋(애니메이션 없이)") {
                var transaction = Transaction()
                transaction.disablesAnimations = true     // .animation 모디파이어가 끼어들지 못하게
                withTransaction(transaction) { score = 0 }
            }
        }
    }
}
✅ 속성마다 다른 곡선, 범위는 클로저 안으로 — animation(_:body:) (iOS 17)
struct ElevatedAvatar: View {
    var selected: Bool

    var body: some View {
        Image(systemName: "bird.fill")
            .font(.system(size: 60))
            .animation(.bouncy) { content in
                content.scaleEffect(selected ? 1.5 : 1.0)     // 크기는 통통 튀게
            }
            .animation(.smooth) { content in
                content.shadow(radius: selected ? 12 : 0)     // 그림자는 차분하게
            }
    }
}

value 버전 .animation(_:value:)은 leaf 뷰에 적합하다. 임의의 자식 콘텐츠를 받는 컨테이너라면 그 자식이 같은 업데이트에 바뀔 때 뜻밖의 애니메이션이 생길 수 있다. body 클로저 버전은 클로저 안의 animatable 속성에만 복사본을 적용해서 이 위험이 없다(WWDC23 · Explore SwiftUI animation).

2. 제스처 — 따라가기, 탭·길게 누르기, 조합

@GestureState는 제스처가 진행되는 동안만 값을 갖고, 끝나면 초기값으로 자동 복귀하는 상태다(Apple 문서 · GestureState). updating 클로저는 세 번째 인자로 트랜잭션도 받는다 — 제스처 역시 트랜잭션 모델 위에 있다.

✅ 드래그 중엔 따라오고, 놓으면 스프링으로 원위치 — @GestureState
struct SnapBackCard: View {
    @GestureState(resetTransaction: Transaction(animation: .bouncy))
    private var dragOffset = CGSize.zero       // 손을 떼면 .bouncy로 .zero 복귀

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(.blue)
            .frame(width: 120, height: 160)
            .offset(dragOffset)
            .gesture(
                DragGesture()
                    .updating($dragOffset) { value, state, _ in
                        state = value.translation
                    }
            )
    }
}
✅ 놓은 자리에 남아야 하면 — @State 두 개로 "확정분 + 진행분"
struct DropPin: View {
    @State private var committed = CGSize.zero
    @State private var live = CGSize.zero

    var body: some View {
        Circle()
            .fill(.orange)
            .frame(width: 80, height: 80)
            .offset(x: committed.width + live.width,
                    y: committed.height + live.height)
            .gesture(
                DragGesture()
                    .onChanged { live = $0.translation }
                    .onEnded { value in
                        committed.width += value.translation.width
                        committed.height += value.translation.height
                        live = .zero                    // 같은 업데이트에서 넘겨주므로 튀지 않는다
                    }
            )
    }
}
✅ 조합 — 핀치와 회전을 동시에
struct PhotoTransform: View {
    @GestureState private var zoom = 1.0
    @GestureState private var rotation = Angle.zero

    var body: some View {
        Image(systemName: "photo")
            .font(.system(size: 120))
            .scaleEffect(zoom)
            .rotationEffect(rotation)
            .gesture(
                MagnifyGesture()
                    .updating($zoom) { value, state, _ in state = value.magnification }
                    .simultaneously(with:
                        RotateGesture()
                            .updating($rotation) { value, state, _ in state = value.rotation }
                    )
            )
    }
}

탭 횟수와 길게 누르기

더블 탭 하나만 필요하면 .onTapGesture(count: 2) { … } 한 줄이면 된다(Apple 문서 · onTapGesture(count:perform:)). 같은 뷰에 싱글 탭도 있으면 둘의 관계를 정해야 한다. exclusively(before:)는 "앞 제스처에 우선권을 주고, 둘 중 하나만 성공"하는 조합이라 UIKit의 require(toFail:) 자리다. 길게 누르기는 onLongPressGesture로 붙이고, onPressingChanged로 "누르는 중" 피드백을 준다.

✅ 더블 탭 = 확대, 싱글 탭 = 메시지, 길게 누르기 = 누르는 동안 강조
struct TapAndHold: View {
    @State private var zoomed = false
    @State private var pressing = false
    @State private var message = "대기"

    var body: some View {
        VStack(spacing: 24) {
            Image(systemName: "photo")
                .font(.system(size: 80))
                .scaleEffect(zoomed ? 1.6 : 1.0)
                .gesture(
                    TapGesture(count: 2)
                        .onEnded { withAnimation(.snappy) { zoomed.toggle() } }
                        .exclusively(before:              // 더블 탭이 실패해야 싱글 탭
                            TapGesture().onEnded { message = "한 번 탭" })
                )

            Text(message)
                .padding()
                .background(pressing ? Color.yellow : Color.gray.opacity(0.2))
                .onLongPressGesture(minimumDuration: 0.5) {
                    message = "길게 눌렀다"             // 0.5초를 채웠을 때
                } onPressingChanged: { isPressing in
                    pressing = isPressing              // 누르는 동안 true
                }
        }
    }
}

조합 3종 — 동시 · 순서 · 배타

Apple 문서 · Composing SwiftUI gestures는 조합을 세 가지로 나눈다. 동시(simultaneously(with:))는 둘 다 함께 인식, 순서(sequenced(before:))는 첫 제스처가 성공한 뒤에야 둘째가 이벤트를 받음, 배타(exclusively(before:))는 하나만 성공한다. 위 핀치+회전이 "동시", 더블/싱글 탭이 "배타"였다. 아래는 문서 예제와 같은 "길게 누른 뒤 드래그"(순서)다.

simultaneously(with:) sequenced(before:) exclusively(before:) A 핀치 B 회전 같은 시간에 둘 다 A 길게 B 드래그 A 성공 후에야 B 시작 A 더블 탭 (우선) B 싱글 탭 A가 실패해야 B 차례 · 하나만 성공
UIKit에서 델리게이트와 require(toFail:)로 풀던 recognizer 관계가 연산자 세 개로 정리된다.
✅ 길게 누른 뒤 드래그 — sequenced(before:)와 상태 enum
struct HoldThenDrag: View {
    enum DragState {
        case inactive
        case pressing
        case dragging(CGSize)

        var offset: CGSize {
            if case .dragging(let t) = self { return t }
            return .zero
        }
        var isActive: Bool {
            if case .inactive = self { return false }
            return true
        }
    }

    @GestureState private var dragState = DragState.inactive
    @State private var position = CGSize.zero

    var body: some View {
        let holdThenDrag = LongPressGesture(minimumDuration: 0.4)
            .sequenced(before: DragGesture())
            .updating($dragState) { value, state, _ in
                switch value {
                case .first(true):                    // 길게 누르는 중
                    state = .pressing
                case .second(true, let drag):         // 길게 누르기 성공 → 드래그
                    state = .dragging(drag?.translation ?? .zero)
                default:
                    state = .inactive
                }
            }
            .onEnded { value in
                guard case .second(true, let drag?) = value else { return }
                position.width += drag.translation.width
                position.height += drag.translation.height
            }

        Circle()
            .fill(dragState.isActive ? Color.orange : Color.blue)
            .frame(width: 90, height: 90)
            .scaleEffect(dragState.isActive ? 1.15 : 1.0)
            .offset(x: position.width + dragState.offset.width,
                    y: position.height + dragState.offset.height)
            .animation(.snappy, value: dragState.isActive)
            .gesture(holdThenDrag)
    }
}

sequenced의 값은 .first(Bool)(길게 누르기 진행) → .second(Bool, DragGesture.Value?)(드래그) 순서로 바뀐다(Apple 문서 · SequenceGesture.Value). 진행 중 모습은 @GestureState, 놓은 위치는 @State — 위 DropPin과 같은 "확정분 + 진행분" 구조다.

자식이 이미 제스처를 가진 컨테이너에는 붙이는 방식으로 우선순위를 정한다. highPriorityGesture는 "뷰가 정의한 제스처보다 높은 우선순위", simultaneousGesture는 "같은 우선순위로 동시에"다. 일반 .gesture로 붙이면 자식 제스처가 먼저다.

✅ 부모 vs 자식 제스처 — 한 줄로 우선순위 선택
struct ParentVsChild: View {
    var body: some View {
        HStack(spacing: 24) {
            Image(systemName: "heart.fill")
                .onTapGesture { print("하트") }       // 자식 제스처
            Text("행")
        }
        .highPriorityGesture(TapGesture().onEnded { print("행") })     // 하트를 눌러도 "행"만
        // .simultaneousGesture(TapGesture().onEnded { print("행") }) // 하트를 누르면 "하트"+"행"
    }
}
UIKitSwiftUI
UITapGestureRecognizeronTapGesture — 단, 버튼 역할이면 Button
numberOfTapsRequired = 2onTapGesture(count: 2) · TapGesture(count: 2)
UILongPressGestureRecognizer (minimumPressDuration, allowableMovement)onLongPressGesture(minimumDuration:maximumDistance:) · LongPressGesture
UIPanGestureRecognizer + .began/.changed/.endedDragGesture + updating/onChanged/onEnded
UIPinchGestureRecognizer / UIRotationGestureRecognizerMagnifyGesture / RotateGesture (iOS 17)
gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:).simultaneously(with:) · simultaneousGesture
require(toFail:) (더블 탭 실패 시에만 싱글 탭).exclusively(before:) — 앞 제스처 우선
길게 누른 뒤 팬: 상태 플래그를 직접 관리.sequenced(before:) — 첫 제스처 성공 후 둘째 시작
부모 recognizer가 자식 제스처를 이기게 조정highPriorityGesture (동시에 받으려면 simultaneousGesture)
UIView.animate(withDuration:)withAnimation
🆕 최신 API iOS 18+

iOS 18부터 SwiftUI Animation으로 UIKit 뷰를 움직일 수 있다(UIView.animate(_:changes:completion:)). UIViewRepresentable의 updateUIView에서는 context.animate { … }가 현재 트랜잭션의 애니메이션을 UIKit 쪽으로 이어 준다. 드래그 중 .interactiveSpring으로 계속 재조준하다가 손을 떼면 속도를 이어받는 패턴을 UIKit에서도 속도 계산 없이 쓸 수 있다(WWDC24 · Enhance your UI animations and transitions).

❌ 버튼 역할에 onTapGesture
struct FakeButton: View {
    @State private var liked = false
    var body: some View {
        Image(systemName: liked ? "heart.fill" : "heart")
            .onTapGesture { liked.toggle() }   // 보조기술에 "버튼"으로 알려지지 않는다
    }
}
✅ Button + 동작 줄이기 설정 존중
struct LikeButton: View {
    @State private var liked = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    var body: some View {
        Button {
            withAnimation(reduceMotion ? nil : .bouncy) { liked.toggle() }
        } label: {
            Image(systemName: liked ? "heart.fill" : "heart")
                .scaleEffect(liked ? 1.2 : 1.0)
        }
        .accessibilityLabel(liked ? "좋아요 취소" : "좋아요")
    }
}
♿ 접근성 한 줄

큰 이동·튀는 모션은 "동작 줄이기" 사용자에게 불편하다. @Environment(\.accessibilityReduceMotion)이 true면 withAnimation(nil)로 즉시 바꾸거나 fade 같은 약한 효과로 대신한다(Apple 문서).

3. 14일 총정리 지도 + 다음에 공부할 것

14일 동안 본 모든 도구는 결국 세 문장으로 모인다. ① 화면은 상태의 함수다. ② identity가 수명과 애니메이션을 정한다. ③ 부모가 제안하고 자식이 정한다. 아래 지도에서 각 Day가 어느 문장을 받치는지 확인한다.

다시 읽을 때 참고: 큰 주제는 한 Day를 1부·2부 두 챕터로 나눴다 — Day 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14. 증상표에서 Day를 고른 뒤 해당 부로 바로 간다. 이미지·툴바·메뉴·현지화·접근성 심화처럼 Day에 없는 실무 부품은 부록 A·부록 B에 있다.

UI = f(상태) · identity = 수명 · 제안 → 결정 생각 방식 Day 1선언형 사고 Day 2뷰 조립 Day 4identity·수명 body = 설명서 이름표 = 수명 상태·데이터 Day 3진실은 한 곳 Day 5Observation Day 12동시성·.task Day 13데이터·앱 구조 배치·목록 Day 73단계 협상 Day 8레이아웃 도구 Day 9리스트·성능 부모가 제안 자식이 결정 흐름·통합 Day 6UIKit 공존 Day 10내비게이션 Day 11입력·폼·포커스 Day 14 · 오늘애니메이션·제스처 다음 → 스프링 심화 · SwiftData 스키마/히스토리 · Instruments · 접근성 · iOS 26+ 새 디자인
14일은 네 갈래(생각·데이터·배치·흐름)로 묶이고, 모두 맨 위 세 문장 위에 서 있다.
증상먼저 떠올릴 Day확인할 것
값을 바꿨는데 화면이 안 바뀐다Day 3 · 5진실이 둘인가? body가 그 프로퍼티를 읽나?
상태가 갑자기 초기화된다 / 깜빡 바뀐다Day 4 · 14if/else 분기, .id(), ForEach ID
크기·위치가 예상과 다르다Day 7 · 8누가 제안하고 누가 결정했나, 모디파이어 순서
스크롤이 버벅인다Day 9_printChanges, 요소당 뷰 수, 무거운 body
화면 이동·딥링크가 꼬인다Day 10경로 배열(NavigationPath)이 진실의 원천인가
비동기 결과가 늦게·중복으로 온다Day 12.task(id:) 취소, MainActor
재시작하면 데이터가 사라진다Day 13@State/@AppStorage/SwiftData 중 맞는 수명인가
UIKit에 넣은 SwiftUI 화면의 크기가 틀리다 / 셀 높이가 안 맞는다Day 6UIHostingController sizingOptions, UIHostingConfiguration, sizeThatFits
키보드가 필드를 가리거나 포커스가 안 옮겨 간다Day 11 · 2부@FocusState · onSubmit · 키보드 툴바 · scrollDismissesKeyboard
번역이 안 된다 / VoiceOver 읽는 순서가 이상하다부록 B리터럴 vs 변수(LocalizedStringKey), String Catalog · accessibilityElement(children:)
서버 이미지가 안 뜬다 / 툴바 버튼 위치가 틀리다부록 AAsyncImage phase · toolbar placement

다음에 공부할 것 (모두 Apple 자료)

📎 먼저 이 코스의 부록 두 편으로 실무 공백을 메운다: 부록 A · 매일 쓰는 부품(이미지·툴바·메뉴·공유·햅틱), 부록 B · 출시 가능한 앱(현지화·테마·접근성 심화·앱 구조 다리).

주제왜 다음인가자료
스프링 심화duration·bounce 감각, 제스처 후 속도 이어받기WWDC23 · Animate with springs
SwiftData 스키마·마이그레이션관계·유니크 제약·스키마 변경WWDC23 · Model your schema · Dive deeper
SwiftData 히스토리·동기화확장·서버와 변경 맞추기WWDC24 · Track model changes · WWDC26 · What's new iOS 27+
성능 계측추측 대신 Instruments로 느린 업데이트 찾기WWDC25 · Optimize SwiftUI performance with Instruments
접근성VoiceOver·Dynamic Type을 SwiftUI/UIKit 양쪽에서WWDC23 · Build accessible apps with SwiftUI and UIKit
새 디자인·APILiquid Glass 툴바·탭 등 최신 변화WWDC25 · What's new in SwiftUI iOS 26+
🔑 회사 코드에 적용하는 순서

① 새 화면 하나를 UIHostingController로 넣는다(Day 6). ② 그 화면의 상태를 @Observable 모델 하나로 모은다(Day 5). ③ 모델에 Swift Testing 테스트를 붙인다(Day 13). ④ 레이아웃·목록·내비게이션은 필요한 Day를 위 증상표로 찾아 다시 읽는다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
컨테이너 뷰에 .animation(_:value:) 대신 animation(_:body:)를 권하는 이유는?
value 버전은 그 위치 아래 전체의 트랜잭션을 덮어써서, 같은 업데이트에 바뀐 임의의 자식 콘텐츠까지 움직일 수 있다. body 클로저 버전은 클로저 안의 animatable 속성에만 애니메이션 복사본을 적용하므로 자식에게 새지 않는다.
쉽게 말하면 교실 전체에 "천천히!"라고 외치는 대신, 내 책상 위 물건에만 쪽지를 붙이는 것이다.
@GestureState와 @State는 드래그에서 각각 언제 쓰나?
@GestureState는 제스처가 진행되는 동안만 값을 갖고 끝나면 초기값으로 자동 복귀한다 — "놓으면 원위치" 카드에 맞다(resetTransaction으로 복귀 애니메이션 지정). 놓은 자리에 남아야 하면 @State 두 개로 "확정분 + 진행분"을 나눠 onEnded에서 확정분에 더한다.
쉽게 말하면 GestureState는 손을 떼면 제자리로 돌아가는 고무줄, State는 놓은 자리에 꽂히는 압정이다.
🔮 코드 예측: 1절 ScoreBoard에서 "+1"을 누르면 숫자와 원은 각각 어떻게 바뀌나? "리셋"을 누르면?
"+1": 숫자는 .transaction { $0.animation = nil } 때문에 즉시 바뀌고, 원은 .bouncy로 커진다. "리셋": withTransaction에 애니메이션이 없고 disablesAnimations까지 켜져 있어서 둘 다 즉시 원래대로 돌아간다.
쉽게 말하면 방송으로 "천천히!"라고 했지만 숫자판은 귀마개를 끼고 있어서 바로 바뀐다.
🔁 1부 복습: 토글하면 원이 부드럽게 색이 바뀌지 않고 "깜빡" 교체된다. 무엇을 의심하나?
identity가 바뀐 것이다. if/else로 서로 다른 뷰를 갈아 끼우고 있거나 .id() 값이 바뀌고 있다. 같은 뷰에 삼항 연산자로 값만 바꾸면 보간된다. 반대로 정말 넣고 빼는 뷰라면 .transition + 애니메이션 트랜잭션을 준다.
쉽게 말하면 같은 친구가 옷만 갈아입으면 자연스럽지만, 다른 친구로 바뀌면 "교대"가 된다.

🛠 미니 과제 (선택 · 10분)

"좋아요" 버튼을 Preview 하나로 완성한다.

시작 코드
import SwiftUI

struct LikeCounter: View {
    @State private var liked = false
    @State private var count = 41

    var body: some View {
        HStack {
            Image(systemName: liked ? "heart.fill" : "heart")
                .font(.largeTitle)
                .foregroundStyle(.pink)
            Text("\(count)")
                .font(.title.monospacedDigit())
        }
        .onTapGesture {
            liked.toggle()
            count += liked ? 1 : -1
        }
    }
}

#Preview { LikeCounter() }

확인 방법: Live Preview에서 누를 때마다 하트가 한 번 튀고 숫자는 즉시 42↔41로 바뀐다. 시뮬레이터 설정 › 손쉬운 사용 › 동작 › 동작 줄이기를 켜면 튀지 않는다.

정답 코드 보기
예시 정답
struct LikeCounterSolution: View {
    @State private var liked = false
    @State private var count = 41
    @State private var taps = 0
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    var body: some View {
        Button {
            liked.toggle()
            count += liked ? 1 : -1
            if !reduceMotion { taps += 1 }            // 동작 줄이기면 trigger를 건드리지 않는다
        } label: {
            HStack {
                Image(systemName: liked ? "heart.fill" : "heart")
                    .font(.largeTitle)
                    .foregroundStyle(.pink)
                    .phaseAnimator([1.0, 1.3], trigger: taps) { content, scale in
                        content.scaleEffect(scale)
                    } animation: { _ in .bouncy }
                Text("\(count)")
                    .font(.title.monospacedDigit())
                    .transaction { $0.animation = nil }
            }
        }
        .buttonStyle(.plain)
        .accessibilityLabel(liked ? "좋아요 취소, \(count)개" : "좋아요, \(count)개")
    }
}

#Preview { LikeCounterSolution() }

출처 · 참고자료