BLACK FRIDAY: Save 50% on all my Swift books and bundles! >>

Can anyone help me conceptually understand how to work with NavigationStack across files/views?

Forums > SwiftUI

I am an absolute beginner so explaining these concepts as if to an infant would be great. :)

I've watched 3-4 tutorials now on NavigationStack but they all show simplistic examples in a single file. I am interested in understanding how you can add/remove views to a single NavigationStack across files and views.

Here is the basic structure:

ContentView.swift has my TabView in, and the first tab item there is for my Home() view.

In Home(), which lives in Home.swift, I have added a NavigationLink, and that should add the Splash() view from Splash.swiftto the NavigationStack.

However, now I am in the Splash() view of Splash.swift, how do I know what the current NavigationStack is? How do I manipulate it, such as popping back to the beginning of the stack? Does the project 'just know'? Or do I have to pass the stack along through each view as a parameter that I can then manipulate?

Or should it be passed around different views by a different mechanism? Is this what ObservableObject is for? Or should I be using some kind of environment variable?

I have seen examples where a @State is set in the beginning, and then that is passed to different views and picked up as a @Binding in other views. However, when I do this, I am no longer sure what to put in the preview call??

For example, in Home.swift:

@State private var stackPath = NavigationPath()

var body: some View {
        NavigationStack(path: $stackPath) {
            VStack {
                    ZStack {
                    // More code

                        if isSportMenuShowing.isSportMenuShowing {
                            SportMenu(isSportMenuShowing: isSportMenuShowing, path: $stackPath)
                                .transition(.move(edge: .top))
                        }
                    }
                Spacer()
            }
        }
    }

And you can see there, I am sending path into SportMenu() as a parameter.

However, then the preview inside SportMenu.swift complains "Cannot find '$path' in scope" when I do this:

#Preview {
    SportMenu(isSportMenuShowing: ContentView().isSportMenuShowing, path: $path)
}

How am I supposed to get the Preview to know about the path? I read this excellent explanation on Previews by @Obelix https://www.hackingwithswift.com/forums/swiftui/binding-variable-won-t-preview/10974/10987 but remain confused as to what I should be giving the Preview to satisfy its needs?

1      

This is simplified example but basically you can see how it works. I hope comments are enough to understand what is going on. Just copy paste to see it in work.

import SwiftUI

struct ContentView: View {
    // Here you monitor stack for views
    @State private var navPath: [String] = []

    var body: some View {
        NavigationStack(path: $navPath) {
            Screen1(path: $navPath)
        }
        // During navigation you will see that printed to console
        // So you know if any value is added to array for tracking
        .onChange(of: navPath) { oldValue, newValue in
            print(newValue)
        }
    }
}

// This can be in separate file
struct Screen1: View {
    @Binding var path: [String]

    var body: some View {
        Text("Screen 1")
            .font(.title)
        // When yo press this navigation link it will navigate to another View2
        // And value("View 2) will be added to path
        // This is how navigation is tracked
        NavigationLink(value: "View 2") {
            Text("Go to Screen 2")
        }
        .navigationDestination(for: String.self) { pathValue in
            // depending on the value you pass you will navigate accordingly
            if pathValue == "View 2" {
                Screen2(path: $path)
            } else if pathValue == "View 3" {
                Screen3(path: $path)
            }
        }
    }
}

// This can be in separate file
struct Screen2: View {
    @Binding var path: [String]
    var body: some View {
        Text("Screen 2")
            .font(.title)
        // When yo press this navigation link it will navigate to another View2
        // And value("View 3) will be added to path
        // This is how navigation is tracked
        NavigationLink(value: "View 3") {
            Text("Go to Screen 3")
        }
    }
}

// This can be in separate file
struct Screen3: View {
    @Binding var path: [String]
    var body: some View {
        Text("Screen 3")
            .font(.title)
        Button("Back to root") {
            // to navigate to root view you simply pop all the views from navPath
            // when you remove all items it will bring you back to root view.
            path.removeAll()
        }
    }
}

