Navigating in Jetpack Compose
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
- 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
NavController
to delegate the navigation to, so we have a setter method to accept it. - We have a
navigate
method that takes aNavigationRoute
. - We have a method to pop the backstack.
- And finally we have a
clear
method, that we will use to release theNavController
.
The implementation of this Navigator
is pretty straightforward:
- We keep a reference to the
NavController
. - In our setter, we update our reference with the
NavController
we receive as argument. - For navigation, we call the
buildRoute
on theNavigationRoute
argument, and we use that to navigate via thenavController
. - For
popBackStack
, we simply delegate to thenavController
. - And finally the clear method sets the
navController
back tonull
.
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
AndroidEntryPoint
. - We inject the
Navigator
. - In the composition, we get a
NavController
using the factory methodrememberNavController
. - Because our
Navigator
is a Singleton, we have to make sure we are releasing theNavController
when the composable is destroyed, so we use aDisposableEffect
for that. - In the main body of the
DisposableEffect
we initialize theNavigator
with theNavController
. - And in the
onDispose
block of theDisposableEffect
, which is called when the composable leaves the composition, or the key (navController
) changes, we call theclear
method to release thenavController
.
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:
- Our
object
inherits from theNavigationRoute
interface. - 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:
- We use a
data class
instead of anObject
as we have arguments. - The argument is provided in the constructor.
- We override the
buildRoute
which uses theroot
for the Two screen and appends the argument we received at instantiation. - 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:
- The only change we need is to add the secondary constructor that accepts the
SavedStateHandle
and builds the correspondingNavigationTwoRoute
instance.
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
@HiltViewModel
. - The
SavedStateHandle
will be passed in the constructor. - We use this
SavedStateHandle
to reconstruct theNavigationTwoRoute
class, so that we can then retrieve thevalue
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:
- As we need to put our Enum in a
Bundle
, we make itParcelable
. - 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
NavType
which 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
Bundle
. - We override the
get
method which retrieves the argument from theBundle
. - We do the same with the
parseValue
method, which deserializes the type. - And finally we also override the
put
method, which stores it in theBundle
.
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
DestinationRoute
class for the Three screen is adata class
that takes the required arguments in the constructor. - We also provide a secondary constructor so that we can restore this class from the
SavedStateHandle
object that ViewModels receive in their constructor. - We override the
buildRoute
method, using the root for the Three screen and the 2 arguments we need for this screen. - This is the
NavType
class 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
navArgs
for this screen, using our customTheeRouteParamType
class and theNavType.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:
- We extend our
Navigator
interface with 2 methods, asaveData
that accepts aParcelable
to store, and an optionalkey
to 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
SavedStateHandle
and restoring the payload using the suppliedkey
.
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.