Navigating in Jetpack Compose

Jossi Wolf
Jun 7 · 14 min read

If you are working on a mobile app, chances are you need some form of navigation. Getting navigation right isn’t easy with many challenges at hand: back stack handling, lifecycles, state saving and restoration and deep linking are just some of them. In this post, we’ll explore the Navigation component’s support for Jetpack Compose and take a look under the hood!

Could I have chosen a more cliche photo? Probably not! Photo by Mick Haupt on Unsplash. Thanks, Mick!

Getting started!

Before getting started, we’ll add a dependency on navigation-compose, the Navigation component’s artifact for Compose support.

Let’s jump into the code!

First, we create and memoize a NavController using the rememberNavController method. rememberNavController returns a NavHostController which is a subclass of NavController that offers some additional APIs that a NavHost can use. When referring to the NavController and using it later on, we will use it as NavController as we don’t need to know about these additional APIs ourselves; it’s just important for the NavHost.

We pass this into our NavHost composable. The NavHost composable is responsible for hosting the content of the NavDestination associated with the NavBackStackEntry (we will look at the details in a bit!).

The lambda we pass to the NavHost is the builder for our navigation graph. In here, we have access to a NavGraphBuilder and can construct and declare our navigation graph. This is where we declare our destinations and nested graphs. If you are coming from the “old” navigation library, this might feel a bit weird at first! There is no XML anymore, not even for the navigation graphs. Even though the Kotlin DSL has been available for a long time, it had been overshadowed by XML so far.
This also means that we won’t have a visual representation of the graph for the foreseeable future. The rendered view of the XML navigation graph was very useful, so let’s hope that we will get this for Compose at some point!

Declaring a composable destination is easy: We use the composable method provided by navigation-compose.

It is an extension function on NavGraphBuilder, essentially a convenience wrapper around NavGraphBuilder‘s addDestination method. In here, a NavDestination specific to the ComposeNavigator is created. ComposeNavigator is the Navigator responsible for handling the back stack and navigation of composables.

NavGraphBuilder is part of the common (non-compose-specific) navigation API and mainly offers a addDestination method that adds the destination to the nav graph.

Looking back at our code, we pass a route to that composable function, telling the NavGraphBuilder what route we want to use later to navigate to this destination. If you are coming from the “old” navigation library, a route is roughly the equivalent of defining an id for a destination. Starting with version 2.4.0 of the navigation library, the NavDestination‘s id is automatically set (and updated every time the route is updated) based on its route, so defining an id isn’t needed.

The last parameter of the composable function is a @Composable lambda which will be set as the destination’s content. When we navigate to this destination, the NavHost will host this composable.

In our @Composable lambda, we just host the FeedScreen composable. While you could theoretically declare all your content directly in the nav graph, please don’t do it. Things will get messy soon and cleaning up will be tedious!

Awesome! We have created our NavController, used the NavHost composable and the composable NavGraphBuilder function to create a composable destination and add it to the navigation graph. Let’s add a second destination and navigate!

Let’s take a look at what changed:

First, we added a new destination to the graph and added the AdoptionScreen composable. To navigate when a button in our FeedScreen is clicked we added the NavController to the FeedScreen‘s parameters. Finally, when the button is clicked we call NavController#navigate with the route of the destination we want to navigate to.

If you are not into the specifics of how things work under the hood, feel free to skip ahead to the next section. This interlude provides a high-level overview that can be useful for your understanding of navigation-compose.

When we call navigate, the NavController figures out what it has to do to get us to that destination. First, it checks if there is a destination associated with the requested route and if it is in the navigation graph. We don’t want to navigate into the depths of interstellar space!

Note: when navigating with a route, this is treated as a deep link internally. That also means that you get deep linking for free if you are using the Navigation Kotlin DSL and registering destinations with routes instead of ids.

After the NavController is sure that the destination we want to navigate to exists, the NavController looks at all the navigation options (should the back stack be popped? Should the destination be launched single top?) and creates an NavBackStackEntry if the destination isn’t on the back stack yet (i.e. when we haven’t navigated to it before or popped it off) or retrieves the back stack entry for the destination if it is on the back stack. That might be the case when we have the following flow:

Diagram of a navigation flow with the “Feed” starting destination and an “Adoption” destination. First, we navigate from the “Feed” to the “Adoption” destination and then navigate back to the “Feed” after.
Diagram of a navigation flow with the “Feed” starting destination and an “Adoption” destination. First, we navigate from the “Feed” to the “Adoption” destination and then navigate back to the “Feed” after.

A back stack entry is created and added to the back stack for our “Feed” starting destination. When we navigate to the “Adoption” screen, a back stack entry is created for the adoption screen. When we navigate back to the “Feed”, we already have the “Feed” back stack entry on the back stack. Just like Fragments, Activities or other components we know in Android development, a NavBackStackEntry has a lifecycle so that a back stack entry can remain on the back stack while not being active, i.e. because it is not at the top of the back stack.

To navigate, the NavController looks at the requested NavDestination‘s Navigator. In our case, that is ComposeNavigator since we are navigating to a composable destination. The NavController calls the navigator‘s navigate function with the requested destination and navigation options which carries out the navigator‘s navigation logic, adding the back stack entry to its back stack if needed. Finally, the entry is also added to the NavController’s back stack. A navigator only has back stack entries that it knows how to handle (entries created from this navigator‘s destinations) while the NavController maintains the back stack for the whole graph. You can think of the NavController as “big boss” and the navigators as “small bosses”.
Additionally, the navigator (to be more precise, the navigator’s NavigatorState) moves the back stack entry to the RESUMED state as it is now ready to be displayed. The NavigatorState also moves the previous entry’s state to the CREATED state, indicating that it’s not active anymore.

Meanwhile, the NavHost composable looks at the ComposeNavigator‘s back stack which now has the added or updated NavBackStackEntry. It recomposes with the updated list of back stack entries and emits each back stack entries’ destination’s content (the @Composable lambda we passed in earlier) into the composition. In simple terms, it invokes the @Composable lambda that we passed to the composable function in the NavGraphBuilder.

And tada, we have arrived at our destination!🎉

Alright, we’re done with our small interlude, let’s get back to our code! In our case, we haven’t specified any navigation options when navigating — we just call navigate with the route.

By default, the previous destination (our FeedScreen destination) will be kept on the back stack. When we want to go back from our AdoptionScreen, we just have to pop that destination off the back stack.

Instead of defining where we want to go, we tell the NavController that the destination at the adopt route is the topmost destination that should be popped off the back stack by also setting inclusive to true. Note that this is the default behavior for popBackStack, so we could also just call navController.popBackStack() without any arguments. This way, we could navigate to the adopt route from anywhere and always go back to the place we came from. Alternatively, we could also use navController.navigateUp() here. It attempts to navigate up in the navigation hierarchy. In most cases, that means just popping the current entry off the back stack, but if the app was opened through a deep link using navigateUp makes sure that you go back to the place you came from, e.g. another app.

And with that, we have our most basic form of navigation. Let’s clean up the code a bit!

Cleaning up

As you can imagine, repeating our routes everywhere we want to navigate isn’t a very scalable approach. We want to be able to re-use the routes. This will prevent us from introducing bugs with a typo and help us when we want to change our navigation logic. There are a few approaches for this, i.e. defining all routes as constants in an object (or multiple).

This is what we used at first, but I favor Chris Banesimplementation using sealed classes by now. It is a bit easier to read and generally easier to maintain.

Kotlin 1.4 and 1.5’s relaxed rules for sealed classes allow a clean separation for these definitions and make sealed classes a great fit here.

Let’s go and update our code from earlier to use that!

When navigating to a destination, we often want to pass an ID or another argument that is needed to load specific data. Say our FeedScreen now had a list of cute puppies up for adoption and we wanted to show the adoption page for a specific pup when it’s clicked:

Our Screen.Adopt destination doesn’t know how to handle arguments yet, so let’s jump back to our routes and add an argument first.