#Preview {
    ContentView()
}

How am I supposed to get the Preview to know about the path? I read this excellent explanation on Previews but remain what I should be giving the Preview to satisfy its needs?

so if you separate those structs out of this file #Preview will ask for parameters. As an example you can use .constat() to provide binding values for preview.

#Preview {
    // For the navigation to work with Navigaiton Link you will have to wrap it
    // in NavigationStack. You can provide any binding value that conforms to String
    // you can use .constant to work as a binding
    NavigationStack {
        Screen1(path: .constant([""]))
    }
}

2      

Thanks @ygeras will try this in the morning. If I had any hair left I would have torn it out today trying to get my head around this, this helps a lot.

1      

Save 50% in my WWDC sale.

SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.

Save 50% on all our books and bundles!

Related question: if I have a full screen menu that is brought in over the top, which is not part of the NavigationStack, and I click an item in this menu, how do I both dismiss the menu (which is done currently by the toggleMenu() method), and also move to the destination in the menu that I do want added to the NavigationStack?

For example, I have been trying this:

NavigationLink(value: "SportSplash") {
                            Button(action: {
                                withAnimation {
                                    isSportMenuShowing.toggleMenu()
                                }
                            }) {
                                Text("")
                                    .frame(width: 150, height: 40)
                                    .background(Color.white)
                                    .opacity(0.1)
                            }
                        }.navigationDestination(for: String.self) { path in
                            if path == "SportSplash" {
                                SportSplash(isSportMenuShowing: isSportMenuShowing, path: $path)
                            }
                        }

But having an action: in the Button seems to stop the .navigationDestination part working, so what's the right way to handle this eventuality? Essentially doing two distinct things with one tap.

1      

I have also tried this:

NavigationLink(value: "SportSplash") {
    Text("")
        .frame(width: 150, height: 40)
        .background(Color.white)
        .opacity(0.1)
}
.navigationDestination(for: String.self) { pathValue in
    if pathValue == "SportSplash" {
        isSportMenuShowing.toggleMenu() // Type () cannot conform to 'View'
        SportSplash(isSportMenuShowing: isSportMenuShowing, path: $path)
    }
}

But notice that the isSportMenuShowing.toggleMenu() produces an error because is isn't a view.

1      

I will continue using my example as snippets of your code do not show full picture what is going on in your particular case.

You should understand that NavigationStack is not the same concept as poping up modal views such as (fullscreencover and sheet). I have added some functionality of fullscreencover and mark NEW PARTS with comments. So you can play around and see how modal views are controlled. It is usually @State var that is responsible for showing the view and not navigationpath as in NavigationStack.

import SwiftUI

struct ContentView: View {
    // Here you monitor stack for views
    @State private var navPath: [String] = []

    var body: some View {
        NavigationStack(path: $navPath) {
            Screen1(path: $navPath)
        }
        // During navigation you will see that printed to console
        // So you know if any value is added to array for tracking
        .onChange(of: navPath) { oldValue, newValue in
            print(newValue)
        }
    }
}

// This can be in separate file
struct Screen1: View {
    @Binding var path: [String]

    var body: some View {
        Text("Screen 1")
            .font(.title)
        // When yo press this navigation link it will navigate to another View2
        // And value("View 2) will be added to path
        // This is how navigation is tracked
        NavigationLink(value: "View 2") {
            Text("Go to Screen 2")
        }
        .navigationDestination(for: String.self) { pathValue in
            // depending on the value you pass you will navigate accordingly
            if pathValue == "View 2" {
                Screen2(path: $path)
            } else if pathValue == "View 3" {
                Screen3(path: $path)
            }
        }
    }
}

// This can be in separate file
struct Screen2: View {
    @Binding var path: [String]

    // THIS IS NEW
    // This will control modelView visibility
    @State private var isFullScreenCoverVisible = false
    // END OF NEW

