썸네일만 보시고도 벌써 문제점을 파악했다면... 눈썰미가 좋으시군요 😆
SwiftUI에서는 List
, NavigationStack
와 같이 여러 시스템 동작들이 임베드된 View를 제공한다. 그럼 그 View들에 적절한 인자들을 집어넣었다면, 알아서 잘 동작하지 않을까?
놀랍게도 NavigationStack
에 그렇지 않은 동작이 있었다. 다음 예제를 보자.
(이후 등장할 예제를 위해 DetailView
에 Binding
이 전달되게끔 하느라 조금 복잡해졌지만, 크게 중요한 건 아니다)
struct ContentView: View {
@State var values: [String] = ...
var body: some View {
NavigationStack {
List(values.indices, id: \.self) { index in
NavigationLink(value: index) {
Text(values[index])
}
}
.navigationTitle("Navigation")
.navigationDestination(for: Int.self) { index in
DetailView(value: $values[index])
}
}
}
}
struct DetailView: View {
@Binding var value: String
var body: some View {
Text("detail view")
}
}
내가 기대한 건 다음 동작이었다.
하지만 현실은 다음과 같았다. 위와 비교해보며 무엇이 다른지 살펴보자.
차이를 알겠는가? push/pop 트랜지션 동안, 선택된 NavigationLink
UI에 시각적 피드백이 나타나지 않는다. 구글링해봐도 이 주제로 논의된 글 자체를 찾을 수 없었다. 애초에 Navigation은 List
또는 NavigationLink
를 사용하지 않고 구현하는 경우가 많은 것 같기도 하고, 나름 사소하다면 사소한 문제이다. 아무튼 나에게 있어서는 너무 거슬렸다보니 해결하기 위해 몇 가지 실험을 했고, 나름대로 결론을 내릴 수 있었다.
글을 길게 쓰기 귀찮기 때문에, 결론 도출 과정은 건너뛰고 해결 방법만 설명하겠다.
NavigationStack
/NavigationSplitView
내에서 destination view와의 push/pop 트랜지션이 정상 작동하기 위해서는 다음 조건을 만족해야 한다 :
- 선택된 버튼이
List
내의NavigationLink
이어야 한다. (이건 뭐 당연하고)- push 시점에 해당 destination의 parent view의
body
가 재평가되어야 한다.
두번째 조건의 달성방법을 구체화하면 다음과 같다.
- 상위 View에서 특정 Item의 값을 UI에 표시하고 있다.
- 상위 View로부터 해당 값의 two-way Binding을 받아 destination View의
onAppear
시점에 값을 변형시킨다.
예를 들자면 다음과 같다. 다음 예제에서는 DetailView
가 onAppear
될 때 value
값을 변형시켜 ContentView
의 body
가 재평가되도록 유도하고 있다.
struct ContentView: View {
// 앞 내용과 동일...
}
struct DetailView: View {
@Binding var value: String
var body: some View {
Text("detail view")
.onAppear {
// value 값을 변형시킨다. ex) value.append("a")
}
}
}
달라진 부분은 onAppear
modifier뿐이지만 문제가 해결된다. 왜 이런 방법이 먹히는 것일까? 구글링을 해봐도 이 문제에 대해 알고있는 개발자가 없는 것 같기에 추측하자면, selection
인자가 SwiftUI 관점에서 일종의 '상수'로 보관되어 있는 것이 아닌가 싶다. 분명 내부적으로는 selection
값을 보관하고 있다가 push/pop 때마다 해당 값을 변경시킬 텐데, 위 해결 방법이 먹히는 까닭은 push될 때부터 pop될 때까지 해당 parent View가 최신 selection
이 반영되지 않은 기존 View를 참조하여 트랜지션을 수행했었기 때문이라고밖에 생각되지 않는다.
이 생각을 뒷받침하는 것이 바로 NavigationSplitView
의 사용 예시다. NavigationSplitView
는 기본적으로 List
의 selection
인자를 필요로 한다. 위 예제가 NavigationSplitView
를 사용하도록 변형해보자.
struct ContentView: View {
@State var values: [String] = Array(1...5).map { String($0) }
@State var selectedIndex: Int?
var body: some View {
NavigationSplitView {
List(values.indices, id: \.self, selection: $selectedIndex) { index in
NavigationLink(value: values[index]) {
Text(values[index])
}
}
.listStyle(.inset)
.navigationTitle("Navigation")
} detail: {
if let selectedIndex {
DetailView(value: $values[selectedIndex])
} else {
Text("Empty")
}
}
}
}
struct DetailView: View {
@Binding var value: String
var body: some View {
Text("detail view")
}
}
NavigationSplitView
는 iOS의 세로모드처럼 화면의 가로폭이 좁은 환경에서는 자동으로 NavigationStack
모드로 동작하는데, 실행해본 결과 onAppear
modifier 없이도 push/pop 트랜지션이 잘 작동하는 것을 확인할 수 있었다. List
의 selection
인자는 Binding
을 받기 때문이다. 하지만 NavigationStack
은 navigationDestination
modifier를 필수로 요구하므로, List
의 selection
인자를 사용할 수는 없는 노릇이다.
(또한 Apple에서는 List
의 selection
인자와 navigationDestination
modifier를 동시에 사용하는 것을 피하라고 권고했다)
그렇다면 path
인자를 사용하는 것은 어떨까? path
인자도 Binding
을 받는다.
NavigationStack(path: $myPath) { ... }
안타깝게도 위 방법만으로는 문제를 해결하지 못한다. List
UI에 영향을 줄 수 있는 selection
인자와는 다르게, path
인자는 root View의 UI에 영향을 주지 않기 때문(=List
와 상호작용하지 않음)이다. 보다 명확히 말하자면, 다음과 같이 root View 어딘가에 해당 path
를 UI의 일부로 노출해줘야만 한다.
NavigationStack(path: $myPath) {
...
Text(path.description)
}
처음부터 path
를 UI에 표시하고 싶어했던 특수한 경우가 아니라면, 위 방법을 쓸일은 없을 것이다.
위에서 제시한 해결 방법을 그대로 쓸 수는 없다. 당연히 약간의(?) 꼼수가 필요하다. 결론만 얘기하자면 다음과 같이 정리된다.
DetailView
의 OnAppear
에서 상위 View로부터 받은 dependency 값을 변경한다.
Dependency의 종류에 따른 변경 방법:
State
의 Binding
: 값을 실제로 변형시켰다가, 기존 상태로 복구시킨다.ObservedObject
: 값을 변형시키지 않는 쓰기 연산, 또는 Parent view가 가진 ObservableObject
객체의 objectWillChange.send()
호출@Observable
매크로 객체 : 값을 변형시키지 않는 쓰기 연산첨언하자면, value-semantic State
는 변경된 값이 이전과 다른지 체크하지만, ObservableObject
객체 및 @Observable
매크로 객체는 체크하지 않는다.
하나씩 간단한 사용 예시를 확인해보자.
@Binding var value: String
var body: some View {
Text("detail view")
.onAppear {
value.append(" ")
value.removeLast()
}
}
두 가지 해결 방법이 있다.
class MyObject: ObservableObject {
var value: String = ""
}
extension MyObject: Identifiable, Hashable { ... }
class MyObjectArray: ObservableObject {
@Published var array: [MyObject] = [...]
}
struct ContentView: View {
@StateObject var objects = MyObjectArray()
var body: some View {
NavigationStack {
List(objects.array) { object in
NavigationLink(...) { ... }
// 각 객체의 변경사항 알림을 부모 객체에 전파한다.
.onReceive(object.objectWillChange) {
objects.objectWillChange.send()
}
}
}
}
}
struct DetailView: View {
@ObservedObject var object: MyObject
var body: some View {
Text("detail view")
.onAppear {
// 둘중 어느쪽을 골라도 무방하다.
// object.value += ""
object.objectWillChange.send()
}
}
}
여기에서는 Detail View에서 @Bindable
을 사용했지만, 사실 각 필드의 Binding
을 받을 수 있다는 것 이외에는 일반 프로퍼티와 사용성에 차이가 없다. 따라서 꼭 @Bindable
을 명시할 필요는 없다.
@Observable class MyObject: Identifiable, Hashable { ... }
struct ObservableDetailView: View {
@Bindable var object: MyObject
var body: some View {
Text("detail view")
.onAppear {
object.value += ""
}
}
}
참고로 위의 데이터 종류별 View 코드들을 프로젝트 형태로 깃허브에 업로드해놓았으니, 혹시 관심이 있다면 아래 참고자료를 확인하자.