Articles, podcasts and news about Swift development, by John Sundell.

Programmatic navigation in SwiftUI

Published on 01 Oct 2021
Discover page available: SwiftUI

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.

Essential Developer

Essential Developer: Learn the most up-to-date techniques and strategies for testing new and legacy Swift code in this free practical course for developers who want to become complete senior iOS developers. This virtual event includes lectures, mentoring sessions, and step-by-step instructions. Click to learn more.

Switching tabs

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.

Controlling navigation stacks

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.

Support Swift by Sundell by checking out this sponsor:

Essential Developer

Essential Developer: Learn the most up-to-date techniques and strategies for testing new and legacy Swift code in this free practical course for developers who want to become complete senior iOS developers. This virtual event includes lectures, mentoring sessions, and step-by-step instructions. Click to learn more.

Conclusion

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!