Routing and Deeplink with NavigationStack

Safwen Debbichi
Bforbank Tech
Published in
8 min readJun 1, 2024

NavigationStack vs NavigationView

When NavigationView was first introduced in SwiftUI, people questioned the viability of it’s new way of navigation. Most of the projects that migrated from UIKit to SwiftUI kept using UIKit’s UINavigationController to manage their application navigation for two reasons: either they didn’t trust the NavigationView way of navigation or the SwiftUI’s observation based behavior made navigation incompatible with legacy design patterns like Coordinators.

Generally, we want to trigger navigation programmatically because we need to implement some business logic in the background and navigate based on that logic. SwiftUI providesd the NavigationLink, which accepts a Bool or a Hashable binding, allowing us to navigate programmatically. However, this method of navigation lacks flexibility. Additionally, having to set a NavigationLink with an EmptyView as its label makes the process messy and looking like a workaround.

NavigationStack came in with a more advanced way of navigation. It still supports NavigationLink but without a binding. Now a NavigationLink is like a Button that, when clicked, the value provided in the NavigationLink will be passed to the destination view via the navigationDestination view modifier.

NavigationStack & NavigationPath

NavigationStack has two initializers, both of which take a ViewBuilder for a root view. One initializer also takes an additional binding of NavigationPath or a binding of Data parameter called ‘path’.

When tracking navigation for homogeneous view data, we can use Data binding. However, when dealing with heterogeneous and more complex navigation, we can use NavigationPath.

NavigationPath

A NavigationPath is a simply a type-erased list of Data representing the stack of views in the navigation.

This NavigationPath keeps track of the navigation stack state and allows us to implement more advanced navigation features like deeplink, state restoration, and programmatically navigate :

  • Deeplink: Having access to the NavigationPath and resetting or injecting data in it, directly impacts the current navigation as like we said the NavigationPath is like a URL path of the current navigation. This allows us to make ‘deep’ navigation.
  • State Restoration: When injecting Codable data in the NavigationPath, the path’s codable representation can be used to save the current path and restore it later. This can be useful to create save points in a flow.
  • Programmatical Navigation: we can discard NavigationLinks to trigger navigation by directly appending or removing elements in the NavigationPath.

Routing via Navigation Destination View Modifier

SwiftUI provided a new view modifier navigationDestination, to be able to chose the desired View destination for the triggered navigation.

The view modifier needs to be used inside the NavigationStack. And it there are three ways to use it.

  • By providing a Boolean binding, when the Boolean passes from false to true, the navigation is triggered
  • By providing a Hashable Optional Binding, when the value of the Hashable passes from nil to a non-nil value, the navigation is triggered and we have access to the provided value in the destination’s view builder
  • By providing the Type of the expected changed value, the view modifier will trigger every time the value changes. This can be used by NavigationLinks and NavigationPath

Basic Navigation

We can use NavigationStack in a very basic way. Meaning, we can trigger navigation with a NavigationLink, a binding of Boolean or a binding of Hashable.

Example:

struct SomeStruct: Hashable {
let firstname: String
let lastname: String
}

struct SomeView: View {
@State var isPresented = false
@State var value: SomeStruct?
var body: some View {
NavigationStack {
VStack(spacing: 50) {
// Navigation Link navigation
NavigationLink("NavigationLink", value: "Toto")
// Boolean binding triggered Navigation
Button {
isPresented.toggle()
} label: { Text("Boolean Binding Navigation") }
// Hashable binding triggered Navigation
Button {
value = .init(firstname: "Toto", lastname: "Titi")
} label: { Text("Hashable Binding Navigation") }
}
// Hashable value binding destination
.navigationDestination(item: $value) { value in
VStack {
Text(value.firstname)
Text(value.lastname)
}
}
// Boolean value binding destination
.navigationDestination(isPresented: $isPresented) {
Text("Hello !")
}
// Hashable value coming from NavigationLink
.navigationDestination(for: String.self) { value in
Text(value)
}
}
}
}

Advanced Navigation

In order to achieve advanced navigation like deeplinks and state restoration, we have to initialize the NavigationStack with a NavigationPath. But to be able to manipulate the NavigationPath, we need to propagate through the NavigationStack using @EnvironmentObject or with @Envionment.

Router

I will create a modifiable Environment Value called Router that will hold the current NavigationPath of my NavigationStack and will be able to change the navigation from any view.

Please follow this article to see how we create a modifiable EnvironmentValue

struct Router: DynamicEnvironment {
var id: UUID
var path: NavigationPath

init(id: UUID = UUID(), path: NavigationPath = NavigationPath()) {
self.path = path
self.id = id
}

init<Component: PathComponent>(id: UUID = UUID(), components: [Component] = []) {
self.path = NavigationPath(components)
self.id = id
}
}

