How to use NavigationStack in SwiftUI
Sep 02, 2024“NavigationView will be deprecated in a future version of iOS: use NavigationStack or NavigationSplitView instead.”
You might have seen this warning when using NavigationView
. Apple deprecated NavigationView
in favor of NavigationStack
and NavigationSplitView
for SwiftUI apps targeting iOS 16 and later.
Table of Contents
- Difference between NavigationStack and NavigationView
- What is NavigationStack SwiftUI
- How to go to a new view in SwiftUI using NavigationStack
- Programmatic navigation with NavigationStack
Difference between NavigationStack and NavigationView
While NavigationView
still functions currently, it's no longer the recommended approach for building navigation in SwiftUI. NavigationView
offered a basic way to handle navigation, but it lacked the flexibility for more complex navigation scenarios. It relied heavily on the view hierarchy, which could be cumbersome for intricate navigation flows. Apple recommends using NavigationStack
for single-column navigation (common on iPhones) and NavigationSplitView
for multi-column navigation (commonly used on iPadOS and macOS).
What is NavigationStack SwiftUI
NavigationStack
manages a stack of views, with the root view always at the bottom. NavigationLink
adds new views on top of the stack. Here's a simpler way to understand using a NavigationStack in SwiftUI:
Imagine a stack of plates. The bottom plate represents the main screen of your app (the root view). You can't remove it. Think of NavigationLink
as a way to add new plates on top. When someone taps a NavigationLink
, a new view is added to the top of the stack, just like placing a new plate on top.
Back buttons or swipe gestures remove views from the top, going back in navigation history ie, removing the topmost plate. The topmost plate (view) is always the one you see on the screen.
How to go to a new view in SwiftUI using NavigationStack
To go to a new view in SwiftUI, you first need to wrap the root view of your app's window scene within NavigationStack
. This establishes the navigation hierarchy for your app.
struct ContentView: View {
var body: some View {
NavigationStack {
// Your app's content goes here
}
}
}
Then, you'll have to use `NavigationLink` to add new views to the navigation stack. There are two main ways to achieve navigation using NavigationStack
and NavigationLink
.
1. Navigation Based on Views:
This is the most straightforward approach. You directly link a NavigationLink
to a specific destination view. When the user taps the link, the destination view is pushed onto the navigation stack, and the user navigates to that view.
struct ContentView: View {
var body: some View {
NavigationStack {
VStack(spacing: 40) {
NavigationLink("Details") {
DetailsScreen()
}
NavigationLink("Profiles") {
ProfileScreen()
}
NavigationLink("Settings") {
SettingsScreen()
}
}
.navigationTitle("Main View")
}
}
}
struct DetailsScreen: View {
var body: some View {
Text("Details Screen")
.navigationTitle("Details")
}
}
struct ProfileScreen: View {
var body: some View {
Text("Profile Screen")
.navigationTitle("Profile")
}
}
struct SettingsScreen: View {
var body: some View {
Text("Settings Screen")
.navigationTitle("Settings")
}
}
Result:
This code builds a simple app with different screens. The main screen ("Main View") has buttons for Details, Profiles, and Settings. The NavigationStack
will contain the root view ie, the ContentView
. Whenever the user navigates to any of the screens(Details, Profile, or Settings), that view is also pushed to the stack. When navigating back to the Main View, the newly added view is popped out, remaining just the ContentView
in the stack.
2. Navigation Based on Data Types:
This approach offers more flexibility. You associate a data type with a NavigationLink using the navigationDestination(for:destination:)
modifier. Then, you can create multiple NavigationLink instances presenting different data of the same type. The navigationDestination
defines the view to be displayed based on the specific data type.
Parameters
- data: The type of data that this
destination
matches. - destination: A view builder that defines a view to display when the stack's navigation state contains a value of type
data
. The closure takes one argument, which is the value of thedata
to present.
enum NavigationDestinations: String, CaseIterable, Hashable {
case Details
case Profiles
case Settings
}
struct ContentView: View {
let screens = NavigationDestinations.allCases
var body: some View {
NavigationStack {
VStack(spacing: 40) {
ForEach(screens, id: \.self) { screen in
NavigationLink(value: screen) {
Text(screen.rawValue)
}
}
}
.navigationTitle("Main View")
.navigationDestination(for: NavigationDestinations.self) { screen in
switch screen {
case .Details:
DetailsScreen()
case .Profiles:
ProfileScreen()
case .Settings:
SettingsScreen()
}
}
}
}
}
Result:
In this example, an enum called NavigationDestinations
listing the different screens ("Details", "Profiles", and "Settings") is defined. Instead of separate navigation links for each screen, the ContentView
uses a single navigationDestination
modifier. This modifier takes the chosen NavigationDestinations
enum case and uses a switch statement to display the corresponding screen. This also works the same as before. This separates navigation logic from the view hierarchy.
In case you have multiple different data types, you will have to specify multiple destinations. For example,
struct ContentView: View {
let screens = NavigationDestinations.allCases
var body: some View {
NavigationStack {
VStack(spacing: 40) {
ForEach(screens, id: \.self) { screen in
NavigationLink(value: screen) {
Text(screen.rawValue)
}
}
NavigationLink(value: "Search") {
Text("Search")
}
}
.navigationTitle("Main View")
.navigationDestination(for: NavigationDestinations.self) { screen in
switch screen {
case .Details:
DetailsScreen()
case .Profiles:
ProfileScreen()
case .Settings:
SettingsScreen()
}
}
.navigationDestination(for: String.self) { text in
SearchScreen(text: text)
}
}
}
}
struct SearchScreen: View {
let text: String
var body: some View {
Text("\(text) Screen")
.navigationTitle(text)
}
}
Result:
An additional navigationDestination
is added, this time for the String type. This allows handling the "Search"
screen navigation. When the user clicks the "Search"
button, the destination value is the string "Search"
. The navigationDestination
for String
captures this value and uses it to create a SearchScreen
instance, passing the received string ("Search") as the search text.
In this case the navigation works for both the data types - the NavigationDestinations
enum and for the String
. This helps us for scalable apps because you might have different data types and you can switch between your views based on the data type.
Programmatic navigation with NavigationStack
You can also navigate programmatically using NavigationPath
within NavigationStack
. NavigationPath
is a collection type that represents the navigation history within a NavigationStack
. This allows you to push or pop specific views in the navigation stack based on data.
Bind the NavigationStack's path property to a @State
variable of type NavigationPath
in your view.
struct ContentView: View {
let screens = NavigationDestinations.allCases
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
VStack(spacing: 40) {
ForEach(screens, id: \.self) { screen in
NavigationLink(value: screen) {
Text(screen.rawValue)
}
}
}
.navigationTitle("Main View")
.navigationDestination(for: NavigationDestinations.self) { screen in
switch screen {
case .Details:
DetailsScreen()
case .Profiles:
ProfileScreen()
case .Settings:
SettingsScreen()
}
}
}
}
}
This navigationPath
variable helps to track all the navigation. The NavigationStack
uses the $navigationPath
binding to connect the internal navigation state with the navigationPath
variable. This allows for two-way communication between the UI and the navigation stack. In practice, it works very similarly to an array containing all the destinations navigated to reach a particular screen. For example, when navigating to DetailsScreen
, the navigationPath
will be containing two elements ie, the rootView(ie, the ContentView
) and the DetailsScreen
.
It also enables the definition of a pre-configured navigation route to a new destination, while also maintaining a history of all the traversed screens. For example, update the navigationPath
with some pre-configured routes like this:
@State private var navigationPath = [NavigationDestinations.Details, NavigationDestinations.Settings, NavigationDestinations.Profiles]
Result:
In this case, the app starts with "Details", "Settings", and then "Profiles" on the navigation stack and hence will automatically navigate to the ProfilesScreen
when the app starts. Behind that we have the SettingsScreen
and behind it we have the DetailsScreen
and at the root, we have the MainView(ContentView
).
By modifying the navigationPath array directly, you can programmatically push and pop screens from the navigation stack.
Programatically push a new view
Use the append(_:)
method on the NavigationPath
to add a new element representing the view you want to push. The new element can be any data type that conforms to Hashable
.
.navigationDestination(for: NavigationDestinations.self) { screen in
switch screen {
case .Details:
DetailsScreen()
case .Profiles:
ProfileScreen()
case .Settings:
SettingsScreen()
}
Button("Add view") {
navigationPath.append(NavigationDestinations.Details)
}
}
Result:
It uses the append(_:)
method to add a new element, NavigationDestinations.Details
, to the end of the navigation path array. Clicking the "Add view"
button will programmatically push the "Details" screen onto the navigation stack. This demonstrates how you can use the navigationPath
variable to control navigation beyond just defining the initial state.
Programatically pop a view
Use the removeLast()
method on the NavigationPath
to remove the last element, effectively popping the topmost view from the stack.
.navigationDestination(for: NavigationDestinations.self) { screen in
switch screen {
case .Details:
DetailsScreen()
case .Profiles:
ProfileScreen()
case .Settings:
SettingsScreen()
}
Button("Add view") {
navigationPath.append(NavigationDestinations.Details)
}
Button("Back") {
navigationPath.removeLast()
}
}
Result:
Clicking the "Back"
button will pop the topmost screen from the navigation stack. This allows users to navigate back in the history.
Programatically go back to root view
You can replace the entire navigation stack with a new path using navigationPath = NavigationPath()
. This pops all screens from the navigation stack, essentially taking the user back to the starting point i.e the root view.
.navigationDestination(for: NavigationDestinations.self) { screen in
switch screen {
case .Details:
DetailsScreen()
case .Profiles:
ProfileScreen()
case .Settings:
SettingsScreen()
}
Button("Add view") {
navigationPath.append(NavigationDestinations.Details)
}
Button("Back") {
navigationPath.removeLast()
}
Button("Root View") {
navigationPath = NavigationPath()
}
}
Result:
Clicking the "Root view"
button will reset the navigationPath
to an empty state, essentially taking the user back to the "Main View"
in this example.
Conclusion
NavigationStack
is the new recommended approach for building navigation in SwiftUI apps targeting iOS 16
and later. These new components offer more control and flexibility for complex navigation flows. While NavigationView still functions for now, transitioning your projects to the new navigation system is recommended to ensure compatibility with future versions of iOS.
Where to go next?
In this article you learned what is NavigationStack, two navigation approaches and programmatic navigation using NavigationPath. To further enhance your SwiftUI codebase, we strongly recommend delving into core mobile engineering concepts like MVVM in SwiftUI and Dependency Injection in Swift.