NavigationStack in Action: Explore the Power of SwiftUI Navigation.

In this Post I’ll be explore NavigationStack. A necessary component for building modern Apps with SwiftUI.

Having a good understanding on how to make a great Navigation is a fundamental tool in our arsenal as iOS Developers.

Navigation is one of those topics that you can start to think of as simple enough like, ok, if I tap on some part of the UI I will navigate to some other part of the App, ok, how complex this can be?

Well, initially you start small and you make your way up, and if you don’t have a good foundation on this topic you may find yourself in a Navigation mess where you don’t know how to keep adding screens, or worst, you start to add screens only to find that they don’t work and you cannot find the root of the problem.

Specifically in this post I’ll be digging in how we can make use of the NavigationStack, starting with iOS 16, and how it differs from the initial implementation of the NavigationView.

I will be using the latest APIs from iOS 17 with the new Observation pattern.

Along the way I’ll be exploring .navigationDestination View Modifier, NavigationStack, what’s new in NavigationLink and how can we build a good structure from the beginning and gain an advantage right from the start.

Let’s begin.


How Navigation looks before iOS 16?

The first thing to note here is that NavigationView is deprecated starting with iOS 16.

Let’s see an example of how we can transition from the old way to the new way.

You can find the final project in the link below.

Pay attention and read the commit history, there you can check out all the steps from here so you can follow through the refactor.

Let’s begin with the old way.

We’ll be displaying a simple List of Dogs for our example, again with dogs, yeah, but with no pics this time 🙃. (if you want free dog pics check out my other post about async/await 😉)

First let’s create a simple enough Dog data model:

struct Dog: Identifiable, Hashable {
  var id = UUID()
  var name: String
}

Let’s make a simple enough ViewModel where we can have a collection of dogs, like this:

struct DogViewModel {
  var dogs = [
      Dog(name: "Max"),
      Dog(name: "Buddy"),
      Dog(name: "Charlie"),
      Dog(name: "Rocky"),
      Dog(name: "Duke"),
			//.. more dogs 
	]
}

Now we can feed our view with the viewModel

struct ContentView: View {
  let dogVM = DogViewModel()

  var body: some View {
    NavigationView {
      List(dogVM.dogs) { dog in
        NavigationLink {
          DogDetails(dog: dog)
        } label: {
          Label(dog.name, systemImage: "heart")
        }
      }
      .navigationTitle("Dogs World")
    }
	}
}

struct DogDetails: View {
  let dog: Dog
  
  var body: some View {
    Text(dog.name)
  }
}

If you are following along you find that this just works.

But, note an annoying problem here.

DogDetails views is “pre” loading when our root view is loaded, all at once. As you may imagine, this is pretty annoying.. if you have some kind of complexity in the other views, SwiftUI is loading all of them even when you not have been yet tapped on any of the links, just by loading the root view it is loading every link that it contains, insane.

Let’s add an initializer on the second view, DogDetails to probe that this problem is happening.

Below our dog: Dog declaration let’s write:

 init(dog: Dog) {
   self.dog = dog
   print("dog is: \\(dog)")
 }

Let’s see it in action, write it with me, come on, there is a simple thing to start a new project and start typing away (remember to type it, not copy pasting!).

OK, now, run the app.

As you can see all the views are being loaded when the root view is loaded.

This means that NavigationView doesn’t support Lazy Loading.

You can see that you have a List with all the Dog names and if you tap on each name you will transition to the next view which is DogDetails, where we simply render the name text.

Now, what’s new?


The new NavigationStack

So if your App supports a minimum deployment target of iOS 16 you can freely transition to the new NavigationStack.

This is a lot more powerful and it comes packed with a few new things:

  1. NavigationStack as a replacement for NavigationView.
  2. New NavigationLink constructs.
  3. .navigationDestination View Modifier.

Let’s tackle the new changes.

First start changing NavigationView for NavigationStack.

NavigationStack {
	List(dogVM.dogs) { dog in
	  NavigationLink {
	    DogDetails(dog: dog)
	  } label: {
	    Label(dog.name, systemImage: "heart")
	  }
	}
	.navigationTitle("Dogs World")
}

This only change works, but the point now is that we can access the new view modifier: .navigationDestination and we can leverage also the new NavigationLink that now takes also a value that allow us to handle the destination of the navigation.

NavigationStack {
  List(dogVM.dogs) { dog in
    NavigationLink(dog.name, value: dog)
  }
  .navigationTitle("Dogs World")
  .navigationDestination(for: Dog.self) { dog in
    DogDetails(dog: dog)
  }
} 

This new value that takes a NavigationLink needs to match with the type that we’re using inside .navigationDestination.

When the user hits the link, SwiftUI will look into our .navigationDestination for a matching value and if it’s found, it will add that view to a stack of views.

This way we are defining a Stack where each NavigationLink is adding a new value into. In this case just Dogs.

Now the system will always show the latest item on the Stack, and by going back, just swiping back or taping the back button, the stack will be emptied and the root view will be shown, hence the List dog view in our case.

But this is just scratching the surface.


Managing your Stack state.

Here is when this stuff is getting more interesting.

SwiftUI now give us the flexibility to handle the state of our NavigationStack allowing us to manipulate it like any array.

Just declaring a @State variable that holds an Array of the data type that we’re dealing with.

The same thing as before, but now we are explicitly declaring where we want our stacked views to live.

In this way we can keep an eye on what is on the Stack, observe it, add and remove views from it or make whatever we want with it.

Let’s see how it works.

@State var presentedDogs: [Dog] = []
let dogVM = DogViewModel()

var body: some View {
  NavigationStack(path: $presentedDogs) {
    List(dogVM.dogs) { dog in
      NavigationLink(dog.name, value: dog)
     }
    .navigationTitle("Dogs World")
    .navigationDestination(for: Dog.self) { dog in
      DogDetails(dog: dog)
    }
  }
}

Here we define a common @State var which will hold the value for our presentedDogs.

Now our NavigationStack initializer changes to take a path with the binding to our just declared @State var, informing to the Stack where we want to stack our views.

Cool, at this point the functionality is the same, but now we can do things like creating new views that you simply can push into the stack, like this:

NavigationLink("Add a New Dog!", value: Dog(name: "A new dog"))

But, let’s make our presentation a little more robust and interesting before we can continue.

What if we need to present a different data type and not just dogs?


Handling different data types

In order to use our own Stack we need to have a model that conforms to Hashable.

In this case if we want to present another kind of data, let’s say Cats, we can do so by creating an enumeration with associated values, make it Hashable and use that instead of our initial Dog model.

Let’s see.

enum Screen: Hashable {
  case dogs(Dog)
  case cats(Cat)
}

In the following code I’ve refactored the view to something more appealing to our example, let’s see.

