Android Architecture Deep Dive — Part 3 — Coordinators and Navigation

JD McCormack
7 min readMar 11, 2020

In the previous post, we went over the basics of MVVM. We spoke briefly about some of the work required for navigating in MVVM, but didn’t look under the hood. This post aims to talk about Jetpack Navigation, problems with it and MVVM, and ways to get around it.

We’ll also talk about a popular pattern on iOS that doesn’t get much attention on Android, the coordinator pattern.

The work here is adapted from: https://medium.com/google-developer-experts/using-navigation-architecture-component-in-a-large-banking-app-ac84936a42c2

https://medium.com/the-kotlin-chronicle/how-can-we-use-livedata-to-implement-navigation-in-our-android-apps-lets-explore-some-options-d7f7aa62db12

and
http://hannesdorfmann.com/android/coordinators-android

All are worth a read.

Jetpack Navigation — Multimodule

Jetpack Navigation has dramatically simplified navigating between fragments on Android. Instead of managing Fragment transitions directly, you are able to generate a navigation graph that can handle the complexities of each transition. Jetpack navigation also makes deep linking incredibly simple

Jetpack Navigation does work in a multimodule setup, but you have to plan accordingly. Take a look at the code below:

This nav graph sits in the “app” module. When the app starts, it will navigate to whatever “startDestination” points to in the root of the graph. We have additional navigation graphs in each of the feature modules:

We then include the submodule graph in the parent nav graph. From there you can easily navigate to the submodule. However, you can only navigate to the start of the other module’s nav graph. So while in “app” you can navigate to “numberflow_navigation” but you cannot navigate to “numberflow_landingFragment”.

To actually navigate, we just grab the nav controller from a fragment and navigate to the id.

findNavController().navigate(R.id.numberflow_navigation)

Simple right? But there’s a problem.

Jetpack Navigation — MVVM Issues

Just like all other forms of navigation in Android, the actual navigation even needs to happen in the UI layer, the fragment or the activity. But in MVVM we want the ViewModel to decide where to navigate. In MVP this is a bit simpler as in the pseudocode below:

class SampleFragment: Fragment(), SampleView {
val presenterSamplePresenter = SamplePresenter(this)

fun onUIEvent() {
presenterSamplePresenter
}

override fun navigateToDestinationA() {
TODO("not implemented")
}

override fun navigateToDestinationB() {
TODO("not implemented")
}
}

class SamplePresenter(private val sampleView: SampleView) {
fun onUIEvent() {
if (a) {
sampleView.navigateToDestinationA()
} else {
sampleView.navigateToDestinationB()
}
}
}

interface SampleView {
fun navigateToDestinationA()
fun navigateToDestinationB()
}

This is pretty simple because the Presenter can call the View directly in MVVM, but we don’t have that luxury in MVVM. To further complicate matters, we have to plan for arguments being passed from fragment to fragment. These must go in a bundle, and we’d ideally want the Presenter or ViewModel to configure this. However, bundle is notoriously hard to test so putting that directly into the VM is out of the question.

Jetpack Navigation — MVVM Solutions!

All is not lost! We some finagling we can create a nice flow that allows the VM and Navigation to coexist. If you’re thinking like we were, we can probably use LiveData to tell the fragment to navigate. However, traditional LiveData’s refire on rotation and can have multiple observers. When we first tried this route we found that on rotation your entire navigation flow would fire multiple times. We were able to get around this with a helpful NavigationLiveData Class:

This will allow us to emit events for navigating. But what should we emit? We could simply emit the ID of where to navigate, but that wouldn’t help with navigation arguments. We could emit the arguments on separate LiveData’s and have the View stitch everything together, but that method is messy and error-prone. Instead, we’ll introduce two new base classes: NavigationDestination and NavigationEvent. Let’s take a look at these classes.

