SwiftUI에는 서로 다른 View를 구분하는 특별한 매커니즘이 존재한다. SwiftUI에서 서로 다른 View를 구분하는 것은 매우 중요한데, 그 이유는 서로 같은 View의 상태 변화에 대한 화면 전환과 서로 다른 View 간의 화면 전환은 SwiftUI에게 다르게 인식되기 때문이다. 예를 들어, 동일한 View 사이의 상태 변화는 애니메이션 효과를 보여주고, 서로 다른 View 사이의 상태 변화는 fade in/out과 같은 전환 효과를 보여준다.
먼저 동일한 View의 상태 변화에 대한 화면 전환 효과를 살펴보자.
import SwiftUI
struct ContentView: View {
@State private var isOn: Bool = false
var body: some View {
ZStack {
Circle()
.foregroundStyle(isOn ? .yellow : .black)
.frame(width: 100, height: 100)
.offset(y: isOn ? -200: 0)
Button(isOn ? "off" : "on") {
withAnimation {
isOn.toggle()
}
}
.frame(maxHeight: .infinity, alignment: .bottom)
}
}
}
이 코드는 Button을 눌러 isOn 변수의 값을 true 또는 false로 변경하고, 그 상태에 따라 Circle의 색상과 위치를 변경하는 간단한 View이다. isOn 값이 true일 때는 Circle이 노란색으로 변하며 위쪽으로 이동하고, false일 때는 검은색으로 변경되며 원래 위치로 돌아간다. 중요한 점은 Circle 자체는 동일한 View이며 isOn 변수의 상태에 따라 색상과 위치가 애니메이션으로 변화하게 된다.
다음으로 서로 다른 View일 때, 상태 변화에 대한 View 전환 효과를 살펴보자.
import SwiftUI
struct ContentView: View {
@State private var isOn: Bool = false
var body: some View {
ZStack {
if isOn {
YellowCircle()
} else {
BlackCircle()
.foregroundStyle(.black)
.frame(width: 100, height: 100)
}
Button(isOn ? "off" : "on") {
withAnimation {
isOn.toggle()
}
}
.frame(maxHeight: .infinity, alignment: .bottom)
}
}
}
이번에는 if-else를 통해 상태 변수 isOn에 대해서 서로 다른 View를 보여준다. isOn이 true이면 노란색 Circle이 위쪽으로 이동한 상태로 나타나고, false일 때는 검은색 Circle이 기본 위치에 표시된다. 이런 방식은 상태에 따라 두 개의 개별적인 View를 조건에 따라 교체하는 방식으로 동작한다. 따라서 전환 효과 또한 fade-in/out으로 아래와 같이 동작하게 된다.
SwiftUI에서는 View를 구분하기 위해 Identity라는 것을 사용한다. 말그대로 서로 다른 View를 식별하는 식별자이다. 이때 Identity는 아래와 같이 크게 두 개로 구분할 수 있다.
시간을 거슬러 기존 UIKit 또는 AppKit에서는 Explicit Identity의 한 종류라고 할 수 있는 Pointer Identity를 사용하여 View를 식별한다.
잠시 SwiftUI의 Explicit Identity와 Pointer Identity를 비교해 보자면..
UIKit의 UIView, AppKit의 NSView 모두 class 타입이기 때문에 Pointer Identity는 각 View의 메모리 위치를 참조하여 서로 다른 View를 식별할 수 있다. 다시말해, 같은 메모리를 참조하고 있다면 서로 같은 View라고 할 수 있는 것이다.
하지만 SwiftUI의 View는 값 타입이기 때문에 Pointer Identity와는 다른 형태의 Explicit Identity를 사용한다. 가령, 아래의 View는 SwiftUI의 Explicit Identity를 가장 잘 보여주는 예시 중 하나이다.
import SwiftUI
struct NumberList: View {
var body: some View {
List {
ForEach(0..<10, id: \.self) { num in
Text("\(num)")
}
}
}
}
ForEach의 id 파라미터를 통해 List 내부에 표시될 View의 Identity를 명시적으로 설정할 수 있다. 그럼 List는 내부의 id를 통해 내부 View의 변경을 파악하고 적절한 애니메이션을 생성한다.
또 다른 예시는 SwiftUI에서 제공하는 id(_:) Modifier를 통해 View의 Identity를 명시적으로 설정할 수 있다.
import SwiftUI
struct ContentView: View {
var body: some View {
ScrollViewReader { proxy in
ScrollView {
ForEach(0..<10, id: \.self) { i in
VStack {
Text("Title")
.font(.title)
.bold()
.frame(maxWidth: .infinity, alignment: .leading)
Text(content)
.padding(.bottom)
}
.id(i)
}
}
.padding()
Button {
withAnimation {
proxy.scrollTo(0)
}
} label: {
Text("Move to top")
}
.buttonStyle(.borderedProminent)
}
}
}
가령, 위와 같이 ScrollViewReader에서 ScrollView의 특정 View로 이동하기 위해 id(_:) Modifier의 파라미터에 Custom Identifier를 전달하여 Explicit Identity를 설정할 수 있다. 위의 코드를 살펴보면 알 수 있듯, 모든 View에 명시적으로 Custom Identifier를 전달할 필요는 없다.
그렇다고 명시적으로 Indentity를 설정하지 않은 View에는 Indentity가 존재하지 않는 것은 아니다.
SwiftUI는 이렇게 명시적으로 Indentity가 설정되지 않은 View에 대해서는 View hierarchy를 통해 Implicit Identitiy를 생성한다.
View hierarchy를 이용한 Implicit identity를 Structural Indentity라고 한다. 말그대로 View의 상대적인 구조에 따라 암시적으로 할당되는 identity 값을 의미한다. 사실, 이미 위에서 Structural Indentity를 효과적으로 활용한 예시를 살펴본 적이 있다.
if isOn {
YellowCircle()
} else {
BlackCircle()
}
Button(isOn ? "off" : "on") {
withAnimation {
isOn.toggle()
}
}
.frame(maxHeight: .infinity, alignment: .bottom)
바로 if-else를 사용하여 조건문에 따라 서로 다른 View를 식별하는 것이 Structural Indentity를 사용한 대표적인 예시이다. 이렇게 조건문을 통해 서로 다른 View가 표시되는 경우, @ViewBuilder를 통해 내부적으로 true 또는 false Content를 가지는 하나의 제네릭 타입 View인 _ConditionalContent로 변환된다. 제네릭 타입 View로 SwiftUI는 True view일 때 YellowCircle() View가 False view일 때 BlackCircle() View라는 것을 보장할 수 있으므로, 암시적이고 안정적으로 Indentity를 할당할 수 있다.
SwiftUI의 View는 stuct로 구성된 value 타입이기 때문에 View의 값이 변경되면 사실은 변경된 값에 대한 새로운 인스턴스를 생성하고, 변경 전 값에 대한 인스턴스는 메모리에서 사라진다.
그렇다면, 새로 생성된 View 인스턴스는 다른 View일까?
하지만 SwiftUI의 관점에서 값이 변경되기 전과 변경된 후의 두 View는 모두 동일하다. View가 처음 생성되면 위에서 살펴본 매커니즘에 의해 고유한 Identity를 할당 받는다. 그리고 이 Identity는 View의 값이 변경 되더라도 동일하게 유지되기 때문에 View의 값이 바뀌더라도 동일한 View로 인식된다. 만약 View의 Identity가 변경되거나, View가 메모리에서 사라진다면 해당 View의 Lifetime은 종료된다.
if isOn {
YellowCircle()
} else {
BlackCircle()
.foregroundStyle(.black)
.frame(width: 100, height: 100)
}
위에서 살펴봤던 예시를 다시 살펴보자. if-else 조건식을 통해 isOn이 true인 경우 YellowCircle View가 생성되면서 View의 Identity가 할당되고 Lifetime이 시작된다. YellowCircle View 내부에 값이 변경되어도 동일한 Identity로 Lifetime은 유지된다. 하지만 isOn이 false가 되면 기존 YellowCircle View는 메모리에서 사라지며 View Lifetime로 여기서 종료된다. 반대로 BlackCircle View가 새로 생성되어 Identity가 할당되고 새롭게 View Lifetime이 시작된다.