1부 목표 · 21분
- SwiftUI가 코드에서 보는 세 가지(identity · lifetime · dependencies)를 대고, UIKit의 포인터 identity와 비교한다.
if/else분기 코드를 보고 identity가 몇 개인지 세고,_ConditionalContent타입으로 근거를 댄다.- "같은 것의 다른 모습"이면 삼항 연산자·inert 모디파이어로 바꿔 상태와 애니메이션을 살린다.
- 모디파이어 확장 안에 숨은
if를 찾아 값 분기로 고친다.
1. SwiftUI가 코드를 볼 때 보는 것 🔥 핵심
WWDC21 · Demystify SwiftUI는 질문 하나로 시작한다: SwiftUI가 여러분의 코드를 보면 무엇을 보는가?
"When SwiftUI looks at your code, what does it see? The answer is three things: identity, lifetime, and dependencies." — SwiftUI가 보는 것은 세 가지다. identity(업데이트를 넘어 같은 것/다른 것을 구분), lifetime(뷰와 데이터가 존재하는 기간), dependencies(언제 왜 갱신할지). 오늘은 앞의 두 개를 다룬다. 의존성은 Day 5·Day 9에서 이어진다.
UIKit은 주소, SwiftUI는 구조
UIKit의 UIView는 클래스라 인스턴스마다 메모리 주소가 있다. 세션 표현으로 "The pointer is a natural source of explicit identity" — 주소가 같으면 같은 뷰다. 그런데 "SwiftUI doesn't use pointers because SwiftUI views are value types". struct에는 영속적인 주소가 없으니 SwiftUI는 다른 방법으로 이름표를 만든다.
some View 뒤에 숨은 긴 제네릭 타입이 그 이름표다.| 종류 | 이름표의 근거 | 쓰는 법 | UIKit 대응 |
|---|---|---|---|
| structural identity | 뷰의 타입 + 계층 안 위치 | 아무것도 안 함 (기본) | —(UIKit엔 없음) |
| explicit identity | 내가 준 Hashable 값 | ForEach(_:id:), Identifiable, .id(_:) | 포인터, reuseIdentifier+IndexPath, diffable의 item identifier |
세션은 "every view has an identity, even if it's not explicit"라고 강조한다. 명시적 id를 붙이지 않은 뷰도 전부 구조적 identity를 갖는다.
identity가 전환 애니메이션을 결정한다
세션의 "Good Dog, Bad Dog" 앱은 탭하면 발바닥 아이콘이 위(착함)↔아래(나쁨)로 바뀐다. 이 두 아이콘이 다른 뷰라면 하나는 사라지고 하나는 나타나야 한다(fade). 같은 뷰라면 화면을 가로질러 미끄러져야 한다(slide). 세션 표현: "Views that share the same identity represent different states of the same conceptual UI element."
struct PawView: View {
let tint: Color
var body: some View {
Image(systemName: "pawprint.fill")
.font(.system(size: 60))
.foregroundStyle(tint)
}
}
struct GoodDogBadDog: View {
@State private var isGood = true
var body: some View {
VStack {
if isGood {
PawView(tint: .green)
Spacer()
} else {
Spacer()
PawView(tint: .red)
}
}
.contentShape(Rectangle())
.onTapGesture { withAnimation { isGood.toggle() } }
}
}struct GoodDogBadDog: View {
@State private var isGood = true
var body: some View {
PawView(tint: isGood ? .green : .red)
.frame(maxHeight: .infinity, alignment: isGood ? .top : .bottom)
.contentShape(Rectangle())
.onTapGesture { withAnimation { isGood.toggle() } }
}
}"By default, try to preserve identity and provide more fluid transitions." 두 방식 모두 쓸 수 있지만 기본은 identity를 지키는 쪽이다. 그래야 전환이 부드럽고 상태도 살아남는다. 전환(transition) 자체는 Day 14에서 다룬다.
아이콘 하나로 상태를 표현할 때는 뷰를 둘로 나누지 말고 PawView(...).accessibilityLabel(isGood ? "착한 강아지" : "나쁜 강아지")처럼 같은 뷰의 라벨 값을 바꾼다. 구조도 하나, VoiceOver가 읽는 내용도 상태를 따라간다.
2. structural identity — if/else는 "숨기기"가 아니라 "다른 뷰" 🔥 핵심
세션 설명: SwiftUI는 뷰의 제네릭 타입을 본다. if문은 ViewBuilder가 "_ConditionalContent view, which is generic over its true and false content"로 바꾼다. true 쪽과 false 쪽은 타입 안에서 자리가 다르다 → 서로 다른 identity다.
struct EditForm: View {
@State private var draft = ""
var body: some View { TextField("소개", text: $draft) }
}
struct SummaryCard: View {
var body: some View { Text("요약") }
}
struct ProfileView: View {
@State private var isEditing = false
var body: some View {
VStack {
if isEditing {
EditForm() // _ConditionalContent의 true 자리
} else {
SummaryCard() // false 자리 — 다른 identity
}
Button("전환") { isEditing.toggle() }
}
// body 타입: VStack<TupleView<(_ConditionalContent<EditForm, SummaryCard>, Button<Text>)>>
}
}편집 모드를 껐다 켜면 EditForm의 입력이 사라진다. UIKit 감각으로는 "숨겼다 보여준 것"이지만, SwiftUI 눈에는 뷰를 없애고 다른 뷰를 만든 것이다. 없어진 뷰의 @State도 함께 사라진다.
if/else vs 삼항 연산자
모습만 바뀌는 거라면 뷰를 분기하지 말고 값을 분기한다.
struct ProfileCard: View {
@State private var isExpanded = false
var body: some View {
Text(isExpanded ? "자세히…" : "요약").onTapGesture { isExpanded.toggle() }
}
}
struct SelectableCard: View {
let isSelected: Bool
var body: some View {
if isSelected {
ProfileCard().background(.green)
} else {
ProfileCard().background(.red) // 선택이 바뀌면 isExpanded가 초기화된다
}
}
}struct SelectableCard: View {
let isSelected: Bool
var body: some View {
ProfileCard()
.background(isSelected ? .green : .red)
}
}모디파이어 안의 분기 — 숨은 if를 찾아라
세션의 treat jar 예제는 분기가 모디파이어 안에 숨어 있다. 유통기한이 지나면 셀을 흐리게 하는 모디파이어인데, 날짜가 지나는 순간 셀의 identity가 바뀐다. 세션: "you might have branches like this across files without even being aware of it!" Day 2에서 예고한 if 모디파이어 확장도 정확히 같은 문제다.
struct ExpirationModifier: ViewModifier {
var date: Date
func body(content: Content) -> some View {
if date < .now {
content.opacity(0.3) // 분기 A
} else {
content // 분기 B — 다른 identity
}
}
}
extension View {
// Day 2의 "if 모디파이어" 확장도 똑같이 identity를 가른다
@ViewBuilder
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition { transform(self) } else { self }
}
}struct ExpirationModifier: ViewModifier {
var date: Date
func body(content: Content) -> some View {
content.opacity(date < .now ? 0.3 : 1.0) // 1.0 = 아무 효과 없음
}
}"An opacity of 1 has no effect. We call modifiers like this 'inert modifiers,' because they don't affect the rendered result." 그리고 "SwiftUI modifiers are cheap, so there is little inherent cost with this pattern." — 효과가 없는 값의 모디파이어는 프레임워크가 걷어내므로 비용이 거의 없다. 세션은 환경 값을 조건부로 쓸 때 transformEnvironment를 inert 방식의 예로 든다. (WWDC21 · Demystify SwiftUI)
| 하고 싶은 것 | 분기 대신 쓸 "효과 없는 값" |
|---|---|
| 조건부로 흐리게 | .opacity(cond ? 0.3 : 1) |
| 조건부 여백 | .padding(cond ? 16 : 0) |
| 조건부 크기 강조 | .scaleEffect(cond ? 1.2 : 1) |
| 조건부 색 | .foregroundStyle(cond ? .red : .primary) |
| 조건부 비활성화 | .disabled(cond) — 원래 Bool을 받는다 |
세션: "When you introduce a branch, pause for a second and consider whether you're representing multiple views or two states of the same view." 진짜 다른 화면(로딩 ↔ 콘텐츠, 로그인 ↔ 메인)이면 if/else가 맞다. 같은 것의 다른 모습(선택/비선택, 펼침/접힘)이면 값을 분기한다.
직접 확인하는 실험
struct IdentityLab: View {
@State private var isOn = true
var body: some View {
VStack(alignment: .leading, spacing: 24) {
Toggle("토글해 보세요", isOn: $isOn)
Text("❌ if / else — 분기가 갈리면 입력이 사라진다").font(.caption)
if isOn {
DraftField(label: "A")
} else {
DraftField(label: "B")
}
Text("✅ 같은 뷰 + 값만 바뀜 — 입력이 유지된다").font(.caption)
DraftField(label: isOn ? "A" : "B")
}
.padding()
}
}
private struct DraftField: View {
let label: String
@State private var draft = "" // 이 상태의 수명을 관찰한다
var body: some View {
TextField("입력 후 토글 (\(label))", text: $draft)
.textFieldStyle(.roundedBorder)
}
}
#Preview { IdentityLab() }✅ 복습 · 5분
structural identity와 explicit identity를 각각 한 문장으로 정의하고, UIKit에서 가장 가까운 개념을 하나씩 대라.
Hashable 값(ForEach의 id, .id(_:))으로 붙는 이름표다. UIKit에서 가장 가까운 건 포인터 identity, 그리고 diffable data source의 item identifier다.유통기한이 지난 셀을 흐리게 하려고 if date < .now { content.opacity(0.3) } else { content }를 썼다. 무엇이 문제이고 어떻게 고치나?
content.opacity(date < .now ? 0.3 : 1.0)처럼 조건을 모디파이어 값 안으로 옮긴다. 불투명도 1은 아무 효과가 없는 inert 값이고, 세션 표현대로 모디파이어는 싸서 비용 부담도 거의 없다.struct Counter: View {
@State private var n = 0
var body: some View { Button("n = \(n)") { n += 1 } }
}
struct Host: View {
@State private var on = false
var body: some View {
VStack {
if on { Counter().background(.green) } else { Counter().background(.red) }
Counter().background(on ? .green : .red)
Toggle("on", isOn: $on)
}
}
}🔮 코드 예측: 위 코드에서 두 Counter를 각각 3번씩 탭한 뒤 Toggle을 켜면 두 버튼은 각각 무엇을 표시하나?
if/else라 true/false 자리가 다른 identity다 — 토글하면 false 쪽 Counter가 사라지고 true 쪽이 새 저장소로 시작한다. 아래는 뷰 하나에 배경 값만 바뀌므로 identity와 상태가 그대로다.🔁 지난 Day 복습: Day 3에서 "자식이 부모 값을 편집해야 하면 @Binding"이라고 했다. 오늘 기준으로, if/else 안의 EditForm이 가진 @State draft를 분기를 오가도 살리려면 어떻게 하나?
draft를 분기 바깥의 부모(ProfileView)로 올려 @State private var draft로 두고, EditForm은 @Binding var draft로 받는다. 분기가 바뀌어 EditForm의 identity가 끝나도 진실은 부모에 있으니 사라지지 않는다. Day 3의 "필요한 가장 높은 뷰에 둔다"가 identity 관점에서도 답이다.1부에서 identity가 바뀌면 @State가 사라지는 걸 봤다. 2부는 그 규칙을 수명(lifetime)으로 정리하고 — Day 3의 두 퍼즐, .id()로 일부러 리셋하기, onAppear·task와의 관계 — 이어서 ForEach의 explicit identity와 AnyView의 비용을 다룬다.