Navigating in Jetpack Compose

Francesc Vilarino Guell
10 min readJan 13, 2023

--

Introduction

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

  1. When we add a composable to the navigation graph (in this case, the Home composable), we then
  2. Pass a lambda for each destination we may need to navigate to.
  3. 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:

  1. Our Navigator needs a NavController to delegate the navigation to, so we have a setter method to accept it.
  2. We have a navigate method that takes a NavigationRoute.
  3. We have a method to pop the backstack.
  4. And finally we have a clear method, that we will use to release the NavController.

The implementation of this Navigator is pretty straightforward:

  1. We keep a reference to the NavController.
  2. In our setter, we update our reference with the NavController we receive as argument.
  3. For navigation, we call the buildRoute on the NavigationRoute argument, and we use that to navigate via the navController.
  4. For popBackStack, we simply delegate to the navController.
  5. And finally the clear method sets the navController back to null.

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:

  1. We will use Hilt for Dependency Injection, so we annotate our Activity with AndroidEntryPoint.
  2. We inject the Navigator.
  3. In the composition, we get a NavController using the factory method rememberNavController.
  4. Because our Navigator is a Singleton, we have to make sure we are releasing the NavController when the composable is destroyed, so we use a DisposableEffect for that.
  5. In the main body of the DisposableEffect we initialize the Navigator with the NavController.
  6. And in the onDispose block of the DisposableEffect, which is called when the composable leaves the composition, or the key (navController) changes, we call the clear method to release the navController.

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:

  1. Our object inherits from the NavigationRoute interface.
  2. We override the buildRoute and 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:

  1. We use a data class instead of an Object as we have arguments.
  2. The argument is provided in the constructor.
  3. We override the buildRoute which uses the root for the Two screen and appends the argument we received at instantiation.
  4. This is the route that 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:

  1. The only change we need is to add the secondary constructor that accepts the SavedStateHandle and builds the corresponding NavigationTwoRoute instance.

With this, our Two screen ViewModel would simply have to do what’s show below to retrieve the arguments:

  1. As we are using Hilt, we annotate the ViewModel with @HiltViewModel.
  2. The SavedStateHandle will be passed in the constructor.
  3. We use this SavedStateHandle to reconstruct the NavigationTwoRoute class, so that we can then retrieve the value argument 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:

  1. As we need to put our Enum in a Bundle, we make it Parcelable.
  2. For the navigation, we need to represent our Enum as a String, so we have a method to do the conversion.
  3. 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:

  1. We extend NavType which is a parameterized class, using our Enum as the type.
  2. We provide a name that will be used to put and retrieve the argument in the Bundle.
  3. We override the get method which retrieves the argument from the Bundle.
  4. We do the same with the parseValue method, which deserializes the type.
  5. And finally we also override the put method, which stores it in the Bundle.

With this out of the way, we can now implement the NavigationRoute for our Three screen; let’s see that:

  1. Like we did earlier, our DestinationRoute class for the Three screen is a data class that takes the required arguments in the constructor.
  2. We also provide a secondary constructor so that we can restore this class from the SavedStateHandle object that ViewModels receive in their constructor.
  3. We override the buildRoute method, using the root for the Three screen and the 2 arguments we need for this screen.
  4. This is the NavType class we just discussed a little while ago.
  5. Here we define the route that we will use to identify this screen in the nav graph.
  6. And finally we provide the navArgs for this screen, using our custom TheeRouteParamType class and the NavType.BoolType provided 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 NavigationThreeRoute:

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 NavController's 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 NavController's 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:

  1. We extend our Navigator interface with 2 methods, a saveData that accepts a Parcelable to store, and an optional key to identify this payload.
  2. We add a complimentary method that retrieves the payload.
  3. In our implemementation, we get the previous backstrack entry, retrieve its SavedStateHandle, and put the parcelable using the provided key.
  4. And finally we do the retrieval part, getting the current backstrack entry, retrieving its SavedStateHandle and restoring the payload using the supplied key.

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.

--

--