← SwiftUI 14일 마스터 10 / 14 · 1부

Day 10 · 1부 · NavigationStack과 경로·딥링크

push를 값의 배열(path)로 표현하고, 라우터 하나로 코드 이동·딥링크·상태 복원까지 다룬다.
진행률
0 / 0 완료
UIKit에서 화면 이동은 명령이다. pushViewController를 부르고, 딥링크가 오면 VC를 차례로 만들어 setViewControllers에 넣는다. SwiftUI에서 내비게이션 스택은 값의 배열(path)이다. 배열에 값을 넣으면 화면이 push되고, 빼면 pop된다. 오늘은 이 한 문장을 바탕으로 push·모달·검색·탭·딥링크를 모두 상태로 표현한다. 1부는 push 쪽이다. NavigationStack과 값 기반 링크, 코드로 path를 바꾸는 라우터, 딥링크까지 간다.

1부 목표 · 16분

  • NavigationView+NavigationLink(destination:) 코드를 NavigationStack+navigationDestination(for:)로 옮긴다.
  • lazy 컨테이너 안에 붙은 navigationDestination을 찾아 바깥으로 옮긴다.
  • path 타입([Value] · [Route] · NavigationPath)을 상황에 맞게 고르고, @Observable 라우터로 경로를 한 곳에 모은다.
  • 딥링크 URL을 받아 path 배열을 한 번에 바꿔 여러 화면을 즉시 띄운다.

1. NavigationStack — 경로는 데이터다 🔥 핵심

WWDC22 · The SwiftUI cookbook for navigation의 요점은 하나다. 예전 링크는 "보여줄 뷰"를 들고 있었고, 새 링크는 "보여줄 값"을 들고 있다. 값을 뷰로 바꾸는 규칙은 navigationDestination(for:)에 한 번만 적는다. 스택은 자기가 보여주는 값들을 path라는 컬렉션으로 추적한다.

UIKit (UINavigationController)SwiftUI (NavigationStack)
pushViewController(vc, animated:)NavigationLink(value:) 탭 또는 path.append(value)
popViewController(animated:)path.removeLast() 또는 dismiss()
popToRootViewController(animated:)path.removeAll()
setViewControllers([a, b, c], animated:)path = [a, b, c] — 딥링크·상태 복원
navigationItem.title.navigationTitle("…") (push되는 뷰 안에 붙인다)
VC가 다음 VC를 직접 생성링크는 값만 넘기고, 뷰 생성은 navigationDestination이 담당
path [ ] 애플파이 애플파이 파이 크러스트 루트: 목록 상세: 애플파이 상세: 파이 크러스트 append append 뒤로 버튼 = path.removeLast() · 루트로 = path = []
스택은 path의 각 값에 navigationDestination을 적용해 push할 화면을 정한다. 값 하나당 화면 하나다.
❌ 레거시 — NavigationView + 뷰를 들고 있는 링크 (Apple 문서상 deprecated)
import SwiftUI

struct Recipe: Identifiable, Hashable {
    let id: Int
    var name: String
}

struct LegacyRecipeList: View {
    let recipes: [Recipe]

    var body: some View {
        NavigationView {
            List(recipes) { recipe in
                NavigationLink(recipe.name) {
                    Text(recipe.name)            // 목적지 뷰를 링크마다 직접 만든다
                }
            }
            .navigationTitle("레시피")
        }
        .navigationViewStyle(.stack)
    }
}
✅ NavigationStack — 링크는 값, 목적지는 한 곳에서
import SwiftUI

struct Recipe: Identifiable, Hashable {
    let id: Int
    var name: String
}

struct RecipeDetail: View {
    let recipe: Recipe
    var body: some View {
        Text(recipe.name)
            .navigationTitle(recipe.name)        // 제목은 push되는 뷰가 단다
    }
}

struct RecipeList: View {
    let recipes: [Recipe]

    var body: some View {
        NavigationStack {
            List(recipes) { recipe in
                NavigationLink(recipe.name, value: recipe)
            }
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetail(recipe: recipe)
            }
            .navigationTitle("레시피")
        }
    }
}
📝 Apple 원문

"If your app has a minimum deployment target of iOS 16 … or later, transition away from using NavigationView." — Migrating to new navigation types

iOS 16 이상이 최소 타깃이면 NavigationView를 쓰지 말라는 뜻이다. 한 칸짜리는 NavigationStack, 여러 칸(아이패드 사이드바)은 NavigationSplitView로 옮긴다. isActive: 바인딩을 받는 옛 링크도 iOS 16부터 deprecated다(10054).

navigationDestination은 lazy 컨테이너 밖에

10054가 직접 경고하는 실수다. List·LazyVGrid 같은 lazy 컨테이너는 모든 뷰를 즉시 만들지 않는다. 그 안 행에 navigationDestination을 붙이면 스택이 그 선언을 못 볼 수 있고, 행 수만큼 반복 선언된다.

❌ 행마다 목적지 선언 — 화면 밖 행의 선언은 스택에 안 보일 수 있다
import SwiftUI

struct Recipe: Identifiable, Hashable {
    let id: Int
    var name: String
}

struct RecipeGrid: View {
    let recipes: [Recipe]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))]) {
                ForEach(recipes) { recipe in
                    NavigationLink(recipe.name, value: recipe)
                        .navigationDestination(for: Recipe.self) { Text($0.name) }
                }
            }
        }
    }
}
✅ 스크롤뷰 바깥에 한 번 — 스크롤 위치와 무관하게 스택이 본다
import SwiftUI

struct Recipe: Identifiable, Hashable {
    let id: Int
    var name: String
}

struct RecipeGrid: View {
    let recipes: [Recipe]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))]) {
                ForEach(recipes) { recipe in
                    NavigationLink(recipe.name, value: recipe)
                }
            }
        }
        .navigationDestination(for: Recipe.self) { Text($0.name) }
    }
}
🔑 목적지는 스택 안 어디든

navigationDestination은 스택 안이나 push된 뷰 안 어디에 있어도 된다. 링크 가까이 두면 관리가 쉽다. 단, lazy 컨테이너 안쪽만 피한다(10054).

2. 프로그래매틱 이동 · NavigationPath · 딥링크 🔥 핵심

path를 @State로 들고 NavigationStack(path:)에 Binding으로 넘기면 코드로 스택을 조작한다. Apple NavigationStack 문서: 스택은 view를 추가할 때 컬렉션에 값을 넣고, 뺄 때 제거한다. 코드가 배열을 바꾸면 화면이 따라 바뀐다.

path 타입언제특징
[Recipe]한 가지 타입만 push가장 단순. 배열 API 그대로
[Route] (Hashable enum)여러 화면 종류, 앱이 목록을 앎switch 한 곳에서 모든 목적지 처리. 추천 기본값
NavigationPath서로 다른 타입을 섞어 push, 모듈마다 타입이 따로타입 소거. .codable로 저장·복원

@Observable 라우터 — 경로를 한 곳에

10054는 내비게이션 상태를 모델 하나에 모으라고 권한다. 저장·복원을 한 단위로 할 수 있어 항상 일관되기 때문이다. iOS 17에서는 이 모델을 @Observable로 만들고 환경으로 내려보낸다(Day 5). 자식 화면은 라우터를 꺼내 push·pop한다.

✅ Route enum + @Observable Router + onOpenURL 딥링크
import SwiftUI

enum Route: Hashable {
    case category(String)
    case recipe(Int)
}

@Observable final class Router {
    var path: [Route] = []

    func popToRoot() { path.removeAll() }

    /// cookbook://recipe/42?category=dessert
    func handle(_ url: URL) {
        guard url.scheme == "cookbook", url.host() == "recipe",
              let id = Int(url.lastPathComponent) else { return }
        let category = URLComponents(url: url, resolvingAgainstBaseURL: false)?
            .queryItems?.first { $0.name == "category" }?.value ?? "all"
        path = [.category(category), .recipe(id)]     // 두 화면을 한 번에 push
    }
}

struct CookbookRoot: View {
    @State private var router = Router()

    var body: some View {
        NavigationStack(path: $router.path) {
            List(["dessert", "soup"], id: \.self) { name in
                NavigationLink(name, value: Route.category(name))
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .category(let name): CategoryScreen(name: name)
                case .recipe(let id): Text("레시피 #\(id)")
                }
            }
        }
        .environment(router)
        .onOpenURL { url in router.handle(url) }
    }
}

struct CategoryScreen: View {
    let name: String
    @Environment(Router.self) private var router

