This is the second part of the series. For Part 1, please read here: https://proandroiddev.com/safe-compose-arguments-an-improved-way-to-navigate-in-jetpack-compose-95c84722eec2
In my previous article, I created an annotation processor for creating the methods that will be used to navigate in Jetpack Compose. In this part, I am extending the annotation processor to generate code for parcelable, serializable, and list and array types. The basic principle will remain the same, figure out the type of the argument and generate corresponding code accordingly.
First, let’s explore how you would pass a parcelable object in Jetpack Compose. Let’s say we have a class of type User
, containing an id and a name, that we would like to pass as an argument. Following is an example of a class containing id
and name
. It is also implementing the Parcelable
interface.
data class User( | |
val id: Int, | |
val name: String | |
) : Parcelable { | |
constructor(parcel: Parcel) : this( | |
parcel.readInt(), | |
parcel.readString() ?: "" | |
) | |
override fun writeToParcel(parcel: Parcel, flags: Int) { | |
parcel.writeInt(id) | |
parcel.writeString(name) | |
} | |
override fun describeContents(): Int { | |
return 0 | |
} | |
companion object CREATOR : Parcelable.Creator<User> { | |
override fun createFromParcel(parcel: Parcel): User { | |
return User(parcel) | |
} | |
override fun newArray(size: Int): Array<User?> { | |
return arrayOfNulls(size) | |
} | |
} | |
} |
Now to actually pass this in Jetpack Compose, we would have to create our own custom NavType
that will be used by compose internally to put our User type object into a bundle, and later retrieve it.
To do this, we override the androidx.navigation.NavType
class in the following manner:
val UserPage_UniqueUserNavType: NavType<User> = object : NavType<User>(false) { | |
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) | |
} | |
} |
Then we can include our custom NavType in the argument list as follows:
composable( | |
route = "userPage?uniqueUser={uniqueUser}", | |
arguments = mutableListOf( | |
navArgument("uniqueUser") { | |
type = UserPage_UniqueUsersNavType | |
} | |
) { backStackEntry -> | |
// content | |
} | |
) |
Work done? Not yet. To pass an instance of User
in Jetpack Compose, we need to somehow convert it to a valid string that can be parsed back into an object containing the same data. This is achieved using Gson
. We need to convert our object to a JSON representation, and then encode it using Uri
. The following example shows how we can achieve this.
navHostController.navigate("userPage?uniqueUser=${Uri.encode(gson.toJson(uniqueUser))}") |
To get back our User
object, we need to parse its value from the bundle received in the back stack entry.
backStackEntry.arguments?.getParcelable<User>("uniqueUser") |
Now done 😅. To automate this process, our annotation processor needs to do the following-
- Determine the type of argument
- Generate its custom navigation type (
NavType
) - Parse back the argument from the bundle that is received in the back stack entry
The following section details out how the annotation processor performs the aforementioned steps. For full implementation, please check the complete module on GitHub as the article only contains excerpts to explain the concepts.
safe-compose-args/compose-annotation-processor/src/main/java/com/example/compose
Where magic happens
In the previous article, the argument was being resolved to its class in a lot of places. To streamline this process, I have created a set of argument types that can be used to check the argument type.
enum class ComposeArgumentType { | |
INT, | |
LONG, | |
FLOAT, | |
BOOLEAN, | |
STRING, | |
INT_ARRAY, | |
LONG_ARRAY, | |
FLOAT_ARRAY, | |
BOOLEAN_ARRAY, | |
PARCELABLE, | |
PARCELABLE_ARRAY, | |
SERIALIZABLE | |
} |
To determine the type of argument, we have to check a slew of conditions. Primitive types are easy to determine since their resolved class name is of the form Boolean, Int, String...
. After we determine our argument is not of the primitive type, we have to do the following steps.
- Once we determine that the argument is of type
ArrayList
, we have to perform several checks. Whether the ArrayList type is parcelable or is serializable. This can be checked by checking the supertypes of the ArrayList template type (supertypes refers to the superclasses, the classes/interfaces it extends). - If we determine that our object is not an ArrayList, but rather a simple object, then we need to check for parcelable/serializable types.
- If none of the properties satisfies our condition, that means it cannot be put into a bundle, and we throw an error during compilation.
The following code demonstrates how we can determine the argument type.
composeArgumentType = when (resolvedClassDeclarationName) { | |
"Boolean" -> ComposeArgumentType.BOOLEAN | |
"String" -> ComposeArgumentType.STRING | |
"Float" -> ComposeArgumentType.FLOAT | |
"Int" -> ComposeArgumentType.INT | |
"Long" -> ComposeArgumentType.LONG | |
"IntArray" -> ComposeArgumentType.INT_ARRAY | |
"BooleanArray" -> ComposeArgumentType.BOOLEAN_ARRAY | |
"LongArray" -> ComposeArgumentType.LONG_ARRAY | |
"FloatArray" -> ComposeArgumentType.FLOAT_ARRAY | |
else -> when { | |
resolvedClassQualifiedName == "kotlin.collections.ArrayList" -> { | |
var isParcelable = false | |
var isSerializable = false | |
for (argument in typeArguments) { | |
val resolvedArgument = argument.type?.resolve() | |
if ((resolvedArgument?.declaration as? KSClassDeclaration)?.superTypes?.map { it.toString() } | |
?.contains("Parcelable") == true) { | |
isParcelable = true | |
} | |
if ((resolvedArgument?.declaration as? KSClassDeclaration)?.superTypes?.map { it.toString() } | |
?.contains("Serializable") == true) { | |
isSerializable = true | |
} | |
} | |
if (isParcelable) { | |
ComposeArgumentType.PARCELABLE_ARRAY | |
} else if (isSerializable) { | |
ComposeArgumentType.SERIALIZABLE | |
} else { | |
logger.error( | |
"invalid property type, cannot pass it in bundle", | |
property | |
) | |
return null | |
} | |
} | |
(resolvedType.declaration as KSClassDeclaration).superTypes.map { it.toString() } | |
.contains("Parcelable") -> { | |
ComposeArgumentType.PARCELABLE | |
} | |
(resolvedType.declaration as KSClassDeclaration).superTypes.map { it.toString() } | |
.contains("Serializable") -> { | |
ComposeArgumentType.SERIALIZABLE | |
} | |
else -> { | |
logger.error( | |
"invalid property type, cannot pass it in bundle", | |
property | |
) | |
return null | |
} | |
} | |
} |
Job Offers
Once we determine our argument type, we can proceed further and generate custom NavTypes for the ArrayList objects, parcelables and serializables. We loop over our arguments for each destination, and only take those arguments which are of the aforementioned types.
if (!(propertyInfo.composeArgumentType == ComposeArgumentType.PARCELABLE || | |
propertyInfo.composeArgumentType == ComposeArgumentType.PARCELABLE_ARRAY || | |
propertyInfo.composeArgumentType == ComposeArgumentType.SERIALIZABLE | |
)) { | |
return@forEach | |
} |
Then we generate the variable definition for our custom nav type and override the name field to provide our own name field. The code is pretty self-explanatory.
file addLine "val ${className}_${propertyInfo.propertyName.replaceFirstChar { it.uppercase() }}NavType: NavType<" | |
addVariableType(file, propertyInfo) | |
file addPhrase "> = object : NavType<" | |
addVariableType(file, propertyInfo) | |
file addPhrase ">(false) {" | |
tabs++ | |
file addLine "override val name: String" | |
tabs++ | |
file addLine "get() = " | |
file addPhrase "\"${propertyInfo.propertyName}\"" | |
tabs-- | |
Then we generate our get
and put
methods, that will, as the name suggests, get and put the value from the bundle.
file addLine "override fun get(bundle: Bundle, key: String): " | |
addVariableType(file, propertyInfo) | |
file addPhrase "? {" | |
tabs++ | |
when (propertyInfo.composeArgumentType) { | |
ComposeArgumentType.PARCELABLE -> file addLine "return bundle.getParcelable(key)" | |
ComposeArgumentType.PARCELABLE_ARRAY -> file addLine "return bundle.getParcelableArrayList(key)" | |
ComposeArgumentType.SERIALIZABLE -> { | |
file addLine "return bundle.getSerializable(key) as? " | |
addVariableType(file, propertyInfo) | |
} | |
} | |
tabs-- | |
file addLine "}" | |
file addLine "override fun put(bundle: Bundle, key: String, value: " | |
addVariableType(file, propertyInfo) | |
file addPhrase ") {" | |
tabs++ | |
when (propertyInfo.composeArgumentType) { | |
ComposeArgumentType.PARCELABLE -> file addLine "bundle.putParcelable(key, value)" | |
ComposeArgumentType.PARCELABLE_ARRAY -> file addLine "bundle.putParcelableArrayList(key, value)" | |
ComposeArgumentType.SERIALIZABLE -> file addLine "bundle.putSerializable(key, value)" | |
} | |
tabs-- | |
file addLine "}" |
Finally, we generate our parse method, that will convert the custom objects into strings using Gson
.
file addLine "override fun parseValue(value: String): " | |
addVariableType(file, propertyInfo) | |
file addPhrase " {" | |
tabs++ | |
file addLine "return gson.fromJson(value, object : TypeToken<" | |
addVariableType(file, propertyInfo) | |
file addPhrase ">() {}.type)" | |
tabs-- | |
file addLine "}" |
This code will generate custom navigation types for all the types of arguments that can be put into a bundle. These generated variables will not be scoped to a class, but to the entire module, so they will be accessible everywhere. To use these generated navigation types, we can simply specify the navigation type based on its class name and its variable name.
fun getElementNavType(): String { | |
return when (propertyInfo.composeArgumentType) { | |
ComposeArgumentType.BOOLEAN -> "NavType.BoolType" | |
ComposeArgumentType.STRING -> "NavType.StringType" | |
ComposeArgumentType.FLOAT -> "NavType.FloatType" | |
ComposeArgumentType.INT -> "NavType.IntType" | |
ComposeArgumentType.LONG -> "NavType.LongType" | |
ComposeArgumentType.INT_ARRAY -> "IntArrayType" | |
ComposeArgumentType.BOOLEAN_ARRAY -> "BoolArrayType" | |
ComposeArgumentType.FLOAT_ARRAY -> "FloatArrayType" | |
ComposeArgumentType.LONG_ARRAY -> "LongArrayType" | |
else -> { | |
"${className}_${propertyInfo.propertyName.replaceFirstChar { it.uppercase() }}NavType" | |
} | |
} | |
} |
Similarly, to parse the argument back from the bundle, we can check again ComposeArgumentType
and decide how the value should be parsed. The following code shows how this can be achieved.
fun getParsedElement() { | |
when (propertyInfo.composeArgumentType) { | |
ComposeArgumentType.BOOLEAN -> file addPhrase "backStackEntry.arguments?.getBoolean(\"$argumentName\") ?: false" | |
ComposeArgumentType.STRING -> file addPhrase "backStackEntry.arguments?.getString(\"$argumentName\") ?: \"\"" | |
ComposeArgumentType.FLOAT -> file addPhrase "backStackEntry.arguments?.getFloat(\"$argumentName\") ?: 0F" | |
ComposeArgumentType.INT -> file addPhrase "backStackEntry.arguments?.getInt(\"$argumentName\") ?: 0" | |
ComposeArgumentType.LONG -> file addPhrase "backStackEntry.arguments?.getLong(\"$argumentName\") ?: 0L" | |
ComposeArgumentType.INT_ARRAY -> file addPhrase "backStackEntry.arguments?.getIntArray(\"$argumentName\") ?: intArrayOf()" | |
ComposeArgumentType.BOOLEAN_ARRAY -> file addPhrase "backStackEntry.arguments?.getBooleanArray(\"$argumentName\") ?: booleanArrayOf()" | |
ComposeArgumentType.LONG_ARRAY -> file addPhrase "backStackEntry.arguments?.getLongArray(\"$argumentName\") ?: longArrayOf()" | |
ComposeArgumentType.FLOAT_ARRAY -> file addPhrase "backStackEntry.arguments?.getFloatArray(\"$argumentName\") ?: floatArrayOf()" | |
ComposeArgumentType.PARCELABLE -> { | |
file addPhrase "backStackEntry.arguments?.getParcelable<" | |
addVariableType(file, propertyInfo) | |
file addPhrase ">(\"$argumentName\") ?: throw NullPointerException(\"parcel value not found\")" | |
} | |
ComposeArgumentType.PARCELABLE_ARRAY -> { | |
file addPhrase "backStackEntry.arguments?.getParcelableArrayList" | |
visitChildTypeArguments(propertyInfo.typeArguments) | |
file addPhrase "(\"$argumentName\")" | |
file addPhrase " ?: throw NullPointerException(\"parcel value not found\")" | |
} | |
ComposeArgumentType.SERIALIZABLE -> { | |
file addPhrase "backStackEntry.arguments?.getSerializable" | |
file addPhrase "(\"$argumentName\") as? " | |
addVariableType(file, propertyInfo) | |
file addPhrase " ?: throw NullPointerException(\"parcel value not found\")" | |
} | |
} | |
} |
The last thing to do is the conversion of any type of variable into a string representation. This will be done when getting the route for our composable destination. Primitive types Int, Boolean, String, Long, Float
can simply be converted using .toString()
. For other custom types, we have to convert the value using Gson
.
file addLine "return \"$route${if (propertyMap.isNotEmpty()) "?" else ""}\" + " | |
tabs++ | |
tabs++ | |
count = 0 | |
properties.forEach { property -> | |
count++ | |
val propertyInfo = propertyMap[property] ?: run { | |
logger.error("Invalid type argument", property) | |
return | |
} | |
val argumentName = propertyInfo.propertyName | |
file addLine "\"$argumentName=" | |
file addPhrase when (propertyInfo.composeArgumentType) { | |
ComposeArgumentType.INT, | |
ComposeArgumentType.BOOLEAN, | |
ComposeArgumentType.LONG, | |
ComposeArgumentType.FLOAT, | |
ComposeArgumentType.STRING -> "$$argumentName" | |
else -> "\${Uri.encode(gson.toJson($argumentName))}" | |
} | |
if (count == propertyMap.size) { | |
file addPhrase "\"" | |
} else { | |
file addPhrase ",\"" | |
} | |
file addPhrase " + " | |
} | |
file addLine "\"\"" |
That’s it! Now the annotation processor will handle all the types of arguments that can be put into a bundle.
Links
Part 3 is here 🎉- (guide to integrate this library in your own project)
Github repo-