Photo by Jamie Street on Unsplash

Compose Navigation — A great choice for large Android apps

Marcin Piekielny
ITNEXT
Published in
17 min readDec 22, 2023

--

It’s been a while since Jetpack Compose moved to Stable and became a preferred way of building a UI in Android applications. During the last two years, I had an opportunity to work with this new technology on several different projects. They were both new ones built from scratch, as well as old ones, migrated from classic XML layouts.

I was part of different teams, working with different developers, but one aspect of Compose was a common point of confusion and uncertainty. This one thing is Compose Navigation.

Now everything is upside down

Many of us, including me had a lot of doubts if this is a production ready solution. The Compose Navigation is very different from what we are used to when working with fragments.

Previously we’ve been creating an instance of a given fragment and committing it to the fragment manager. In case of the Jetpack Navigation, corresponding directions objects were generated based on the XML navigation graphs.

Now the navigation relies on the string routes. Very similar to how the web applications work. Navigation graphs are defined directly in the composable functions and each destination in the graph has a route set. We are using the same route to navigate to that destination. One thought which came to my mind initially was.

Mom, can we have mobile app at home? I don’t like web apps with URLs.

No. There is a mobile app at home.

Mobile app at home …

NavHost(startDestination = "profile/{userId}") {
...
composable("profile/{userId}") {...}
}
navController.navigate("profile/user1234")

Looking for some alternatives

At the beginning I rejected the existence of this official library and started looking for some alternative. Keeping fragment-based navigation was an obvious first choice. Fragments can work as containers where we put the Compose UI while keeping navigation the old way.

But no one really wants to stay with old solution where there are new possibilities available. My second attempt was to try some 3rd party solutions. Two of them are really promising and deserve to be highlighted in this article.

However, any 3rd party tool, especially a new and relatively young one, carries some rick. We do not know how long it will be maintained and supported by its creators. When we rely heavily on such a tool and it gets abandoned, we may face a difficult choice: keep an unsupported solution or migrate to another one in a costly manner. Therefore, I was not convinced that choosing any of them is the right long-term decision.

Everyone deserves a chance, even the Compose Navigation

With a lot of scepticism I started playing around with this web-ish navigation solution. Initially in private projects, then through small proof of concepts in commercial ones, building complete business features, up to the delivery of entire applications with 100% Compose Navigation.

And I have to surprise you. After this journey I see great potential in this technology. It simplifies many aspects of mobile app navigation, allows the implementation of non-trivial business flows and, most importantly, scales very well in large, modular applications. Sounds interesting? So sit back and enjoy reading 🚀

One talk from Android Dev Summit that changes everything

An original documentation of the Compose Navigation library was very superficial. All the examples show navigation logic build with hardcoded string routes. We can all agree that this is not very bulletproof approach. It also misses some wider context how to build full flows and structure the implementation in a real world codebase.

But luckily, in 2022 the Android Dev Summit happened. During this event Google showed us much more details on how to use the Compose Navigation. They also published a new documentation page which describes concepts from the talk. Below you can find links to both of them.

The idea is simple, instead of using hardcoded strings everywhere, we create named extensions

That’s all and that’s it. We don’t have to build any super complex, generic and custom navigation framework on our own. We just create two very simple extension functions for each individual screen in the application.

One is an extension of the NavController. It allows us to navigate to the given screen. Second one extends NavGraphBuilder. We use it to include selected screen as a destination in the NavHost .

const val LOGIN_ROUTE = "login"

fun NavController.navigateToLogin() {
navigate(LOGIN_ROUTE)
}

fun NavGraphBuilder.loginScreen(
onNavigateBack: () -> Unit,
onNavigateToForgotPassword: () -> Unit,
) {
composable(route = LOGIN_kotROUTE) {
val loginViewModel: LoginViewModel = hiltViewModel()
val state by loginViewModel.uiState.collectAsStateWithLifecycle()

LoginScreen(
state = state,
onLoginClick = viewModel::submitLogin,
onNavigateBack = onNavigateBack,
onNavigateToForgotPassword = onNavigateToForgotPassword,
)
}
}

The second extension also connects the screen composable with a corresponding view model. When we use Compose Navigation, view models live as long as destinations in the NavHost, which we create using composable(route) function.

We may notice here, that screen doesn’t perform any navigation on its own. It just exposes navigation events which are simple lambdas. And why do we do so? Why not just pass NavController object to the screen? So let me answer you 😄

