Navigating in Jetpack Compose
In today’s article we’ll learn how to navigate between screens on a Jetpack Compose app using a solution different from the one suggested by Google in their documentation, while still using the Navigation library from the Android Architecture Components.
Google’s navigation proposal
How it works
First, we need to describe how Google suggests we implement navigation in Jetpack Compose. The documentation and samples from Google, when it comes to navigation, all use the same approach: pass a lambda to the root composable of each screen that will allow that composable to trigger navigation to a different screen. For instance, a typical implementation would look something like this
In this approach we can see that
- When we add a composable to the navigation graph (in this case, the Home composable), we then
- Pass a lambda for each destination we may need to navigate to.
- And we also pass a lambda if we need to pop the backstack.
Why I think it’s not the best approach
While this solution works and may be perfectly fine for smaller apps, I think it does not scale well as the app grows more complex. There are 2 main points for this reasoning:
- A root composable usually has a fairly large number of other composables that it embeds, and those additional composables may need to navigate to other screens as a result of some user action, so we find ourselves passing a not insignificant number of lambdas to the root composable, which in turn needs to propagate those lambdas down the chain to where they are needed.
- It is not unusual that we need to navigate to a different section of the app after some asynchronous operation has completed. For instance, the user may tap a button to save some data to a database, an asynchronous operation, and only if the operation succeeds we would want to navigate the user to the next screen. By using Google’s solution we can’t navigate directly from the composable, we instead need to pass an action to the viewmodel to perform the async operation and, once it succeeds, the viewmodel needs to pass an event back to the composable to trigger the navigation. This can become rather convoluted with all the back and forth between the composable and the viewmodel.
So while Google’s approach might work perfectly fine in some scenarios, I think it makes sense to look at other options that do not have the 2 drawbacks listed above.
The alternative approach
The alternative approach I suggest consists in having a dedicated class that will handle the navigation. This class can then be injected in ViewModels and we can then trigger navigation from the ViewModel, leaving the composables to handle only composition, rather than navigation logic.
To describe this approach we will write a very simple app that consists of 3 screens, a main screen that we will name One with 2 buttons to navigate to the 2 other screens, which we will name Two and Three. Both of the secondary screens take some data that we want to pass while navigating to them, and from each secondary screen we can pop the stack to return to the first screen, or navigate to the other secondary screen. This diagram shows how navigation will work on our sample app:
For our sample app the Two screen will accept a String as argument, and the Three screen will accept an Enum and a boolean.
To keep this article more concise I will not show or describe the UI for the screens, as that’s not particularly relevant to the discussion, but you can check this GitHub repo if you want to see how those are implemented, or if you want to follow along using the code.
Defining the navigation classes
So the first thing we want to do is define the navigation classes. To navigate to a screen using Jetpack Compose all we need is a String, which is the route identifying the screen we want to navigate to. These strings are of the form
root/arg1/arg2 for mandatory arguments and
root?arg1=value1&arg2=value2 for optional ones. But this is just a detail, at the end of the day all we have to deal with is a String — all arguments are converted to a string regardless of their original type when building the route.
With this in mind, we are going to define an interface that will be used for each destination in our app; this interface will have a single method,
buildRoute which will return a string representing the route we want to navigate to:
Now that we have this, we can create our Navigator class, which will do the actual navigation. Our Navigator will have 2 main methods, a
navigate method that will accept a
NavigationRoute as described above, and a
popBackStack method to pop the current screen from the stack. Let’s see how we define the Navigator:
- Our Navigator needs a
NavControllerto delegate the navigation to, so we have a setter method to accept it.
- We have a
navigatemethod that takes a
- We have a method to pop the backstack.
- And finally we have a
clearmethod, that we will use to release the
The implementation of this
Navigator is pretty straightforward:
- We keep a reference to the
- In our setter, we update our reference with the
NavControllerwe receive as argument.
- For navigation, we call the
NavigationRouteargument, and we use that to navigate via the
popBackStack, we simply delegate to the
- And finally the clear method sets the
This gives us the basic pieces we need to navigate. We still have to implement the
NavigationRoute classes, we’ll do that in a bit.
Initializing the Navigator
First we will see how we can instantiate and initialize the navigator in our app. We will do that from the
MainActivity, where we set the root composable for the app:
- We will use Hilt for Dependency Injection, so we annotate our Activity with
- We inject the
- In the composition, we get a
NavControllerusing the factory method
- Because our
Navigatoris a Singleton, we have to make sure we are releasing the
NavControllerwhen the composable is destroyed, so we use a
- In the main body of the
DisposableEffectwe initialize the
- And in the
onDisposeblock of the
DisposableEffect, which is called when the composable leaves the composition, or the key (
navController) changes, we call the
clearmethod to release the
With this out of the way, we can start implementing the navigation classes for each screen in our app.
The One screen Navigation class
The One screen is the simplest one because there is no navigation to it, it’s the start route and we can only go back to it by popping the stack. As we do not have any state to hold here, we can use an
Object to represent this destination, as shown below:
objectinherits from the
- We override the
buildRouteand simply return a constant identifying the One screen.
With this we can now add the One screen to our navigation graph:
The Two screen navigation class
Next we will define the
NavigationRoute for the Two screen. As we mentioned earlier, this screen will accept a String as argument, so we can’t use an
Object like for the previous one, and we will instead use a
data class. It could as well just be a regular
class, it does not make much difference. Our class will take the String as its single constructor argument and will use that to navigate to the Two screen. Let’s see how we would implement this class:
- We use a
data classinstead of an
Objectas we have arguments.
- The argument is provided in the constructor.
- We override the
buildRoutewhich uses the
rootfor the Two screen and appends the argument we received at instantiation.
- This is the
routethat we will use when building the navigation graph.
This is pretty straightforward, we can now add this to the
MainActivity as shown below:
Now if we want to navigate to the Two screen all we have to do is call
navigate on our
Navigator class passing a
NavigationTwoRoute class as argument:
We would call this from our ViewModel, possibly after some asynchronous work has completed.
As we are using the Jetpack Navigation library, the argument we pass via the route will be available in the destination (screen Two) ViewModel via the
SavedStateHandle object that the ViewModel receives in its constructor. We can retrieve the argument from the
SavedStateHandle using the
key defined in the
NavigationTwoRoute class, but we can do better, we can update our
NavigationTwoRoute class to do that for us so that we encapsulate all the logic to serialize and deserialize the arguments in the Navigation class. We can do this in a couple of different ways, we could add a
getValue or similar method to the class, one method per argument, or we can instead add a secondary constructor that will accept the
SavedStatehandle and populate the class with the payload contained within. This second approach appears to more desirable, as we only need 1 new method, a secondary constructor, as opposed to a new method per argument passed in. Let’s update our
NavigationTwoRoute class with this secondary constructor:
- The only change we need is to add the secondary constructor that accepts the
SavedStateHandleand builds the corresponding
With this, our Two screen ViewModel would simply have to do what’s show below to retrieve the arguments:
- As we are using Hilt, we annotate the ViewModel with
SavedStateHandlewill be passed in the constructor.
- We use this
SavedStateHandleto reconstruct the
NavigationTwoRouteclass, so that we can then retrieve the
valueargument used to navigate to this screen.
This completes our Two screen which simply took a String as argument. Let’s see how we can handle the Three screen which accepts 2 arguments.
The Three screen navigation class
For the Three screen we want to pass an Enum and a boolean. In Jetpack Compose Navigation all arguments need to be passed as String, so if the value we are passing is not a String, we need to define a
NavType class that tells the navigation framework how to serialize and deserialize this argument in a
Bundle, represented as a String. For primitives and arrays of primitives the navigation library provides ready-made
NavType classes, but for our Enum we’ll have to define one.
Let’s first define our Enum class and later we’ll create the
NavType class to go with it:
- As we need to put our Enum in a
Bundle, we make it
- For the navigation, we need to represent our Enum as a String, so we have a method to do the conversion.
- And in order to deserialize it, we have a method that takes a String representation of our Enum and returns the corresponding Enum instance. Note that we are not validating the input string for brevity sake.
Now that we have our Enum defined, we can create the
NavType class so that we can pass it as path in the route, to do so we need to extend the
NavType class as shown here:
- We extend
NavTypewhich is a parameterized class, using our Enum as the type.
- We provide a name that will be used to put and retrieve the argument in the
- We override the
getmethod which retrieves the argument from the
- We do the same with the
parseValuemethod, which deserializes the type.
- And finally we also override the
putmethod, which stores it in the
With this out of the way, we can now implement the
NavigationRoute for our Three screen; let’s see that:
- Like we did earlier, our
DestinationRouteclass for the Three screen is a
data classthat takes the required arguments in the constructor.
- We also provide a secondary constructor so that we can restore this class from the
SavedStateHandleobject that ViewModels receive in their constructor.
- We override the
buildRoutemethod, using the root for the Three screen and the 2 arguments we need for this screen.
- This is the
NavTypeclass we just discussed a little while ago.
- Here we define the route that we will use to identify this screen in the nav graph.
- And finally we provide the
navArgsfor this screen, using our custom
TheeRouteParamTypeclass and the
NavType.BoolTypeprovided by the library.
With this, we can now complete our nav graph with the Three screen:
We can see that we use the
route and the
arguments from the
NavigationThreeRoute, we keep those in the navigation class to keep the navigation graph more concise.
Like we did before, we can now retrieve the arguments in the Three screen ViewModel, passing in the
SavedStateHandle to build an instance of
Adding return values
Our Navigator now allows us to pass arguments to the destinations, but we may want to return data from one screen back to the previous one. This is possible in the Navigation Library using the
SavedStateHandle associated with each route (the same that is provided to the ViewModel).
When a screen wants to pass some data to the previous screen we can use the
previousBackStackEntry property and then use the
SavedStateHandle to store the data to return. Likewise, if we want to retrieve the data once we have popped the backstack and returned to the previous screen, we can use the
currentBackStackEntry and then get its
SavedStateHandle and retrieve the payload.
We can encapsulate this in our
Navigator class to simplify this and have a common solution for all destinations — let’s see how:
- We extend our
Navigatorinterface with 2 methods, a
saveDatathat accepts a
Parcelableto store, and an optional
keyto identify this payload.
- We add a complimentary method that retrieves the payload.
- In our implemementation, we get the previous backstrack entry, retrieve its
SavedStateHandle, and put the parcelable using the provided key.
- And finally we do the retrieval part, getting the current backstrack entry, retrieving its
SavedStateHandleand restoring the payload using the supplied
And that’s pretty much it, we now have a Navigator class that we can inject in our ViewModels that allows us to navigate to any screen by providing an instance of
NavigationRoute — the navigation logic is in the ViewModel, so that composables are solely responsible for rendering the UI and forwarding events to the ViewModel. A sample project showcasing the principles discussed in this article is available on this GitHub repo.