Arguments are defined using curly brackets that enclose the arguments’ name. We will use this name to retrieve the argument later on. The curly brackets register this as a placeholder and that this is where the argument is expected when the route is created.
Of course, the format of the route is completely up to you, but it makes sense to follow RESTful URL design. Think of the route as the identifier for the screen. It should be unique, clear and easy to understand.

For required arguments, define the arguments as path parameters. For our Adopt route, we always require the dogId to be present so we define it as a parameter of the path. If we wanted to supply optional arguments we would use the query parameter syntax: adopt?dogId={dogId}.

Going back to our nav graph builder, the @Composable lambda that the compose function takes a parameter: The NavBackStackEntry of the destination. A NavBackStackEntry holds, among other important information, the arguments extracted from the route that is being navigated to.

From the back stack entry, we can extract the dogId we declared as an argument in the route. Note that the entry’s arguments are nullable, but we can be sure that this data is here as it is part of the route we defined. When navigating, the requested route has to match a destination’s route or its pattern exactly. Since our dogId argument is part of the route, we can’t get into the sticky situation of this argument being missing. It is still a good practice to think about handling this case though and not to sweep it under the rug with a simple non-null assertion.

Now that we have our nav destination set up, we can add the argument to our navigate call in the feed! To do that, we have to modify the route that we want to navigate to. We were previously using Screen.Adopt as route but since updated this route to be the template so we can’t add our argument here. Instead, we can create a createRoute function with the needed parameters that will build the route. Full credit to Chris Banes for this idea!

You can find more information on navigating with arguments, optional arguments and argument types other than strings in the official Android Developers documentation.

Cool, everything looks great and works well, right? Well… for the first few screens. As our app becomes more complex, we’ll want to navigate to one destination from more than one place. We end up passing down the NavController to at least each screen-level composable. This creates a dependency between the composable and the NavController, making it harder to test and create @Previews for. The navigation-compose testing guidelines also state this:

Screenshot of the Android Developers documentation about testing navigation-compose code. It reads: “Testing: We strongly recommend that you decouple the Navigation code from your composable destinations to enable testing each composable in isolation, separate from the NavHost composable. The level of indirection provided by the composable lambda is what allows you to separate your Navigation code from the composable itself.”
Screenshot of the Android Developers documentation about testing navigation-compose code. It reads: “Testing: We strongly recommend that you decouple the Navigation code from your composable destinations to enable testing each composable in isolation, separate from the NavHost composable. The level of indirection provided by the composable lambda is what allows you to separate your Navigation code from the composable itself.”
https://developer.android.com/jetpack/compose/navigation#testing

Apart from testing, it also makes changing our navigation logic harder. If we were navigating to our adoption screen from 5 different places, each calling navController.navigate , we would have an at best annoying and at worst hard and bug-inducing time updating this destination’s arguments if we wanted to add one or change the navigation logic in another way like popping the back stack. The magical phrase is “co-location of navigate calls” — we want to make sure that all our navigate calls live in one place instead of being splattered around 30 different composables.

Updating our code, instead of passing the NavController to FeedScreen and AdoptionScreen, we can change them to accept a lambda instead:

After that, we update our CuteDogPicturesApp to pass down that lambda instead.

This way, we have decoupled our composable from actual navigation dependencies, can easily fake this behavior and easily refactor it later on. If we wanted to, we could even extract the actual navigate call into a local function. As different destinations might require different navigation logic (you might want to pop the back stack when you are navigating from screen B to C, but not when navigating from A to C) I recommend holding off on this (most likely) premature optimization. Through the co-location of your navigate calls you already have a good starting place if you want to extract even more things later on.

Using nested navigation graphs, we can group a set of destinations and modularize it. If you used the nav component before, you will probably know the <navigation> XML tag which could be used to declare a nested navigation graph. With the Navigation DSL, there is a navigation extension function similar to the composable extensions that navigation-compose provides. This navigation extension function is provided by the common (non-compose-specific) navigation artefact.

To declare a nested graph, we call the navigation method with the route of this nested graph so that it can be navigated to and also set the start destination. We have also introduced a DogScreen sealed class that represents the routes in this graph. The Screen sealed class now represents top-level destinations while nested destinations are defined in their own sealed classes. This makes the code easier to read and maintain as more destinations get added. We could also move the DogScreen class into its own file for more separation!

