The Past
So if you like me, an Android Developer who uses Jetpack Compose daily, you probably have the same issue as me when using the navigation library.
I’m personally not so fond of the String route basis for the navigation. There’s so much to declare and remember for basic things, like the String route to associate with your Composable function, keys for each argument, and NavType for each argument.
In addition, because your route is a plain String, you need to implement a way to connect your String route to your arguments like storing it in the same class or the same package or encapsulating it in a sealed class.
// Inside NavHost | |
composable<SomeProfile>( | |
typeMap = mapOf( | |
typeOf<Account>() to NavType.CustomNavType | |
) | |
) { | |
// no TypeMap | |
val arg = it.toRoute<SomeProfile>() | |
// Your Composable Screen | |
} | |
// Inside ViewModel | |
// TypeMap is defined again | |
val arg = savedStateHandle.toRoute<SomeProfile>( | |
typeOf<Account>() to NavType.CustomNavType | |
) |
dependencies { | |
implementation(libs.compose.navigation) | |
implementation(libs.gson) | |
implementation(kotlin("reflect")) | |
} |
inline fun <reified destination : Any> SavedStateHandle.getArg(): destination? = try { | |
destination::class.primaryConstructor?.callBy( | |
destination::class.primaryConstructor?.parameters?.associate { parameter -> | |
parameter to parameter.getValueFrom(this) | |
}.orEmpty() | |
) | |
} catch (t: Throwable) { | |
null | |
} | |
inline fun <reified destinationClass : Any> NavBackStackEntry.getArg(): destinationClass? = try { | |
destinationClass::class.primaryConstructor?.callBy( | |
destinationClass::class.primaryConstructor?.parameters?.associate { parameter -> | |
parameter to parameter.getValueFrom(this.arguments ?: Bundle()) | |
}.orEmpty() | |
) | |
} catch (t: Throwable) { | |
null | |
} |
// ** Register route to NavGraph | |
// Before | |
composable( | |
route = "somehow.this.works", | |
arguments = listOf( | |
navArgument("id") { | |
type = NavType.IntType | |
nullable = false | |
}, | |
navArgument("name") { | |
type = NavType.StringType | |
nullable = false | |
} | |
) | |
) { | |
// Your composable | |
} | |
//After | |
@Serializer | |
data class Profile(val id : int, val name : String) | |
composable<Profile> { | |
// Your composable | |
} | |
// ** Navigating | |
// Before | |
navController.navigate("somehow.this.works?id=1&name=Something") | |
// After | |
navController.navigate(Profile(1, "Something")) |
[versions] | |
compose-navigation = "2.8.0" | |
gson = "2.10.1" | |
kotlin = "1.9.10" | |
reflect = "0.1.0" | |
[libraries] | |
compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "compose-navigation" } | |
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } | |
reflect-nav = { group = "io.github.jimlyas", name = "reflection-navigation", version.ref = "reflect" } |
// In NavHost | |
composeRoute<SomeProfile> { | |
val args = it.getArg<SomeProfile>() | |
} | |
// In ViewModel | |
val args = savedStateHandler.getArg<SomeProfile>() |
@ReflectiveRoute | |
data class SomeProfile(val id: Int, val name: String, val account: Account) | |
data class Account(val currency: String, val balance: BigDecimal) | |
// Inside NavHost | |
composeRoute<SomeProfile> { | |
// Your Composable Screen | |
} |
// This is your route | |
val route = "somehow.this.works" | |
// your Arguments | |
val param1 = "yourParam" | |
val param2 = "yourOtherParam" | |
// need to declare your NavType for each arguments | |
// need to declare if the param nullable | |
val arguments = listOf( | |
navArgument(param1) { | |
type = NavType.StringType | |
nullable = true | |
}, | |
navArgument(param2) { | |
type = NavType.IntType | |
nullable = false | |
} | |
) |
inline fun <reified routeClass : Any> NavGraphBuilder.composeRoute( | |
deepLinks: List<NavDeepLink> = emptyList(), | |
noinline enterTransition: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
EnterTransition?)? = null, | |
noinline exitTransition: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
ExitTransition?)? = null, | |
noinline popEnterTransition: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
EnterTransition?)? = enterTransition, | |
noinline popExitTransition: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
ExitTransition?)? = exitTransition, | |
noinline sizeTransform: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
SizeTransform?)? = null, | |
noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit | |
) { | |
val routeKClass = routeClass::class | |
val args = routeKClass.primaryConstructor?.parameters?.map { param -> | |
navArgument(param.name.orEmpty()) { | |
type = param.toNavType() | |
nullable = param.type.isMarkedNullable | |
} | |
}.orEmpty() | |
val routeName = buildString { | |
append(routeKClass.asRouteName()) | |
append(QUESTION_MARK) | |
args.map { it.name }.forEach { s -> append("$s={$s}&") } | |
deleteAt(lastIndex) | |
} | |
composable( | |
route = routeName, | |
arguments = args, | |
deepLinks = deepLinks, | |
enterTransition = enterTransition, | |
exitTransition = exitTransition, | |
popEnterTransition = popEnterTransition, | |
popExitTransition = popExitTransition, | |
sizeTransform = sizeTransform, | |
content = content | |
) | |
} |
composable<SomeProfile>( | |
typeMap = mapOf(typeOf<Account>() to NavType.CustomNavType) | |
) { | |
// Your composable function | |
} |
@Serializable | |
data class SomeProfile(val id: Int, val name: String, val account: Account) | |
@Serializable | |
data class Account(val currency: String, val balance: @Contextual BigDecimal) | |
fun NavGraphBuilder.someRoute() { | |
composable<SomeProfile> { SomeScreen() } | |
} | |
@Composable | |
internal fun SomeScreen() { | |
Text(text = "Still Trying") | |
} |
Furthermore, parameters are now will be parsed as String because, in a String-based route, all navigation arguments are treated as query parameters or paths like in a URI.
So when the new Type Safety was introduced in Compose Navigation, I was so thrilled to try it out.
The Present
In the new Type Safe navigation, The underlying implementation of the String-based route is still the same. But using the power of Serializer, they make it less painful than before.
Instead of registering your route as a String and using said String when navigating to another Screen, you now use Object/Class as your route. And when you need to navigate to that Screen you need to pass the instance.
// Inside NavHost | |
composable<SomeProfile>( | |
typeMap = mapOf( | |
typeOf<Account>() to NavType.CustomNavType | |
) | |
) { | |
// no TypeMap | |
val arg = it.toRoute<SomeProfile>() | |
// Your Composable Screen | |
} | |
// Inside ViewModel | |
// TypeMap is defined again | |
val arg = savedStateHandle.toRoute<SomeProfile>( | |
typeOf<Account>() to NavType.CustomNavType | |
) |
dependencies { | |
implementation(libs.compose.navigation) | |
implementation(libs.gson) | |
implementation(kotlin("reflect")) | |
} |
inline fun <reified destination : Any> SavedStateHandle.getArg(): destination? = try { | |
destination::class.primaryConstructor?.callBy( | |
destination::class.primaryConstructor?.parameters?.associate { parameter -> | |
parameter to parameter.getValueFrom(this) | |
}.orEmpty() | |
) | |
} catch (t: Throwable) { | |
null | |
} | |
inline fun <reified destinationClass : Any> NavBackStackEntry.getArg(): destinationClass? = try { | |
destinationClass::class.primaryConstructor?.callBy( | |
destinationClass::class.primaryConstructor?.parameters?.associate { parameter -> | |
parameter to parameter.getValueFrom(this.arguments ?: Bundle()) | |
}.orEmpty() | |
) | |
} catch (t: Throwable) { | |
null | |
} |
// ** Register route to NavGraph | |
// Before | |
composable( | |
route = "somehow.this.works", | |
arguments = listOf( | |
navArgument("id") { | |
type = NavType.IntType | |
nullable = false | |
}, | |
navArgument("name") { | |
type = NavType.StringType | |
nullable = false | |
} | |
) | |
) { | |
// Your composable | |
} | |
//After | |
@Serializer | |
data class Profile(val id : int, val name : String) | |
composable<Profile> { | |
// Your composable | |
} | |
// ** Navigating | |
// Before | |
navController.navigate("somehow.this.works?id=1&name=Something") | |
// After | |
navController.navigate(Profile(1, "Something")) |
[versions] | |
compose-navigation = "2.8.0" | |
gson = "2.10.1" | |
kotlin = "1.9.10" | |
reflect = "0.1.0" | |
[libraries] | |
compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "compose-navigation" } | |
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } | |
reflect-nav = { group = "io.github.jimlyas", name = "reflection-navigation", version.ref = "reflect" } |
// In NavHost | |
composeRoute<SomeProfile> { | |
val args = it.getArg<SomeProfile>() | |
} | |
// In ViewModel | |
val args = savedStateHandler.getArg<SomeProfile>() |
@ReflectiveRoute | |
data class SomeProfile(val id: Int, val name: String, val account: Account) | |
data class Account(val currency: String, val balance: BigDecimal) | |
// Inside NavHost | |
composeRoute<SomeProfile> { | |
// Your Composable Screen | |
} |
// This is your route | |
val route = "somehow.this.works" | |
// your Arguments | |
val param1 = "yourParam" | |
val param2 = "yourOtherParam" | |
// need to declare your NavType for each arguments | |
// need to declare if the param nullable | |
val arguments = listOf( | |
navArgument(param1) { | |
type = NavType.StringType | |
nullable = true | |
}, | |
navArgument(param2) { | |
type = NavType.IntType | |
nullable = false | |
} | |
) |
inline fun <reified routeClass : Any> NavGraphBuilder.composeRoute( | |
deepLinks: List<NavDeepLink> = emptyList(), | |
noinline enterTransition: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
EnterTransition?)? = null, | |
noinline exitTransition: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
ExitTransition?)? = null, | |
noinline popEnterTransition: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
EnterTransition?)? = enterTransition, | |
noinline popExitTransition: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
ExitTransition?)? = exitTransition, | |
noinline sizeTransform: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
SizeTransform?)? = null, | |
noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit | |
) { | |
val routeKClass = routeClass::class | |
val args = routeKClass.primaryConstructor?.parameters?.map { param -> | |
navArgument(param.name.orEmpty()) { | |
type = param.toNavType() | |
nullable = param.type.isMarkedNullable | |
} | |
}.orEmpty() | |
val routeName = buildString { | |
append(routeKClass.asRouteName()) | |
append(QUESTION_MARK) | |
args.map { it.name }.forEach { s -> append("$s={$s}&") } | |
deleteAt(lastIndex) | |
} | |
composable( | |
route = routeName, | |
arguments = args, | |
deepLinks = deepLinks, | |
enterTransition = enterTransition, | |
exitTransition = exitTransition, | |
popEnterTransition = popEnterTransition, | |
popExitTransition = popExitTransition, | |
sizeTransform = sizeTransform, | |
content = content | |
) | |
} |
composable<SomeProfile>( | |
typeMap = mapOf(typeOf<Account>() to NavType.CustomNavType) | |
) { | |
// Your composable function | |
} |
@Serializable | |
data class SomeProfile(val id: Int, val name: String, val account: Account) | |
@Serializable | |
data class Account(val currency: String, val balance: @Contextual BigDecimal) | |
fun NavGraphBuilder.someRoute() { | |
composable<SomeProfile> { SomeScreen() } | |
} | |
@Composable | |
internal fun SomeScreen() { | |
Text(text = "Still Trying") | |
} |
Everything is all and well in the world, or is it?
If all of your arguments are primitive types like String, int, Boolean, etc… then you can relax and use the latest compose navigation version. But if not, perhaps you put third-party classes as your navigation argument like BigDecimal
or UUID
then we need to talk.
It is considered bad practice to include complex data as an argument, as mentioned here:
But sometimes we can’t help it, either the application flow forces us to pass some data (or even a list of objects) or we are just lazy, so sometimes we want to pass the “unsupported parameter” type as arguments. But how to do that in the latest navigation compose library?
Let’s say we have a code like this:
// Inside NavHost | |
composable<SomeProfile>( | |
typeMap = mapOf( | |
typeOf<Account>() to NavType.CustomNavType | |
) | |
) { | |
// no TypeMap | |
val arg = it.toRoute<SomeProfile>() | |
// Your Composable Screen | |
} | |
// Inside ViewModel | |
// TypeMap is defined again | |
val arg = savedStateHandle.toRoute<SomeProfile>( | |
typeOf<Account>() to NavType.CustomNavType | |
) |
dependencies { | |
implementation(libs.compose.navigation) | |
implementation(libs.gson) | |
implementation(kotlin("reflect")) | |
} |
inline fun <reified destination : Any> SavedStateHandle.getArg(): destination? = try { | |
destination::class.primaryConstructor?.callBy( | |
destination::class.primaryConstructor?.parameters?.associate { parameter -> | |
parameter to parameter.getValueFrom(this) | |
}.orEmpty() | |
) | |
} catch (t: Throwable) { | |
null | |
} | |
inline fun <reified destinationClass : Any> NavBackStackEntry.getArg(): destinationClass? = try { | |
destinationClass::class.primaryConstructor?.callBy( | |
destinationClass::class.primaryConstructor?.parameters?.associate { parameter -> | |
parameter to parameter.getValueFrom(this.arguments ?: Bundle()) | |
}.orEmpty() | |
) | |
} catch (t: Throwable) { | |
null | |
} |
// ** Register route to NavGraph | |
// Before | |
composable( | |
route = "somehow.this.works", | |
arguments = listOf( | |
navArgument("id") { | |
type = NavType.IntType | |
nullable = false | |
}, | |
navArgument("name") { | |
type = NavType.StringType | |
nullable = false | |
} | |
) | |
) { | |
// Your composable | |
} | |
//After | |
@Serializer | |
data class Profile(val id : int, val name : String) | |
composable<Profile> { | |
// Your composable | |
} | |
// ** Navigating | |
// Before | |
navController.navigate("somehow.this.works?id=1&name=Something") | |
// After | |
navController.navigate(Profile(1, "Something")) |
[versions] | |
compose-navigation = "2.8.0" | |
gson = "2.10.1" | |
kotlin = "1.9.10" | |
reflect = "0.1.0" | |
[libraries] | |
compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "compose-navigation" } | |
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } | |
reflect-nav = { group = "io.github.jimlyas", name = "reflection-navigation", version.ref = "reflect" } |
// In NavHost | |
composeRoute<SomeProfile> { | |
val args = it.getArg<SomeProfile>() | |
} | |
// In ViewModel | |
val args = savedStateHandler.getArg<SomeProfile>() |
@ReflectiveRoute | |
data class SomeProfile(val id: Int, val name: String, val account: Account) | |
data class Account(val currency: String, val balance: BigDecimal) | |
// Inside NavHost | |
composeRoute<SomeProfile> { | |
// Your Composable Screen | |
} |
// This is your route | |
val route = "somehow.this.works" | |
// your Arguments | |
val param1 = "yourParam" | |
val param2 = "yourOtherParam" | |
// need to declare your NavType for each arguments | |
// need to declare if the param nullable | |
val arguments = listOf( | |
navArgument(param1) { | |
type = NavType.StringType | |
nullable = true | |
}, | |
navArgument(param2) { | |
type = NavType.IntType | |
nullable = false | |
} | |
) |
inline fun <reified routeClass : Any> NavGraphBuilder.composeRoute( | |
deepLinks: List<NavDeepLink> = emptyList(), | |
noinline enterTransition: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
EnterTransition?)? = null, | |
noinline exitTransition: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
ExitTransition?)? = null, | |
noinline popEnterTransition: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
EnterTransition?)? = enterTransition, | |
noinline popExitTransition: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
ExitTransition?)? = exitTransition, | |
noinline sizeTransform: | |
(AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards | |
SizeTransform?)? = null, | |
noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit | |
) { | |
val routeKClass = routeClass::class | |
val args = routeKClass.primaryConstructor?.parameters?.map { param -> | |
navArgument(param.name.orEmpty()) { | |
type = param.toNavType() | |
nullable = param.type.isMarkedNullable | |
} | |
}.orEmpty() | |
val routeName = buildString { | |
append(routeKClass.asRouteName()) | |
append(QUESTION_MARK) | |
args.map { it.name }.forEach { s -> append("$s={$s}&") } | |
deleteAt(lastIndex) | |
} | |
composable( | |
route = routeName, | |
arguments = args, | |
deepLinks = deepLinks, | |
enterTransition = enterTransition, | |
exitTransition = exitTransition, | |
popEnterTransition = popEnterTransition, | |
popExitTransition = popExitTransition, | |
sizeTransform = sizeTransform, | |
content = content | |
) | |
} |
composable<SomeProfile>( | |
typeMap = mapOf(typeOf<Account>() to NavType.CustomNavType) | |
) { | |
// Your composable function | |
} |
@Serializable | |
data class SomeProfile(val id: Int, val name: String, val account: Account) | |
@Serializable | |
data class Account(val currency: String, val balance: @Contextual BigDecimal) | |
fun NavGraphBuilder.someRoute() { | |
composable<SomeProfile> { SomeScreen() } | |
} | |
@Composable | |
internal fun SomeScreen() { | |
Text(text = "Still Trying") | |
} |
In addition to adding annotation @contextual annotation to the BigDecimal
, we need to add @Serializable annotation too to the Account class. What happened when we built this code?
Serializer still can’t parse the Account
class as an argument, because we still have to add Custom NavType when declaring it from the composable function. So it will look something like this:
Job Offers
You can look on the internet what the CustomNavType will be for your code, or use generic to handle it dynamically. You’ll need to add custom this NavType for the custom class.
In addition, it still can’t provide mapping for classes that we can’t add the @serializer annotation to, like
BigDecimal
. Here’s the link to the issueTracker:
https://issuetracker.google.com/issues/348468840?source=post_page—–4c5b565f660f——————————–
I don’t think there’s a workaround for that right now, so you will have to wait until the library supports it natively.
The other thing is collecting the navigation arguments can be done in two ways:
- From
NavBackStackEntry
from calling the composable function inNavGraphBuilder
- From
SavedStateHandle
fromViewModel
These TypeMaps will be used to parse your argument from String to intended type when called from NavBackStackEntry. On the other hand, when using SavedStateHandle, you need to pass the TypeMap again somehow.
Perhaps there might be a good reason why TypeMap still needs to be re-defined from ViewModel, or maybe the SavedStateHandle can’t configure which NavType to use from the given route, or it just simply can’t access it from the current implementation.
And just like before, there’s currently no workaround if you don’t want to do that, you will have to pass your NavType again if you’re accessing your arguments from SavedStateHandle.
or is it?
The idea
I usually use my arguments from ViewModel and I often pass complex data as arguments, so with the latest composable navigation library I have two issues:
- Currently, Serializer can’t use third-party classes as arguments
- I’m just too lazy to be registering NavType every time I register my route and when I want to access my arguments in ViewModel
I want to make new type of abstraction or extension of the current API to fulfill my needs. I’m fully aware there are some libraries that can do that for me already, but I want to see what I’m able to make.
I want to be able to make the implementation as close as possible to the official compose navigation library but with my own spin. So this is what I came up with:
Using Kotlin Reflection, Extension Function, and GSON, it is possible to do all that.
The Implementation
So first let’s add the dependencies what we will be using like this:
And adding it to the build.gradle.kts
file like this:
Now we’re all set, let’s get cooking.
For dong this, we’re going to use the old plain String-based route because there’s not much we can do with the latest implementation that uses Type-Safety and Serializer.
First, let’s make the extension function to declare our route:
From the generic type, we call :class
to get the KClass
that will be used for creating the route name and getting all the parameters of the KClass
and registering it as the navigation arguments.
I usedprimaryConstructor.parameters
to access all the parameters for the constructor in the type of KParameter
. From the KParameter
instance, we will use the parameter name that is registered in the KClass
as the navigation argument key. Another thing we’re going to do is define the NavType
from KParameter.type
to know which NavType
is compatible with the parameter type, and for the nullability, we can easily call Kparameter.type.isMarkedNullable
to know if the parameter is nullable and the navigation argument will reflect that.
After getting all the navigation argument types, It’s time to define the route for the composable. Using buildString
, we will add:
- The qualified name of the
KClass
, and shorten it to only contain three words using theasRouteName
function - Add all the arguments from earlier as URI parameter type
It will look something like this:
package.name.ClassName?param1={param1}¶m2={param2}
Next, we define code for navigating to another Screen:
From the generic type, we call :class
to get the KClass
type of route parameter.
I used URI builder to help add more parameters for the URI that will be passed to the navigation function. For the URI, I added the qualified name from KClass
like before using the asRouteName
function.
declaredMemberProperties
will give us the parameters that we will add to the URI. Looping the parameters and accessing the parameter name and its value before adding it to the URI builder earlier.
The result URI will look like this:
android-app://androidx.navigation/package.name.ClassName?param1=param1Value¶m2=param2Value
This is basically the same URI that will be passed when using the String-type route
Lastly, for accessing the navigation argument the code will look like this:
There are two extension functions from NavBackStackEntry
and SavedStateHandle
with the name getArg for simplicity. With Kotlin Reflection, it will call the primary constructor of KClass
from generic.
But before calling the primary constructor, we’re going to need all the primary constructor’s parameters that we will get from the receiver of the extension function.
This code will initialize the given generic type and return the instance.
The Result
By making a new function composeRoute
, I was able to make an abstraction of the old plain String as a route so the consumer no longer needs to do all the additional stuff that you need to do earlier.
Please don’t give too much attention to the annotation, it doesn’t really do much
Navigating to another screen will look the same as the official compose navigation library.
Whether you’re accessing your navigation arguments from NavBackStackEntry
or SavedStateHandle
, you’ll only need to call the getArg
function and define which route you want to access.
Using these three extensions functions, you’ll have more or less similar experience as when using the official library. In addition whether your navigation uses all primitive data types or not, nevertheless you can use this out of the box.
If you’re interested or want to deep dive more to the code, I made this as a library here:
https://github.com/jimlyas/reflection-navigation?source=post_page—–4c5b565f660f——————————–
Please do check it out or try it out, and give me your comments on what do you think.
Thank for reading.
This article is previously published on proandroiddev.com