Navigating the Complexities: Multi-Module Navigation with Navigation Component

Rahmi Cemre Ünal
7 min readAug 15, 2023

--

Welcome back, fellow Android enthusiasts! In the previous post, we explored a scalable approach to navigation that simplified our app’s journey with the traditional FragmentManager. Today, we’re taking it up a notch! Get ready to embark on an exciting adventure into the realm of Android Jetpack’s Navigation Component.

As developers, we strive for clean, efficient, and maintainable code. With the Jetpack Navigation Component, Google has provided us with a powerful toolkit that streamlines the navigation process, making it a breeze to navigate between destinations in our app. I hope this post will guide you through the implementation of a similar navigation system as before while leveraging the multi-module architecture.

So, let’s buckle up, fasten our seatbelts, and dive into the world of Jetpack Navigation to unlock the seamless navigation experience we’ve been dreaming of! 🚀

Why not just use deep links?

Although the Jetpack Navigation Component which we are going to use already supports multi-module navigation out of the box, it has some significant flaws to build scalable applications using it.

It encourages you to use deep links which is “in my opinion” just introducing a series of significant problems in a multi-module project.

Some possible questions we may need to answer before committing to this approach:

  • Where should we keep the deep link URIs, which will be mandatory to launch a feature from another independent module?
  • It’s basically a string-based approach, should we hardcode it when we need it?
  • What happens when we need to refactor a destination in the graph?
  • We are using a strongly typed language to build modern apps but are we really restricted to using primitives for the navigation? We always need to remember to also refactor the hardcoded URIs so that nothing breaks.

The Real Issue

The Jetpack’s Navigation Component needs to resolve the full graph which means adding a new destination requires editing the same navigation graph over and over again. Destinations can also be declared in separate navigation XML files but they need to be included in the main navigation graph manually at the end of the day. This means we need to touch the module where the main navigation graph exists every time there is a change. It will just trigger a probably expensive and unnecessary build for the modules where dependent on our common navigation module. This alone just breaks the modularization mentality and bottleneck our build performance.

So because of these reasons, we’ll tweak the current solution provided by the navigation component, and introduce a new one that is just a little bit verbose but much more scalable and maintainable in the long run.

Building the Nav Graph

Let me get straight to the point, we want to be able to do this:

But, as mentioned before, there is no way to navigate between independent nav graphs without including them all together into one common parent nav graph.

So instead of making features dependent on each other, what we actually want is to build something like:

Also, it would be great if we don’t need to touch the navigation layer when we want to change the main nav graph in any way. Let’s figure out how we can achieve this.

Abstracted Navigation Destinations

We start by creating a simple module, which contains a very straightforward interface. It will allow the features to add themselves to the main navigation graph:

interface NavigationNode {
fun addNode(navGraphBuilder: NavGraphBuilder)
}

The other feature modules can depend on this core navigation module and implement this NavigationNode interface and they can customize it for their special needs.

For the first feature, it can be just a fragment:

class FirstNavigationNode @Inject constructor() : NavigationNode {

override fun addNode(navGraphBuilder: NavGraphBuilder) {
navGraphBuilder.apply {
fragment<FirstFragment>(ROUTE)
}
}

companion object {
const val ROUTE = "first_feature_route"
}
}

To emphasize the power of this structure, let’s imagine that the second feature contains 2 fragments. They also can be in an individual nav graph:

class SecondNavigationNode @Inject constructor() : NavigationNode {
override fun addNode(navGraphBuilder: NavGraphBuilder) {
navGraphBuilder.apply {
navigation(startDestination = START_DESTINATION, route= ROUTE) {
fragment<SecondFragment>(START_DESTINATION)
fragment<ThirdFragment>(THIRD_FRAGMENT_DESTINATION)
}
}
}

companion object {
private const val START_DESTINATION = "second_fragment_destination"
private const val THIRD_FRAGMENT_DESTINATION= "third_fragment_destination"
const val ROUTE = "second_feature_route"
}

}

Now we know how to define the navigation nodes in individual feature modules but they still need to be included in the main nav graph. To achieve this we still need one more little touch.

Since the destinations are now abstracted behind the NavigationNode interface, we can use Dagger’s multi-bind feature @IntoSet to get all of them at once without knowing how many different implementations exist.

In the first feature module:

@Module
@InstallIn(SingletonComponent::class)
interface NavigationModule {
@IntoSet
@Binds
fun bindNavigationNode(firstNavigationNode: FirstNavigationNode): NavigationNode
}

In the second feature module:

@Module
@InstallIn(SingletonComponent::class)
interface NavigationModule {
@IntoSet
@Binds
fun bindNavigationNode(secondNavigationNode: SecondNavigationNode): NavigationNode
}

At this point, it actually doesn’t matter how many nodes-implementations we have, we will access them all together.

In the module where the main nav graph exists:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

@Inject
lateinit var navigationNodes: @JvmSuppressWildcards Set<NavigationNode>

@Inject
lateinit var navController: NavController