As your app grows, so will your navigation graph. Eventually, you will need nested navigation and end up with a very long navigation graph definition that is hard to read and maintain.

Since the composable and navigation functions are just extensions on NavGraphBuilder, we can also use extension functions to break up our navigation graph:

As our navigation graph grows, these can be hosted in their own respective files too to make things easier to work with. If your app is modularized, this also enables you to encapsulate the navigation and routes for a module within that module. For our example, that’d mean exposing the addFeedGraph extension function from the dog module. To navigate outside of this nested graph, addFeedGraph would also accept a lambda to navigate to the adopt screen.

If you are interested in modularization, I highly recommend Joe Birch’s article about modularized navigation in Compose!

Lessons from the real world

At Snapp Mobile, we are currently working with a technologically progressive customer that asked us to deploy Jetpack Compose in a greenfield project. We have used Compose and navigation-compose for a few months there now and from our experience, following the above best practices is the most important.

Before reading the official advice on decoupling composables from the NavController we were passing it down to all of our screen-level composables and created tight coupling between composables and the navigation library. Coming from using the navigation library with Fragments (sorry, Jake) and Activities, we were used to calling findNavController in our Fragments as we didn’t have any abstraction previously. Passing down the NavController was the “natural” way at first, but as the code base and navigation graphs grew it became obvious that it led to messy code that’s hard to change — we are still working on undoing that damage.

If you are just starting with navigation-compose , follow this best practice. If you have already been using it for a bit I suggest starting to think about ways to refactor this code as soon as possible to reduce the number of things you have to update. Chris Banes refactored his TiVi app’s navigation to co-locate navigate calls recently if you are looking for inspiration.

Properly defining routes and navigation graphs and splitting them up is another important point. As pointed out earlier, we started out defining our routes in (nested) objects which became very hard to manage and is also not the most pleasant thing to refactor later on. Leveraging sealed classes and their relaxed rules in the latest Kotlin versions is important to be able to keep an overview of things. Thinking about an app with 40+ navigation destinations, defining all routes in one place will end up in a quite big, hard-to-read and unmaintainable file.

As of Navigation 2.4.0-alpha02, transitions between composable destinations are not supported yet. We haven’t needed it in our current project (yet), but if transitions between destinations are a requirement this is good to keep in mind when making a decision about navigation-compose in your project. However, the compose animation and navigation teams are working on the issue and we should see something out when navigation 2.4.0 goes stable. Meanwhile, I recommend tracking this issue.

Apart from these three gotchas, while we encountered the occasional bug or two, navigation-compose works very well and we are using it and its Hilt integration and are happy. As with all new things, we are still in the process of figuring out new best practices but are happy with our approach so far. Navigation 2.4.0 also landed support for multiple back stacks and fixed a ton of other bugs, so seeing the team actively work on requested features has been great!

You’ve made it to the end! Congrats! Even though some contents are similar, check out the official Android Developers documentation about navigation-compose. Chris’ pull request in Tivi is a good example of migrating away from bad navigation patterns. The Tivi repository itself is a good reference for implementing navigation using navigation-compose in a real-world app too if you want to get some inspiration.
The official navigation samples are a good starting point too, but keep in mind that they are just that: samples. They don’t necessarily follow all best practices (like not passing down your NavController), so take these with a grain of salt and don’t blindly copy them.

Apart from the official resources, there are also some cool community projects for navigation in Compose. While we haven’t used these in production, I’ve heard other people say that they like these libraries. It’s definitely worth checking out Zsolt Kocsi’s compose-router, Zach Klippenstein’s compose-backstack and Arkadii Ivanov’s Decompose libraries.

Have you used navigation-compose? I’d love to hear about your real-world experiences!

Thanks to Volodymyr Galandzij, Mark Dickson, ashdavies ™ and Ian Lake for their lovely suggestions and reviews!

Google Developers Experts

Experts on various Google products talking tech.