    var body: some View {
        Text("Screen 2")
            .font(.title)
        // When yo press this navigation link it will navigate to another View2
        // And value("View 3) will be added to path
        // This is how navigation is tracked
        NavigationLink(value: "View 3") {
            Text("Go to Screen 3")
        }

        // THIS IS NEW
        Button("Show full screen cover") {
            // By changing this state to true
            isFullScreenCoverVisible = true
        }
        // cover will be launched by adding this modifier
        .fullScreenCover(isPresented: $isFullScreenCoverVisible) {
            FullScreenCover()
        }
        // END OF NEW
    }
}

// This can be in separate file
struct Screen3: View {
    @Binding var path: [String]
    var body: some View {
        Text("Screen 3")
            .font(.title)
        Button("Back to root") {
            // to navigate to root view you simply pop all the views from navPath
            // when you remove all items it will bring you back to root view.
            path.removeAll()
        }
    }
}

// THIS IS NEW
struct FullScreenCover: View {
    // This will dismiss the full screen cover
    @Environment(\.dismiss) var dismiss

    var body: some View {
        VStack {
            Text("This is full screen cover")
                .font(.largeTitle)

            Button("Dismiss") {
                dismiss()
            }
            .buttonStyle(.borderedProminent)
        }
    }
}
// END OF NEW

#Preview {
    ContentView()
}

1      

Thanks again for looking at my issue. The key thing I need to do, is add to the NavigationStack, while simultaneously dismissing the fullScreenCover.

So, imagine one view bringing in a menu as a fullScreenCover. In that full screen cover is a link to another view. I want to click that link to go to the new view, while dismissing the fullScreenCover at the same time (or at least from the same tap)

Is that possible?

1      

Well I got this working but using something that is apparently deprecated. Is there an iOS 17 friendly way to do the same thing?

Here is what I did, first added this function:

var tap: some Gesture {
        TapGesture(count: 1)
            .onEnded { _ in self.tapped = !self.tapped
                withAnimation {
                    isSportMenuShowing.toggleMenu()
                }
            }
    }

That part is now animating out my popover menu.

Then inside my body view I am doing this to load the NavigationLink:

NavigationLink("SportSplash", destination: SportSplash(isSportMenuShowing: isSportMenuShowing, path: $path), isActive: $tapped)

And that is pushing the SportSplash view into the NavigationStack. However, I have a warning that "'init(_:destination:isActive:)' was deprecated in iOS 16.0: use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView" and I don't seem able to write that functionality in a way that is iOS17 friendly.

Is this a horrible way to do this?

1      

I do not think this will work that way. I just modified the code in FullScreen view so you can play following two navigation paths as follows:

  • MainPathStarts: View 1 -> View 2 -> View 3
  • MainPathStarts: View 1 -> View 2 -> SubPathStarts: Fullscreen -> View 3

As you mentioned in your post you want it to look like this?

MainPathStarts: View 1 -> View 2 -> SubPathStarts: Fullscreen then Dismiss -> X but how that view than could exist? View 3

My understanding is that SwiftUI using stack, that is it can push and pop the views that is LIFO cycle -> last in first out. So you want to break that rule that is not really possible (maybe there are some workarounds though).

You push your View 1, then you push your View 2, you push your fullscreen, then you try to push View 3, but at the same time pop fullscreen, which breaks everthing.

Using arrays for pushing views we can picture it like so:

[View 1, View 2, View 3] // this is how views will be pushed via NavigationStack

[View 2, View2, FullScreenCover [View 3]] // so when you will try to dismiss FullScreenCover, View 3 cannot exits by itself.
struct ContentView: View {
    // Here you monitor stack for views
    @State private var navPath: [String] = []

    var body: some View {
        NavigationStack(path: $navPath) {
            Screen1(path: $navPath)
        }
        // During navigation you will see that printed to console
        // So you know if any value is added to array for tracking
        .onChange(of: navPath) { oldValue, newValue in
            print(newValue)
        }
    }
}

// This can be in separate file
struct Screen1: View {
    @Binding var path: [String]

