Tomáš Repčík - 11. 5. 2024

Jetpack Compose Screen Navigation With Type Safety

Introduction of type safety to the navigation compose library

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 the plugin to the project-level build.gradle:

plugins {
    ...
    alias(libs.plugins.jetbrains.kotlin.serialization) apply false
}

and add it to the dependencies to 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)
}

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 generalization. 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 part by part.

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.

Passing complex data classes

There might be need to pass something more complex between the screens then primitives only. Here is 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.

With our mapper in our hand, we can put it into the navigation composable so it knows to use 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 convert 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.

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 advices:

If you want to add transition animation into between the screen, I recommend reading this article.

Thanks for reading and follow for more!

The full code example is here.

Resources:

Subscribe for more
LinkedIn GitHub Mail Medium