In the executable code below I try to implement the main navigation view for an (iPadOS) app. The expected behaviour is as follows:
- A
NavigationSplitView
is 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
NavigationSplitView
aNavigationStack
is 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
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 theNavigationStack
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 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()
}