One place where we can understand the entire navigation flow

A great advantage of the original Jetpack Navigation library was the visual representation of the navigation graph. It is very intuitive to take one graph file and see what screens open one by one.

In Compose Navigation we no more use XML graphs but our navigation extensions help us build equally readable graphs directly in the Kotlin code.

@Composable
fun RootHost() {
val navController = rememberNavController()

NavHost(navController = navController, startDestinatin = HOME_ROUTE) {
homeScreen(
onNavigateToLogin = {
navController.navigateToLogin()
},
onNavigateToProduct = { productId ->
navController.navigateToProduct(productId)
},
)
loginScren(
onNavigateBack = {
navController.popBackStack()
},
onNavigateToForgotPassword = {
navController.onNavigateToForgotPassword()
}
),
forgotPasswordScreen(
onNavigateBack = {
navController.popBackStack()
}
)
productScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
}

This is a single source of truth of how navigation works in our application. Based on the above graph we know than from the homeScreen we can move to the loginScreen and to the productScreen. And from the login screen we go further to the forgotPasswordScreen. All this information in one place. No more jumping through dozens of files to understand what the screen order is 🎉

Big application are not so simple. Can we scale it?

The example above looks nice, but real world applications are not as simple as 4 or 5 screens which we can put in a single NavHost. If our application has 20, 50 or 100 screens, we don’t want to have thousands of lines of code in one file. Readability would be extremely poor and maintenance very difficult.

At this point, we should keep in mind, that our applications are not just big pools of screens. If we treat them so, we end up with a juicy spaghetti 🍝 where understanding how user moves around the app is extremely difficult. In fact, every application splits into smaller business flows. Each flow is a set of screens that together create some broader business functionality.

Let’s think of an e-commerce application. What is a main purpose of such an app? Buying products! So this application for sure has an order flow, where user selects delivery, enters his address, chooses how to pay, confirms all the information and so on.

Order flow in e-commerce application

To make ordering easier we let user to have an account in our application. This way he can speed-up next orders because we already know where to deliver them and what delivery option user prefers. We need flow where user can login to the application. It might happen that user forgot his password so we let him to reset it as well.

Login flow in e-commerce application

And what if user is already logged in and wants to modify some of his data? No problem. In the my data flow he can edit addresses, change password or even delete the account completely.

My data flow in e-commerce application

Just 3 flows and already a lot of screens

15 screens to be precise and we still miss many aspects of an e-commerce application. What about browsing products with proper filters, viewing selected products with all the details, available variants, sizes etc. We might also want to have a list of favorite products, special offers with promo codes, general settings and maybe some loyalty program to collect points and keep user engaged.

As we can see, they are different flows of the application and each of them includes its own unique screens. The codebase should reflect this structure of our business. It applies also to the navigation implementation. Login flow doesn’t need to know anything about the delivery or payment. From the product browser we don’t need to access password settings. At the same time editing saved delivery addresses doesn’t involve any information about user’s favorite products.

I believe we all get the point now. However, one question remains — how to implement it? Do we have some mechanism to split our navigation to reflect different flows of the application?

The answer is yes, we have nested graphs

This is where Compose Navigation really shines. Nested graphs fit perfectly into multi-module applications and I highly recommend using both nested graphs and modularity in larger projects.

In this approach each business flow of the application is implemented in a separate module. Single module may contain one or multiple screens, depending on the case. All the screens have visibility set to internal so they are not visible for other modules. The same applies to their corresponding view models as well as navigation extension which we discussed earlier. The only public unit in the module is the navigation graph which is the entry point to a given flow. In the example below it is the OrderGraph.

Structure of the order module

We implement nested graphs very similarly to screens, using extension functions. Just like screen, each graph is assigned a specific route.

const val ORDER_GRAPH_ROUTE = "order-graph"

fun NavController.navigateToOrderGraph() {
navigate(ORDER_GRAPH_ROUTE)
}

fun NavGraphBuilder.orderGraph(
navController: NavController,
onNavigateToProductCard: (String) -> Unit,
) {
navigation(
route = ORDER_GRAPH_ROUTE,
startDestination = DELIVERY_ROUTE,
) {
deliveryScreen(
onNavigateBack = {
navController.popBackStack()
},
onNavigateToAddress = {
navController.navigateToAddress()
}
)
addressScreen(...)
paymentScreen(...)
summaryScreen(
onNavigateBack = {
navController.popBackStack()
},
onNavigateToConfirmation = {
navController.navigateToConfirmation()
},
onNavigateToProductCard = onNavigateToProductCard,
)
confirmationScreen(...)
}
}

NavController extension is exactly the same as the one we had for screens. The one which extends NavGraphBuilder differs a bit. We use a navigation function instead of composable to define a nested graph. Inside of it we can add screens and set the start destination, same as we did for the standard NavHost.

We also pass an instance of NavController so the nested graph can navigate across internal screens. Nested graphs are not supposed to have their own NavController, they share it with a root NavHost.

So how do we move from one flow to another?

Nested graphs are fully autonomous and independently navigate between screens within the same module. But what if we want to navigate outside this module?

Let’s assume that when placing an order, the user wants to see the details of one of the ordered products. In that case we need to move user from the OrderGraph which is inside the :order module to the ProductCardGraph which is inside the :product-card module. Does it mean that these modules have to depend on each other right now?

We keep nested graphs and their modules fully independent

The key to good modularity is to keep individual modules as independent as possible. We don’t want to introduce a direct dependency between two different modules just because we need to navigate from one to another.

And luckily, with Compose Navigation we don’t have to. All we need to do is to expose navigation events from our nested graphs. We do it using lambda parameters, same as we did for screens.

fun NavGraphBuilder.orderGraph(
navController: NavController,
// Navigation event to move outside the module
onNavigateToProductCard: (String) -> Unit,
) {
...
}

App module combines all the nested graphs

Navigation events exposed by the nested graph simply communicate what navigation should be performed and eventually pass some required arguments.

The rest of the job is done by the :app module. It combines all the nested graphs into a single NavHost and handles navigation events which they expose.

@Composable
fun RootHost() {
val navController = rememberNavController()

NavHost(
navController = navController,
startDestination = HOME_GRAPH_ROUTE
) {
homeGraph(
navController = navController,
onNavigateToLogin = {
navController.navigateToLoginGraph()
},
onNavigateToProductCard = { productId ->
navController.navigateToProductCardGraph()
}
)
productBrowserGraph(...)
productCardGraph(...)
favoritesGraph(...)
loginGraph(...)
cartGraph(
onNavigateToOrder = {
navController.navigateToOrderGraph()
}
)
orderGraph(...)
}
}

This way we keep all our feature modules fully independent from each other. The :app module is the only module which knows about all the different features of the application.

Dependencies between modules

Adding a bottom navigation

Real world applications quite often rely on popular navigation patterns like bottom navigation bar or navigation drawer. Adding such a solution to our codebase can significantly increase the complexity of navigation.

Let’s thing about the bottom navigation bar. Now we have to consider two different levels of navigation. First level takes an entire screen space. Second one operates in a smaller container, where navigation bar is always visible at the bottom of the screen.

Two different levels of navigation

Compose Navigation let’s us create multiple levels of navigation by creating separate NavHost composables. Each of them has its own NavController and we can nest one inside the other.

In that case, we need to have two different NavHost containers. On the first level we switch between full screen flows. We can distinguish the LoginGraph, the OrderGraph but also the NavigationBarHost which contains another NavHost.

On the second level, inside the NavigationBarHost we switch between flows, where there is a navigation bar displayed on the screen. Here we will find HomeGraph, CartGraph, ProfileGraph and several others.

Dependencies between modules with two levels of navigation
@Composable
fun RootHost() {
val navController = rememberNavController()

NavHost(
navController = navController,
startDestination = NAVIGATION_BAR_HOST_ROUTE
) {
navigationBarHost(
onNavigateToLogin = {
navController.navigateToLoginGraph()
},
onNavigateToOrder = {
navController.navigateToOrderGraph()
}
)
loginGraph(...)
registrationGraph(...)
orderGraph(...)
}
}
const val NAVIGATION_BAR_HOST_ROUTE = "navigation-bar-host"

fun NavGraphBuilder.navigationBarHost(
onNavigateToLogin: () -> Unit,
onNavigateToOrder: () -> Unit,
) {
composable(route = NAVIGATION_BAR_HOST_ROUTE) {
val navController = rememberNavController()

Scaffold(
bottomBar = {
NavigationBar(navController)
}
) { padding ->
NavHost(
navController = navController,
startDestination = HOME_GRAPH_ROUTE,
modifier = Modifier.padding(padding)
) {
homeGraph(...)
categoryBrowserGraph(...)
favoritesGraph(...)
cartGraph(...)
profileGraph(...)
}
}
}
}

Common nested graph inside the bottom navigation

But in complex application, bottom bar is not always jus a simple switch mechanism for showing different tabs. We can go deeper into each tab creating a nested navigation inside each of them.

In our e-commerce app we have 5 different tabs where each of them has its own nested graph:

  • HomeGraph
  • CategoryBrowserGraph
  • FavoritesGraph
  • CartGraph
  • ProfileGraph

But at the same time, we have other nested graphs that can be open in scope of different tabs. An example of such a flow is a ProductCardGraph which we can navigate to from 4 different graphs.

The same nested graph open from different tabs

To achieve it we don’t need to introduce any direct dependency between individual graphs which represent the bottom navigation tabs and the ProductCardGraph. We can still keep these graphs and their modules fully independent.

All we need to do is to include the ProductCardGraph inside the NavigationBarHost composable and navigate to it through navigation events exposed by other graphs which represent main tabs of the bottom navigation bar.

const val NAVIGATION_BAR_HOST_ROUTE = "navigation-bar-host"

fun NavGraphBuilder.navigationBarHost(
onNavigateToLogin: () -> Unit,
onNavigateToOrder: () -> Unit,
) {
composable(route = NAVIGATION_BAR_HOST_ROUTE) {
val navController = rememberNavController()

Scaffold(
bottomBar = {
NavigationBar(navController)
}
) { padding ->
NavHost(
navController = navController,
startDestination = HOME_GRAPH_ROUTE,
modifier = Modifier.padding(padding)
) {
homeGraph(
// We can open product card from home
onNavigateToProductCard = { productId ->
navController.navigateToProductCard(productId)
}
)
productBrowserGraph(
// We can open product card from browser
onNavigateToProductCard = { productId ->
navController.navigateToProductCard(productId)
}
)
favoritesGraph(
// We can open product card from favorites
onNavigateToProductCard = { productId ->
navController.navigateToProductCard(productId)
}
)
cartGraph(
// We can open product card from cart
onNavigateToProductCard = { productId ->
navController.navigateToProductCard(productId)
}
)
profileGraph(...)
// Here we put this common nested graph
productCardGraph(...)
}
}
}
}

Don’t forget about the indicator

One missing part is a logic to display an indicator on a proper item of the NavigationBar. To achieve it we define a list of routes which are considered as main tabs.

val navigationBarRoutes = listOf(
HOME_GRAPH_ROUTE,
CATEGORY_BROWSER_GRAPH_ROUTE,
FAVORITES_GRAPH_ROUTE,
CART_GRAPH_ROUTE,
PROFILE_GRAPH_ROUTE,
)

Then we add a function which uses a NavController, takes a currentBackStack from it and checks which tab is the last one in the back stack.

fun isRouteSelected(route: String): Flow<Boolean> {
return navController.currentBackStack.map { backStack ->
backStack
.map { it.destination.route }
.lastOrNull { navigationBarRoutes.contains(it) }
.let { it == route }
}
}

Lastly we use it in the NavigationBar to show and hide indicators for particular items.

NavigationBar {
val isHomeSelected by isRouteSelected(HOME_GRAPH_ROUTE).collectAsState(false)

NavigationBarItem(
selected = isHomeSelected,
onClick = {
state.openRoute(HOME_GRAPH_ROUTE)
},
icon = {
Icon(imageVector = Icons.Default.Home, contentDescription = null)
}
)
...
}

Opening dialogs, including full screen ones

When our flow moves deeper inside the NavigationBarHost we might want to open a single screen which is displayed above the NavigationBar and takes a full screen space. In the my data flow we have several such screens. We use them mostly as forms to input or edit some information like user personal data, address or passsword.

My data flow in e-commerce application

In that case we don’t need to move user from the NavigationBarHost to the RootHost just because we want to display a single screen which takes a full screen space. It is a nice solution to use a full screen dialogs in such a scenario, as they are directly supported by the Compose Navigation.

Small change and our screen becomes a full-screen dialog

When we define for instance the ChangePasswordNavigation we make it the same way we did for other screens. The only difference is that we use a dialog function instead of a composable in our NavGraphBuilder extension. We also set the usePlatformDefaultWidth to false so the dialog can take a full screen space and be a full-screen dialog.

internal const val CHANGE_PASSWORD_ROUTE = "change-password"

internal fun NavController.navigateToChangePassword() {
navigate(CHANGE_PASSWORD_ROUTE)
}

internal fun NavGraphBuilder.changePasswordScreen(onDismiss: () -> Unit) {
// Just a small change and that's it
dialog(
route = CHANGE_PASSWORD_ROUTE,
dialogProperties = DialogProperties(usePlatformDefaultWidth = false)
) {
val viewModel: ChangePasswordViewModel = hiltViewModel()

ChangePasswordScreen(
onCloseClick = onDismiss,
onSaveClick = viewModel::changePassword,
)
}
}

We can achieve the same result not only for dialogs but also for the bottom sheets. Although they are not supported in the stable Navigation Compose, we can find an alpha version as part of Google Accompanist.

Just look how easy it is to add deep links

So we see how solid the Compose Navigation is when it comes to building large, scalable, mutli-module applications which contain many different flows with dozens of screens. But there is one more thing which it does really well — deep links.

In real world apps it is a crucial business requirement to be able to open certain parts of the application from external links. They are usually sent to user via e-mails, sms messages or push notifications. In various projects I saw many different solutions which were supposed to handle deeplinks in the application. They were usually very custom, heavy and complex ones.

With the Compose Navigation this whole complexity can be significantly reduced. Because our main navigation relies on the URLs we basically have a support for deep links out of the box. With two simple steps we can deeplink to any destination in the app. It doesn't matter if this is a single screen, a whole nested graph or even a dialog or a bottom sheet.

Each NavGraphBuilder extension which we used in this article has an additional deepLinks parameter. Because we already have our ROUTE constants defined we can reuse them as deep links. We just add a BASE_DEEP_LINK to them.

// ForgotPasswordNavigation.kt

composable(
route = FORGOT_PASSWORD_ROUTE,
// Deeplink to open a single screen
deeplinks = listOf(
navDeepLink { uriPattern = BASE_DEEP_LINK + FORGOT_PASSWORD_ROUTE }
)
)
// ChangePasswordNavigation.kt

dialog(
route = CHANGE_PASSWORD_ROUTE,
// Deeplink to open a single dialog
deeplinks = listOf(
navDeepLink { uriPattern = BASE_DEEP_LINK + CHANGE_PASSWORD_ROUTE }
)
)
// ProfileGraph.kt

navigation(
startDestination = PROFILE_ROUTE,
route = PROFILE_GRAPH_ROUTE,
// Deeplink to open a whole nested graph
deepLinks = listOf(
navDeepLink { uriPattern = BASE_DEEP_LINK + PROFILE_GRAPH_ROUTE }
)
)

This BASE_DEEP_LINK is a simple base URL which is global for our application. It allows Android to decide which deep links are passed to our app and which are not.

const val BASE_DEEP_LINK = "app://com.maruchin.androidnavigation"

And this decision is made based on the intent-filter which we set for our MainActivity in the Manifest file. The host attribute takes exactly the same value as the BASE_DEEP_LINK .

<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="app" android:host="com.maruchin.androidnavigation" />
</intent-filter>

And that’s it. No more setup, no custom implementation, Compose Navigation will do the rest opening a selected destination. What’s even better, it properly handles back stack recreation based on our graphs declaration. For example when we deep link to the /forgot-password screen and we click back, we don’t exit the app. We move to the login screen and from the login we navigate back to home.

Summing up

It has been a long journey for me that started with a lot of skepticism and rejection of this new route-based navigation pattern for Android. However, after a long time, many experiments and checking various alternatives, I have come to very solid conclusions.

Compose Navigation is actually a convenient and definitely a production-ready solution. Indeed, route-based navigation looks strange at the beginning, but when you wrap it with simple extension functions it starts to work really well. And you can support deep links at almost zero cost 💰

This library simplifies many aspect of Android navigation, offering a great back stack management, even in complex scenarios like bottom navigation with nested flows.

And what’s the most important, it scales very well in large, mulit-module projects. We can easily divide our application into fully independent features, each of which has its own autonomous navigation graph. The individual graphs become single source of truth to understand how the navigation behaves in certain features.

So? Will you give it a try?

I hope I have dispelled your fears 👻 at least a little and you’ll try to play around with Compose Navigation in your projects. Route based navigation is not so scary as it seems to be 😄

And if you want to see the source code used in this article, check out my repository where I built a sample e-commerce application, with an extensive navigation structure, a lot of screens nicely divided into 11 modules, with independent nested graphs.

--

--