Skip to content

dilrajsingh1997/safe-compose-args

Repository files navigation

compose-annotation
compose-annotation

compose-annotation-processor
compose-annotation

Safe-Compose-Args

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.

Concept of Interface

Interface defines the structure of a composable destination. This has many benefits as detailed out in the articles. A short version is as follows-

  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

Salient features

  1. Support for default values
  2. Support for nullable types
  3. Support for optional arguments
  4. Support for serializable and parcelable types
  5. Support for list type objects (ArrayList)
  6. Support for native array types, IntArray in kotlin or int[] in java (IntArray, LongArray, BooleanArray, FloatArray)

Integration guide to include this as a library in your project

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")
}

Integration-Guide

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages