NavigationStack's path are being emptied when NavigationStack disappears

518 views Asked by At

In the executable code below I try to implement the main navigation view for an (iPadOS) app. The expected behaviour is as follows:

  1. A NavigationSplitView is used to present a sidebar to the user containing the main categories of the app
  2. If a user selects a category, in the detail view of the NavigationSplitView a NavigationStack is presented
  3. 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
  4. The navigation paths for each NavigationStack are 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 the NavigationStack of 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 the NavigationStack.

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()
}
0

There are 0 answers