Navigation Destination gives a base class we can emit on a LiveNavigationField. NavigationEvent is used to convert the extendable NavigationDestination into a data class. NavigationArguments is an interface we can use to turn a map into a bundle. NavigationArgs is the key to making this work with the VM. The VM will just need to know about a class with a Map in it instead of actually knowing about the Bundle implementation. Here’s an example of using the destination class:

class LandingDestination : NavigationDestination(R.id.numberflow_navigation)

class LandingDestinationData(private val data: Boolean) : NavigationArguments {
override fun getBundle(): Bundle {
return bundleOf(
BUNDLE_KEY to data
)
}
}

We also create a LiveNavigationViewModel that other VM’s can extend from:

And some helper methods in the BaseFragment for interacting with this:

Now whenever the VM needs to emit a new destination, it can push the event onto the navigationLiveDataField. All the fragment has to do is call configureNavigationListener and pass in the appropriate ViewModel. This creates a testable framework for getting Jetpack ViewModel and Navigation working together.

Adding a Coordinator — Motivation:

This all has been working great for us. However, one important point to note is that the ViewModels are completely in charge of figuring out what screen to navigate to first. In a lot of simple situations, this is completely fine. But for larger apps, there may be complicated business requirements for deciding what screen to navigate to next.

Let’s take a look at an example. We have a book purchasing app that allows users to shop for books without being logged in, but requires them to log in to purchase anything:

Let’s assume we have 4 modules: app, search, login, and purchase. We start in the app module and present the home screen. The user can choose to sign-in on the home screen and they’ll be taken to the login module, then returned to the app module. Alternatively, the user can decide to start searching for a book to buy and will be taken into the search module.

When the user finds a book they want, the system needs to determine if they’re logged in. If they are, they are taken straight to the purchase module. If they are not, they need to go to the login module first. We need to ensure that the user is then brought straight to the purchasing module after they log in and not popped back to the home screen. We can code this all into a ViewModel in the search module, or we can use coordinators!

Adding a Coordinator — Implementation:

To implement the coordinator pattern, we’ll need a RootCoordinator that will live in the root app module, as well as separate coordinators for each feature module. The root coordinator will be responsible for starting the feature modules and knowing what to do when a feature module finishes. The feature modules will only expose a “start” function to other modules. This function will take in any parameters needed to make decisions as well as a callback that will be called when the module is finished. These coordinators will be Singletons.

Let’s look at the RootCoordinator first. This coordinator has a start function that will navigate to the Mainfragment. There is logic for starting all of the feature modules and callbacks for when each module completes. If you examine the searchCoordinatorFinished function, we can see there’s logic for checking if the user is logged in before navigating to the next module.

Let’s take a look at a feature coordinator. We can see the only way to start this flow is to include a bookSearchRepository as well as a callback to determine if the user is logged in or not. All other functions determine where to navigate to given a particular screen the user is on. Some return a single destination. The starting function, however, uses the repository to do some pre-checks to figure out where in the flow to drop the user. Maybe they exited the app after looking at a Book and we want to start them further down the flow. With the coordinator, that check is easy and we can push them along the workflow.

A quick note, if you are going to try to drop them further down into the flow, make sure you configure the backstack behavior in the navigation graph or else they’ll navigate backward in unexpected ways. When the module is finished, the search coordinator defers to the root coordinator which will do a login check and navigate the user to the proper destination. You can also use the coordinator to handle this in the case of the navigateBackFromSearchResults() function.

It is also important to understand that using a singleton can come with drawbacks. We need to ensure there’s no state associated with the singleton, which we are violating by storing the callback in the feature coordinators. However, by only exposing a single “start” function to the root coordinator this state becomes unimportant as there is no other way to initiate the use of this coordinator.

Conclusion

This was a pretty dense section. However, it’s worth the effort as jetpack navigation really simplifies switching fragments on Android. I recommend you follow the navigator pattern in your single activity apps. Whether or not to use a coordinator is really determined by your app size and how complicated your flows are. If the app always follows a specific happy path with a few error states then this is probably overkill. But if you have a lot of different edge cases where screen order needs to change this can be a great way to centralize all of these decisions.

--

--