Blog Infos
Author
Published
Topics
,
Published

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.

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-

@Composable
fun DemoScreen() {
val navController = rememberNavController()
val graph = remember(navController) {
NavigationGraph(navController)
}
NavHost(
navController = navController,
startDestination = "home"
) {
composable(
route = "home",
arguments = listOf()
) { backStackEntry ->
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "This is home page", textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(20.dp))
Button(onClick = { graph.openUserPage(UUID.randomUUID().toString(), false) }) {
Text(text = "Go to user page", textAlign = TextAlign.Center)
}
}
}
}
}
}
view raw demo.kt hosted with ❤ by GitHub

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

composable(
route = "userPage?userId={userId},isLoggedIn={isLoggedIn}",
arguments = listOf(
navArgument("userId") {
type = NavType.StringType
},
navArgument("isLoggedIn") {
type = NavType.BoolType
},
)
) { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId") ?: ""
val isLoggedIn = backStackEntry.arguments?.getBoolean("isLoggedIn") ?: false
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "This is user page with userId: $userId", textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(20.dp))
Text(text = "Is user logged in $isLoggedIn", textAlign = TextAlign.Center)
}
}
}
view raw demo.kt hosted with ❤ by GitHub

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

class NavigationGraph(private val navHostController: NavHostController) {
val openUserPage: (String, Boolean) -> Unit = { userId, isLoggedIn ->
navHostController.navigate("userPage?" +
"userId=$userId," +
"isLoggedIn=$isLoggedIn"
)
}
}
view raw demo.kt hosted with ❤ by GitHub

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(val route: String)

It will expect a route, for our example of the user page, the annotation will be used as follows-

@ComposeDestination(route = "userPage")

Next, we 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(route = "userPage")
abstract class UserPage {
    abstract val userId: String
    abstract val isLoggedIn: Boolean
}

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

class UserPageDestination {
data class UserPageArgs (
val userId: kotlin.String,
val isLoggedIn: kotlin.Boolean,
)
companion object {
fun parseArguments(backStackEntry: NavBackStackEntry): UserPageArgs {
return UserPageArgs(
userId = backStackEntry.arguments?.getString("userId") ?: "",
isLoggedIn = backStackEntry.arguments?.getBoolean("isLoggedIn") ?: false,
)
}
val argumentList: MutableList<NamedNavArgument>
get() = mutableListOf(
navArgument("userId") {
type = NavType.StringType
},
navArgument("isLoggedIn") {
type = NavType.BoolType
},
)
fun getDestination(userId: kotlin.String, isLoggedIn: kotlin.Boolean, ): String {
return "userPage?" +
"userId=$userId," +
"isLoggedIn=$isLoggedIn" +
""
}
val route = "userPage?userId={userId},isLoggedIn={isLoggedIn}"
}
}
view raw demo.kt hosted with ❤ by GitHub

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-

composable(
route = UserPageDestination.route,
arguments = UserPageDestination.argumentList
) { backStackEntry ->
val (userId, isLoggedIn) = UserPageDestination.parseArguments(backStackEntry)
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "This is user page with userId: $userId", textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(20.dp))
Text(text = "Is user logged in $isLoggedIn", textAlign = TextAlign.Center)
}
}
}
view raw demo.kt hosted with ❤ by GitHub

The following demonstrates how the navigation graph will get updated-

val openUserPage: (String, Boolean) -> Unit = { userId, isLoggedIn ->
navHostController.navigate(UserPageDestination.getDestination(userId, isLoggedIn))
}
view raw demo.kt hosted with ❤ by GitHub

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-

@ComposeDestination(route = "userPage")
abstract class UserPage {
    abstract val userId: String
    abstract val isLoggedIn: Boolean
    abstract val userName: String
}

Now our annotation processor will generate the following file-

