Let’s get started right away.
Overview
If you want to use programmatic SwiftUI navigation, you need to:
Define a set of possible screens you want to navigate do. Because we want to constraint ourselves only to a few possible screens, we need to define an
enum
Define
NavigationStack
with apath
, ideally at the very root of your appDefine a variable that will hold the current
path
Define a
.navigationDestination
inside thatNavigationStack
The Code
Defining the possible screens
Let’s get started by defining the possible screens we can navigate to:
enum NavigationDestination: Hashable
{
case userInfo, settings
}
As you can see, the enum has to be Hashable
.
Defining the navigation stack
I like to define the navigation stack as close to the main view as possible, so we don’t get weird visual glitches:
struct ContentView: View
{
var body: some View
{
NavigationStack(path: <we'll come back to this later>)
{
VStack
{
// More code will go here
}
}
}
}
As you can see, we left out some things. First, the path
is empty for now. We’ll put the variable that will hold the current navigation state in later. And second, the VStack
, which is where the rest of the app will go.
Defining the variable that will hold the current path
There are many ways you can do this, but I always create a class called AppState
that holds the current state of the app. So, let’s create that class and define a variable that will hold the current path inside of it:
@Observable
class AppState
{
var navigationPath: NavigationPath = .init()
}
Now, we can create the AppState
and propagate it into the rest of the app:
@main
struct KCCApp: App
{
var appState: AppState = .init()
var body: some Scene
{
WindowGroup
{
ContentView()
.environment(appState)
}
}
}
Let’s use it in our main content view:
struct ContentView: View
{
@Environment(AppState.self) var appState
var body: some View
{
@Bindable var appState: AppState = appState
NavigationStack(path: $appState.navigationPath)
{
VStack
{
// More code will go here
}
}
}
}
Defining navigation destinations
Keep in mind, you have to attach the .navigationDestination
modifier to the VStack
inside the NavigationStack
, and not to the NavigationStack
itself!
struct ContentView: View
{
@Environment(AppState.self) var appState
var body: some View
{
@Bindable var appState: AppState = appState
NavigationStack(path: $appState.navigationPath)
{
VStack
{
// More code will go here
}
.navigationDestination(for: NavigationDestination.self) { destination in
switch destination
{
case .userInfo:
Text("User Info")
case .settings:
Text("Settings")
}
}
}
}
}
In a nutshell, we’re telling the navigationDestination
:
Whenever the navigationPath changes, and the new item in it is a NavigationDestination, check which type of NavigationDestination it is, and open the right view for it.
Making it work
Now, all we need to do is add a NavigationDestination
to the navigationPath
:
struct ContentView: View
{
@Environment(AppState.self) var appState
var body: some View
{
@Bindable var appState: AppState = appState
NavigationStack(path: $appState.navigationPath)
{
VStack
{
Button
{
appState.navigationPath.append(NavigationDestination.userInfo)
} label: {
Text("Open User Info")
}
.buttonStyle(.bordered)
Button
{
appState.navigationPath.append(NavigationDestination.settings)
} label: {
Text("Open Settings")
}
}
.navigationDestination(for: NavigationDestination.self) { destination in
switch destination
{
case .userInfo:
Text("User Info")
case .settings:
Text("Settings")
}
}
}
}
}
And here it is! You have done it.
Some extra stuff
More convenient navigation
If you want to go further beyond, you can create a function inside AppState
that will append the right screen for you, so you don’t have to keep writing out the entire enum every time:
@Observable
class AppState
{
var navigationPath: NavigationPath = .init()
// MARK: - Functions
func navigate(to destination: NavigationDestination)
{
self.navigationPath.append(destination)
}
}
And then you can use it like this:
Button
{
appState.navigate(to: .userInfo)
} label: {
Text("Open User Info")
}
.buttonStyle(.bordered)
Button
{
appState.navigate(to: .settings)
} label: {
Text("Open Settings")
}
Navigating out of the stack
If you want to return to the root screen, you can make a few modifications to the enum, navigation function defined above, and the navigationDestination
modifier:
enum NavigationDestination: Hashable
{
case userInfo, settings, root
}
@Observable
class AppState
{
var navigationPath: NavigationPath = .init()
// MARK: - Functions
func navigate(to destination: NavigationDestination)
{
if destination == .root
{
self.navigationPath = .init()
}
else
{
self.navigationPath.append(destination)
}
}
}
struct ContentView: View
{
@Environment(AppState.self) var appState
var body: some View
{
@Bindable var appState: AppState = appState
NavigationStack(path: $appState.navigationPath)
{
VStack
{
Button
{
appState.navigate(to: .userInfo)
} label: {
Text("Open User Info")
}
.buttonStyle(.bordered)
Button
{
appState.navigate(to: .settings)
} label: {
Text("Open Settings")
}
}
.navigationDestination(for: NavigationDestination.self) { destination in
switch destination
{
case .userInfo:
Text("User Info")
Button
{
appState.navigate(to: .root)
} label: {
Text("Take me home")
}
case .settings:
Text("Settings")
case .root:
EmptyView()
}
}
}
}
}
We have basically told the app:
If the destination is root, clear the current navigation stack (e.g. take us back home). If not, then take us to the new screen. Also, because one of the navigation options is root, if that gets selected, just don't add anything to the stack.
And that’s all I’ve got for you! Now you can experiment :)
Leave a comment if you have any questions or ideas.