Improving SwiftUI Navigation for the Coordinator Pattern
In this post, we’ll explore how to manage SwiftUI navigation state with a single array. This will make it much simpler to hoist that state into a high-level view, and reap the benefits of the coordinator pattern.
The coordinator pattern has become very popular in the iOS community. It allows us to write isolated views that have zero knowledge of their context within an app. Navigation state is hoisted out of individual screens into a higher-level coordinator object, and this separation of concerns allows views to be more re-usable and testable.
The problem
On the other hand, SwiftUI gives us NavigationLink
, which encourages the fragmentation of navigation state throughout various views in a navigation flow. NavigationLink
also assumes that a particular screen in an app knows about the screens that follow it in the navigation flow: how to create them, when to push them onto the stack, and whether they should be presented or pushed. Further, NavigationLink
assumes that any changes to the navigation stack are triggered by a user interaction. In reality, navigation state may need to change as a result of an API call, a deeplink, state restoration or a timer. All of this makes it difficult to implement the coordinator pattern in SwiftUI.
The goal
It would be ideal if we could manage navigation state in one place, as easily as managing an array - adding and removing views to trigger pushes and pops. Let’s explore what that might look like…
The first question is what type the array should be. If we want it to accept absolutely any type of view, it would have to be an array of AnyView
s, but using AnyView
is problematic: it impedes SwiftUI’s ability to track what’s changed and make efficient updates. Instead, let’s manage an array of screen identifiers, that we can use to create the corresponding views when we need them, e.g.:
1 | enum Screen { |
With those defined, we can initialize an array of screens in our coordinator with @State var stack: [Screen] = [.homeView]
. This array of screens will represent the screens in our navigation stack. Appending a screen will trigger a push, and dropping screens will trigger a pop.
We are going to need a translation layer to translate any changes we make to this stack
array into something SwiftUI will understand - a hierarchy of views and NavigationLink
s - and to update our stack whenever the user pops back. Let’s imagine we have a view that handles that translation, and we’ll call it NStack
. It will need to accept a binding to the stack
and a ViewBuilder
closure that takes a Screen
and returns the corresponding view. With that in place our coordinator might look like this:
1 | struct AppCoordinator: View { |
With this approach, navigation can be entirely driven by a single piece of state, managed by our coordinator. And each screen’s view can invoke a closure when a particular UI action is taken, requiring no knowledge of other screens in the flow.
The implementation
Now let’s try to implement it. We have an array of Screen
s, but we need to translate that to a SwiftUI navigation stack: where the first view contains a link to the second, and the second view contains a link to the third etc. Described that way, SwiftUI’s navigation stack sounds a little like a Linked List, so let’s try and represent it that way:
1 | indirect enum NavigationNode<ScreenView: View> { |
Each navigation node is either a view that pushes another node, or the end of our list. The view
case contains another NavigationNode
as an associated value, so the list of nodes can continue for as long as we need. Because of this recursive definition, we need to mark the enum as indirect
. Otherwise, it’s not clear how much memory a NavigationNode
should take up, because it can grow to any size.
Notice that all of the views in the list have the same type ScreenView
. That might seem like a problem, since we want to be able to push different screen views. But ScreenView
in this case is the result of the ViewBuilder
closure passed to the NStack
. Since this will typically be the result of a switch statement, ScreenView
will be a conditional view type that could be any of the screen types we need to support. So NavigationNode
is capable of representing a list of any number of views, each of which is one of the screens we support. This matches exactly what we can describe with our array of Screen
s.
Now that we have our NavigationNode
, let’s make it a View
. It might seem unusual to make a SwiftUI view from an enum, as we’re used to using structs for that purpose, but an enum can work just as well:
1 | indirect enum NavigationNode<ScreenView: View>: View { |
If the node is the end
, we return an EmptyView
. This should only be seen if our stack is empty. If the node is a view, we return the view, along with a NavigationLink
in the background, which pushes the next node. The NavigationLink
should be invisible so its label is an EmptyView
, and it’s set to hidden()
. The isActive
binding is a little trickier, so we’ll come back to that.
Next, let’s see if we can transform our array of screens into a NavigationNode
representing the full stack. This is the job of our NStack
, which we’ve already decided should take a binding to an array of Screen
s and a ViewBuilder
closure to build a view for a given screen:
1 | struct NStack<Screen, ScreenView: View> { |
Now let’s make this NStack
a view. We need to transform the array of screens into a NavigationNode
. We’ll do so by starting with a NavigationNode.end
and working backwards, with each new node pushing the node created by previous iterations:
1 | struct NStack<Screen, ScreenView: View>: View { |
Great. Now it’s time to turn our attention back to the NavigationNode
and the isActive
binding on its NavigationLink
. This binding will be responsible for deciding if the next view should be pushed, and for updating the stack when the pushed view is popped back (e.g. if the user taps or swipes back). We’ll need a couple of extra parameters in order to do that, so we’ll amend the code above to additionally pass the stack binding and the node’s index when we create one:
1 | struct NStack<Screen, ScreenView: View>: View { |
Now that the node has those values, we can use them to create the isActive
binding for the NavigationLink
. In the getter, we want to check that the stack’s count is higher than our index - if so, the view is being pushed, so we can return true
. We also need to check that the pushedNode
is not an end
node, as we never want to push one of those. The setter is important, as that’s how we’ll be notified that the user has tapped the back button or swiped to go back. In the setter, we want to check if the new value is false. If so, then the user is navigating back. At this point we can truncate the stack so that the pushing view is at the top of the stack:
1 | Binding( |
That completes the translation layer between our desired API and what SwiftUI gives us: the coordinator code above now works as intended. All in all, it’s less than 50 lines of code. Here’s the implementation in full:
1 | struct NStack<Screen, ScreenView: View>: View { |
Evaluating the solution
We can now manage navigation with a single piece of state, rather than various pieces of state distributed among our views. Pushing new screens is as simple as stack.append(.newScreen)
, and if the user taps or swipes back, or uses the long press gesture to go further back, the navigation state will automatically get updated to reflect the change. Programmatically popping is as simple as stack = stack.dropLast()
, and you can easily pop back to the root or to a specific screen, because the navigation stack can be examined at runtime.
The screen views themselves no longer need to have any knowledge of any other screens in the navigation flow - they can simply invoke a closure, e.g. with a Button
, and leave the coordinator to decide what view, if any, should be pushed or presented.
Not only does this make the coordinator pattern more at home in SwiftUI, it even has some advantages over the coordinator pattern in UIKit. With this approach, coordinators are just views, which means they can be composed and configured in all the normal ways views can. You can present a coordinator, add it to a TabView
, or even push a child coordinator onto the navigation stack of a parent coordinator, just as you would a view. Note that NStack
does not wrap its content in a NavigationView
- that way, multiple coordinators can be nested within a single NavigationView
.
In UIKit, child coordinators are a useful pattern, but some extra work is often required: e.g. to keep a strong reference to the child coordinator, pass it a UINavigationController
, tell it to start()
or to listen for the user tapping back. None of that extra work is necessary in the SwiftUI version. The one caveat is that the child coordinator should only ever be the top view in its parent’s stack - as the parent passes navigation responsibilities to the child when it is pushed.
NStack
is available as part of FlowStacks
on GitHub. The library also includes a PStack
object for presenting and dismissing views in the same way. It also provides ‘flows’ (NFlow
and PFlow
), each of which is a thin wrapper around an array, adding some convenience methods, e.g. for pushing views, popping to the root view or popping to a particular screen. You can see it in action, along with an example using view models, in the README.
I’d love to hear your thoughts on whether this approach is useful and if it has any downsides I haven’t considered.
At the moment, SwiftUI does not support increasing the navigation stack by more than one in a single update. I’ve opened FB9200490
in the hope that this will be resolved.