Safe compose arguments: An improved way to navigate in jetpack compose — Part 1

Dilraj Singh
ProAndroidDev
Published in
7 min readNov 23, 2021

--

This article is for those who are familiar with navigation among different composable destinations. For a quick refresh, please refer to the official android documentation.

(Image source: https://jaymantri.com/post/126027489738/download)

We have all been there when cramming different things into a single state becomes very cumbersome, and some of the functionality has to be moved out to a new composable. For this, we first introduce a NavHost, and inside the NavHost we make different composables, distinguished by a unique name (route to be specific). And we may also want to pass some arguments to the new destination. The way it is currently done in jetpack compose is by appending the route with arguments and their respective values. For example, let’s say that we have a composable with route = “userPage”, and we want to pass arguments “userId” and “isLoggedIn”. The following snippets show how to do that in jetpack compose.

First, let’s create our parent composable which has a navigation graph as the parent composable and a default home page to show at the start-

Next let’s add our “userPage” composable as follows, which expects 2 parameters-

To navigate to our composable, we have to define it inside our navigation graph, which can be accomplished as follows-

Now we can call openUserPage from anywhere to go to the user page. This approach can be tricky as the project gets bigger and complex since in the long run many composables would be present and each one would have its own route, and own set of arguments.

An additional overhead with this occurs when we want to add an argument to an existing composable. Let’s say we want to add a parameter, “userName”, to our “userPage” composable. To do this, we would need to do the following-

1. Update the route from “userPage?userId={userId},isLoggedIn={isLoggedIn}” to

userPage?userId={userId},isLoggedIn={isLoggedIn},userName={userName}

2. Update the list of arguments and add

navArgument("userName") {
type = NavType.StringType
}

3. Parse the argument from backStackEntry as follows-

val userName = backStackEntry.arguments?.getString("userName") ?: ""

4. Update our navigation graph method as follows-

val openUserPage: (String, Boolean, String) -> Unit = { userId, isLoggedIn, userName ->
navHostController.navigate("userPage?" +
"userId=$userId," +
"isLoggedIn=$isLoggedIn," +
"userName=$userName"
)
}

This is very cumbersome and error-prone, as if we miss adding the parameter in any of the strings, then it will throw the following run time exception-

java.lang.IllegalArgumentException: navigation destination -800103511 is not a direct child of this NavGraph

This can especially be frustrating if we update multiple composables in one go, and debugging which one is causing the issue can be time-taking. Also, the probability of re-using an existing key can be high. By an existing key, I mean extras can refer to some analytics constants in the beginning, and if some other person introduces extras as a parameter for logging, it will lead to undesired results.

Only if somehow we could generate the routes of each composable by just specifying the parameter names and their types.

Enter annotation processing.

Annotation processing is a tool that will scan our code-base, find all the annotations that we require to search, and allow us to generate code at compile-time that can be used at other places.

To use an annotation processor to solve our problem, we first define our annotation-

@Target(AnnotationTarget.CLASS)
annotation class ComposeDestination

For our example of the user page, the annotation will be used as follows. We also need to define the arguments that this composable expects. For this, I am following the approach of using an abstract class, with abstract variables, just to specify the argument name and its type.

@ComposeDestination
abstract class UserPage {
abstract val userId: String
abstract val isLoggedIn: Boolean
}

Now our annotation processor generates the following code from this class.

To use this, we simply update all the places where the user page route is accessed. The following demonstrates how the changes will be reflected in our composable definition-

The following demonstrates how the navigation graph will get updated-

This will eliminate the string use from every place, and replace it with the new class that we generated. This has many advantages over using the previous approach.

  1. Compile-time safety for all the number of arguments for any composable and their types
  2. Make sure the same key is not re-used for different arguments
  3. Automatic parsing of values

Let’s see how these points are accomplished-

Compile-time safety for all the number of arguments for any composable and their types

Since we are relying on the generated code to pass arguments and to retrieve them through a data class, we can be sure that we don’t pass a string from one end and try to retrieve a boolean from the other end (we have all been there :)).

This also means that if we decide to add an argument to an existing composable, it will give us a compile-time error to expect the correct number of arguments. Let’s do deep dive into this.

Taking our previous example, we decide to update the user page composable to expect a userName variable as well. To do this, we modify the abstract class as follows-

abstract class UserPage {
abstract val userId: String
abstract val isLoggedIn: Boolean
abstract val userName: String
}

Now our annotation processor will generate the following file-

Notice how the new parameters are reflected automatically everywhere. Now if we try to use the method

navHostController.navigate(UserPageDestination.getDestination(userId, isLoggedIn))

it will give us a compile-time error as follows-

e: /Users/dilrajsingh/Desktop/Code/Safecomposeargs/app/src/main/java/com/example/safecomposeargs/NavigationGraph.kt: (10, 89): No value passed for parameter 'userName'

This way, we can update our navigation graph lambda to accept a third parameter, which will again cause the invoking place to pass the third parameter, and so on…, we can be sure that the new argument is properly propagated. Also, since userName is a string, we can be sure that everyone passes a string as the user name argument.

Making sure the same key is not re-used for different arguments

This is simple to explain. Since we have defined the arguments in our abstract class, kotlin compiler will make sure that two variables cannot have the same name. The following

abstract class UserPage {
abstract val userId: String
abstract val isLoggedIn: Boolean
abstract val userId: String
}

will produce the following

Conflicting declarations: public abstract val userId: String, public abstract val userId: String

Automatic parsing of values

This is also easy to explain. We can rely on the compiler-generated method (annotation processing) to parse the different arguments-

Coming to the annotation processing part, I am using KSP to generate all this code, and below is the link to this file.

The following section explains the generation of all the methods used in this article.

To get all the classes that our annotated with ComposeDestination , we can run the following code-

This will give us a list of classes annotated without annotation. Next, we create a new file, GeneratedFunctions.kt, add the basic imports and visit all the classes one by one.

The Visitor file overrides the KSVisitorVoid class. I am primarily using the visitClassDeclaration overridden method and determining the arguments by getting the class variables.

The following code will generate our data class to hold the arguments.

Next, to make our method accessible, we add the companion object declaration and add the first method to parse the arguments.

Next, we generate our argument list variable.

Next, we add the getDestination method, which will expect the variables as function arguments.

At the last, we generate our route, which will define the destination for our composable.

Hence, we can reduce our reliance on strings as destinations, and make the compiler do work for us.

Please go through the readme file on GitHub to find the feature set of the latest release.

If you have any feedback/suggestions/improvements, please add them in the comments.

If you also want to develop such awesome things, come work at ShareChat. Apply to the relevant position below.

https://sharechat.hire.trakstar.com/

Links

Part 2 is here 🎉- (inclusion of parcelable types)

Part 3 is here 🎉- (guide to integrate this library in your own project)

Complete repo-

Passing parcelizable data type-

Sample KSP project (I referred to this while developing this functionality)-

Some extension functions referred to in this article-

Thanks…

--

--