Programmatic navigation in SwiftUI

Christos Karaiskos
5 min readNov 24, 2019

--

Forcing navigation to a specific view as a response to an external event has always been a common action in iOS development. Deep links to our app, taps on push notifications, Home Screen quick actions, in-app modals triggered by our backend are common examples that showcase the need to land on a specific screen in our app.

Forcing Navigation in UIKit vs SwiftUI

In UIKit, the developer can create this forced hierarchy step by step, e.g. by commanding the tab bar to select its 2nd item, by grabbing the active UINavigationController and pushing several UIViewController instances to it, by presenting a modal directly over the main window’s rootViewController or even by utilizing a second UIWindow. This can effectively occur from any part of the codebase.

On the contrary, and due to its declarative nature, SwiftUI requires each view to list all its possible navigation paths up front. Whether any of those paths is active or not is decided by some condition (e.g. a Bool variable that controls whether a view is presented). The framework usually accepts bindings to such variables, in order to be able to change their values internally when some aspect of navigation changes (e.g. user dismisses the presented view).

Force-selecting the visible tab in TabView

In order to be able to programmatically change the selected tab, we can provide our TabView with a selection binding to a Hashable type during initialization and use a unique tag of the same type for each of our tab views. Any change to that variable programmatically will force a tab change, as long as there is a tab with the exact same tag. A user-initiated tap on another tab will still change the value of this variable, but this is handled internally by the framework through our provided binding. For this example, I have chosen to use an Int tag, although any Hashable type will do (e.g. String or an enum with a Hashable raw type).

Force-pushing views within NavigationView

Using a NavigationLink within a NavigationView provides two alternatives:

The first alternative takes a Binding<Bool> parameter for deciding if the link should be active. This is useful for views where only a single NavigationLink exists. Here is an example:

Pushing or popping the view programmatically simply requires setting the proper value to the isScreenActive variable.

If we want to programmatically push different NavigationLink destinations in our NavigationView, the solution is to give a tag to each NavigationLink and use a single nullable selection binding which automatically decides the link that is active at any time. If the selection variable is nil, no destination view is shown.

Force-presenting a view modally

Modals in SwiftUI are called sheets. Their presentation is controlled either by a Bool binding or by a binding to an Identifiable item. Often, this variable is toggled as a result of a user action in another UI element within the view, e.g. a button being tapped, resulting to the presentation taking place. As in previous cases, this variable can also be set programmatically, forcing presentation.

The plain Bool case looks like this:

The alternative is to use an Identifiable type:

A non-nil modalItem will trigger presentation. This way, the modal view can be customized according to the modal item’s contents at the point of presentation.

Coordinating forced navigation

The examples in the previous sections have declared the navigation-controlling variables as @State. This would be enough if navigation to the next view solely happened because of some internal action in the current view. What we want, though, is to be able to override these variables from outside the view. E.g., when a 3D Touch Home Screen action is triggered, our UIAppDelegate subclass (or UISceneDelegate subclass for iOS ≥ 13) is the instance that receives the event and must propagate it somehow to the relevant view(s) so that the proper UI is built.

My personal approach is to keep an instance of a custom ScreenCoordinator class in the environment and expose there all navigation-controlling variables that need to be accessed from various parts of the app. Any change in this observable object will immediately propagate to the affected views. For a very simple app containing some of the views presented above, that may look something like (of course, in a real app, names would be far more descriptive than this):

When we want to show a specific view, we simply set the proper combination of values on our screen coordinator object to match our intended result. The object itself is initialized at the point where our root view is first set up and a reference is stored so that we can change when needed:

Through the environment, the screen coordinator propagates to each affected view. To access it, the view must declare:

Bindings work as usual, but this time refer to the screen coordinator’s properties, e.g. $screenCoordinator.selectedPushedItem.

Summary

Forced navigation in SwiftUI requires a different approach when compared to UIKit. In SwiftUI, each view is responsible for monitoring the conditions under which navigation to other views occurs. This is performed in the form of binding variables whose value can be changed by our code or the framework itself. Controlling navigation from any part of our app lies in the way we control and propagate the values of these navigation-controlling variables to each affected view.

--

--