class UserPageDestination {
data class UserPageArgs (
val userId: kotlin.String,
val isLoggedIn: kotlin.Boolean,
val userName: kotlin.String,
)
companion object {
fun parseArguments(backStackEntry: NavBackStackEntry): UserPageArgs {
return UserPageArgs(
userId = backStackEntry.arguments?.getString("userId") ?: "",
isLoggedIn = backStackEntry.arguments?.getBoolean("isLoggedIn") ?: false,
userName = backStackEntry.arguments?.getString("userName") ?: "",
)
}
val argumentList: MutableList<NamedNavArgument>
get() = mutableListOf(
navArgument("userId") {
type = NavType.StringType
},
navArgument("isLoggedIn") {
type = NavType.BoolType
},
navArgument("userName") {
type = NavType.StringType
},
)
fun getDestination(userId: kotlin.String, isLoggedIn: kotlin.Boolean, userName: kotlin.String, ): String {
return "userPage?" +
"userId=$userId," +
"isLoggedIn=$isLoggedIn," +
"userName=$userName" +
""
}
val route = "userPage?userId={userId},isLoggedIn={isLoggedIn},userName={userName}"
}
}
view raw demo.kt hosted with ❤ by GitHub

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Navigation superpowers at your fingertips

This talk will begin by the demonstration of a beautiful sample app built with Compose Mulitplatform and Appyx, complete with:
Watch Video

Navigation superpowers at your fingertips

Zsolt Kocsi
Principal Android engineer
Bumble

Navigation superpowers at your fingertips

Zsolt Kocsi
Principal Android en ...
Bumble

Navigation superpowers at your fingertips

Zsolt Kocsi
Principal Android enginee ...
Bumble

Jobs

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

@ComposeDestination(route = "userPage")
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-

fun parseArguments(backStackEntry: NavBackStackEntry): UserPageArgs {
return UserPageArgs(
userId = backStackEntry.arguments?.getString("userId") ?: "",
isLoggedIn = backStackEntry.arguments?.getBoolean("isLoggedIn") ?: false,
userName = backStackEntry.arguments?.getString("userName") ?: "",
)
}
// usage
val (userId, isLoggedIn) = UserPageDestination.parseArguments(backStackEntry)
view raw demo.kt hosted with ❤ by GitHub

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

safe-compose-args/ComposeSymbolProcessor.kt at main · dilrajsingh1997/safe-compose-args

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-

val symbols = resolver
.getSymbolsWithAnnotation("com.example.annotation.ComposeDestination")
.filterIsInstance<KSClassDeclaration>()
view raw demo.kt hosted with ❤ by GitHub

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.

val file = codeGenerator.createNewFile(
dependencies = Dependencies(false, *resolver.getAllFiles().toList().toTypedArray()),
packageName = packageName,
fileName = "GeneratedFunctions"
)
file addLine "package $packageName"
file addLine "import androidx.navigation.*"
symbols.forEach { it.accept(Visitor(file, resolver), Unit) }
view raw demo.kt hosted with ❤ by GitHub

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

val annotation: KSAnnotation = classDeclaration.annotations.first {
it.shortName.asString() == "ComposeDestination"
}
val route = classDeclaration.simpleName.asString()
// this will contain the class variables
val properties: Sequence<KSPropertyDeclaration> = classDeclaration.getAllProperties()
view raw demo.kt hosted with ❤ by GitHub

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

file addLine "data class $dataClassName ("
tabs++
properties.forEach { property ->
val argumentName = property.simpleName.asString()
val resolvedType: KSType = property.type.resolve()
file addLine "val $argumentName: "
file addPhrase (resolvedType.declaration.qualifiedName?.asString() ?: run {
logger.error("Invalid property type", property)
return
})
file addPhrase if (resolvedType.nullability == Nullability.NULLABLE) "?" else ""
file addPhrase ", "
}
tabs--
file addLine ")"
view raw demo.kt hosted with ❤ by GitHub

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

file addLine "fun parseArguments(backStackEntry: NavBackStackEntry): $dataClassName {"
tabs++
file addLine "return "
file addPhrase "$dataClassName("
tabs++
properties.forEach { property ->
val argumentName = property.simpleName.asString()
val resolvedType: KSType = property.type.resolve()
fun getParsedElement(): String {
return try {
when (resolver.getClassDeclarationByName(resolvedType.declaration.qualifiedName!!)
?.toString()) {
"Boolean" -> "backStackEntry.arguments?.getBoolean(\"$argumentName\") ?: false
"String" -> "backStackEntry.arguments?.getString(\"$argumentName\") ?: \"\""
"Float" -> "backStackEntry.arguments?.getFloat(\"$argumentName\") ?: 0F"
"Int" -> "backStackEntry.arguments?.getInt(\"$argumentName\") ?: 0"
"Long" -> "backStackEntry.arguments?.getLong(\"$argumentName\") ?: 0L"
else -> {
logger.error("complex data types not yet supported", property)
""
}
}
} catch (e: Exception) {
""
}
}
file addLine "$argumentName = ${getParsedElement()}"
file addPhrase ", "
}
tabs--
file addLine ")"
tabs--
file addLine "}"
view raw demo.kt hosted with ❤ by GitHub