NavigationStack(path: $presentedScreens) {
  ScrollView {
    LazyVStack(spacing: 10, pinnedViews: [.sectionHeaders]) {
      Section {
        ForEach(dogVM.dogs) { dog in
          NavigationLink(dog.name, value: Screen.dog(dog))
            .font(.title).fontWeight(.light)
        }
      } header: {
        HStack {
          Text("Dogs")
            .font(.title2).fontWeight(.bold)
          Spacer()
        }
        .padding(12)
        .background(Color.primary
          .colorInvert()
          .opacity(0.75))
      }
      
      Section {
        ForEach(catsVM.cats) { cat in
          NavigationLink(cat.name, value: Screen.cat(cat))
            .font(.title).fontWeight(.light)
        }
      } header: {
        HStack {
          Text("Cats")
            .font(.title2).fontWeight(.bold)
          Spacer()
          
        }
        .padding(12)
        .background(Color.primary
          .colorInvert()
          .opacity(0.75)
        )
      }
    }
  }
  .navigationTitle("Pets World")
  .navigationDestination(for: Screen.self) { screen in
    switch screen {
    case .dog(let dog):
      DogDetails(dog: dog)
    case .cat(let cat):
      CatDetails(cat: cat)
	  }

Here I’ve added a Cat model, with its ViewModel, just like the Dog model and its being instantiated it just below the dogsVM. – we can do better but this is not the point of the post! –

Then the important part here is that now the .navigationDestination can switch over the Screen enum and not just a single model giving us the flexibility that we need in this case.

To make this work you have to also match your type in the NavigationLink so we use our new enum with its corresponding type.

Come on, give it a try!

See how the LazyVStack allow us to have pinnedViews with .sectionHeaders and also with .sectionFooters (we’re not using it in this example) which is very cool IMO.

In this way we can manage a simple view hierarchy, but the flexibility is more profound than this.

First refactor a little more to make our models more interesting.


A little refactor

Our ScrollView now is simply our PetsRootView and it contains all inside the NavigationStack.

Our Content view now looks like this:

struct ContentView: View {
  @State var presentedScreens: [Screen] = []
  var pets = PetsViewModel()
  
  var body: some View {
    NavigationStack(path: $presentedScreens) {
      PetsRootView(pets: pets)
        .navigationTitle("Pets World")
        .navigationDestination(for: Screen.self) { screen in
          switch screen {
          case .dog(let dog):
            DogDetails(dog: dog)
          case .cat(let cat):
            CatDetails(cat: cat)
          }
        }
    }
  }
}

Now its simpler to reason about.

You can start to know with more clarity where to place new views and how you can start to handle more sophisticated navigation in a more programmatic way.

You can also go forward and encapsulate your navigation logic inside a new component that can be shared across the whole app with an @Environment object property where you can manage the different paths that your app may hold.


Sharing the Navigation data

Let’s create our new simple component, always with the latest changes that comes with iOS 17.

For more info on the new Observable Pattern in SwiftUI check my previous post: The @Observable Macro: A New Way to Track Changes to Data in SwiftUI .

Create a new file called AppRoute

@Observable
class AppRouter {
  var pets = [Route]()
  
  public enum Route: Hashable {
    case dog(Dog)
    case cat(Cat)
  }
}

After this we need to set it as an Environment object that we are going to define in our App entry point.

@main
struct SwiftUINavDemoApp: App {
  @State private var router = AppRouter()
  
  var body: some Scene {
    WindowGroup {
      ContentView()
        .environment(router)
    }
  }
}

In our App entry point we define the @State variable and then the .environment object with our View Modifier .environment.

Then our ContentView loads this with @Environment object to define our NavigationStack Path.

struct ContentView: View {
  var pets = PetsViewModel()
  
  @Environment(AppRouter.self) private var router
  
  var body: some View {
    @Bindable var router = router
    
    NavigationStack(path: $router.pets) {
      PetsRootView(pets: pets)
        .navigationTitle("Pets World")
        .navigationDestination(for: AppRouter.Route.self) { screen in
          switch screen {
          case .dog(let dog):
            DogDetails(dog: dog)
          case .cat(let cat):
            CatDetails(cat: cat)
          }
        }
    }
  }
}

Look inside the body, we need to set a @Bindable that correspond to the @Environment if not the compiler gets mad. Probably a SwiftUI Bug here.

Then you can define the binding property to the path of the NavigationStack and also our .navigationDestination needs to reflect the type that we are dealing with.

In our PetsRootView we modify our NavigationLink to be like this.

NavigationLink(dog.name, value: AppRouter.Route.dog(dog))

Don’t forget to set  .environment(AppRouter()) in your preview!

#Preview {
  ContentView()
    .environment(AppRouter())
}

As you can see now you have a greater control over your navigation.

After all this work you can start exploring with the new navigation structure.

You can define methods inside your AppRouter and even start adding new paths for completely new different navigation destinations.


What’s next?

What comes next it’s up to you, you can explore further also with NavigationPath() which uses type erasure where you can make your navigation even more powerful.

Check it out: https://developer.apple.com/documentation/swiftui/navigationpath


Summary

Now let’s summarize the goodies of Navigation in SwiftUI (After iOS 16 of course 😉 )

  1. The implementation of NavigationStack give us a much more robust Navigation combined with pre defined Navigation Destinations.
  2. The Navigation is now value based so you can start combining common types.
  3. You can have a complete programmatic navigation with the use of NavigationPath.
  4. Now you can abstract your navigation and leverage different paths for your view hierarchies.
  5. Much more flexibility and clarity in the code.

That’s a wrap!

Hope you have learned something new today!

Thanks for reading! 🤓


Github Repo: https://github.com/FeRHeDio/SwiftUINavDemo.git