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이 담당 |
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)
}
}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("레시피")
}
}
}"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한다.
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)
}
}path 없이 "값이 있으면 push"만 필요하면 navigationDestination(item:destination:)(iOS 17.0+)이 있다. sheet(item:)의 push 버전이다. Bool로는 navigationDestination(isPresented:)(iOS 16.0+).
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) 상세")
}
}
}
}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:)를 붙이면 왜 문제인가?
🔮 코드 예측: 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) }가 느린 이유는?
2부는 "잠깐 옆길"인 모달(sheet(item:)·fullScreenCover·alert·confirmationDialog)과 dismiss, 그리고 searchable·탭별 스택·흔한 실수를 다룬다.