override fun onCreate(savedInstanceState: Bundle?) {
setTheme(R.style.Theme_NoteApp)
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupNavGraph()
}

private fun setupNavGraph() {
navController.graph = navController.createGraph(
startDestination = FirstNavigationNode.ROUTE
) {
navigationNodes.forEach { navNode ->
navNode.addNode(this)
}
}
}
}

In this way, the graph will be built dynamically and can be modified later without worrying about forgetting to add — remove any destinations from it.

Please notice the navController is also injected and therefore it needs to be provided from the same module as the main navigation graph exists:

@Module
@InstallIn(ActivityComponent::class)
object ActivityModule {
@Provides
fun provideNavController(@ActivityContext activityContext: Context): NavController {
val activity = activityContext as FragmentActivity
val navHostFragment =
activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
return navHostFragment.navController
}
}

So now we don’t need to manage the relationship between destinations manually, but still need to define how to launch a feature, which has a destination(s) in it.

As in the previous post, we still want the same controlled access to the feature by creating a single entry point regardless of what technology we use under the hood.

It will be pretty similar so the explanation will be shorter in this post. We’ll just replace the fragmentManager with the navController.

So let’s see how can we navigate from our first feature to the second feature. Since the first feature is the start destination of our graph and will be launched at the start, we don’t need to do anything about it.

We need to create an interface and an individual module to launch our second feature.

We will share this new module with the other modules that need to launch our feature. In this case, it will be shared with the first feature.

interface SecondFeatureCommunicator {
fun launchFeature(secondFeatureArguments: SecondFeatureArguments)

companion object {
const val featureNavKey = "secondFeatureNavKey"
}

data class SecondFeatureArguments(
val id: Int
)
}
class SecondFeatureCommunicatorImpl @Inject constructor(private val navController: NavController) :
SecondFeatureCommunicator {

override fun launchFeature(secondFeatureArguments: SecondFeatureArguments) {
navController.navigateWithAnimation(
route = SecondNavigationNode.ROUTE,
args = bundleOf(SecondFeatureCommunicator.featureNavKey to secondFeatureArguments.toParcelable())
)
}

private fun SecondFeatureArguments.toParcelable(): SecondFeatureParcelableArguments {
return SecondFeatureParcelableArguments(id = id)
}
}

The navController operates with destination ids not with route strings but we can write a cute little extension function called navigateWithAnimation to handle this and even add some custom animations for fun:

fun NavController.navigateWithAnimation(route: String, args: Bundle?, navOptions: NavOptions? = null) {
findDestination(route)?.id?.let { destinationId ->
val navigationOptions = navOptions ?: NavOptions.Builder()
.setEnterAnim(R.anim.slide_in_right)
.setExitAnim(R.anim.slide_out_left)
.setPopEnterAnim(R.anim.slide_in_left)
.setPopExitAnim(R.anim.slide_out_right)
.build()
navigate(resId = destinationId, args = args, navOptions = navigationOptions)
}
}

Of course, the feature communicator and its implementation need to be defined in our DI module to not leak the implementation details:

@Module
@InstallIn(FragmentComponent::class)
interface FragmentModule {
@Binds
fun bindCommunicator(secondFeatureCommunicatorImpl: SecondFeatureCommunicatorImpl): SecondFeatureCommunicator
}

And then we can inject the communicator into wherever we need to launch our second feature:

@AndroidEntryPoint
class FirstFragment : Fragment() {

@Inject
lateinit var secondFeatureCommunicator: SecondFeatureCommunicator
..

private fun launchSecondFeature(id: Int) {
val secondFeatureArguments = SecondFeatureArguments(id = id)
secondFeatureCommunicator.launchFeature(secondFeatureArguments)
}

Finally, let’s overview the whole picture:

Module dependency
The complete dependency hierarchy

Conclusion: Navigating Forward

The Jetpack’s Navigation Component has many cool features that can be used out of the box. However, as explored in this article, it also has some limitations, particularly when aiming for modularization in large-scale projects. The reliance on deep links raises questions about maintainability, as managing and updating the URIs can become error-prone. The requirement to modify the main navigation graph whenever a new destination is added undermines the very concept of modularization and can lead to unnecessary build bottlenecks.

The alternative approach proposed in this article, involving a custom interface-based navigation system, addresses some of these limitations. By leveraging Dagger’s multi-bind feature and abstracting destinations behind the NavigationNode interface, the approach allows for dynamic assembly of the navigation graph, without the need to modify the main graph every time a change occurs. This solution offers a more scalable and maintainable way to navigate between independent modules.

Of course, these setup adds serious complexity to the project and it is questionable how necessary they are since we actually could get the same effect in a simpler flow with the FragmentManager.

Ultimately, the choice between the FragmentManager and the Navigation Component depends on the project’s specific requirements and priorities. While the Navigation Component simplifies navigation in many cases, it’s important to thoroughly evaluate its constraints within the modularization context. This evaluation prevents the realization of an untimely setback: discovering that even minor alterations lead to complete builds, subsequently affecting our overall productivity.

--

--