Next, we generate our argument list variable.

file addLine "val argumentList"
file addPhrase ": MutableList<NamedNavArgument> "
tabs ++
file addLine "get() = mutableListOf("
count = 0
properties.forEach { property ->
count ++
val argumentName = property.simpleName.asString()
val resolvedType: KSType = property.type.resolve()
fun getElementNavType(): String {
return try {
when (resolver.getClassDeclarationByName(resolvedType.declaration.qualifiedName!!)
?.toString()) {
"Boolean" -> "NavType.BoolType"
"String" -> "NavType.StringType"
"Float" -> "NavType.FloatType"
"Int" -> "NavType.IntType"
"Long" -> "NavType.LongType"
else -> {
logger.error("complex data types not yet supported", property)
""
}
}
} catch (e: Exception) {
""
}
}
tabs ++
file addLine "navArgument(\"$argumentName\") {"
tabs ++
file addLine "type = ${getElementNavType()}"
tabs --
file addLine "},"
tabs --
argumentString += "$argumentName={$argumentName}"
if (count != propertyCount) {
argumentString += ","
}
}
file addLine ")"
view raw demo.kt hosted with ❤ by GitHub

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

file addLine "fun getDestination("
properties.forEach { property ->
val argumentName = property.simpleName.asString()
val resolvedType: KSType = property.type.resolve()
file addPhrase "$argumentName: "
file addPhrase (resolvedType.declaration.qualifiedName?.asString() ?: run {
logger.error("Invalid property type", property)
return
})
file addPhrase if (resolvedType.nullability == Nullability.NULLABLE) "?" else ""
file addPhrase ", "
}
file addPhrase "): String {"
tabs ++
file addLine "return \"$route${if (propertyCount > 0) "?" else ""}\" + "
tabs ++
tabs ++
count = 0
properties.forEach { property ->
count ++
val argumentName = property.simpleName.asString()
file addLine "\"$argumentName="
file addPhrase "$$argumentName"
if (count == propertyCount) {
file addPhrase "\""
} else {
file addPhrase ",\""
}
file addPhrase " + "
}
file addLine "\"\""
tabs --
tabs --
tabs --
file addLine "}"
view raw demo.kt hosted with ❤ by GitHub

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

file addLine "val route = \"$route"
if (argumentString.isNotEmpty()) {
file addPhrase "?"
file addPhrase argumentString
}
file addPhrase "\""
view raw demo.kt hosted with ❤ by GitHub

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

Next up:- Add support for arrays, parcelable and serializable types. I’ll be also working on exporting this project as a library so that anyone can include it in their project. Using parcelable is a little bit complicated in jetpack compose. If someone wants to use parcelable and serializable types, we can anytime extend the compiler-generated methods. Let me explain.

We can do

composable(
route = UserPageDestination.route + ",list={list}",
arguments = UserPageDestination.argumentList.apply {
add(navArgument("list") {
type = // your type here
})
}
) { backStackEntry ->
val (userId, isLoggedIn) = UserPageDestination.parseArguments(backStackEntry)
val list = backStackEntry.arguments?.getParcelableArrayList("list")
// content
}
view raw demo.kt hosted with ❤ by GitHub

And similar to our navigation graph.

val openUserPage = { userId, isLoggedIn, list ->
navHostController.navigate(UserPageDestination.getDestination(userId, isLoggedIn) + ",list=$list")
}
view raw demo.kt hosted with ❤ by GitHub

This problem will be solved in the next part of this article, where the support for complex data types will be added.

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

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-

infix fun OutputStream.addLine(line: String) {
this.write("\n".toByteArray())
repeat((1..tabs).count()) {
this.write("\t".toByteArray())
}
this.write(line.toByteArray())
}
infix fun OutputStream.addPhrase(line: String) {
this.write(line.toByteArray())
}
view raw demo.kt hosted with ❤ by GitHub

Thanks…

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu