At first glance, navigation in SwiftUI seems pretty straightforward. With a combination of NavigationView, NavigationLink and .sheet
, we can quickly link views together in ways that resemble UIKit.
But when an app grows more than a handful of views, or you find yourself in situations where you need a more flexible method of triggering navigation, you’ll quickly discover that the framework falls short.
This is largely due to navigation being tightly coupled and hardcoded within its views, becoming increasingly more difficult to:
- Reuse views in different flows
- Adjust or make changes to our flows as we iterate
- Trigger navigation outside the the scope of the view (i.e view models, network requests)
Advanced architectural patterns usually advocate for keeping navigation logic independent of their views, so what approach can we take to decouple navigation logic? Let's start with a little abstraction
Abstracting navigation
If we were to strip out any infrastructure or UI frameworks from the navigation experience, we could map it out into a flow diagram like the following:
When there is an intent to navigate, we determine which direction we would like to go. Are we returning to a previous experience (navigating backward)? Or are we moving onto the next step of the flow (navigating forward)? Based on the direction, the intent will either result in a new destination being presented or some form of a dismissal
Lets try and translate the above into relevant entities:
enum NavigationDirection {
case back
case forward(destination: NavigationDestination, style: NavigationStyle)
}
A navigation intent will either be a forward or backward motion. In the case of going back, there isn't an obvious need to include any associative data. However when it comes to advancing to another screen or experience, a navigable entity should know it's destination
enum NavigationDestination {
case addTodo
case todoDetails(todo: Todo)
}
The NavigationDestination enum is where we'd map out all the possible destinations (screens) of our app. We can leverage associative types here by including any relevant information a destination needs to build its views as demonstrated with the todoDetails
case.
enum NavigationStyle {
case push
case present
}
It's fairly common a platform will have more than way of presenting a view. The NavigationStyle entity will provide a means of specifying the intended presentations style.
With these entities in place, we now have a good representation of navigation that does not include any framework specific code.
Presentation
Given the reactive functional nature of SwiftUI, it's likely that our view models will be observable objects that we can subscribe to and receive updates. With that in mind, a good means of communicating navigation intents as they occur would be via a published property:
import Combine
class ViewModel: ObservableObject {
@Published
var navigationDirection: NavigationDirection?
}
All changes to the published property by the view model will be communicated to an interested View:
class ViewModel: ObservableObject {
@Published
var navigationDirection: NavigationDirection?
// ..
func todoTapped(_ todo: Todo) {
navigationDirection = .forward(destination: .todoDetails(todo: todo), style: .push)
}
// ..
}
With this approach, our view models have the flexibility to trigger navigation updates in a variety of ways, even during async operations:
class ViewModel: ObservableObject {
private var todoRepository = TodoRepository.shared
private var cancellables = Set<AnyCancellable>()
@Published
var navigationDirection: NavigationDirection?
@Published
var loading: Bool = false
func addTodo(title: String) {
loading = true
todoRepository.addTodo(title: title)
.receive(on: DispatchQueue.main)
.sink { [unowned self] _ in
navigationDirection = .back
}
.store(in: &cancellables)
}
// ..
}
That's pretty neat but how will our View be able to process a NavigationDirection update?
The final layer
ViewModifiers are great in the sense that not only can they modify the look and feel of our views, but they can also add functionality in a reusable composable way. Leveraging this functionality, we can create a modifier to provide any view with the capability to respond to a NavigationDirection update:
struct NavigationHandler: ViewModifier {
@Binding
var navigationDirection: NavigationDirection?
var onDismiss: ((NavigationDestination) -> Void)?
@State
private var destination: NavigationDestination?
@State
private var sheetActive = false
@State
private var linkActive = false
@Environment(\.presentationMode) var presentation
let viewFactory = ViewFactory()
func body(content: Content) -> some View {
content
.background(
EmptyView()
.sheet(isPresented: $sheetActive, onDismiss: {
if let destination = destination {
onDismiss?(destination)
}
}) {
buildDestination(destination)
}
)
.background(
NavigationLink(destination: buildDestination(destination), isActive: $linkActive) {
EmptyView()
}
)
.onChange(of: navigationDirection, perform: { direction in
switch direction {
case .forward(let destination, let style):
self.destination = destination
switch style {
case .present:
sheetActive = true
case .push:
linkActive = true
}
case .back:
presentation.wrappedValue.dismiss()
case .none:
break
}
navigationDirection = nil
})
}
@ViewBuilder
private func buildDestination(_ destination: NavigationDestination?) -> some View {
if let destination = destination {
viewFactory.makeView(destination)
} else {
EmptyView()
}
}
}
Above, we put together a modifier that attaches a sheet and an invisible NavigationLink to the relevant view. via the onChange
method, we subscribe to changes from from the binding navigationDirection
property and based on the direction we either:
- Present or push the destination depending on the supplied style when moving forward or
- Dismiss the current view regardless of its presentation style using the presentationMode environment value when moving backward
Once we set the destination and toggle the state of either presentation styles, the modifier will turn to our buildDestination()
method to build the relevant view associated with the destination. In the code example above, we are using a factory approach to provide the view.
Let's make a quick extension to make our modifier easier to to apply to our views:
extension View {
func handleNavigation(_ navigationDirection: Binding<NavigationDirection?>,
onDismiss: ((NavigationDestination) -> Void)? = nil) -> some View {
self.modifier(NavigationHandler(navigationDirection: navigationDirection,
onDismiss: onDismiss))
}
}
and now an example of view that makes use of the modifier:
struct TodoListView: View {
@StateObject
private var viewModel = ViewModel()
var body: some View {
NavigationView {
Text("This view will be able to process a navigation update")
.handleNavigation($viewModel.navigationDirection)
}
}
}
Note: we access the
viewModel
via the$-prefix syntax
to generate a binding to it's navigationDirection property
And there we have it, a somewhat abstracted approach to handling navigation in our SwiftUI apps.
Benefits to this approach:
- Navigation logic is separated from our view, making it a lot easier to change user flows or reuse our views in different areas of the app.
- With the view model being responsible for orchestrating navigation, we can do flexible things like trigger a navigation after a network request completes
- We can now test navigation interactions via our view models' unit tests
An important note: We still need to manually ensure that a view's hierarchy has access to a NavigationView when using the push presentation style. This takes us away from true abstraction nirvana, but despite this minor setback we find ourselves in a much more flexible state.
Conclusion
SwiftUI's approach to navigation can quickly get challenging and limiting when it comes to programmatic navigation or building modular user flows. By abstracting and separating navigation logic from our views, we can increase the reusability, modularity and maintainability of our code base.
You can find a sample project that demonstrates the concepts of this article here
I hope you enjoyed this article and found it useful. If you have any questions, comments, or feedback i'd love to hear them. Contact me or follow me on Twitter