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: Do
g 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:
NavigationStack
as a replacement forNavigationView
.- New
NavigationLink
constructs. - .
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 .environmen
t 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 😉 )
- The implementation of
NavigationStack
give us a much more robust Navigation combined with pre defined Navigation Destinations. - The Navigation is now value based so you can start combining common types.
- You can have a complete programmatic navigation with the use of
NavigationPath
. - Now you can abstract your navigation and leverage different paths for your view hierarchies.
- 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