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).
.transaction), 좁은 범위만 바꿀(animation(_:body:)) 수 있다.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 }
}
}
}
}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 클로저는 세 번째 인자로 트랜잭션도 받는다 — 제스처 역시 트랜잭션 모델 위에 있다.
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
}
)
}
}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:))는 하나만 성공한다. 위 핀치+회전이 "동시", 더블/싱글 탭이 "배타"였다. 아래는 문서 예제와 같은 "길게 누른 뒤 드래그"(순서)다.
require(toFail:)로 풀던 recognizer 관계가 연산자 세 개로 정리된다.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로 붙이면 자식 제스처가 먼저다.
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("행") }) // 하트를 누르면 "하트"+"행"
}
}| UIKit | SwiftUI |
|---|---|
UITapGestureRecognizer | onTapGesture — 단, 버튼 역할이면 Button |
numberOfTapsRequired = 2 | onTapGesture(count: 2) · TapGesture(count: 2) |
UILongPressGestureRecognizer (minimumPressDuration, allowableMovement) | onLongPressGesture(minimumDuration:maximumDistance:) · LongPressGesture |
UIPanGestureRecognizer + .began/.changed/.ended | DragGesture + updating/onChanged/onEnded |
UIPinchGestureRecognizer / UIRotationGestureRecognizer | MagnifyGesture / RotateGesture (iOS 17) |
gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) | .simultaneously(with:) · simultaneousGesture |
require(toFail:) (더블 탭 실패 시에만 싱글 탭) | .exclusively(before:) — 앞 제스처 우선 |
| 길게 누른 뒤 팬: 상태 플래그를 직접 관리 | .sequenced(before:) — 첫 제스처 성공 후 둘째 시작 |
| 부모 recognizer가 자식 제스처를 이기게 조정 | highPriorityGesture (동시에 받으려면 simultaneousGesture) |
UIView.animate(withDuration:) | withAnimation |
iOS 18부터 SwiftUI Animation으로 UIKit 뷰를 움직일 수 있다(UIView.animate(_:changes:completion:)). UIViewRepresentable의 updateUIView에서는 context.animate { … }가 현재 트랜잭션의 애니메이션을 UIKit 쪽으로 이어 준다. 드래그 중 .interactiveSpring으로 계속 재조준하다가 손을 떼면 속도를 이어받는 패턴을 UIKit에서도 속도 계산 없이 쓸 수 있다(WWDC24 · Enhance your UI animations and transitions).
struct FakeButton: View {
@State private var liked = false
var body: some View {
Image(systemName: liked ? "heart.fill" : "heart")
.onTapGesture { liked.toggle() } // 보조기술에 "버튼"으로 알려지지 않는다
}
}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에 있다.
| 증상 | 먼저 떠올릴 Day | 확인할 것 |
|---|---|---|
| 값을 바꿨는데 화면이 안 바뀐다 | Day 3 · 5 | 진실이 둘인가? body가 그 프로퍼티를 읽나? |
| 상태가 갑자기 초기화된다 / 깜빡 바뀐다 | Day 4 · 14 | if/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 6 | UIHostingController sizingOptions, UIHostingConfiguration, sizeThatFits |
| 키보드가 필드를 가리거나 포커스가 안 옮겨 간다 | Day 11 · 2부 | @FocusState · onSubmit · 키보드 툴바 · scrollDismissesKeyboard |
| 번역이 안 된다 / VoiceOver 읽는 순서가 이상하다 | 부록 B | 리터럴 vs 변수(LocalizedStringKey), String Catalog · accessibilityElement(children:) |
| 서버 이미지가 안 뜬다 / 툴바 버튼 위치가 틀리다 | 부록 A | AsyncImage 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 |
| 새 디자인·API | Liquid 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:)를 권하는 이유는?
@GestureState와 @State는 드래그에서 각각 언제 쓰나?
@GestureState는 제스처가 진행되는 동안만 값을 갖고 끝나면 초기값으로 자동 복귀한다 — "놓으면 원위치" 카드에 맞다(resetTransaction으로 복귀 애니메이션 지정). 놓은 자리에 남아야 하면 @State 두 개로 "확정분 + 진행분"을 나눠 onEnded에서 확정분에 더한다.🔮 코드 예측: 1절 ScoreBoard에서 "+1"을 누르면 숫자와 원은 각각 어떻게 바뀌나? "리셋"을 누르면?
.transaction { $0.animation = nil } 때문에 즉시 바뀌고, 원은 .bouncy로 커진다. "리셋": withTransaction에 애니메이션이 없고 disablesAnimations까지 켜져 있어서 둘 다 즉시 원래대로 돌아간다.🔁 1부 복습: 토글하면 원이 부드럽게 색이 바뀌지 않고 "깜빡" 교체된다. 무엇을 의심하나?
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() }- 요구 1: 탭 영역을
Button으로 바꾸고, 하트는.bouncy로 1.3배까지 커졌다 돌아오게 한다(phaseAnimator+trigger). - 요구 2: 숫자는 애니메이션 없이 즉시 바뀌게 한다.
- 요구 3: "동작 줄이기"가 켜져 있으면 튀는 효과 없이 바꾼다.
확인 방법: 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() }