Passing arguments to screens in Jetpack Compose

How to leverage the framework tools to pass arguments between screens in Jetpack Compose

Introduction

In anything but a trivial app we want to have the business logic of our screens separate from the UI (our Composable functions), and one way to do that is using the ViewModel classes available from the AndroidX libraries to host that business logic. In this short article we will see how we can have our ViewModels automatically receive the navigation arguments when we navigate from one screen to another.

Adding the necessary dependencies

The first thing we need to do is add the dependencies for navigation, hilt and viewmodel, which we can do by adding these lines to our app’s build.gradle file:

// Navigation

implementation "androidx.navigation:navigation-compose:$navigation_version"

// ViewModel

implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_compose_version"

// Hilt

implementation "com.google.dagger:hilt-android:$hilt_version"

implementation "androidx.hilt:hilt-navigation-compose:1.0.0-rc01"

kapt "com.google.dagger:hilt-compiler:$hilt_version"

Defining the routes

Next we want to define the routes that the user can navigate to in our app. In this example we will only have 2 screens, and navigation will be from Screen One to Screen Two.

When defining the routes there has to be a root destination that will be the destination users land on when launching the app. Routes are defined as a string, and if there are any arguments that the route expects, those are defined as a path parameter in that route string, similar to the path segments of a URL. It is also possible to define arguments as query parameters, but this is better left for the scenario where arguments are optional. If you are interested in reading more about this you can check this link.

For our example, our Screen Two will accept a single argument that we will define as of type String, so our route to the 2nd screen will have a path element for that argument. Arguments in the route are defined as a placeholder wrapped in curly braces, so for instance {userId}. We will call our routes one and two for our screens, and we will name our argument for the 2nd screen as arg for this example. With all that said, our routes will be:

const val DestinationOneRoute = "one"

const val DestinationTwoRoot = "two"

const val DestinationOneArg = "arg"

const val DestinationTwoRoute = "$DestinationTwoRoot/{$DestinationOneArg}"

We have split the route to the 2nd screen into the root element and the argument element so that we can use those later separately, we will see how shortly.

Building the navigation graph

Next we want to build our navigation graph using the routes we defined earlier. For this we use the NavHost function provided by the navigation library. This function takes a few arguments, namely:

  • a navigation controller, that we need to instantiate and remember, using rememberNavController()

  • the start destination

  • an optional Modifier

  • an optional route for the navigation graph

  • a lambda that adds the destinations to the nav graph

In the lambda for the NavHost we will add each screen with their respective route. This registers each screen in our navigation graph, so that we can navigate to each of them using the unique route associated with each of them.

It will probably become clearer if we see the code, so let’s do that:

Let’s see how we build the navigation graph:

  1. first we instantiate the NavController that we will use to navigate to our routes

  2. then we build the navigation graph by calling NavHost and passing the navController, the start route and the lambda

  3. inside the lambda we call composable to add a route to the nav graph, and we specify which route identifies this entry in the navigation graph

  4. inside the composable we define our first screen

  5. we add a 2nd composable for the 2nd screen

  6. likewise, we specify a route to identify this screen

  7. finally we add our 2nd screen to the navigation graph

The 2 most important things to note here are the following:

  • OneScreen takes a lambda that will navigate to TwoScreen. This lamba has an argument that we will use to navigate to the 2nd screen. We can see in the buildTwoRoute function how we are constructing the route for the 2nd screen, using the root part of the route and then the argument as a path element on that route.

  • TwoScreen uses a ViewModel to handle its business logic. We can see here that we are not retrieving the argument we pass in the route when navigating to the 2nd screen; we will see now how this value is automatically populated for us by the navigation library.

Retrieving the navigation arguments in the ViewModel

As we saw above, the first screen navigates to the second and in doing so passes an argument. To retrieve this argument, all we need to do is define our ViewModel constructor as having an argument of type SavedStateHandle. This is a glorified map that has our navigation arguments and that we can also use to persist data from the ViewModel. When we navigate using the navigation library this SavedStateHandle is pre-populated with the navigation arguments, using as key the placeholder we used when defining the route, so all we need to do is retrieve them, which we can do by calling get on the SavedStateHandle object:

It’s as simple as this:

  1. annotate the viewmodel with @HiltViewModel so that Dagger can inject it when we request an instance with hiltViewModel as we did when building the navigation graph

  2. in the constructor, specify a SavedStateHandle that Hilt will inject

  3. finally, retrieve the value from the SavedStatehandle object using the same key we used to define the argument placeholder in the route

And that’s it. In this example we are passing a single String argument, but you can build on top of this and pass additional arguments and arguments of different types. However, it is worth pointing out that the navigation arguments should not be used to pass large objects (like a string representation of a Json object), you should instead pass an id that references the data you need at your destination and then you can retrieve the necessary data in your viewmodel using that id from your repository or any other data source you may have in your app.

A complete app using the principles discussed in this post is available on GitHub.