NavigationStack의 push/pop 트랜지션에 대한 고찰

Doldamul·2023년 8월 31일
1
post-thumbnail
post-custom-banner

썸네일만 보시고도 벌써 문제점을 파악했다면... 눈썰미가 좋으시군요 😆

문제점

SwiftUI에서는 List, NavigationStack와 같이 여러 시스템 동작들이 임베드된 View를 제공한다. 그럼 그 View들에 적절한 인자들을 집어넣었다면, 알아서 잘 동작하지 않을까?

놀랍게도 NavigationStack에 그렇지 않은 동작이 있었다. 다음 예제를 보자.
(이후 등장할 예제를 위해 DetailViewBinding이 전달되게끔 하느라 조금 복잡해졌지만, 크게 중요한 건 아니다)

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 시점에 값을 변형시킨다.

예를 들자면 다음과 같다. 다음 예제에서는 DetailViewonAppear될 때 value값을 변형시켜 ContentViewbody가 재평가되도록 유도하고 있다.

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는 기본적으로 Listselection 인자를 필요로 한다. 위 예제가 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 트랜지션이 잘 작동하는 것을 확인할 수 있었다. Listselection 인자는 Binding을 받기 때문이다. 하지만 NavigationStacknavigationDestination modifier를 필수로 요구하므로, Listselection 인자를 사용할 수는 없는 노릇이다.
(또한 Apple에서는 Listselection 인자와 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에 표시하고 싶어했던 특수한 경우가 아니라면, 위 방법을 쓸일은 없을 것이다.

적용 방법

위에서 제시한 해결 방법을 그대로 쓸 수는 없다. 당연히 약간의(?) 꼼수가 필요하다. 결론만 얘기하자면 다음과 같이 정리된다.

DetailViewOnAppear에서 상위 View로부터 받은 dependency 값을 변경한다.
Dependency의 종류에 따른 변경 방법:

  • constant value : 꼼수 불가능
  • value-semantic StateBinding : 값을 실제로 변형시켰다가, 기존 상태로 복구시킨다.
  • ObservedObject : 값을 변형시키지 않는 쓰기 연산, 또는 Parent view가 가진 ObservableObject 객체의 objectWillChange.send() 호출
  • @Observable 매크로 객체 : 값을 변형시키지 않는 쓰기 연산

첨언하자면, value-semantic State는 변경된 값이 이전과 다른지 체크하지만, ObservableObject 객체 및 @Observable 매크로 객체는 체크하지 않는다.

하나씩 간단한 사용 예시를 확인해보자.

value-semantic State의 Binding

@Binding var value: String
    
var body: some View {
    Text("detail view")
        .onAppear {
            value.append(" ")
            value.removeLast()
        }
}

ObservedObject

두 가지 해결 방법이 있다.

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()
            }
    }
}

@Observable 매크로 객체

여기에서는 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 코드들을 프로젝트 형태로 깃허브에 업로드해놓았으니, 혹시 관심이 있다면 아래 참고자료를 확인하자.

참고자료

profile
덕질은 삶의 활력소다. 내가 애플을 좋아하는 이유. 재밌거덩
post-custom-banner

0개의 댓글