The following code implements the mutating methods, push, pop and popToRoot:

extension Router {
// Removes the current NavigationPath and creates a new one with a list of Hashable Components
mutating func setFullPath<Component: PathComponent>(components: [Component]) {
path = NavigationPath(components)
}
// Pushes one or more component by appending the NavigationPath
mutating func push<Component: PathComponent>(component: Component...) {
for item in component {
path.append(item)
}
}
// Remove a count of components from the NavigationPath
mutating func pop(count: Int = 1) {
path.removeLast(count)
}
// Reinitializes the NavigationPath
mutating func popToRoot() {
path = NavigationPath()
}
}

The following code implements methods that saves, loads and removes the NavigationPath codable representation:

extension Router {
func save(forKey key: String) {
guard let codable = path.codable else { return }
do {
let encoder = JSONEncoder()
let data = try encoder.encode(codable)
UserDefaults.standard.setValue(data, forKey: key)
} catch {}
}

func removeSave(forKey key: String) {
UserDefaults.standard.removeObject(forKey: key)
}

mutating func load(forKey key: String) {
guard let data = UserDefaults.standard.value(forKey: key) as? Data else {
self.path = NavigationPath()
return
}
do {
let representation = try JSONDecoder().decode(
NavigationPath.CodableRepresentation.self,
from: data)
self.path = NavigationPath(representation)
} catch {
self.path = NavigationPath()
}
}
}

PathComponent

I created a protocol called PathComponent, that conforms to Codable and Hashable so that every path component would conform to this protocol:

protocol PathComponent: Codable, Hashable, CaseIterable  {
var id: String { get }
}

Here is an example of path declaration:

enum WelcomePath: PathComponent {
var id: String {
switch self {
case .login: "login"
case .registration: "registration"
}
}

case login, registration
}
enum MainPath: PathComponent {
var id: String {
switch self {
case .home: "home"
}
}

case home
}
struct RegistrationSubPath: PathComponent {
var id: String { step.id }
var step: RegisterStep
}

enum RegisterStep: Codable, Hashable {
case first, second, third(savePoint: Applicant? = nil), fourth
}

As you can see, all of the above are PathComponents but some are Enums and other are Struct. which Proves the power of NavigationPath, because we can insert any PathComponent in the NavigationPath.

Router in action

Here is how i initialized the NavigationStack using the Router the being injected as EnvironmentValue on top of the NavigationStack.

@main
struct NavigationStackExampleApp: App {
var body: some Scene {
WindowGroup {
WelcomeView()
.modifier(DynamicEnvironmentModifier(keyPath: \.router, proxy: Router(path: NavigationPath())))
}
}
}
struct WelcomeView: View {
@Environment(\.router) @Binding var router: Router
var body: some View {
NavigationStack(path: _router.wrappedValue.path) {
VStack {
Button(action: {
navigateToLogin()
}, label: {
Text("Login")
.foregroundColor(.white)
.padding()
}).frame(maxWidth: .infinity)
.background(Color.blue.cornerRadius(8))
.padding()
Button(action: {
navigateToRegistration()
}, label: {
Text("Register")
.foregroundColor(.white)
.padding()
}).frame(maxWidth: .infinity)
.background(Color.blue.cornerRadius(8))
.padding()
}
.onAppear {
navigateToSavePoint()
}
.navigationDestination(for: WelcomePath.self) { path in
switch path {
case .login: LoginView()
case .registration: RegisterView()
}
}
.navigationDestination(for: MainPath.self) { path in
switch path {
case .home: HomeView()
}
}
}.modifier(DynamicEnvironmentModifier(keyPath: \.registrationContext, proxy: .init(applicant: .init())))
}

func navigateToLogin() {
router.push(component: WelcomePath.login)
}

func navigateToRegistration() {
router.push(component: WelcomePath.registration)
}

func navigateToSavePoint() {
router.load(forKey: kRegistrationSavePointKey)
}
}

As we can see in the navigation destination, the WelcomeView handles the navigation by Path type and routes to LoginView, RegisterView and HomeView.

Now will try to handle routing inside of the Registration workflow:

struct RegisterView: View {
@Environment(\.router) @Binding var router: Router
@Environment(\.registrationContext) @Binding var registrationContext: RegistrationContext
var body: some View {
VStack {
Button {
router.push(component: RegistrationSubPath(step: .first))
} label: {
Text("Start")
.foregroundStyle(.white)
}.padding()
.background(Color.black)
.clipShape(RoundedRectangle(cornerSize: .init(width: 6, height: 6)))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.cyan.ignoresSafeArea())
.navigationTitle("Registration")
.navigationBarTitleDisplayMode(.large)
.navigationDestination(for: RegistrationSubPath.self) { path in
switch path.step {
case .first: RegisterFirstStepView()
case .second: RegisterSecondStepView()
case .third(let applicant): RegisterThirdStepView(applicant: applicant)
case .fourth: RegisterFourthStepView()
}
}
}
}

Here the RegisterView handles the routing for the 4 steps, and every step corresponds to a view. And it starts by pushing the first step of the registration.

State Restoration

In the third step of registration we can get the codable of the current NavigationPath and store it in the UserDefaults and then restore it once we restart the application:

struct RegisterThirdStepView: View {
@Environment(\.router) @Binding var router: Router
@Environment(\.registrationContext) @Binding var registrationContext: RegistrationContext
var applicant: Applicant?
var body: some View {
VStack {
TextField(text: _registrationContext.wrappedValue.applicant.age) {
Text("Age")
}.textFieldStyle(.roundedBorder)
.padding(50)
Spacer()
Button(action: {
navigateToNextView()
}, label: {
Text("Next")
}).padding()
.background(Color.black)
.clipShape(RoundedRectangle(cornerSize: .init(width: 6, height: 6)))
Button(action: {
navigateBack()
}, label: {
Text("Back")
}).padding()
.background(Color.black)
.clipShape(RoundedRectangle(cornerSize: .init(width: 6, height: 6)))
Spacer()
Button {
saveProgress()
} label: {
Text("Save Progress")
}.padding()
.background(Color.black)
.clipShape(RoundedRectangle(cornerSize: .init(width: 6, height: 6)))
Button {
clearProgress()
} label: {
Text("Clear Progress")
}.padding()
.background(Color.black)
.clipShape(RoundedRectangle(cornerSize: .init(width: 6, height: 6)))
Spacer()
Text(registrationContext.applicant.description)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.purple.ignoresSafeArea())
.navigationBarTitleDisplayMode(.large)
.navigationTitle("Registration 3/4")
.onAppear {
if let applicant {
registrationContext.applicant = applicant
}
}
}

// Saves the current NavigationPath
func saveProgress() {
router.save(forKey: kRegistrationSavePointKey)
}
// Remove the stored codable of the NavigationPath and resets it
func clearProgress() {
router.removeSave(forKey: kRegistrationSavePointKey)
router.popToRoot()
}

func navigateBack() {
router.pop()
}

func navigateToNextView() {
router.push(component: RegistrationSubPath(step: .fourth))
}
}

Deeplink

To achieve deeplinks we can set the full path of the NavigationPath to land on the desired screen.

struct RegisterFourthStepView: View {
@Environment(\.router) @Binding var router: Router
@Environment(\.registrationContext) @Binding var registrationContext: RegistrationContext
var body: some View {
VStack {
TextField(text: _registrationContext.wrappedValue.applicant.country) {
Text("Country")
}.textFieldStyle(.roundedBorder)
.padding(50)
Spacer()
Button(action: {
navigateToLogin()
}, label: {
Text("Finish")
}).padding()
.background(Color.black)
.clipShape(RoundedRectangle(cornerSize: .init(width: 6, height: 6)))
Button(action: {
navigateBack()
}, label: {
Text("Back")
}).padding()
.background(Color.black)
.clipShape(RoundedRectangle(cornerSize: .init(width: 6, height: 6)))
Button(action: {
navigateBackToStep()
}, label: {
Text("Go Back to step 2")
}).padding()
.background(Color.black)
.clipShape(RoundedRectangle(cornerSize: .init(width: 6, height: 6)))
Button(action: {
router.popToRoot()
}, label: {
Text("Go Back To Welcome")
}).padding()
.background(Color.black)
.clipShape(RoundedRectangle(cornerSize: .init(width: 6, height: 6)))
Spacer()
Text(registrationContext.applicant.description)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.green.ignoresSafeArea())
.navigationBarTitleDisplayMode(.large)
.navigationTitle("Registration 4/4")
}

func navigateBack() {
router.pop()
}

func navigateBackToStep() {
router.pop(count: 2)
}

func navigateBackToWelcome() {
router.popToRoot()
}

func navigateToLogin() {
router.setFullPath(components: [WelcomePath.login])
}

The navigateBackToStep() pops two views to get back the second step and the navigateToLogin() replaces the current NavigationPath by the login path which is a very easy deeplink to do but very hard to do with NavigationView. Once we set the full NavigationPath to .login, the stack will have only login in it and not the previews stack.

Conclusion

In conclusion, navigation in SwiftUI is becoming very powerful thanks to NavigationStack. Now it’s up to us to use it the best way possible with an architecture that respects it’s way of working.

Example’s source code: https://github.com/SafwenD/NavigationStackExample

Mutable EnvironmentValue article: https://medium.com/@safwen.debbichi.91/mutable-environmentvalue-c61af3de7187

Souces: Apple’s Documentation

--

--