Exploring Scalable SwiftUI Navigation
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:
- Model: Encapsulates data and business logic of the application.
- 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.
- 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
.
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)
}
}
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!