I really like SwiftUI but there is something that I’m not fond of, the fact that in order to push a new view into the stack you need to create it inside NavigationLink. Imagine you have view A and after pressing a button you can either go to view B or C. You can have an if, and create a NavigationLink to B or C based on a @State variable.
Although this approach works, and is perfectly fine, I don’t want view A to know anything about B and C.
One solution is to embed SwiftUI views inside UIHostingController, avoid NavigationLink and just have Buttons to execute actions and trigger something on a Coordinator object responsible for the navigation.
What I want to show you is how to use a Coordinator object to handle navigation all in SwiftUI, without pushing and popping view controllers in UIKit.
The sample project
This is the link of the sample project on GitHub. The app is really simple, just two views. The first one has a button to make a call, then we can land on a view to handle success, or another view to display an error based on the result of the call. We don’t make an actual call, just wait for a couple of seconds and then toss a coin, get a random Bool so we know if the call is successful or not.
Once we have the result, we either push the success or error view. From there, we tap back and go back to the first view.
In order to accomplish that without the first view knowing anything about success and error views, I implemented 3 actors: Coordinator, ContainerView and its ContainerViewModel.
Coordinator
You can see the implementation here.
The coordinator is responsible to handle app states, so it is the single source of truth for the current state of the app, and handles the View Models. For each app state, there is a corresponding view and its view model, managed by the coordinator. As you’ll see later, it is the coordinator that performs actions, changes the app’s state and ultimately push or pop views in the stack.
ContainerView
We may call this view a generic view (see the implementation here).
As the name suggests, this is actually a container. It’s body property looks really simple
@Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
viewModel.currentView()
navigationLink()
}.onReceive(viewModel.getViewActionPublisher()) { action in
if case .dismiss = action {
presentationMode.wrappedValue.dismiss()
}
}
}
A VStack containing two function calls to get the view body and a dynamic navigation link. Then a publisher from the view model to dismiss the view (could also be a modal) by triggering dismiss on presentationMode.
Let’s start with the NavigationLink
private func navigationLink() -> some View {
let newContainer = viewModel.nextView()
return NavigationLink(destination: newContainer, isActive: $viewModel.enableNavigationLink){}
}
The actual view comes from the view model, similar to what we do by calling viewModel.currentView() in our body, and the NavigationLink becomes active when enableNavigationLink in the view models is true.
As you can tell, ContainerView knows nothing about the actual views. Everything comes from the model, and this is just a container to display dynamic generated content.
Let’s now take a look at the view model, since our container view depends on it in order to display anything.
func currentView() -> some View {
return ViewFactory.viewForState(viewState, coordinator: coordinator)
}
func nextView() -> some View {
let viewModel = coordinator.viewModel(after:self)
return ContainerView(viewModel: viewModel)
}
I’ll show you ViewFactory in the next chapter, for now assume it gives us a View depending on an enum value.
The function nextView is used by the dynamic navigation link, we ask the coordinator for the view model following the current one, and return a ContainerView with that view model. As you now know, the view model will provide the currentView for this new ContainerView so it is all dynamic and decided by the coordinator.
View Factory
I think the name is quite telling, this is a factory for views 🙂 and it is a pattern I often use in my code.
class ViewFactory {
static func viewForState(_ state:ViewState, coordinator:Coordinator) -> some View {
@ViewBuilder var renderedView: some View {
if state == .start {
StartView(actionsHandler:coordinator)
}
if state == .progress {
ProgressView(actionsHandler:coordinator)
}
if state == .success {
SuccessView(coordinator: coordinator)
}
else if state == .failure {
ErrorView(actionsHandler: coordinator)
}
}
return renderedView
}
}
That’s it. This class is only responsible for creating views based on the value of an enum. I prefer to put this logic in a separate class so there is a single point where I can make changes if all those views require a different parameter, or have to be constructed in a different way.
All these view have one parameter in common: an object called ActionHandler that is able to perform actions when a Button is tapped. This is the first view, called StartView
struct StartView: View {
let actionsHandler:ActionsHandler
var body: some View {
Button("make call") {
actionsHandler.executeAction(.makeCall)
}
}
}
Remember, this body is embedded in a ContinarView, this is the result of one of those viewModel.currentView() we saw before.
Conclusion
This is just a proof of concept, I spent only a few hours playing with the sample project but in the end I was able to make it work.
The gist is having a Coordinator object, similar to what happens in the Coordinator pattern, handle the navigation between views.
I’m not necessarily advocating for dynamic views to be the norm. In the app you’re writing, it may make perfect sense to have a NavigationLink with the destination known at compile time.
I just wanted to show an alternative way to handle navigation. What you saw here can be applied to modal views as well, you’d only have to add a .sheet to the ContainerView with a dynamic view inside, pretty much the same thing I did with navigationLink().
This is useful if you can have multiple different destinations or modals and don’t feel like putting too much logic in the View.
Let me know what you think, and happy coding 🙂