So far we have relied on the SwiftUI navigation APIs to handle navigation between our screens in both our NavigationStacks and TabViews in response to user input.
However, when facing complex navigation hierarchies and requirements, it can become necessary for us to take on the responsibility of managing our navigation programmatically. In this post we will learn the ways SwiftUI allows us to perform programmatic navigation in both NavigationStack and TabView.
I highly recommend you check out our previous posts in the series:
Programmatically controlling NavigationStack
Starting with iOS 16, when we declare our NavigationStack, we have two initializers available to us, each taking a binding to a path variable. The first initializer accepts a collection of a single data type, and the second accepts a type-erased collection type called NavigationPath.
Both of these collections represent the current state of our navigation stack, allowing us to both observe and modify it programmatically. To see how the path variable corresponds to our stack, let’s take a look at a depiction:
Notice how the root screen is not added to our path collection, instead, only the screens added on top of the root screen in the navigation stack are appended. If our path variable is empty, that means we are only displaying the root screen. Alternatively, if it is not empty, any screens contained in the collection are screens added to our stack hierarchy, with the last element representing the top of stack (our currently visible screen).
Programmatic navigation using single data type path
When we only have a single data type associated with a navigation destination, we can make use of the first initializer provided for NavigationStack.
Here we use a State property called presentedArticles to manage the state of our navigation stack.
Pass Binding to presentedArticles as the path argument for our NavigationStack's initializer.
Now, we can use the reference to our presentedArticles variable to control navigation as needed. For example, we can use it to quickly pop back to our root view:
We could even use it to add multiple views at once, allowing us to rapidly navigate deep into our view hierarchy:
As stated earlier, the path variable matches our NavigationStack's current state. Any changes made programmatically to it will be reflected by our view hierarchy. The opposite is also true, any changes made to our view hierarchy as a result of user input, will be reflected in our path variable, allowing us to observe when screens are added or removed.
By observing our navigation stack through the path binding, we can take any necessary action required when views are added/removed in our stack. Imagine we have a document management app and we want to keep a record of the path of folders a user navigates through.
We can use the onChange(of:) view modifier to observe changes to our path and log them:
Now every time a user navigates between different folders, we can keep an accurate record.
Programmatic navigation for different view types
When we have multiple data types corresponding to different navigation destinations, we can take advantage of SwiftUI’s NavigationPath type. With NavigationPath we can achieve all the same functionality we’ve already covered above with only minor tweaks in some places.
All we have to do is change the path variable type to NavigationPath and pass it to the second initializer provided for NavigationStack:
Our new path variable that can now support multiple data types. In this example it can hold both Article and String data used for navigation.
Pass Binding for our new variable to our NavigationStack.
Allows us to navigate to Article destinations.
Allows us to navigate to Other destination by associating it with String types.
The popToRoot() method we implemented earlier and our observation of the path variable don't require any big change, other than updating to use the new variable of course. The only method we need to tweak a bit now that we are using NavigationPath is presentMultipleArticles(), which we will also rename. To get the same functionality as before our method now looks like this:
Since the append method for NavigationPath only accepts Hashable conforming types, we take advantage of Swift's generics capabilities to ensure only Hashable data types are appended to our presentedViews variable.
Programmatically controlling TabView
As stated earlier, so far we have allowed the SwiftUI navigation APIs to navigate between tabs based on user input. When a user clicks on a particular tab, the system automatically navigates to the selected tab. Additionally, we have no explicit way of knowing which tab is selected, something that may be useful to us (more on this below).
Similar to NavigationStack, starting in iOS 16, TabView has a second initializer that accepts a selection binding which holds an Int value corresponding to the currently selected tab.
We use a @State property to hold our currently active tab value. We choose to start at the first tab.
Pass Binding to activeTab in our TabViews initializer.
In order to associate a value with our tabs, we must use the tag(_:) modifier. Here we associate it to an Int value.
Now, when ever users click on a tab, our activeTab variable will hold the Int value matching the tag value of the current tab. Additionally, if we modify the activeTab property in our code, the current tab displayed in our view will be changed to the one matching the value we set.
Relying on an Int value to represent our current tab is simple, however, there is a cleaner and more explicit way to keep track of our tabs, and that is accomplished by using an Enumeration.
Enumerations assign related names to a set of integer values by default, making them a perfect fit for our use case. Heres what our updated code looks like when we define and use an enum:
Finally, we can also observe which tab is selected by observing the activeTab state variable like so:
One shortcoming of this observation method is that it only fires when we click on a different tab than the one we are currently in. As useful as this may be, it would be even better if we could also know when users click on the tab we are currently on as well. More on this below.
Pop to root using TabView
Detecting when the current tab is clicked is useful for situations where we wish to enable users to quickly pop back to the root screen of a tab by clicking on that same tab. This is a time-saving shortcut provided in many popular apps such as Instagram and Yelp to name a few.
When users navigate deep into the view hierarchy of an active tab and wish to return to the first screen, they can simply click on the active tab instead of manually dismissing each screen.
The first step in detecting when the current tab is clicked, is to create a simple extension on Binding:
Unlike SwiftUI’s onChange(of:) view modifier, this extension will allow us to detect all tab selections, including the case where the current tab is selected by triggering the code in the closure we pass. Now that we have this extension we can use it to observe tab selections:
Use our new extension to notify our NavController object of the tab that was just clicked. More on NavController below.
Pass our new NavController object to the workouts screen. WorkoutsScreen will have its navigation stack programmatically controlled by it. This is shown below.
Here is the definition of the NavController object. It provides the workoutsStack used by the NavigationStack in WorkoutsScreen. We can see that it pops to the root of the navigation stack for the workouts screen when the workouts tab is clicked, only if it is our currently active tab:
In this post, we learned about programmatic navigation in SwiftUI, a powerful technique that grants you greater control over your app's navigation flow. We explored how to control navigation in both NavigationStack and TabView using path and selection bindings respectively. By programmatically managing your navigation, you can handle complex hierarchies and navigate to specific destinations with ease.