• new navigation APIs

    • 기존 NavigationLink는 프로그래밍적으로 네비게이션을 하기 위해서는 binding을 링크 갯수만큼 만들어야 했다.

    • 이제는 컨테이너 전체의 navigation을 묶어서 지정해줄 수 있다.

      NavigationStack(path: $path) { // 네비게이션 스택 전체를 의미하는 콜렉션의 binding
      	NavigationLink("Details", value: value)
      }
      
    • 앱 구조를 표현할 수 있는 새로운 컨테이너 타입들

      • NavigationStack(Push-pop)
      • NavigationSplitView(multicolumn)
        • 아이폰이나 slide over, watch, apple tv에서는 단일 스택으로 동작

        • two column 버전의 생성자와 three column 버전의 생성자를 제공

          NavigationSplitView {
          	RecipeCategories()
          } detail: {
          	RecipeGrid()
          }
          
          NavigationSplitView {
          	RecipeCategories()
          } content: {
          	RecipeList()
          } detail: {
          	RecipeDetail
          }
          
        • 그 외에도 column 너비, 사이드바 프레젠테이션, 프로그래밍 방식으로 column을 보이고 숨기는 것 등의 다양한 커스텀 옵션 제공

    • NavigationLink API의 변화

      • 기존 버전

        NavigationLink("Show Detail") {
        	DetailView()
        }
        
      • 신규 버전 → View가 아니라 value를 받는다.

        • 실제로 링크가 어떻게 동작하는지는, 이를 감싸고 있는 게 뭐냐에 따라 달라진다.
        NavigationLink("Apple Pie", value: applePieRecipe)
        
  • Recipes for navigation

    • 싱글 스택 - 뷰 기반 네비 게이션

      var body: some View {
      	NavigationStack {
      		List(Category.all) { category in
      			Section(category.localizedName) {
      				ForEach(dataModel.recipes(in: category)) { recipe in
      					NavigationLink(recipe.name) {
      						RecipeDetail(recipe: recipe)
      					}
      				}
      			}
      		}
      		.navigationTitle("Categories")
      	}
      }
      
    • 상태기반 싱글 스택 네비게이션

      var body: some View {
      	NavigationStack {
      		List(Category.all) { 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)
      		}
      	}
      }
      
    • NavigationStack의 원리

      • NavigationStack은 자신이 보여주고 있는 모든 데이터에 대한 path를 가지고 있다.
        • rootView만 있는 상태에서는 path가 비어있다.
        • 이 path에 값이 채워지면, 그에 따라서 스택이 채워진다.
        • path에 있는 값을 빼면 스택이 비워지거나 조작된다.
          • binding으로 하위뷰에 넘기면, 하위뷰에서도 스택 전체를 조작하는 것도 가능할 것이다.
    • NavigationSplitView

      • state값을 바꾸면 코드로 네비게이션 조작이 가능해진다.
      • iPhone, WatchOS, tvOS 에서는 NavigationStack처럼 동작한다.
      @State private var selectedCategory: Category?
      @State private var selectedRecipe: Recipe?
      
      var body: some View {
      	NavigationSplitView {
      		List(Category.allCases, selection: $selectedCategory) { category in
      			NavigationLink(category.localizedName, value: category)
      		}.navigationTitle("Categories")
      	} content: {
      			List(dataModel.recipes(in: selectedCategory), selection: $selectedRecipe) { recipe in
      			NavigationLink(recipe.name, value: recipe)
      		}.navigationTitle(selectedCategory?.localizedName ?? " Recipes")
      	} detail: {
      		RecipeDetail(recipes: selectedRecipe)
      	}
      }
      
    • 두 개를 모두 쓰는 경우 - Multi columns with stack

      • navigationDestination은 어디에 붙어야 하는가?
        • LazyVStack안의 뷰에 붙이면, LazyVStack이 뷰를 한꺼번에 로딩하지 않기 때문에 처음 로딩했을 때 NavigationStack이 navigation이 있다는 것을 인지하지 못하게 된다.
        • GridItem마다 navigationDestination이 반복되는 것도 별로다.
        • 그래서 최상단 뷰인 스크롤 뷰에 붙인다.
      @State private var selectedCategory: Category?
      @State private var path: [Recipe] = []
      
      var body: some View {
      	NavigationSplitView {
      		List(Category.allCases, selection: $selectedCategory) { category in
      			NavigationLink(category.localizedName, value: category)
      		}.navigationTitle("Categories")
      	} detail: {
      		NavigationStack(path: $path) {
      			RecipeGrid(category: selectedCategory)
      		}
      	}
      }
      
      struct RecipeGrid: View {
      	var category: Category?
      
      	var body: some View {
      		if let category = category {
      			ScrollView {
      				LazyVGrid(columns: columns) {
      					ForEach(dataModel.recipes(in: category)) { recipe in
      						NavigationLink(value: recipe) { RecipeTile(recipe: recipe) }
      					}
      				}
      			}
      			.navigationTitle(category.name)
      			.navigationDestination(for: Recipe.self) { recipe in RecipeDetail(recipe: recipe) }
      		} else {
      			EmptyView()
      		}
      	}
      }
      
  • Persistent state

    • Codable과 SceneStorage를 사용한다.
      • Navigation 상태를 모델링한다.

        class NavigationModel: ObservableObject {
        		@Published var selectedCategory: Category?
        		
        		@Published var recipePath: [Recipe] = []
        }
        
        // 뷰에서는 이렇게 바꾸자.
        @StateObject private var navModel = NavigationModel()
        
      • 이에 Codable을 채택한다.

        • 여기서는 recipes 정보 자체를 담지 않고, recipe의 Id만 담기 위해서 커스텀한다.
        class NavigationModel: ObservableObject, Codable {
        		@Published var selectedCategory: Category?
        		
        		@Published var recipePath: [Recipe] = []
        
        		enum CodingKeys: String, CodingKey {
                case selectedCategory
                case recipePathIds
            }
        
            func encode(to encoder: Encoder) throws {
                var container = encoder.container(keyedBy: CodingKeys.self)
                try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory)
                try container.encode(recipePath.map(\\.id), forKey: .recipePathIds)
            }
        
        		required init(from decoder: Decoder) throws {
                let container = try decoder.container(keyedBy: CodingKeys.self)
                self.selectedCategory = try container.decodeIfPresent(
                    Category.self, forKey: .selectedCategory)
        
                let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds)
                self.recipePath = recipePathIds.compactMap { DataModel.shared[$0] }
            }
        }
        
        
      • SceneStorage를 통해서 저장하고 복구한다.

        • 옵셔널인 경우, scene이 처음 만들어지는 경우는 nil이다.
        • 이후 앱이 상황에 따라 자동으로 저장하고 복구한다.
        @SceneStorage("navigation") private var data: Data?
        
        var body: some View {
        	NavigationSplitView { ... }
        		.task {
        			if let data = data {
        				navModel.jsonData = data
        			}
        
        			for await _ in navModel.objectWillChangeSequence {
        				data = navModel.jsonData
        			}
        		}
        }
        
  • 새로운 API로 빠르게 넘어가는 것을 권장한다.

    • 특히 NavigationLink(isActive: …)는 deprecated될 거라서 꼭 넘어가라.
  • List와 NavigationStackView, NavigationSplitView는 섞어쓰도록 만들어져 있음을 염두에 두라.

    • navigationDestination은 stack 혹은 subView 어디에나 있어도 인식하지만, LazyContainer 안에는 넣지말 것
  • NavigationSplitView가 적절한 상황이 있다면 써보라.

    • iPhone 세로 모드에서는 어차피 스택이다.
    • 이후에 iPhone Pro Max 가로모드나, iPad, Mac 이식을 할 때 자동으로 바꿔준다.