SwiftUI NavigationStack은 무엇이 달라졌을까

SwiftUI를 사용하면서 한번씩 네비게이션 플로우 구현이 UIKit에서 사용했을때보다 다소 불편하고 적용이 어렵다고 느껴지는 경우가 있었다. 이 글을 정리하면서 어떤 방식으로 SwiftUI에서 네비게이션을 적용하면 좋을지 한번 알아보도록 하자

 

기존 Navigation 방식

이전 방식에서 SwiftUI는 NavigationView, NavigationLink를 사용해 네비게이션을 통한 뷰 이동을 구현했다. 하지만 이 방식에는 몇가지 문제가 있다고 느껴졌는데

 

먼저 NavigationView 내부에 내장된 네비게이션 상태는 암묵적인 상태로 관리되었기 때문에 프로그램적으로 뒤로가기 , 특정화면으로 이동 등 과 같은 제어가 다소 어려운 측면이 있었다.

 

예를 들어 동적 컨텐츠를 다루거나 딥링크로 특정 화면으로 이동하려고 할 때 명시적인 상태 조작이 어려워 원하는 흐름을 구현하기 복잡했다.

 

두번째로 여러단계의 화면 전환이나 동적으로 추가되는 화면에 대해 내부 로직이 불명확해지기 쉬운 상태였다. 리스트 항목을 눌러 여러 상세 화면으로 이동할 때 화면 간 전환 경로를 추적하고 관리하기가 어려운 측면이 존재했다.

 

마지막 세번째로 NavigationLink를 사용할 때 각 링크에 어떤 데이터가 연결되어 있는지 명확하게 관리하지 않으면, 나중에 잘못된 화면 전환이나 오류가 발생할 가능성이 있었다.

 

NavigationStack 등장 이유와 개선점

https://developer.apple.com/documentation/swiftui/navigationstack/

이러한 문제를 해결하기 위해 iOS 16부터 NavigationStack이라는 것이 등장하게 되었는데 이 스택이 이전 문제점을 완벽하게 해결했다고는 할 순 없지만 그래도 이전 문제점을 어느정도 상쇄시킬 수 있는 역할을 할 수 있게 된 것 같다.

 

NavigationStack은 NavigationPath라는 상태 변수를 사용해 현재 스택(화면 전환 경로)을 명시적으로 관리한다. 프로그램적으로 화면을 추가하거나 제거할 수 있어 딥링크나 네비게이션 로직 구현이 조금 쉬워졌다.

 

NavigationStack은 스택에 저장하는 각 요소에 대해 타입을 명시할 수 있어 enum이나 struct를 사용해 어떤 화면이 언제 나타나야 하는지 정의할 수 있다. 화면 전환의 오류를 방지하고 컴파일 타임에 오류를 잡을 수 있어 안정성이 높아지는 측면이 있다.

 

또한, 버튼 클릭이나 특정 이벤트에 따라 navigationPath 배열을 조작하면 네이게이션 흐름을 코드로 직접 제어할 수 있다는게 큰 특징이다. 사용자 액션 외에도 다양한 조건( 로그인 후 특정 화면으로 이동 등 )에서 네비게이션을 쉽게 구현할 수 있게 된다.

 

NavigationStack의 내부 동작 방식과 로직

NavigationStack은 전통적인 스택처럼 마지막에 들어간 것이 가장 먼저 나오게 작동된다.

상태변수인 NavigationPath를 활용하는 방식인데 예를들어

@State private var navigationPath = NavigationPath()

이와 같이 선언된 변수는 현재 네비게이션 스택의 상태를 나타낸다. 이 변수는 배열처럼 동작하며, 각 요소는 Hashble 프로토콜을 준수해야한다.

 

스택에 새로운 요소를 추가하면(예: navigationPath.append(Screen.detail)), SwiftUI는 해당 요소에 대응하는 화면을 생성하게 되고 스택에서 해당 요소를 제거하면 화면이 사라지게 되는 방식이다.

 

navigationDestination(for: Screen.self) 라는 클로저를 통해 스택에 들어있는 각 요소가 어떤 뷰와 연결되어 있는지 정의한다. SwiftUI는 스택에 있는 각 타입별 값을 확인하고, 매핑된 뷰를 자동으로 렌더링한다.

 

예시

// 네비게이션 경로에 넣을 데이터 타입 (타입 안전성을 위해 enum 사용)
enum Screen: Hashable {
    case detail
    case second
}

struct ContentView: View {
    // NavigationPath를 이용해 현재 네비게이션 상태를 관리
    @State private var navigationPath = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $navigationPath) {
            VStack(spacing: 20) {
                // NavigationLink를 통한 기본 네비게이션
                NavigationLink("Go to Detail", value: Screen.detail)
                NavigationLink("Go to Second", value: Screen.second)
                
                // 버튼을 통해 프로그램적으로 네비게이션 스택에 값을 추가
                Button("Programmatically Navigate to Detail") {
                    navigationPath.append(Screen.detail)
                }
            }
            .navigationTitle("Home")
            // 스택에 있는 요소에 따라 어떤 화면을 보여줄지 정의
            .navigationDestination(for: Screen.self) { screen in
                switch screen {
                case .detail:
                    DetailView()
                case .second:
                    SecondView()
                }
            }
        }
    }
}
struct DetailView: View {
    var body: some View {
        VStack {
            Text("Detail View")
                .font(.largeTitle)
        }
        .navigationTitle("Detail")
    }
}

struct SecondView: View {
    var body: some View {
        VStack {
            Text("Second View")
                .font(.largeTitle)
        }
        .navigationTitle("Second")
    }
}

NavigationStack은 전체 네비게이션 계층을 감싸고 $navigationPath 바인딩을 통해 현재 스택 상태를 관리한다. navigationPath는 스택처럼 동작하기 때문에 요소를 추가하면 새로운 화면이 푸시되고, 제거하면 뒤로 가게 되는 형식이다.

 

Screen이라는 enum을 사용해 각 화면을 타입 안전한 상태로 구분하고 navigationPath.append(Screen.detail)을 호출해 코드로 직접 네비게이션 스택을 변경한다.

 

이로 인해 사용자 인터랙션 뿐만 아니라 다양한 이벤트에 따라 네비게이션 흐름을 제어할 수 있다.

 


기존의 NavigationView와 NavigationLink 방식은 간단하게 사용할 순 있었지만 네비게이션 상태의 명시적 관리, 동적 경로 처리 등 제어가 어려운 측면이 있었다. NavigationStack은 이러한 문제점을 해결할 수 있었고 명시적인 NavigationPath를 통해 상태를 관리하고, 타입 안전한 데이터 기반의 전환 로직을 제공하면서 복잡한 네비게이션 흐름을 보다 쉽게 구현할 수 있도록 개선된 것 같다.

 

오늘은 이렇게 네비게이션에 대해서 알아봤는데 이 내용을 토대로 실제 지금 하고 있는 방식에 적용해봐야겠다 🙂