Nested Navigation Graphs in Jetpack Compose

In a recent blog post, we took a look at how we can use Compose Navigation in a modularized application. With this approach we saw many benefits — allowing us to centralise our navigation logic in a single place, while also decoupling navigation dependencies from the rest of our application.

When building out the navigation for Minimise, every Composable destination was placed into a root graph. While this works, it isn’t very practical — it means that the entire navigation of our app is structured as a single graph. Instead, it would make more sense to group composable destinations into logical groups that each represent the different features of our application. That way we can navigate to different parts of our app by feature, instead of navigating to individual destinations from each of those features. Similar to the previous efforts of decoupling our navigation responsibilities, this helps to decouple the details of our features from our root graph — for example, allowing us to navigate to the “Create Item” feature instead of needed to know about each of the destinations inside of that feature. Alongside this, we get a better organization of our navigation graph — reducing any friction when it comes to building on / maintaining our project and understanding how the navigation is structured.

In this post we’re going to take a look at how we can create nested navigation graphs in Jetpack Compose 🙌

Root Navigation Host

In the last article about modularized navigation, we created a NavHost that held each of our composable destinations in the form of `composable` references.

Each of these references declared the root which they would be assigned to handling, along with the composable which would be composed when that destination has been navigated to.

NavHost(
navController,
startDestination = NavigationDirections.Authentication.destination
) {
composable(NavigationDirections.Authentication.destination) {
Authentication(hiltNavGraphViewModel())
}
composable(NavigationDirections.Dashboard.destination) {
Dashboard(hiltNavGraphViewModel())
}
}

In that example we were only using two composable destinations, which makes it hard to envision why there might be a need for nested navigation and truly feel the benefits of having such a thing in place. For examples sake, lets add a few more composable routes for different screens of the application:

NavHost(
navController,
startDestination = NavigationDirections.Authentication.destination
) {
composable(NavigationDirections.Onboarding.destination) {
Onboarding(hiltNavGraphViewModel())
}
composable(NavigationDirections.Authentication.destination) {
Authentication(hiltNavGraphViewModel())
}
composable(NavigationDirections.ForgotPassword.destination) {
ForgotPassword(hiltNavGraphViewModel())
}
composable(NavigationDirections.Dashboard.destination) {
Dashboard(hiltNavGraphViewModel())
}
composable(NavigationDirections.Settings.destination) {
Settings(hiltNavGraphViewModel())
}
composable(NavigationDirections.CreateItem.destination) {
CreateItem(hiltNavGraphViewModel())
}
composable(NavigationDirections.EditItem.destination) {
EditItem(hiltNavGraphViewModel())
}
...
}

With these additions here, we can start to see our Root Navigation Host being built out quite a bit — as our application grows (which Minimize currently is!), we’re only going to see more and more destinations added here. Not only is this starting to bloat our navigation graph, but it’s starting to get difficult to work out how our navigation graph is built out and what routes navigate to where.

Declaring a Nested Navigation Graph

To tidy things up a bit here, we’re going to split out different parts of our navigation graph into their own separate sections.

To do this, we’re going to use the NavGraphBuilder.navigation() extension function which allows us to build a nested navigation graph. We’ll start here by using this function and utilising its builder argument to provide the composables that make up the nested graph.

navigation(...) {
composable(OnboardingDirections.authentication.destination) {
Authentication(
navController.hiltNavGraphViewModel(
route = OnboardingDirections.authentication.destination
)
)
}
composable(AuthenticationDirections.forgotPassword().destination){
ResetPassword(
navController.hiltNavGraphViewModel(
route =AuthenticationDirections.forgotPassword().destination
)
)
}
}

We now have a nested graph containing our Authentication and Reset Password destinations. This separates our Authentication related composables out from the rest of our navigation graph, essentially creating a feature graph that is nested inside of our root navigation graph. You’ll notice that our composable destinations look exactly the same as they previously did — we keep the route definitions in place as we still need to be able to navigate to these individual destinations, the difference with this nesting is now being able to navigate to our feature as a whole.

To achieve this, the navigation extension function also contains two other arguments, the first being used to declare the destination to be used when this feature graph is navigated to. This is declared in the form of a startDestination and should refer to the route of one of the contained composable destinations. In my case I want the nested graph to start at the Authentication composable, so for the startDestination this will match the root of that composable destination.

navigation(
startDestination =
AuthenticationDirections.authentication.destination
) {
composable(AuthenticationDirections.authentication.destination) {
Authentication(
navController.hiltNavGraphViewModel(
route = AuthenticationDirections.authentication.destination
)
)
}
...
}

While we’ve defined the starting destination for our nested graph, we’re yet to provide a way for our nested graph to be navigated to. Similar to our composable destinations, we also need to define a route for our nested graph — this route can then be used to navigate to our nested graph when performing navigation. As per the previous article, I had defined my navigation directions in Kotlin objects. Alongside the NavigationCommand references for each destination, we’ll add a root field that will be used to declare the route for that nested feature graph. Here we’ll declare a route path for our Authentication feature as Authentication. Regardless of whether you’re using a similar setup as below, the route path just needs to consist of a string that defines the path used to navigate to this feature.

object AuthenticationDirections {
val root = object : NavigationCommand {
override val arguments = emptyList<NamedNavArgument>()
override val destination = "authentication"
}
val authenticate = object : NavigationCommand {
override val arguments = emptyList<NamedNavArgument>()
override val destination = "authenticate"
}

val forgotPassword = object : NavigationCommand {
override val arguments = emptyList<NamedNavArgument>()
override val destination = "forgot_password"
}
}

With this in place, we can now apply this route to our nested graph using the navigation route argument. At this point, we now have a way to navigate to our nested graph — and when doing so, the startDestination will be the first point of navigation that is presented from our graph.

navigation(
startDestination =
AuthenticationDirections.authenticate.destination,
route = AuthenticationDirections.root.destination
) {
...
}

Navigating to a Nested Navigation Graph

With this all in place, we can now perform navigation directly to a nested graph. This is done in the same way that we navigate to a composable destination, using the navigate command on our NavController reference.

navController.navigate(AuthenticationDirections.root.destination)

The above call will navigate to the Dashboard nested graph, taking me to the dashboard feature of my application whose navigation route matches this destination. The startDestination of this nested graph will then be used to depict what composable destination is displayed.

Defining the starting Navigation Graph

Back at the top-level of our navigation graph and the NavHost declaration, we have a startDestination which is used to declare the composable destination that is to be displayed when our graph is loaded. For this we are able to provide the route of a nested graph, meaning that one of our nested feature graphs will be used as the startDestination, and in turn the startDestination of that nested graph will then be the composable that is initially displayed.

NavHost(
navController,
startDestination = AuthenticationDirections.root.destination
)

In this post we’ve looked at how we can use nested navigation graph to provide grouped collections of destinations that represent different features of our app. From this we can see that outside of this organization, nothing changes in the way that we navigate to the different destinations in our graph — this just changes in the way that at some points of our application we are going to be navigating to features rather than individual destinations. While we can still navigate to these individual composable destinations when desired (such as, within the features themselves), being able to navigate to features as a single entity allows us to keep that clear separation of features within our app, and avoid leaking feature details throughout the project. Alongside that, our graph has become much clearer to understand with this logical grouping that is in place.

Android Engineering Lead at Buffer, Google Developer Expert for Android & Flutter - Passionate about mobile development and learning. www.joebirch.co

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store