Best practices for accessing NavigationPath in child views

Hi all

I'm reworking our app in SwiftUI. My ultimate goal is to access the NavigationPath from a child view which is used throughout different NavgationStacks. While searching for I came across different ways of achieving this. As I'm relatively new to SwiftUI it is hard to understand what the actual best practice seems to be.

So for the use case. My app has a TabView and each Tab has its own NavigationStack which looks something like this

struct TabNavigation: View {
    @State private var selectedProductType: StaticProductType = .all
    @StateObject private var appRouter = AppRouter()

    var body: some View {
        TabView(selection: $appRouter.selectedTab) {
            Overview(activeType: $selectedProductType)
                .tabItem {
                    Label("Home", systemImage: "house")
                }
                .tag(Tab.home)
            AssortmentView(router: $appRouter.assortmentRouter, 
                   activeType: $selectedProductType)
                .tabItem {
                    Label(String(localized: "assortment"), systemImage: 
                    "list.bullet")
                }
                .tag(Tab.assortment)
    }
}

The AssortmenView holds the NavigationStack and defines the routes.

struct AssortmentView: View {
    @Binding var router: AssortmentRouter
    @Binding var activeType: StaticProductType
    
    var body: some View {
        NavigationStack(path: $router.navigationPath) {
            VStack {
                ProductTypeNavigation(activeType: $activeType)
                    .padding(.top, 10)
                    .padding(.horizontal, 10)
                Spacer()
                TabView(selection: $activeType) {
                    ListNavigation(type: .all)
                        .tag(StaticProductType.all)
                    ListNavigation(type: .games)
                        .tag(StaticProductType.games)
                    ListNavigation(type: .digital)
                        .tag(StaticProductType.digital)
                    ListNavigation(type: .toys)
                        .tag(StaticProductType.toys)
                    ListNavigation(type: .movies)
                        .tag(StaticProductType.movies)
                    ListNavigation(type: .books)
                        .tag(StaticProductType.books)
                }
                .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
                    
            }
            .addToolbar()
            .navigationDestination(for: AssortmentRouter.Route.self) { route in
                switch route {
                case .overview(let type):
                    OverviewTypeView(type: type)
                case .productDetail(let productId):
                    ProductDetailView(productId: productId)
                        .environmentObject(router)
                case .productList:
                    ProductList()
                }
            }
        }
    }
}

Through my app I often use a view to displaying products. This view is reused over different NavigationStacks.

struct ProductDetailView: View {
    var productId: Int
    @StateObject private var viewModel: ProductDetailViewModel = ProductDetailViewModel()
    @State private var showErrorAlert = false
    @EnvironmentObject var router: AssortmentRouter
    
    var body: some View {
        VStack {
            if !viewModel.isRefreshing {
                let product = viewModel.product
                VStack {
                    Text("Product: \(product.title)")
                    NavigationLink(destination: ProductDetailView(productId: Product.preview.productId)) {
                        Text("Test")
                    }
                }
                .navigationTitle(product.title)
            } else {
                ProgressView()
            }
        }.task {
            await loadProduct()
        }
        .alert("Error", isPresented: $showErrorAlert, presenting: viewModel.localizedError) { _ in
            Button("Try again") {
                Task {
                    await loadProduct()
                }
            }
            Button("Go Back", role: .cancel) {
                // access navigationPath
            }
        } message: { errorMessage in
            Text(errorMessage)
        }
    }
    
    @MainActor
    private func loadProduct() async {
        await viewModel.loadProduct(productId: productId)
        showErrorAlert = viewModel.localizedError != nil
    }
}

In this example I created an AppRouter which holds all information for the routes and some functions to accessing the NavigationPath.

class AppRouter: ObservableObject {
    var assortmentRouter = AssortmentRouter()
    var selectedTab: Tab = .home
    
    func navigateTo(tab: Tab) {
        selectedTab = tab
    }
    
}


class AssortmentRouter: ObservableObject {
    var navigationPath = NavigationPath()
    
    enum Route: Hashable {
        case overview(type: StaticProductType)
        case productList
        case productDetail(productId: Int)
    }
    
    func navigateTo(route: Route) {
        navigationPath.append(route)
    }
}

This works fine as it is. The pro of this solution is that I don't have to pass the NavigationPath down each subview to use it as I can define it as EnvrionmentObject. The problem with this though, I like to reuse ProductDetailView also in my other NavigationStack which won't have a router binding of type AssortmentRouter as you can imagine.

To come back to my initial question, what would be the best way to design this?

  • Passing down a NavigationPath Binding and using different typing for navigationDestinaion values
  • Define a callback which is passed as function parameter to the detail view
  • Using dismiss, but I read that this is can lead to weird behaviour and bugs
  • Any other option? Maybe changing the app architecture to handle this a better way

Apolgize the long post, but I would be really glad to get some feedback on this, so I can do it the right way.

Thank you very much

"The problem with this though, I like to reuse ProductDetailView also in my other NavigationStack..."

You might check out the following article on building navigation destinations that allow for reuse.

https://michaellong.medium.com/advanced-navigation-destinations-in-swiftui-05c3e659f64f?sk=030440d95749f5adc6d2b43ca26baee1

As for allowing subviews to access the NavigationPath, typically you want to shield the path from the subviews by providing some object that manages it.

In Navigator I pass an object down through the environment as an environment value (not environment object).

You might look at the project for ideas.

https://github.com/hmlongco/Navigator

Thank you very much for the links. I will have a look at these.

As for allowing subviews to access the NavigationPath, typically you want to shield the path from the subviews by providing some object that manages it.

It's interesting that so much accepted answers found online for popping a view is pass down the NavigationPath as Binding. But good to know to not do that.

"is pass down the NavigationPath as Binding"

I might pass a closure downstream, but by and large I believe that your child views should be ignorant as possible of the navigation mechanism being used.

Exposing the path goes against that somewhat... ;)

Best practices for accessing NavigationPath in child views
 
 
Q