Swift/Swift CS

[iOS/SwiftUI] NavigationStack 공식문서 뜯어보기

힛해 2024. 8. 31. 15:39
728x90
 

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

모든 네비게이션 스택은 스택이 표시하는 모든 데이터를 나타내는 경로를 추적한다.

 

스택은 스택 내부 또는 스택에 푸시된 뷰 내부에서 선언된 모든 네비게이션을 추적한다.

 

아무것도 하지 않은 상태

 

대상을 매핑해 스택에서 푸시할 뷰를 결정한다.

path-> navigation destinations -> pushed views

 

경로에 추가하는 값에 따라 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을 함꼐 사용하는 예시를 삷펴보자.