SwiftUI로 프로젝트를 진행하던 도중, Redraw 조건을 명확하게 인지하지 못하고 있어 애먹은 일이 있었습니다.
그래서 뷰가 다시 그려지는 조건에 대해서 확인해보고자 합니다.
PART 1. WWDC - Demystify SwiftUI
WWDC - Demystify SwiftUI를 통해 몇 가지 사실을 알 수 있었습니다.
1. Identity
1-1. View는 Identitiy를 갖는다.
SwiftUI는 이 Identity로 서로 다른 뷰들을 구분한다. 당연하게도 같은 Identity를 가지면 같은 View이다.
1-2. Identity는 Structural과 Explict으로 구분된다.
Explicit은 직접 명시해줄 수 있는 Identity이다.
- modifier로 직접 부여하는 값
- ForEach에 전달하는 id 값 등등 ..
Explicit으로 명시하지 않더라도 뷰는 Identity를 갖는다.
뷰의 계층 & 뷰의 위치를 보고 Structural은 Identitiy가 결정된다.
2. Lifetime
2-1. View와 View의 프로퍼티들은 서로 다른 Lifetime을 갖는다.
View의 Lifetime은 View Identity의 Lifetime과 동일하다.
주의해야 할 점은,
View Value(뷰가 갖는 프로퍼티) lifetime은 View Lifetime과 다르다.
프로퍼티들이 몇 번이고 다시 생성되든 간에, 뷰의 Lifetime에 영향을 주지 않는다.
View Value Lifetime ≠ View Lifetime
2-2. State
물론 예외는 있다. 한 뷰의 생명주기 동안 계속 값을 유지해야 하는 경우는 분명 필요하다.
@State와 @StateObject를 생각해 보자.
이 값들은 View가 다시 그려져도 소멸되지 않는다.
즉 View Lifetime과 동일한 Lifetime을 갖는다.
View State Lifetime = View Lifetime
3. Dependency
뷰가 가지고 있는 모든 Value와 State들을 Dependencies라고 칭한다.
이 값들이 하나라도 변경된다면 뷰는 Redraw 된다.
그리고 이 Dependencies에 영향을 받는 뷰들도 함께 Redraw 된다.
(@Binding으로 연결된 경우 등을 의미하는 것 같음)
이 영향받는 뷰를 찾을 때는 Identity를 보고 찾아간다.
PART 2. 결과
위 세션과, 몇 가지 실험들 그리고 여러 블로그들을 확인하며 제가 정리한 생각은 다음과 같습니다.
(주의) 저는 SwiftUI가 내부적으로 어떻게 구현되어 있는지 알지 못합니다. 지금부터의 내용들은 저의 추론이 상당히 많이 섞여 있으며, 다른 상황에서 다른 결과를 가져올 수 있습니다. 또한 미래에 SwiftUI 버전이 업데이트되면서 이러한 방법도 바뀔지 모르겠습니다.
1. 뷰는 자신의 dependency가 변경되면 Evaluate -> Redraw 순서로 진행된다.
~ 뷰 Dependency (프로퍼티, 상태 값)가 수정된다.
~ Evaluate : view body로 진입하여 선언되어 있는 서브 뷰들을 redraw 해야 하는지 판단한다.
~ Redraw: 뷰를 Redraw 한다.
앞으로 Evaluate이라는 단어를 가장 많이 쓸 예정입니다.
Evaluate는 현재 뷰 body에 진입하고
선언되어 있는 서브 뷰들의 이니셜라이저를 호출하여
"서브 뷰의 Dependencies 변경이 존재하는가?"
를 판단하는 과정으로 정의하겠습니다.
예시로 MyView의 body에 2개의 Subview가 존재한다고 들어보겠습니다.
첫 번째 서브 뷰는 MyView의 myValue를 주입받고 있고, 두 번째 서브 뷰는 상수 값을 주입하고 있습니다.
struct MyView: View {
let myValue: Int
var body: some View {
VStack {
MySubView(value: myValue)
MySubView(value: 0)
}
}
}
struct MySubView: View {
let value: Int
var body: some View {
Text("\(value)")
}
}
MyView의 myValue 값이 어떠한 이유에서 변경이 된다면
1)
MyView의 myValue 값이 변경이 되면 뷰를 Evaluate 하기 위해 body로 진입합니다.
2)
body에 선언된 뷰들의 Dependency가 변경되었는지 확인합니다.
이 뷰들은 우리가 만든 Extract Subview일 수도 있고, SwiftUI에서 제공하는 기본 뷰 형태일 수도 있습니다.
'body에 선언된 뷰들을 Redraw 해야 하는가'를 판단하기 위해서 서브 뷰의 Initializer까지 호출합니다.
Initializer에서 값이 초기화된 이후에 Dependency 변화 여부를 확인합니다.
첫 번째 MySubView는 myValue가 변경되었기 때문에 Redraw 대상에 포함되고
두 번째 MySubView는 dependency의 변경이 없기 때문에 Redraw 대상이 아니게 됩니다.
dynamic property인 dependency가 변경되는 경우, 동일한 값으로 변경되는 경우에도 항상 Redraw 대상이 된다.
이 글에 설명이 되어 있다. 그래서 @Binding, @ObservedObject, @StateObject의 프로퍼티는 동일한 값이 들어오는 경우에도, body 전체가 일단 Evaluate 대상이 되는 것을 알 수 있다.
3)
첫 번째 MySubView를 Redraw 하기 위해 서브 뷰를 대상으로 위 2) 3)을 반복합니다.
(왜냐하면 MySubView의 body에 선언된 뷰들도 Redraw 여부를 판별해야 하기 때문!)
뷰 body에 @State 값에 영향을 받는 내용이 하나도 없는 경우, @State 값이 변경되어도 Evaluate 자체를 하지 않는 상황이 발생한다. (body에 진입조차 하지 않음) 어차피 다시 그릴 뷰가 없으니 Evaluate를 아예 하지 않는 건가 싶기는 한데... 같은 상황에서 일반 value나 @StateObject, @ObservedObject 값이 변경될 때는 Evaluate를 하고 있다. 명확한 이유를 찾지 못했다.
2. body 내의 뷰를 Redraw 하는 조건
- body에 선언된 뷰의 dependency가 이전과 달라졌다면 Redraw
- 이 변경 사항은 꼭 영향받은 dependency에 국한된 것이 아니다. body에 Evaluate를 위해 진입한 이상, 모든 서브 뷰들을 다 고려한다.
3. 뷰의 Redraw는 뷰의 Recreate을 의미하지 않는다.
- 뷰의 lifetime과 뷰 프로퍼티들의 lifetime은 다르다.
- 뷰의 init이 호출되어 프로퍼티가 변경되고 화면이 다시 그려진다고 해도, 뷰의 Identity가 변하지 않는다.
- 만약 뷰가 Redraw 될 때마다 특정 서브 뷰를 항상 초기 상태로 돌리고 싶다면, explicit Identity를 선언하는 꼼수를 부릴 수도 있다.
- .id() modifier
- 단 해당 뷰와 그의 모든 서브 뷰가 모두 재생성되므로 되어서 웬만하면 사용하지 않는 것이 현명해 보인다.
- 주의) 애니메이션이 ugly 해질 수 있음
PART 3. 실험
각 뷰가 Evalutate을 하는지 확인, init 호출, Disappear 호출을 확인하기 위해 아래 코드를 extension으로 추가했습니다.
extension View {
func printWhenEvaluating() -> Void {
print("⭐️ Evaluating \(String(describing: Self.self))")
}
@ViewBuilder
func printWhenViewDisappear() -> some View {
self.onDisappear {
print("❌ Disappear \(String(describing: Self.self))")
}
}
func printWhenInit() -> Void {
print("init()", String(describing: Self.self))
}
}
실험은 상위 뷰의 flag 값이 변화될 때 어떤 서브 뷰들이 Evaluating이 되는가를 중점적으로 관찰했습니다.
(flag 값을 @StateObject, @ObservedObject로 들고 있는 경우 모두 동일한 결과 값을 보여서 여기서는 @State만을 예시로 들었습니다.)
struct Lab1View_State: View {
@State var flag: Bool = false
var body: some View {
VStack(spacing: 10) {
let _ = printWhenEvaluating()
Button {
self.flag.toggle()
} label: {
Text("🔄")
.frame(width: 150, height: 50)
.background(.black)
}
}
}
}
1. SwiftUI.Text & flag 값을 주입받는 서브 뷰
가장 일반적인 형태의 서브 뷰입니다. flag 값을 주입받고 그 값을 body에서 Text로 출력합니다.
struct InputFlagText: View {
let flag: Bool
init(flag: Bool) {
self.flag = flag
self.printWhenInit()
}
var body: some View {
let _ = printWhenEvaluating()
Text("\(String(describing: Self.self)): \(flag ? "🅾️" : "❌")")
.printWhenViewDisappear()
}
}
Lab1View_State body의 버튼 아래에 다음을 추가하였습니다.
Group {
Text("SwiftUI.Text: \(flag ? "🅾️" : "❌")")
InputFlagText(flag: flag)
}
예상했던 대로 변경된 flag 값에 따라서 뷰들이 모두 다시 그려졌고,
서브 뷰의 init과 body에 접근하는 것을 확인할 수 있었습니다.
⭐️ Evaluating Lab1View_State
init() InputFlagText
⭐️ Evaluating InputFlagText
2. init에서 값을 주입받지 않지만, init()에서 값이 변경되는 서브 뷰
struct SetFlagInInitText: View {
let flag: Bool
init() {
self.flag = Bool.random() // init에서 random하게 setting
print(flag ? "🅾️" : "❌", terminator: " ")
self.printWhenInit()
}
var body: some View {
let _ = printWhenEvaluating()
Text("\(String(describing: Self.self)): \(flag ? "🅾️" : "❌")")
.printWhenViewDisappear()
}
}
// body의 버튼 아래 다음 추가!
Group {
Text("SwiftUI.Text: \(flag ? "🅾️" : "❌")")
InputFlagText(flag: flag)
SetFlagInInitText() // New
}
flag를 init에서 랜덤 하게 초기화하고 있는 서브 뷰입니다.
값을 상위 뷰에서 주입받지는 않지만 init에서 세팅하고 있습니다.
Bool.random()에서 기존과 다른 값이 나올 때만 변경될 것을 예상할 수 있습니다.
서브 뷰의 init 안에서 이전 값과 다르게 초기화된 경우에만 서브 뷰의 body에 접근하는 것을 확인할 수 있었습니다.
여기서 저는 Dependency 값 변경이 없다면 서브 뷰의 body에 진입하지도 않는다는 것을 알 수 있었습니다.
(@Binding 같은 상황은 고려하지 않겠습니다.)
- Bool.random() 값이 이전과 다른 경우
⭐️ Evaluating Lab1View_State
init() InputFlagText
🅾️ init() SetFlagInInitText
⭐️ Evaluating InputFlagText
⭐️ Evaluating SetFlagInInitText
- Bool.random() 값이 이전과 같은 경우
⭐️ Evaluating Lab1View_State
init() InputFlagText
❌ init() SetFlagInInitText
⭐️ Evaluating InputFlagText
3. init에서 값을 주입받지 않지만, default value를 선언한 서브 뷰
struct SetFlagInDefaultValueText: View {
let flag: Bool = Bool.random() // default value !
init() {
print(flag ? "🅾️" : "❌", terminator: " ")
self.printWhenInit()
}
var body: some View {
let _ = printWhenEvaluating()
HStack {
Text("\(String(describing: Self.self)): \(flag ? "🅾️" : "❌")")
}
.printWhenViewDisappear()
}
}
// body의 버튼 아래 다음 추가!
Group {
Text("SwiftUI.Text: \(flag ? "🅾️" : "❌")")
InputFlagText(flag: flag)
SetFlagInDefaultValueText() // New
}
혹시 다를까 하여 테스트해봤는데, 2번과 동일한 결과를 보였습니다.
- Bool.random() 값이 이전과 다른 경우
⭐️ Evaluating Lab1View_State
init() InputFlagText
❌ init() SetFlagInDefaultValueText
⭐️ Evaluating InputFlagText
⭐️ Evaluating SetFlagInDefaultValueText
- Bool.random() 값이 이전과 같은 경우
init() InputFlagText
❌ init() SetFlagInDefaultValueText
⭐️ Evaluating InputFlagText
4. init에서 값을 설정하지 않는 서브 뷰
struct SetFlagInBodyText: View {
init() {
self.printWhenInit()
}
var body: some View {
let _ = printWhenEvaluating()
let flag = Bool.random() // 여기서 flag를 생성!
HStack {
Text("\(String(describing: Self.self)): \(flag ? "🅾️" : "❌")")
}
.printWhenViewDisappear()
}
}
// body의 버튼 아래 다음 추가!
Group {
Text("SwiftUI.Text: \(flag ? "🅾️" : "❌")")
InputFlagText(flag: flag)
SetFlagInBodyText() // New
}
이 서브 뷰는 flag 값을 body에서 생성하고 있습니다.
init에서 어떠한 dependency도 변경되지 않기 때문에
서브 뷰는 항상 Redraw 대상이 아니게 되며 Evaluate을 위해 body에 진입하지 않게 됩니다.
⭐️ Evaluating Lab1View_State
init() InputFlagText
init() SetFlagInBodyText
⭐️ Evaluating InputFlagText
+++ 업데이트되지 않는 뷰를 강제로 업데이트
// body의 버튼 아래 다음 추가!
Group {
Text("SwiftUI.Text: \(flag ? "🅾️" : "❌")")
InputFlagText(flag: flag)
SetFlagInBodyText().id(UUID()) // New
}
이 서브 뷰를 강제로 업데이트하고 싶은 경우가 생길 수 있습니다.
Explicit Identity를 서브 뷰에 부여하면 dependency 변경 여부와 관계없이,
SwiftUI는 기존 뷰를 제거하고 아예 새로운 뷰를 생성하게 됩니다.
⭐️ Evaluating Lab1View_State
init() InputFlagText
init() SetFlagInBodyText
⭐️ Evaluating InputFlagText
⭐️ Evaluating SetFlagInBodyText
❌ Disappear HStack<Text>
5. init에서 값을 설정하지만, body에서 그 값을 전혀 사용하지 않는 서브 뷰
struct SetFlagInBodyText2: View {
let flag: Bool
init(flag: Bool) {
self.flag = flag // flag 값 주입
self.printWhenInit()
}
var body: some View {
let _ = printWhenEvaluating()
let flag = Bool.random() // 뷰에 그려지는 flag는 로컬 값 사용
Text("\(String(describing: Self.self)): \(flag ? "🅾️" : "❌")")
.printWhenViewDisappear()
}
}
// body의 버튼 아래 다음 추가!
Group {
Text("SwiftUI.Text: \(flag ? "🅾️" : "❌")")
InputFlagText(flag: flag)
SetFlagInBodyText2(flag: flag) // New
}
이전 실험과 같이 뷰에 사용되는 flag 값은 body에서 로컬 값으로 생성되지만,
init에서 서브 뷰의 Dependency가 변경됩니다.
따라서 서브뷰의 body에 진입하여 Evaluate 할 것을 예상할 수 있습니다.
⭐️ Evaluating Lab1View_State
init() InputFlagText
init() SetFlagInBodyText2
⭐️ Evaluating InputFlagText
⭐️ Evaluating SetFlagInBodyText2
그리고 서브 뷰의 body 내부의 로컬 flag 값이 변경되면 Text는 Redraw 됩니다.
위의 실험들을 진행하면서 강제로 id modifier를 부여한 경우를 제외하고는 뷰의 onDisappear가 호출되지 않았습니다.
View Value의 lifetime과 View의 lifetime은 다르기 때문입니다.
불필요한 View의 생성과 삭제를 막기 위해서는
ForEach와 같은 뷰에서 사용하는 id 값 (Explicit Idenitity)
또는 conditional statement의 사용(Structural Identity)에 더 유의해야 할 것 같습니다.
감사한 분들
https://developer.apple.com/videos/play/wwdc2021/10022/
https://eunjin3786.tistory.com/559
https://sujinnaljin.medium.com/swiftui-view%EB%A5%BC-redraw-%ED%95%98%EB%8A%94-%EC%A1%B0%EA%B1%B4%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%90%A0%EA%B9%8C-db3d7551df2
https://www.donnywals.com/understanding-how-and-when-swiftui-decides-to-redraw-views/
https://www.youtube.com/watch?v=P1UtnFeIB3I
'iOS > SwiftUI' 카테고리의 다른 글
SwiftUI - TabView 내부 컨텐츠의 OffsetX 구하기 (0) | 2024.08.20 |
---|---|
SwiftUI - GeometryReader, CoordinateSpace (0) | 2024.08.20 |