    var body: some View {
        VStack {
            Button("오늘의 레시피") { router.path.append(.recipe(7)) }
            Button("처음으로") { router.popToRoot() }
        }
        .navigationTitle(name)
    }
}
URL onOpenURL router.handle path = [.cat, .recipe] NavigationStack cookbook:// recipe/42 URL → Route 값 루트 + 2화면 즉시 표시 .environment(router) → 자식 화면도 router.path.append(…) / popToRoot()
딥링크는 "화면을 차례로 띄우는 코드"가 아니라 "path 배열에 값 넣기"가 된다. UIKit의 setViewControllers와 같은 일을 대입 한 줄로 한다.
🔬 더 깊이 — 옵셔널 하나로 push iOS 17

path 없이 "값이 있으면 push"만 필요하면 navigationDestination(item:destination:)(iOS 17.0+)이 있다. sheet(item:)의 push 버전이다. Bool로는 navigationDestination(isPresented:)(iOS 16.0+).

iOS 17 — 선택된 값이 생기면 push, nil이 되면 pop
import SwiftUI

struct PickAndPush: View {
    @State private var selectedID: Int?

    var body: some View {
        NavigationStack {
            List(1...5, id: \.self) { n in
                Button("항목 \(n)") { selectedID = n }
            }
            .navigationDestination(item: $selectedID) { id in
                Text("항목 \(id) 상세")
            }
        }
    }
}
🔬 상태 복원 — NavigationPath.codable + SceneStorage

path의 값이 Codable이면 NavigationPath.codable로 직렬화된다. 이것을 JSONEncoder로 Data로 만들어 @SceneStorage("nav.path")에 넣으면, 시스템이 씬을 복원할 때 값도 돌아온다. 복원은 NavigationPath(decodedRepresentation)로 한다(10054).

⚠️ 10054는 복원용 상태에 Recipe 전체가 아니라 id만 넣으라고 한다. 모델 전체는 DB 내용을 중복 저장하고, 동기화로 데이터가 바뀌면 낡는다. 복원할 때 compactMap으로 없어진 항목은 버린다. @SceneStorage 자체는 Day 13에서 다룬다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
NavigationStack에서 "루트로 돌아가기"와 "딥링크로 3단계 화면 띄우기"는 각각 코드 한 줄로 무엇인가?
루트로: path.removeAll()(또는 path = []). 딥링크: path = [.category("dessert"), .recipe(42)]처럼 배열을 통째로 대입한다. 스택은 path의 값 하나당 화면 하나를 쌓는다.
쉽게 말하면 책갈피 목록을 지우면 첫 페이지로, 목록을 새로 적으면 그 페이지들이 한꺼번에 펼쳐진다.
LazyVGrid의 각 행에 navigationDestination(for:)를 붙이면 왜 문제인가?
lazy 컨테이너는 화면에 보이는 행만 만든다. 아직 만들어지지 않은 행의 선언은 스택이 못 볼 수 있고, 만들어진 행 수만큼 같은 선언이 반복된다(10054). 목적지 선언은 스크롤뷰 바깥에 한 번 둔다.
쉽게 말하면 "택배는 3층으로" 안내문을 아직 안 지은 방마다 붙이면 아무도 못 본다. 건물 입구에 한 장 붙인다.
🔮 코드 예측: 2절 Router에서 router.handle(URL(string: "cookbook://recipe/42")!)를 부르면 path는? 그리고 "cookbook://recipe/abc"면?
첫 번째는 category 쿼리가 없으니 기본값 "all"이 쓰여 path = [.category("all"), .recipe(42)]가 된다. 루트 위에 두 화면이 즉시 쌓인다. 두 번째는 Int("abc")가 nil이라 guard에서 빠져나가고 path는 그대로다.
쉽게 말하면 주소가 맞으면 그 방까지 한 번에 데려다주고, 방 번호가 이상하면 아무 데도 안 간다.
🔁 지난 Day 복습 (Day 9): List 안의 ForEach에서 if item.isVisible { Row(item) }가 느린 이유는?
요소당 뷰 개수가 0 또는 1로 달라진다. List는 모든 행의 ID를 미리 모으는데, 개수가 일정하지 않으면 개수를 알려고 모든 행의 뷰를 실제로 만들어야 한다. 필터는 모델에서 미리 해 둔다.
쉽게 말하면 상자마다 사과가 1개인지 0개인지 모르면 전부 열어 봐야 센다.
➡️ 2부 예고

2부는 "잠깐 옆길"인 모달(sheet(item:)·fullScreenCover·alert·confirmationDialog)과 dismiss, 그리고 searchable·탭별 스택·흔한 실수를 다룬다.

출처 · 참고자료