The recent alpha version of Navigation Compose 2.8.0-alpha08
released the ability to pass types into the navigation.
You do not need to pass strings around as in the stable version, but create your typing and take advantage of linter in programming.
If you are not familiar with compose navigation, I recommend reading my other article about it here:
Dependencies
If you already use the navigation compose, it is enough to bump up the version to 2.8.0-alpha08
or higher. Moreover, we will need a kotlin serialization plugin to make our classes serializable and usable by the navigation framework.
[versions]
...
kotlinxSerializationJson = "1.6.3"
kotlinxSerialization = "1.9.0"
navigationCompose = "2.8.0-alpha08"
[libraries]
...
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
[plugins]
...
jetbrains-kotlin-serialization = { id ="org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinxSerialization"}
Add a plugin to the project-level build.gradle
:
plugins {
...
alias(libs.plugins.jetbrains.kotlin.serialization) apply false
}
and add it to the dependencies at the module level build.gradle
:
plugins {
...
alias(libs.plugins.jetbrains.kotlin.serialization)
id("kotlin-parcelize") // needed only for non-primitive classes
}
depencencies {
...
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlinx.serialization.json)
}
Navigation between 2 screens
Let’s start with a simple example: navigating between 2 screens. ScreenOne
and ScreenTwo
. Screens will contain only one title text and button to move forward or back.
@Composable
fun FirstScreen(onNavigateForward: () -> Unit) {
SimpleScreen(text = "First Screen", textButton = "Go forward") {
onNavigateForward()
}
}
@Composable
fun SecondScreen(onNavigateBack: () -> Unit) {
SimpleScreen(text = "Second Screen", textButton = "Go back") {
onNavigateBack()
}
}
@Composable
fun SimpleScreen(text: String, textButton: String, onClick: () -> Unit) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize()
) {
Text(text)
Button(onClick = onClick) {
Text(textButton)
}
}
}
Declaring routes
Firstly, we will declare our routes with custom type. You can use pure object
instances, but the sealed class can also be used for better generalisation. Let’s declare two screens in the form of data classes:
@Serializable
sealed class Routes{
@Serializable
data object FirstScreen : Routes() // pure data object without any primitive
@Serializable
data class SecondScreen(val customPrimitive: String) : Routes() // data class with custom primitive
}
Notice the @Serializable annotation above all classes. We need to make our classes serializable, so the arguments can be passed around.
Feel free to customize your routes and primitives inside of them as you like. How to pass more complex classes will be shown later on.
Creation of routes and passing the data around
In the following example, the Activity contains NavController
(to control navigation) and NavHost
(to handle all possible routes).
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TypeSafeNavigationJetpackComposeTheme {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Routes.FirstScreen, // custom type for first screen
) {
composable<Routes.FirstScreen> { // custom type as generic
FirstScreen(onNavigateForward = {
// passing object for seconds class
navController.navigate(
Routes.SecondScreen(customPrimitive = "Custom primitive string")
)
})
}
composable<Routes.SecondScreen> {backstackEntry ->
// unpacking the back stack entry to obtain our value
val customValue = backstackEntry.toRoute<Routes.SecondScreen>()
Log.i("SecondScreen", customValue.customPrimitive)
SecondScreen(onNavigateBack = {
navController.navigate(
Routes.FirstScreen
)
})
}
}
}
}
}
}
Let’s go over it step by step.
Firstly, we need to declare the controller and host for the navigation. In the new version, constructors accept custom types, not only strings. That is why, we can pass our data class and everything is fine.
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Routes.FirstScreen, // custom type
) { ... }
Secondly, to declare the path in the host, the composable is used as before with a small addition of generic type, which determines, which class belongs to the destination.
composable<Routes.FirstScreen> { // custom type as generic
...
}
Thirdly, to call another screen in, invoke the controller as usual, but pass your data class with the values, which you need.
navController.navigate(
Routes.SecondScreen(customPrimitive = "Custom primitive string")
)
Fourthly, to get your values back, use the backStackEntry
to get your value and use the value for your next screen.
composable<Routes.SecondScreen> {backStackEntry ->
val customValue = backStackEntry.toRoute<Routes.SecondScreen>()
...
}
And that is it! If you do not pass any complex data among the screens, you are good to go. But, if you want to pass custom data types and organize your screen a bit better, read further.
Job Offers
Passing complex data classes
There might be a need to pass something more complex between the screens than primitives only. Here is an additional data class, which will become part of the input for the second screen.
@Serializable
@Parcelize
data class ScreenInfo(val route: String, val id: Int) : Parcelable
@Serializable
sealed class Routes {
@Serializable
data object FirstScreen : Routes()
@Serializable
data class SecondScreen(val screenInfo: ScreenInfo) : Routes()
}
The important difference is that we need to add @Parcelize
annotation and extend the class with Parcelable
at the same time.
Afterwards, the composable destination needs to know how to serialize and deserialize this custom method. It comes with a special NavType
abstract class, which we need to inherit in the following manner:
val ScreenInfoNavType = object : NavType<ScreenInfo>(isNullableAllowed = false) {
override fun get(bundle: Bundle, key: String): ScreenInfo? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
bundle.getParcelable(key, ScreenInfo::class.java)
} else {
@Suppress("DEPRECATION") // for backwards compatibility
bundle.getParcelable(key)
}
override fun put(bundle: Bundle, key: String, value: ScreenInfo) =
bundle.putParcelable(key, value)
override fun parseValue(value: String): ScreenInfo = Json.decodeFromString(value)
override fun serializeAsValue(value: ScreenInfo): String = Json.encodeToString(value)
override val name: String = "ScreenInfo"
}
It is a mapper, where we show the navigation framework how to serialize and deserialize our custom data class. At the same time, how to pick it up from Android Bundle
and put it into it.
composable<Routes.SecondScreen>(
typeMap = mapOf(typeOf<ScreenInfo>() to ScreenInfoNavType)
) { backStackEntry ->
val parameters = backStackEntry.toRoute<Routes.SecondScreen>()
// use the parameters
}
Afterwards, you are ready to use the data types to any kind of your liking.
Simplified mapper
Here is a class template for the mapper, where you can supply the class type and the serializer, so you do not have to reiterate the code for the mapper every time.
class CustomNavType<T : Parcelable>(
private val clazz: Class<T>,
private val serializer: KSerializer<T>,
) : NavType<T>(isNullableAllowed = false) {
override fun get(bundle: Bundle, key: String): T? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
bundle.getParcelable(key, clazz) as T
} else {
@Suppress("DEPRECATION") // for backwards compatibility
bundle.getParcelable(key)
}
override fun put(bundle: Bundle, key: String, value: T) =
bundle.putParcelable(key, value)
override fun parseValue(value: String): T = Json.decodeFromString(serializer, value)
override fun serializeAsValue(value: T): String = Json.encodeToString(serializer, value)
override val name: String = clazz.name
}
With this class in hand, you can convert data classes to NavTypes with easier and less boilerplate code:
composable<Routes.SecondScreen>(
typeMap = mapOf(typeOf<ScreenInfo>() to CustomNavType(ScreenInfo::class.java, ScreenInfo.serializer()))
) { backStackEntry ->
val parameters = backStackEntry.toRoute<Routes.SecondScreen>()
// use the parameters
}
Nesting the navigation
If the app contains a lot of screens, it can get quickly messy. Luckily, there is a way how to split screens into graphs, so it is not cluttered at one place.
fun NavGraphBuilder.mainGraph(navController: NavController) {
composable<Routes.FirstScreen> {
FirstScreen(onNavigateForward = {
navController.navigate(
Routes.SecondScreen()
)
})
}
composable<Routes.SecondScreen>() {
SecondScreen(onNavigateBack = {
navController.navigate(
Routes.FirstScreen
)
})
}
}
NavGraphBuilder
can be used with custom naming to your set of screens, where you put the screen into composables as above. Otherwise, it is the same as plain composables.
NavHost
then looks like this:
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Routes.FirstScreen,
) {
mainGraph(navController)
}
More about nesting the navigation can be found here:
Conclusion
Even though the implementation is still not straightforward, it is a step in the right direction. With specified types, you will spend less time searching for, which string has a typo in it.
Last pieces of advice:
- separate your navigation into graphs, so you have a good preview of your app
- do not put too much data into navigation logic — e.g. pass the ID of the item and load all the details on the next screen
- do not pass
NavController into the UI composables – keep your UI clean from navigation logic, so you can test UI more easily
If you want to add animation between the screen, I recommend reading this article:
https://tomasrepcik.dev/blog/2023/2023-10-29-android-compose-animations/?source=post_page—–337ec177026e——————————–
Thanks for reading and follow for more!
The full code example is here:
Resources:
https://developer.android.com/develop/ui/compose/navigation?source=post_page—–337ec177026e——————————–
This article is previously published on proandroiddev.com