[iOS/SwiftUI] NavigationStack 공식문서 뜯어보기
NavigationStack | Apple Developer Documentation
A view that displays a root view and enables you to present additional views over the root view.
developer.apple.com
NavigationStack 이란?
RootView를 표시하고 RootView 위에 추가 뷰를 표시할수 있는 뷰.
iOS 16.0 이상에서 동작한다.
@MainActor @preconcurrency
struct NavigationStack<Data, Root> where Root : View
공식문서상에 적힌 개요를 확인해보자.
1. 기본 네비게이션 스택
NavigationStack을 사용하여 뷰의 스택을 루트 뷰 위에 표시한다.
사용자는 NavigationLink를 클릭하거나 탭하여 스택의 상단에 뷰를 추가할 수 있고 뒤로 가기 버튼이나 스와이프 제스처를 사용하여 뷰를 제거할 수 있다.
스택은 항상 최근에 추가된 뷰를 표시하며, 루트 뷰는 제거할 수 없다. ( RootView는 제거할 수 없구나.. )
예를 들어, ParkDetails 뷰를 Park 데이터 타입에 연결하여 네비게이션 링크를 만드는 방법은 다음과 같다.
NavigationStack { List(parks) { park in
NavigationLink(park.name, value: park)
}
.navigationDestination(for: Park.self) { park in
ParkDetails(park: park) }
}
- List는 루트 뷰로 항상 표시됩니다.
- 리스트에서 네비게이션 링크를 선택하면 ParkDetails 뷰가 스택에 추가되어 리스트 위에 표시됩니다.
- 뒤로 가기 버튼을 클릭하면 상세 뷰가 제거되고 리스트가 다시 보인다.
- 스택이 비어 있을 때는 루트 뷰만 보이므로, 뒤로 가기 버튼은 비활성화된다.
- ( 그럼 앱에서 로그인화면과 로그인 후의 홈화면은 둘다 뒤로가기가 보이면 안되는 데 어떻게 해야할까 )
2. 네비게이션 상태 관리
기본적으로 NavigationStack은 스택의 상태를 관리한다.
그러나 코드에서 상태를 공유하고 직접 관리할 수 있다.
예를 들어, Park 상세 뷰의 네비게이션 상태를 관리하기 위해 상태 속성을 만들 수 있다.
@State private var presentedParks: [Park] = []
- 상태를 빈 배열로 초기화하면 스택에는 아무 뷰도 표시되지 않습니다.
- NavigationStack을 path와 root 초기화 방법으로 생성할 때 이 상태 속성을 바인딩으로 제공할 수 있습니다
- ( RootView 를 초기값으로 지정한다는 것 같다 )
NavigationStack(path: $presentedParks) {
List(parks) { park in
NavigationLink(park.name, value: park)
}
.navigationDestination(for: Park.self) { park in
ParkDetails(park: park)
}
}
- 사용자가 네비게이션 링크를 클릭하면 ParkDetails 뷰가 스택에 추가되고, presentedParks 배열에 데이터가 추가됩니다.
- presentedParks 배열을 관찰하여 현재 스택 상태를 읽을 수 있으며, 배열을 수정하여 스택의 뷰를 변경할 수 있습니다.
예를 들어, 특정 Park들을 스택에 표시하려면 다음과 같이 메서드를 정의할 수 있다.
- showParks 메서드는 스택의 표시를 Sequoia로 바꾸며, 이 뷰에서 뒤로 가기 버튼을 클릭하면 Yosemite가 표시됩니다.
func showParks() {
presentedParks = [Park("Yosemite"), Park("Sequoia")]
}
3. 다양한 뷰 타입에 대한 네비게이션
하나의 스택에서 여러 종류의 뷰를 표시하려면, 각 데이터 타입에 대해 navigationDestination(for:destination:) 수식어를 여러 번 추가할 수 있습니다.
스택은 네비게이션 링크를 데이터 타입에 따라 매칭하고, 여러 데이터 타입을 포함하는 스택을 만들려면 NavigationPath 인스턴스를 사용하여 경로를 정의할 수 있다.
- 다양한 데이터 타입의 뷰를 스택에 추가하고,
- 프로그램matically navigation (프로그램적으로 네비게이션)이나 상태 복원 등을 지원할 수 있다.
이 얘기만으로는 사용방법을 확실히 알지는 못하겠다.
아래의 영상을 보면서 따라해보자.
The SwiftUI cookbook for navigation - WWDC22 - Videos - Apple Developer
The recipe for a great app begins with a clear and robust navigation structure. Join the SwiftUI team in our proverbial coding kitchen...
developer.apple.com
영상에서의 개요
- 탐색을 프로그래밍 방식으로 제어할 수 있다.
- 루트밖에 네비게이션 링크 목록이 있을때 링크가 스택에 화면을 push하는 패턴을 계속 사용할 수 있다. ( NavigationLink 를 말하는 듯
NavigationLink
각각에 바인딩이 필요한 방식.
NavigationLink(
"Details",
isActive: $item.showDetail
){
DetailView()
}
NavigationStack
전체 컨테이너로 바인딩을 시킨다
NavigationStack(path:$path){
NavigationLink("Details", value: value)
}
path는 경로의 모든 값을 나타내는 컬렉션이다.
경로에 값을 추가하고 경로를 변환하여 심층 링크를 만들거나 경로에서 모든 항목을 제거해서 루트뷰로 이동할 수 있다.
NavigationSplitView
Mail 또는 Notes와 같은 다중 열 어플리케이션에 적합
NavigationSplitView{
RecipeCategories()
} detail : {
RecipeGrid()
}
Apple 기기 모든 열 스택에 자동으로 적응된다고 한다.
예시
간단하게 어플을 만들때는 유용할 것 같다. 하지만 커스텀을 하거나 디자이너분들이 선호하지 않는 디자인이기에 세부 구성 옵션은 넘기도록하자.
기존 방식을 NavigationStack에 적용해보자
NavigationStack(path: $path) {
List(Category.allCases) { category in
Section(category.localizedName) {
ForEach(dataModel.recipes(in: category)) { recipe in
RecipeDetail(recipe: recipe)
}
}
}
프로그래밍적으로 교체가 불가능 해진다.
그럼 이제 바꾸어보자
NavigationStack(path: $path) {
List(Category.allCases) { category in
Section(category.localizedName) {
ForEach(dataModel.recipes(in: category)) { recipe in
NavigationLink(recipe.name, value: recipe)
}
}
}
.navigationTitle("Categories")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
목적지 뷰를 Destination으로 꺼내고
for: Recipe.self는 해당 수정자가 담당하는 제공된 데이터 유형을 선언한다.
그리고 해당 데이터 유형을 받아서 사용하는 뷰 빌더를 아래에 선언해준다.
그리고 NaviagtionLink(recipe.name, value: recipe) 로 전환하여 값만 받는다.
Path
모든 네비게이션 스택은 스택이 표시하는 모든 데이터를 나타내는 경로를 추적한다.
스택은 스택 내부 또는 스택에 푸시된 뷰 내부에서 선언된 모든 네비게이션을 추적한다.
대상을 매핑해 스택에서 푸시할 뷰를 결정한다.
경로에 추가하는 값에 따라 NavigationStack은 다른 화면을 push한다.
import SwiftUI
// Pushable stack
struct PushableStack: View {
@State private var path: [Recipe] = []
@StateObject private var dataModel = DataModel()
var body: some View {
NavigationStack(path: $path) {
List(Category.allCases) { category in
Section(category.localizedName) {
ForEach(dataModel.recipes(in: category)) { recipe in
NavigationLink(recipe.name, value: recipe)
}
}
}
.navigationTitle("Categories")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
.environmentObject(dataModel)
}
}
NavigationStack path에 모든 경로를 담을 path를 바인딩 해주고
ForEach문과 nagiationDestination 값을 이용해 여러 링크를 만들어주는 모습이다.
추가코드
// Helpers for code example
struct RecipeDetail: View {
@EnvironmentObject private var dataModel: DataModel
var recipe: Recipe
var body: some View {
Text("Recipe details go here")
.navigationTitle(recipe.name)
ForEach(recipe.related.compactMap { dataModel[$0] }) { related in
NavigationLink(related.name, value: related)
}
}
}
class DataModel: ObservableObject {
@Published var recipes: [Recipe] = builtInRecipes
func recipes(in category: Category?) -> [Recipe] {
recipes
.filter { $0.category == category }
.sorted { $0.name < $1.name }
}
subscript(recipeId: Recipe.ID) -> Recipe? {
// A real app would want to maintain an index from identifiers to
// recipes.
recipes.first { recipe in
recipe.id == recipeId
}
}
}
enum Category: Int, Hashable, CaseIterable, Identifiable, Codable {
case dessert
case pancake
case salad
case sandwich
var id: Int { rawValue }
var localizedName: LocalizedStringKey {
switch self {
case .dessert:
return "Dessert"
case .pancake:
return "Pancake"
case .salad:
return "Salad"
case .sandwich:
return "Sandwich"
}
}
}
struct Recipe: Hashable, Identifiable {
let id = UUID()
var name: String
var category: Category
var ingredients: [Ingredient]
var related: [Recipe.ID] = []
var imageName: String? = nil
}
struct Ingredient: Hashable, Identifiable {
let id = UUID()
var description: String
static func fromLines(_ lines: String) -> [Ingredient] {
lines.split(separator: "\n", omittingEmptySubsequences: true)
.map { Ingredient(description: String($0)) }
}
}
let builtInRecipes: [Recipe] = {
var recipes = [
"Apple Pie": Recipe(
name: "Apple Pie", category: .dessert,
ingredients: Ingredient.fromLines(applePie)),
"Baklava": Recipe(
name: "Baklava", category: .dessert,
ingredients: []),
"Bolo de Rolo": Recipe(
name: "Bolo de rolo", category: .dessert,
ingredients: []),
"Chocolate Crackles": Recipe(
name: "Chocolate crackles", category: .dessert,
ingredients: []),
"Crème Brûlée": Recipe(
name: "Crème brûlée", category: .dessert,
ingredients: []),
"Fruit Pie Filling": Recipe(
name: "Fruit Pie Filling", category: .dessert,
ingredients: []),
"Kanom Thong Ek": Recipe(
name: "Kanom Thong Ek", category: .dessert,
ingredients: []),
"Mochi": Recipe(
name: "Mochi", category: .dessert,
ingredients: []),
"Marzipan": Recipe(
name: "Marzipan", category: .dessert,
ingredients: []),
"Pie Crust": Recipe(
name: "Pie Crust", category: .dessert,
ingredients: Ingredient.fromLines(pieCrust)),
"Shortbread Biscuits": Recipe(
name: "Shortbread Biscuits", category: .dessert,
ingredients: []),
"Tiramisu": Recipe(
name: "Tiramisu", category: .dessert,
ingredients: []),
"Crêpe": Recipe(
name: "Crêpe", category: .pancake, ingredients: []),
"Jianbing": Recipe(
name: "Jianbing", category: .pancake, ingredients: []),
"American": Recipe(
name: "American", category: .pancake, ingredients: []),
"Dosa": Recipe(
name: "Dosa", category: .pancake, ingredients: []),
"Injera": Recipe(
name: "Injera", category: .pancake, ingredients: []),
"Acar": Recipe(
name: "Acar", category: .salad, ingredients: []),
"Ambrosia": Recipe(
name: "Ambrosia", category: .salad, ingredients: []),
"Bok l'hong": Recipe(
name: "Bok l'hong", category: .salad, ingredients: []),
"Caprese": Recipe(
name: "Caprese", category: .salad, ingredients: []),
"Ceviche": Recipe(
name: "Ceviche", category: .salad, ingredients: []),
"Çoban salatası": Recipe(
name: "Çoban salatası", category: .salad, ingredients: []),
"Fiambre": Recipe(
name: "Fiambre", category: .salad, ingredients: []),
"Kachumbari": Recipe(
name: "Kachumbari", category: .salad, ingredients: []),
"Niçoise": Recipe(
name: "Niçoise", category: .salad, ingredients: []),
]
recipes["Apple Pie"]!.related = [
recipes["Pie Crust"]!.id,
recipes["Fruit Pie Filling"]!.id,
]
recipes["Pie Crust"]!.related = [recipes["Fruit Pie Filling"]!.id]
recipes["Fruit Pie Filling"]!.related = [recipes["Pie Crust"]!.id]
return Array(recipes.values)
}()
let applePie = """
¾ cup white sugar
2 tablespoons all-purpose flour
½ teaspoon ground cinnamon
¼ teaspoon ground nutmeg
½ teaspoon lemon zest
7 cups thinly sliced apples
2 teaspoons lemon juice
1 tablespoon butter
1 recipe pastry for a 9 inch double crust pie
4 tablespoons milk
"""
let pieCrust = """
2 ½ cups all purpose flour
1 Tbsp. powdered sugar
1 tsp. sea salt
½ cup shortening
½ cup butter (Cold, Cut Into Small Pieces)
⅓ cup cold water (Plus More As Needed)
"""
struct PushableStack_Previews: PreviewProvider {
static var previews: some View {
PushableStack()
}
}
대락적인 사용방법은 알게된 것 같다.
이후 포스팅에서는 TCA와 NavigationStack을 함꼐 사용하는 예시를 삷펴보자.