Preface
By default, the various navigation APIs provided by SwiftUI are largely centered on user direct input—that is, navigation is handled by the system itself when the system responds to events such as button clicks and tag switching.
However, sometimes we might want to have more direct control over how our application's navigation is performed, and while SwiftUI is still not as flexible as UIKit or AppKit in this regard, it does provide quite a few ways to perform fully custom navigation in the built view.
Switch tags
Let's first look at how we can control the tags currently displayed in the TabView. Normally, the tags are switched when the user manually clicks on an item in each tab bar, but by injecting a selection binding into a given TabView we can observe and control the currently displayed tags. Here, all we have to do is to switch between two labels, which are marked with integers 0 and 1: Copy
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: "") } Button("Switch to tab A") { activeTabIndex = 0 } .tag(1) .tabItem { Label("Tab B", systemImage: "") } } } }
But the real good thing is that when identifying and switching labels, we are not limited to using integers. Instead, we are free to represent each tag using any Hashable value—for example by using an enum that contains the case of each tag we want to display. We can then encapsulate this part of the state in an ObservableObject so that we can easily inject into our view hierarchy environment:
enum Tab { case home case search case settings } class TabController: ObservableObject { @Published var activeTab = func open(_ tab: Tab) { activeTab = tab } }
With the above, we can now tag each view in the TabView with the new Tab type, and if we inject TabController into the view hierarchy environment, any view in it can switch the displayed Tab at any time.
struct RootView: View { @StateObject private var tabController = TabController() var body: some View { TabView(selection: $) { HomeView() .tag() .tabItem { Label("Home", systemImage: "house") } SearchView() .tag() .tabItem { Label("Search", systemImage: "magnifyingglass") } SettingsView() .tag() .tabItem { Label("Settings", systemImage: "gearshape") } } .environmentObject(tabController) } }
For example, now our HomeView can switch to the Settings tab using a completely custom button - it just needs to get our TabController from the environment, and then it can call the open method to perform the tag switch, like this:
struct HomeView: View { @EnvironmentObject private var tabController: TabController var body: some View { ScrollView { ... Button("Open settings") { (.settings) } } } }
Very good! Also, since the TabController is an object that is completely controlled by us, we can also use it to switch labels outside the main view hierarchy. For example, we might want to switch labels based on push notifications or other type of server events, which can now be done by calling the same open method in the above view code.
To learn more about environment objects and the rest of the SwiftUI state management system, check out this guide.
Control the navigation stack
Just like the tag view, SwiftUI's NavigationView can also be programmed to be customized. For example, suppose we are developing an application that displays a calendar view as the root view in its main navigation stack, and then the user can open a calendar editing view by clicking the Edit button located in the navigation bar of the application. To connect these two views, we used a NavigationLink which will automatically push it into the navigation stack whenever a given view is clicked:
struct RootView: View { @ObservedObject var calendarController: CalendarController var body: some View { NavigationView { CalendarView( calendar: ) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { NavigationLink("Edit") { CalendarEditView( calendar: $ ) .navigationTitle("Edit your calendar") } } } .navigationTitle("Your calendar") } .navigationViewStyle(.stack) } }
In this case, we use a stacked navigation style on all devices, even an iPad, rather than letting the system choose which navigation style to use.
Now let's assume that we want our CalendarView to display its edited view in a custom way without building a separate instance. To do this, we can inject an isActive binding into the NavigationLink of the edit button and pass it to our CalendarView:
struct RootView: View { @ObservedObject var calendarController: CalendarController @State private var isEditViewShown = false var body: some View { NavigationView { CalendarView( calendar: , isEditViewShown: $isEditViewShown ) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { NavigationLink("Edit", isActive: $isEditViewShown) { CalendarEditView( calendar: $ ) .navigationTitle("Edit your calendar") } } } .navigationTitle("Your calendar") } .navigationViewStyle(.stack) } }
If we now also update the CalendarView so that it accepts the above values using the @Binding binding property, now we can simply set the property to true and our root view's NavigationLink will be automatically fired as long as we want to display our edit view:
struct CalendarView: View { var calendar: Calendar @Binding var isEditViewShown: Bool var body: some View { ScrollView { ... Button("Edit calendar settings") { isEditViewShown = true } } } }
Of course, we can also choose to encapsulate the isEditViewShown property in some form of ObservableObject, such as the NavigationController, just like when we worked on TabView before.
This is how we trigger NavigationLink that appears in our user interface in a custom programmatic way - but what if we want to perform this kind of navigation without giving the user any direct control?
For example, let's now assume that we are developing a video editing application that includes the export function. When the user enters the export process, a VideoExportView is displayed as a modal. Once the export operation is completed, we want to push the VideoExportFinishedView to the navigation stack of that modal.
Initially, this may seem very tricky because (since SwiftUI is a declarative UI framework) there is no push method, which we can call when we want to add a new view into the navigation stack. In fact, the only built-in way to display a new view in a NavigationView is to use NavigationLink, which needs to be part of our view hierarchy itself.
That said, these NavigationLinks are not necessarily visible in fact – so in this case one way to achieve our goal is to add a hidden navigation link to our view, which we can then programatically trigger after the video export operation is done. If we also hide the system-provided return button in our target view, then we can completely lock the user to be able to manually navigate between the two views:
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") { { didFinish = true } } .disabled() NavigationLink("Hidden finish link", isActive: $didFinish) { VideoExportFinishedView(doneAction: { () }) .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: "") ... Button("Done", action: doneAction) } } }
Instead of having it retrieve the current presentationMode itself, we inject a doedAction closure into VideoExportFinishedView, we want to decouple the entire modal flow, not just that particular view. To learn more, see "Decoupled SwiftUI modal or detailed view".
Using such a hidden NavigationLink can definitely be considered a somewhat "black" solution, but it works very well, and if we think of a navigation link as a connection between two views in the navigation stack (rather than just a button), the above settings make sense.
summary
Although SwiftUI's navigation system is still not as flexible as the systems provided by UIKit and AppKit, it is already powerful enough to meet many different usage scenarios - especially when combined with SwiftUI's very comprehensive state management system.
Of course, we can also choose to wrap our SwiftUI view hierarchy in a managed controller and use only UIKit/AppKit to implement our navigation code. Which solution is the most appropriate may depend on how much custom and programmatic navigation we actually want to perform in each project.
This is the end of this article about SwiftUI custom navigation. For more related SwiftUI custom navigation content, please search for my previous articles or continue browsing the related articles below. I hope everyone will support me in the future!