    var body: some View {
        Text("Screen 1")
            .font(.title)
        // When yo press this navigation link it will navigate to another View2
        // And value("View 2) will be added to path
        // This is how navigation is tracked
        NavigationLink(value: "View 2") {
            Text("Go to Screen 2")
        }
        .navigationDestination(for: String.self) { pathValue in
            // depending on the value you pass you will navigate accordingly
            if pathValue == "View 2" {
                Screen2(path: $path)
            } else if pathValue == "View 3" {
                Screen3(path: $path)
            }
        }
    }
}

// This can be in separate file
struct Screen2: View {
    @Binding var path: [String]

    // THIS IS NEW
    // This will control modelView visibility
    @State private var isFullScreenCoverVisible = false
    // END OF NEW

    var body: some View {
        Text("Screen 2")
            .font(.title)
        // When yo press this navigation link it will navigate to another View2
        // And value("View 3) will be added to path
        // This is how navigation is tracked
        NavigationLink(value: "View 3") {
            Text("Go to Screen 3")
        }

        // THIS IS NEW
        Button("Show full screen cover") {
            // By changing this state to true
            isFullScreenCoverVisible = true
        }
        // cover will be launched by adding this modifier
        .fullScreenCover(isPresented: $isFullScreenCoverVisible) {
            FullScreenCover(path: $path)
        }
        // END OF NEW
    }
}

// This can be in separate file
struct Screen3: View {
    @Binding var path: [String]
    var body: some View {
        Text("Screen 3")
            .font(.title)
        Button("Back to root") {
            // to navigate to root view you simply pop all the views from navPath
            // when you remove all items it will bring you back to root view.
            path.removeAll()
        }
    }
}

// THIS IS NEW
struct FullScreenCover: View {
    // This will dismiss the full screen cover
    @Environment(\.dismiss) var dismiss
    // This is binding from the main navigation
    @Binding var path: [String]
    // This is navigaiton start anew
    @State private var localPath = [String]()

    var body: some View {
        NavigationStack(path: $localPath) {
            VStack {
                Text("This is full screen cover")
                    .font(.largeTitle)

                // This will also navigate to View 3 but starting new navigation path
                NavigationLink(value: "View 3") {
                    Text("Go to Screen 3")
                }

                Button("Dismiss") {
                    dismiss()
                }
                .buttonStyle(.borderedProminent)
            }
            .navigationDestination(for: String.self) { value in
                if value == "View 3" {
                    Screen3(path: $path)
                }
            }
        }
        .onChange(of: localPath) { oldValue, newValue in
            // as you can see this prints new array of ["View 3"] not adding it to "main" navigation path
            print(newValue)
        }
    }
}
// END OF NEW

#Preview {
    ContentView()
}

Maybe for more visibility what is going on you can replace fullscreencover modifier with sheet and you will see how path follows its way and fullscreencover basically doing the same, only it covers all the screen.

.sheet(isPresented: $isFullScreenCoverVisible) {
            FullScreenCover(path: $path)
        }

1      

Actually, seems like there is an easy workaround for that. If you modify fullscreencover to below code. It starts behaving this way that you're probably trying to achieve.

So basically logic as follows:

You pass into fullscreencover the path you're using for navigation tracking. Than add item to that pass via Button structure and then dismiss it. So this way it seems to be working just fine. Hopefully there is no conflict in order of execution in the rest of the code.

struct FullScreenCover: View {
    // This will dismiss the full screen cover
    @Environment(\.dismiss) var dismiss
    // This is binding from the main navigation
    @Binding var path: [String]

    var body: some View {

        VStack {
            Text("This is full screen cover")
                .font(.largeTitle)

            // We will add item to initial path from main screen
            // and dismiss that
            Button("Go to Screen 3") {
                path.append("View 3")
                dismiss()
            }

            Button("Dismiss") {
                dismiss()
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

just a small note. it is not necessary to use var path: [String] you can modify it with var path = NavigationPath() and add Any items and not only Strings.

1      

Save 50% in my WWDC sale.

SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.

Save 50% on all our books and bundles!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.