By default, the various navigation APIs that SwiftUI provides are very much centered around direct user input — that is, navigation that’s handled by the system in response to events like button taps and tab switching.
However, sometimes we might want to take more direct control over how an app’s navigation is performed, and although SwiftUI still isn’t nearly as flexible as UIKit or AppKit in this regard, it does offer quite a few ways for us to perform completely programmatic navigation within the views that we build.
Let’s start by taking a look at how we can take control over what tab that’s currently displayed within a TabView
. Normally, tabs are switched whenever the user manually taps an item within each tab bar, but by injecting a selection
binding into a given TabView
, we can both observe and control what tab that’s currently displayed. Here we’re doing just that to switch between two tabs that are tagged using the integers 0
and 1
:
struct RootView: View {
@State private var activeTabIndex = 0
var body: some View {
TabView(selection: $activeTabIndex) {
Button("Switch to tab B") {
activeTabIndex = 1
}
.tag(0)
.tabItem { Label("Tab A", systemImage: "a.circle") }
Button("Switch to tab A") {
activeTabIndex = 0
}
.tag(1)
.tabItem { Label("Tab B", systemImage: "b.circle") }
}
}
}
What’s really great, though, is that we’re not just limited to using integers when identifying and switching tabs. Instead, we can freely represent each tab using any Hashable
value — for example by using as an enum that contains cases for each tab that we’re looking to display. We could then encapsulate that piece of state within an ObservableObject
that we’ll be able to easily inject into our view hierarchy’s environment:
enum Tab {
case home
case search
case settings
}
class TabController: ObservableObject {
@Published var activeTab = Tab.home
func open(_ tab: Tab) {
activeTab = tab
}
}
With the above in place, we can now tag each of the views within our TabView
using our new Tab
type, and if we then inject our TabController
into our view hierarchy’s environment, then any view within it will be able to switch which tab that’s displayed at any time:
struct RootView: View {
@StateObject private var tabController = TabController()
var body: some View {
TabView(selection: $tabController.activeTab) {
HomeView()
.tag(Tab.home)
.tabItem { Label("Home", systemImage: "house") }
SearchView()
.tag(Tab.search)
.tabItem { Label("Search", systemImage: "magnifyingglass") }
SettingsView()
.tag(Tab.settings)
.tabItem { Label("Settings", systemImage: "gearshape") }
}
.environmentObject(tabController)
}
}
For example, here’s how our HomeView
could now switch to the settings tab using a completely custom button — it just needs to obtain our TabController
from the environment, and it can then call the open
method to perform its tab switch — like this:
struct HomeView: View {
@EnvironmentObject private var tabController: TabController
var body: some View {
ScrollView {
...
Button("Open settings") {
tabController.open(.settings)
}
}
}
}
Neat! Plus, since TabController
is an object that’s under our complete control, we could also use it to switch tabs from outside our main view hierarchy as well. For example, we might want to switch tabs in response to a push notification or some other kind of server event, which could now be done simply by calling the same open
method that we’re using within the above view code.
To learn more about environment objects, and the rest of SwiftUI’s state management system, check out this guide.
Just like tab views, SwiftUI’s NavigationView
can also be controlled programmatically as well. For example, let’s say that we’re working on an app that shows a CalendarView
as the root view within its main navigation stack, and that the user can then open a CalendarEditView
by tapping an edit button located within the app’s navigation bar. To connect those two views, we’re using a NavigationLink
, which automatically pushes a given view onto the navigation stack whenever it was tapped:
struct RootView: View {
@ObservedObject var calendarController: CalendarController
var body: some View {
NavigationView {
CalendarView(
calendar: calendarController.calendar
)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink("Edit") {
CalendarEditView(
calendar: $calendarController.calendar
)
.navigationTitle("Edit your calendar")
}
}
}
.navigationTitle("Your calendar")
}
.navigationViewStyle(.stack)
}
}
In this case, we’re using the stack
navigation style on all devices, even iPads, rather than letting the system pick which navigation style to use.
Now let’s say that we wanted to enable our CalendarView
to programmatically display its edit view, without having to construct a separate instance of it. To do that, we could inject an isActive
binding into our edit button’s NavigationLink
, which we then pass into our CalendarView
as well — like this:
struct RootView: View {
@ObservedObject var calendarController: CalendarController
@State private var isEditViewShown = false
var body: some View {
NavigationView {
CalendarView(
calendar: calendarController.calendar,
isEditViewShown: $isEditViewShown
)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink("Edit", isActive: $isEditViewShown) {
CalendarEditView(
calendar: $calendarController.calendar
)
.navigationTitle("Edit your calendar")
}
}
}
.navigationTitle("Your calendar")
}
.navigationViewStyle(.stack)
}
}
If we now also update CalendarView
to so that it accepts the above value using an @Binding
-marked property, then we can now simply set that property to true
whenever we’d like to display our edit view, and our root view’s NavigationLink
will automatically be triggered:
struct CalendarView: View {
var calendar: Calendar
@Binding var isEditViewShown: Bool
var body: some View {
ScrollView {
...
Button("Edit calendar settings") {
isEditViewShown = true
}
}
}
}
Of course, we could also have chosen to encapsulate our isEditViewShown
property within some form of ObservableObject
, for example a NavigationController
, just like we did when working with TabView
earlier.
So that’s how we can programmatically trigger a NavigationLink
that’s displayed within our UI — but what if we wanted to perform that kind of navigation without giving the user any direct control over it?
For example, let’s now say that we’re working on a video editing app that includes an export feature. When the user enters the export flow, a VideoExportView
is shown as a modal, and once the export operation was completed, we’d like to push a VideoExportFinishedView
onto that modal’s navigation stack.
Initially, that might seem very tricky to do, given that (since SwiftUI is a declarative UI framework) there’s no push
method that we can call whenever we’d like to add a new view to our navigation stack. In fact, the only built-in way to push a new view within a NavigationView
is to use NavigationLink
, which needs to be a part of our view hierarchy itself.
That being said, those navigation links doesn’t actually have to be visible — so one way to accomplish our goal in this case would be to add a hidden NavigationLink
to our view, which we could then programmatically trigger whenever our video export operation was finished. If we then also hide the system-provided back button within our destination view, then we can completely lock the user out of being able to navigate between those two views manually:
struct VideoExportView: View {
@ObservedObject var exporter: VideoExporter
@State private var didFinish = false
@Environment(\.presentationMode) private var presentationMode
var body: some View {
NavigationView {
VStack {
...
Button("Export") {
exporter.export {
didFinish = true
}
}
.disabled(exporter.isExporting)
NavigationLink("Hidden finish link", isActive: $didFinish) {
VideoExportFinishedView(doneAction: {
presentationMode.wrappedValue.dismiss()
})
.navigationTitle("Export completed")
.navigationBarBackButtonHidden(true)
}
.hidden()
}
.navigationTitle("Export this video")
}
.navigationViewStyle(.stack)
}
}
struct VideoExportFinishedView: View {
var doneAction: () -> Void
var body: some View {
VStack {
Label("Your video was exported", systemImage: "checkmark.circle")
...
Button("Done", action: doneAction)
}
}
}
The reason we’re injecting a doneAction
closure into our VideoExportFinishedView
, rather than having it retrieve the current presentationMode
itself, is because we’re looking to dismiss our entire modal flow, rather than just that specific view. To learn more about that, check out “Dismissing a SwiftUI modal or detail view”.
Using a hidden NavigationLink
like that could definitely be considered a somewhat “hacky” solution, but it works wonderfully, and if we look at a navigation link more like a connection between two views within a navigation stack (rather than just being a button), then the above setup does arguably make sense.
Although SwiftUI’s navigation system still isn’t nearly as flexible as those offered by UIKit and AppKit, it’s powerful enough to accommodate quite a lot of different use cases — especially when combined with SwiftUI’s very comprehensive state management system.
Of course, we also always have the option to wrap our SwiftUI view hierarchies within hosting controllers and only use UIKit/AppKit to implement our navigation code. Which solution that will be the most appropriate will likely depend on how much custom and programmatic navigation that we actually want to perform within each project.
I hope that you found this article useful, and feel free to share it if you did. If you have any questions, comments, or feedback, then feel free to reach out via email.
Thanks for reading!