In the executable code below I try to implement the main navigation view for an (iPadOS) app. The expected behaviour is as follows:
- A
NavigationSplitViewis used to present a sidebar to the user containing the main categories of the app - If a user selects a category, in the detail view of the
NavigationSplitViewaNavigationStackis presented - The navigation stack allows users to interact with the specific part of the app associated with the category by pushing other views on the navigation stack
- The navigation paths for each
NavigationStackare stored so that when a user switches between categories of the sidebar, they always return to the child were when they left a category. For example a user selects category A in the sidebar and then pushes two objects to the navigation path array of theNavigationStackof category A. The user then selects category B from the sidebar does some work and then returns to category A. It is expected that they are shown the view that was pushed last on theNavigationStack.
However, (4) is not working. Every time a user selects another category in the sidebar, the navigation paths of the associated NavigationStack becomes empty. For example, if a user pushes three views on the view stack of category A, then uses the sidebar to select category B, the navigation paths of category A become empty and the NavigationStack of category B is displayed.
I kindly ask for help as I cannot find the issue in the code.
Use this code to reproduce the issue:
import SwiftUI
/// Add this view to your app to test the behavior.
///
/// This view displays a sidebar with four entries (RootCategory enum).
/// Each detail view has a NavigationStack that referes to one of the Routing
/// state objects from the RootNavigation.
///
/// The goal is that users can switch the categories in the sidebar without losing
/// their navigation stack of their respective child view once they are going back
/// to a previously selected category.
///
/// However, this does not work because everytime a different categor is selected from
/// the sidebar, the navigation paths of the ChildView's routing is emptied. (see line 82).
struct RootNavigation: View {
@StateObject var categoryOneRouting = Routing()
@StateObject var categoryTwoRouting = Routing()
@StateObject var categoryThreeRouting = Routing()
@StateObject var categoryFourRouting = Routing()
@State var navigationSelection: RootCategory? = .category1
var body: some View {
NavigationSplitView {
List(RootCategory.allCases, selection: $navigationSelection) { category in
NavigationLink(category.name, value: category)
}
.navigationTitle("App Title")
} detail: {
switch navigationSelection {
case .category1:
ChildView(title: RootCategory.category1.name)
.environmentObject(categoryOneRouting)
case .category2:
ChildView(title: RootCategory.category2.name)
.environmentObject(categoryTwoRouting)
case .category3:
ChildView(title: RootCategory.category3.name)
.environmentObject(categoryThreeRouting)
case .category4:
ChildView(title: RootCategory.category4.name)
.environmentObject(categoryFourRouting)
case nil:
Text("Selection does not exist")
}
}
}
}
struct ChildView: View {
let title: String
@EnvironmentObject var routing: Routing
var body: some View {
NavigationStack(path: $routing.routes) {
VStack {
Text(title)
.navigationTitle(title)
Button("Screen 1") {
routing.routes.append(.subview1)
}
Button("Screen 2") {
routing.routes.append(.subview2)
}
Button("Screen 3") {
routing.routes.append(.subview3)
}
}
.navigationDestination(for: Route.self) { route in
Text(route.rawValue)
}
}
.buttonStyle(.borderedProminent)
}
}
class Routing: ObservableObject {
@Published var routes: [Route] = [] {
willSet {
// when selecting another category in the sidebar, the navigation paths of
// the currently visible NavigationStack will be set to empty. Why and how
// can I prevent it from losing the navigation paths of NavigationStacks
// that disappear?
if newValue.isEmpty {
print("Navigation paths just got emptied")
}
}
}
}
enum RootCategory: Int, CaseIterable, Identifiable {
case category1
case category2
case category3
case category4
var id: Int { self.rawValue }
var name: String {
switch self {
case .category1: return "Category 1"
case .category2: return "Category 2"
case .category3: return "Category 3"
case .category4: return "Category 4"
}
}
}
enum Route: String {
case subview1
case subview2
case subview3
}
struct RootNavigation_Previews: PreviewProvider {
static var previews: some View {
RootNavigation()
}
}
Updated
I tried to implement the feedback from lorem ipsum. I changed the code to forego the environment entirely but paths are still being reset everytime the NavigationStack disappears.
Sample code:
import Observation
import SwiftUI
enum AppCategory: String, CaseIterable, Identifiable {
case pizza = "Pizza"
case noodles = "Noodles"
case coffee = "Coffee"
var id: String { self.rawValue }
}
enum AppPath {
case path1
case path2
case path3
case path4
case path5
}
struct SidebarNavigation: View {
@Environment(NavigationViewModel.self) private var viewModel
var body: some View {
let categoryBinding = Binding<AppCategory?> {
viewModel.category
} set: { value in
viewModel.category = value
}
NavigationSplitView {
List(AppCategory.allCases, selection: categoryBinding) { category in
NavigationLink(category.rawValue, value: category)
}
.navigationTitle("App Title")
} detail: {
if let selection = viewModel.category {
NavigationStack(path: viewModel.activeBinding.navigationPaths) {
TestView1(path: .path1, title: selection.rawValue)
.navigationDestination(for: AppPath.self) { path in
TestView1(path: path, title: viewModel.category!.rawValue)
}
}
} else {
Text("No valid selection")
}
}
}
}
@Observable class NavigationViewModel {
@Observable class Routing {
var navigationPaths: [AppPath] = []
}
var category: AppCategory? = .pizza
private var routes: [AppCategory: Routing] = [
.pizza: Routing(),
.noodles: Routing(),
.coffee: Routing()
]
private var bindings: [AppCategory: Binding<Routing>] = [:]
var activeBinding: Binding<Routing> {
binding(for: category!)
}
func value(for category: AppCategory) -> Routing {
routes[category]!
}
func binding(for category: AppCategory) -> Binding<Routing> {
if let existingBindings = bindings[category] {
return existingBindings
}
let newBinding = Binding<Routing> {
self.value(for: category)
} set: { routing in
self.routes[category] = routing
}
bindings[category] = newBinding
return newBinding
}
}
#Preview {
SidebarNavigation()
.environment(NavigationViewModel())
}
Code example 3
import Observation
import SwiftUI
@Observable class NavigationController {
var category1Route = Route()
var category2Route = Route()
var category3Route = Route()
var selectedCategory: AppCategory? = .category1
}
@Observable class Route {
var paths: [Path] = []
}
enum AppCategory: String, CaseIterable, Identifiable {
case category1 = "Category 1"
case category2 = "Category 2"
case category3 = "Category 3"
var id: String { self.rawValue }
}
enum Path {
case path1
case path2
case path3
case path4
case path5
}
struct SwiftUIView: View {
@Bindable var controller = NavigationController()
var body: some View {
NavigationSplitView {
List(AppCategory.allCases, selection: $controller.selectedCategory) { category in
NavigationLink(value: category) {
Text(category.rawValue)
}
}
} detail: {
switch controller.selectedCategory {
case .category1, nil:
NavigationStack(path: $controller.category1Route.paths) {
DetailRootView(route: $controller.category1Route, title: "Category 1 Details", associatedPath: .path1)
.navigationDestination(for: Path.self) { path in
DetailRootView(route: $controller.category1Route, title: "Sub Screen", associatedPath: path)
}
}
case .category2:
NavigationStack(path: $controller.category2Route.paths) {
DetailRootView(route: $controller.category2Route, title: "Category 2 Details", associatedPath: .path1)
.navigationDestination(for: Path.self) { path in
DetailRootView(route: $controller.category2Route, title: "Sub Screen", associatedPath: path)
}
}
case .category3:
NavigationStack(path: $controller.category3Route.paths) {
DetailRootView(route: $controller.category3Route, title: "Category 3 Details", associatedPath: .path1)
.navigationDestination(for: Path.self) { path in
DetailRootView(route: $controller.category3Route, title: "Sub Screen", associatedPath: path)
}
}
}
}
}
}
struct DetailRootView: View {
private static let backgroundColors: [Path: Color] = [
.path1: .indigo,
.path2: .red,
.path3: .green,
.path4: .yellow,
.path5: .cyan,
]
@Binding var route: Route
let title: String
let associatedPath: Path
var body: some View {
VStack {
Button {
route.paths.append(nextPath)
} label: {
Text("Next Page")
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Self.backgroundColors[associatedPath]!)
}
var nextPath: Path {
switch associatedPath {
case .path1: return .path2
case .path2: return .path3
case .path3: return .path4
case .path4: return .path5
case .path5: return .path1
}
}
}
#Preview {
SwiftUIView()
}