This is a compile-time annotation processor that will generate type-safe methods to get/retrieve arguments for a jetpack compose destination so that we don't have to construct string routes or read from back stack entry. Please go through the release notes to find the latest features [annotation] [annotation-processor]. Integration guide is mentioned below. This repo is explained in the following articles:
Part-1
Part-2
Consider the following example for better understanding. Let's say we have a UserPage
composable that expects 6 arguments, including a mix of conventional data types like String
, Boolean
, and some special data types like Parcelable
. This concept of expecting 6 arguments can be represented by the following interface-
@ComposeDestination
interface UserPage {
val userId: String?
val isLoggedIn: Boolean
@HasDefaultValue
val uniqueUser: User?
val uniqueUsers: ArrayList<User>
@OptionalParam
@HasDefaultValue
val userNames: ArrayList<String>
@OptionalParam
val phone: String?
@ArgumentProvider
companion object : IUserPageProvider {
override val uniqueUser: User?
get() = User(id = -1, name = "default")
override val userNames: ArrayList<String>
get() = arrayListOf()
}
}
@HasDefaultValue
denotes that this argument can have a default value, and some extra information needs to be provided to satisfy its value. This is done by overriding an interface (generated by the annotation processor), and providing the necessary values. The annotation processor will only ask for those values which have been marked with @HasDefaultValue
. @OptionalParam
denotes that this is an optional argument, so the value can be null by default.
This will cause the annotation processor to generate the following class-
package com.compose.type_safe_args.safecomposeargs
import androidx.navigation.*
import android.net.Uri
import android.os.Bundle
import com.google.gson.reflect.TypeToken
import com.compose.type_safe_args.annotation.*
import kotlin.String
import kotlin.Boolean
import com.compose.type_safe_args.safecomposeargs.User
import kotlin.collections.ArrayList
import com.compose.type_safe_args.safecomposeargs.UserPage.Companion
data class UserPageArgs (
val userId: String?,
val isLoggedIn: Boolean,
val uniqueUser: User?,
val uniqueUsers: ArrayList<User>,
val userNames: ArrayList<String>,
val phone: String?,
)
fun Companion.parseArguments(backStackEntry: NavBackStackEntry): UserPageArgs {
return UserPageArgs(
userId = backStackEntry.arguments?.getString("userId"),
isLoggedIn = backStackEntry.arguments?.getBoolean("isLoggedIn") ?: false,
uniqueUser = backStackEntry.arguments?.getParcelable<User?>("uniqueUser"),
uniqueUsers = backStackEntry.arguments?.getParcelableArrayList<User>("uniqueUsers") ?: throw NullPointerException("parcel value not found"),
userNames = backStackEntry.arguments?.getSerializable("userNames") as ArrayList<String> ?: throw NullPointerException("parcel value not found"),
phone = backStackEntry.arguments?.getString("phone"),
)
}
val Companion.argumentList: MutableList<NamedNavArgument>
get() = mutableListOf(
navArgument("userId") {
type = NavType.StringType
nullable = true
},
navArgument("isLoggedIn") {
type = NavType.BoolType
nullable = false
},
navArgument("uniqueUser") {
type = UserPage_UniqueUserNavType
nullable = true
defaultValue = Companion.uniqueUser
},
navArgument("uniqueUsers") {
type = UserPage_UniqueUsersNavType
nullable = false
},
navArgument("userNames") {
type = UserPage_UserNamesNavType
nullable = false
defaultValue = Companion.userNames
},
navArgument("phone") {
type = NavType.StringType
nullable = true
},
)
fun Companion.getDestination(userId: String?, isLoggedIn: Boolean, uniqueUser: User? = Companion.uniqueUser, uniqueUsers: ArrayList<User>, userNames: ArrayList<String> = Companion.userNames, phone: String? = null, ): String {
return "UserPage" +
"/$userId" +
"/$isLoggedIn" +
"/${Uri.encode(gson.toJson(uniqueUser))}" +
"/${Uri.encode(gson.toJson(uniqueUsers))}" +
"?userNames=${Uri.encode(gson.toJson(userNames))}," +
"phone=$phone," +
""
}
val Companion.route
get() = "UserPage/{userId}/{isLoggedIn}/{uniqueUser}/{uniqueUsers}?userNames={userNames},phone={phone},"
As you might have noticed, we need a special NavType to work with passing custom data types. The annotation processor will generate these navigation types also for us.
val UserPage_UniqueUserNavType: NavType<User?> = object : NavType<User?>(true) {
override val name: String
get() = "uniqueUser"
override fun get(bundle: Bundle, key: String): User? {
return bundle.getParcelable(key)
}
override fun parseValue(value: String): User? {
return gson.fromJson(value, object : TypeToken<User?>() {}.type)
}
override fun put(bundle: Bundle, key: String, value: User?) {
bundle.putParcelable(key, value)
}
}
val UserPage_UniqueUsersNavType: NavType<ArrayList<User>> = object : NavType<ArrayList<User>>(false) {
override val name: String
get() = "uniqueUsers"
override fun get(bundle: Bundle, key: String): ArrayList<User> {
return bundle.getParcelableArrayList(key)!!
}
override fun parseValue(value: String): ArrayList<User> {
return gson.fromJson(value, object : TypeToken<ArrayList<User>>() {}.type)
}
override fun put(bundle: Bundle, key: String, value: ArrayList<User>) {
bundle.putParcelableArrayList(key, value)
}
}
val UserPage_UserNamesNavType: NavType<ArrayList<String>> = object : NavType<ArrayList<String>>(false) {
override val name: String
get() = "userNames"
override fun get(bundle: Bundle, key: String): ArrayList<String> {
return bundle.getSerializable(key) as ArrayList<String>
}
override fun parseValue(value: String): ArrayList<String> {
return gson.fromJson(value, object : TypeToken<ArrayList<String>>() {}.type)
}
override fun put(bundle: Bundle, key: String, value: ArrayList<String>) {
bundle.putSerializable(key, value)
}
}
Usage in your navigation graph will be as follows-
composable(
route = UserPage.route,
arguments = UserPage.argumentList
) {
val userPageArgs = UserPage.parseArguments(it)
// content
}
Similarly, to navigate to a composable, we can call the generated function. If we see the generated method quickly, we can notice that all the arguments marked with @HasDefaultValue
have a default value, and all the arguments marked with @OptionalParam
has null value by default.
Generated method
fun Companion.getDestination(
userId: String?,
isLoggedIn: Boolean,
uniqueUser: User? = Companion.uniqueUser,
uniqueUsers: ArrayList<User>,
userNames: ArrayList<String> = Companion.userNames,
phone: String? = null,
): String {
return "UserPage" +
"/$userId" +
"/$isLoggedIn" +
"/${Uri.encode(gson.toJson(uniqueUser))}" +
"/${Uri.encode(gson.toJson(uniqueUsers))}" +
"?userNames=${Uri.encode(gson.toJson(userNames))}," +
"phone=$phone," +
""
}
navHostController.navigate(
UserPage.getDestination(
userId = "userId",
isLoggedin = false,
uniqueUsers = arrayListOf(), // notice that uniqueUsers has a default value, but we may choose to provide our own value
// userNames will have the default value as provided in companion object
// phone will be null, as it has no default value, and we are not passing any value for phone, we may choose to override the null value as follows-
phone = 123,
)
)
Full example can be found here.
Interface defines the structure of a composable destination. This has many benefits as detailed out in the articles. A short version is as follows-
- Compile-time safety for all the number of arguments for any composable and their types
- Make sure the same key is not re-used for different arguments
- Support for default values
- Support for nullable types
- Support for optional arguments
- Support for serializable and parcelable types
- Support for list type objects (ArrayList)
- Support for native array types,
IntArray
in kotlin orint[]
in java (IntArray, LongArray, BooleanArray, FloatArray)
The article at the end of the section explains the process in depth. But for a quick setup, please follow the following-
- Include ksp plugin in
app/build.gradle
Groovy
plugins {
id 'com.google.devtools.ksp' version '1.5.30-1.0.0'
}
Kotlin
plugins {
id("com.google.devtools.ksp") version "1.5.30-1.0.0"
}
- Include the ksp library, annotation library and the annotation processor as follows
Groovy
implementation "io.github.dilrajsingh1997:compose-annotation:<latest-version-here>"
ksp "io.github.dilrajsingh1997:compose-annotation-processor:<latest-version-here>"
implementation "com.google.devtools.ksp:symbol-processing-api:1.5.30-1.0.0"
Kotlin
implementation("io.github.dilrajsingh1997:compose-annotation:<latest-version-here>")
ksp("io.github.dilrajsingh1997:compose-annotation-processor:<latest-version-here>")
implementation("com.google.devtools.ksp:symbol-processing-api:1.5.30-1.0.0")
- Construct the gradle file so that the build time generate files are accesible by the normal code
Groovy
WIP :)
Kotlin
androidComponents.onVariants { variant ->
kotlin.sourceSets.findByName(variant.name)?.kotlin?.srcDirs(
file("$buildDir/generated/ksp/${variant.name}/kotlin")
)
}
ksp {
arg("ignoreGenericArgs", "false")
}