Exploring Scalable SwiftUI Navigation

Nikita Goncear
6 min readMar 16, 2024

--

Preview

For newcomers to SwiftUI or those already familiar with it, establishing a solid navigation layer within the app stands as a top priority, being one of the cornerstones of the entire app architecture. Setting up a robust and scalable navigation layer is what could considerably decrease development and maintenance cost of your app, wherever a weak navigation could cause even more bad architectural decisions, and an eventual exponential growth of development costs.

In iOS development, MVVM has emerged as one of the most prevalent architectural patterns, especially in conjunction with SwiftUI framework. Therefore we will be building a navigational layer in compliance with that architecture.

MVVM Architecture Recap

As a brief recap, let’s review the components of MVVM architecture and their respective responsibilities:

  1. Model: Encapsulates data and business logic of the application.
  2. View: Responsible for presenting user interface. Views remain passive, solely focused on displaying data and broadcasting user interactions to the ViewModel. A view should not handle business or navigational logic.
  3. ViewModel: Acting as a binding between View and Model. A ViewModel supplies the View with data and commands. It may contain business logic or purely serve presentational data. Additionally, the ViewModel typically manages user interactions and updates the Model accordingly.

We won’t be going deeply into MVVM as it’s beyond the scope of this article. Instead, our focus will be on building SwiftUI app navigation while conforming to MVVM principles.

SwiftUI Navigation

In order to keep the article short and to the point, we will skip over the detailed breakdown of all SwiftUI’s navigation components, and focus solely on the APIs introduced in iOS 16 — NavigationStack. You can read more about SwiftUI’s navigation APIs in the official documentation.

As per official documentation:

NavigationStack — A view that displays a root view and enables you to present additional views over the root view.

Therefore NavigationStack serves as a component for managing hierarchical navigation within an app. At its core, the NavigationStack maintains a binding to a list of destinations. As destinations are pushed or popped out of the stack, a navigation animation is performed.

Moreover, the NavigationStack enables the automatic handling of navigation gestures and animations, therefore a swipe back gesture or a tap on the back button would pop latest destination in the stack.

Implementation

Routing Layer

For handling the navigation we will introduce a new Router layer. In this context, the Router functions as a stack of navigation views, similar to UINavigationController.viewControllers in the UIKit. Here’s a straightforward implementation of this layer:

class Router: ObservableObject {

@Published var stack = [Route]()

func push(to view: Route) {
stack.append(view)
}

func pop() {
stack.removeLast()
}

func popToRootView() {
stack.removeAll()
}
}
enum Route {
case fooView
case barView
}

extension Route: Hashable {}

Presentation Layer

Our presentational layer consists of an initial view ContentView and its corresponding ViewModel — ContentViewModel.

struct ContentView: View {

let viewModel: ContentViewModel

var body: some View {
VStack(spacing: 8) {
Text("Hello, ContentView!")
Button("Navigate") {
viewModel.didTapNavigateFooView()
}
.font(.subheadline)
}
}
}
class ContentViewModel: ObservableObject {

@Published var router: Router

init(router: Router) {
self.router = router
}

func didTapNavigateFooView() {}
}

Our second view in the hierarchy will be FooView:

struct FooView: View {

@ObservedObject var viewModel: FooViewModel

var body: some View {
VStack(spacing: 8) {
Text("Hello, FooView!")
Button("Navigate") {
viewModel.didTapNavigateBarView()
}
.font(.subheadline)

}
}
}
class FooViewModel: ObservableObject {

private let router: Router

init(router: Router) {
self.router = router
}

func didTapNavigateBarView() {}
}

And finally, we’ll have a very straightforward last view in the hierarchy — BarView:

struct BarView: View {
var body: some View {
Text("Hello, BarView!")
}
}

Now you can run the project and observe the initial view — ContentView with a text label and a button. At this stage, nothing will happen on tapping the button since we haven't handled it yet.

Binding All Together

Now, let’s proceed with implementing the navigation itself. We’ll achieve this by leveraging SwiftUI’s navigation APIs introduced in iOS 16 — NavigationStack.

struct ContentView: View {

@ObservedObject var viewModel: ContentViewModel

var body: some View {
NavigationStack(
path: $viewModel.router.stack
) {
VStack(spacing: 8) {
...
}
.navigationDestination(for: Route.self) { route in
switch route {
case .fooView:
FooView(
viewModel: viewModel.fooViewModel
)
case .barView:
BarView()
}
}
}
}
}

Let’s respond to View’s tap on the “Navigate” button and push routes to the stack directly from within the ViewModel:

class ContentViewModel: ObservableObject {

@Published var router: Router

var fooViewModel: FooViewModel {
.init(router: router)
}

...

func didTapNavigateFooView() {
router.push(to: .fooView)
}
}

If you run the project, you’ll notice that currently, tapping the button doesn’t trigger any visible navigation. The reason for this behaviour lies in the implementation of the @Published property wrapper, which only publishes a change when the wrapped value has been set. Since our router is a reference type, it's not being re-set, and no objectWillChange event is being triggered.

The difference between value and reference types is in how those variables behave when updated. When an inner property of a value type, such as a struct, is modified, the property itself is assigned a new value. However, when dealing with a reference type, the property’s value remains unchanged even if an inner property of the referenced object is updated.

To address this, we’ll rely on the Combine framework to subscribe to changes within the router. This allows us to manually trigger an objectWillChange event, informing ContentView about the changes and prompting it to redraw.

import Combine

class ContentViewModel: ObservableObject {

...

private var cancellables = Set<AnyCancellable>()

init(router: Router) {
...
configureSubscriptions()
}
}

private extension ContentViewModel {

func configureSubscriptions() {
router.$stack
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
}

Now, with our setup in place, we can observe navigation from ContentView to FooView.

ContentView to FooView Navigation

Let’s proceed by pushing BarView to the navigation stack from within FooView, which serves as the second layer in the hierarchy. To achieve this, we’ll implement our didTapNavigateBarView method.

class FooViewModel: ObservableObject {

private let router: Router

...

func didTapNavigateBarView() {
router.push(to: .barView)
}
}
FooView to BarView Navigation

Now, with our setup in place, we can observe navigation from ContentView to FooView, and subsequently to BarView. That way we could have a navigation similar to what previously UIKit’s UINavigationController provided, and handle the presentation logic from within the ViewModel, therefore minimizing View’s logic, keeping it as simple as possible.

Further Enhancement

Moving Logic Out of View

To further enhance our code, we can boost our Route to conform to the View protocol. This enables us to move switch logic out of ContentView, resulting in a cleaner, and more scalable code.

enum Route {

case fooView(viewModel: FooViewModel)
case barView
}

extension Route: View {

var body: some View {
switch self {
case .fooView(let viewModel):
FooView(viewModel: viewModel)
case .barView:
BarView()
}
}
}

extension Route: Hashable {

static func == (lhs: Route, rhs: Route) -> Bool {
switch (lhs, rhs) {
case (.barView, .barView):
return true
case (.fooView, .fooView):
return true
case (.fooView, .barView), (.barView, .fooView):
return false
}
}

func hash(into hasher: inout Hasher) {
hasher.combine(self.hashValue)
}
}

Below, we’ll perform a minor refactoring to push the fooRoute along with the associated value - FooViewModel:

class ContentViewModel: ObservableObject {

...

func didTapNavigateFooView() {
router.push(to: .fooView(viewModel: fooViewModel))
}
}

private extension ContentViewModel {

var fooViewModel: FooViewModel {
.init(router: router)
}

...
}

Now, in the ContentView, we can remove all the routing logic and simply return the destination passed to the stack, as it conforms to the View protocol:

struct ContentView: View {

@ObservedObject var viewModel: ContentViewModel

var body: some View {
NavigationStack(
path: $viewModel.router.stack
) {
VStack(spacing: 8) {
...
}
.navigationDestination(for: Route.self) { $0 }
}
}
}

Conclusion

Navigation is an integral part of any SwiftUI app. By using MVVM architecture and SwiftUI’s NavigationStack, you can build a robust and scalable navigation layer. This approach keeps the Views simple and focuses the navigation logic within the ViewModel and Router, adhering to the principles of MVVM.

Thank you for taking the time to read this article. If you found it helpful, please give it a clap and don’t hesitate to leave any feedback or questions in the comments. Your support and feedback are greatly appreciated!

--

--

Nikita Goncear

iOS engineer and aspiring Medium writer. Sharing Swift/SwiftUI techniques, personal projects, and insights into mobile development. Subscribe for updates!