Compose Navigation in KMP

Privitera Stefano Salvatore
3 min readAug 16, 2024

--

This guide will walk you through setting up and using the official Compose Navigation library in a Kotlin Multiplatform (KMP) project. We’ll cover how to pass custom items between views, a common requirement in many applications.

Routes — Image by Bogdan Karlenko

Setup

Before we start coding, we need to import the necessary dependencies into our Kotlin Multiplatform project. Add the following to your build.gradle.kts file in the composeApp directory:

plugins {
//+++
// To serialize our custom types
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.0"
}

kotlin {
//...
sourceSets {
commonMain.dependencies {
//+++
// serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
// uri
implementation("com.eygraber:uri-kmp:0.0.18")
// compose navigation
implementation("org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha08")
}
}
}

After adding these dependencies, sync your Gradle files.

Defining Routes

For routes without arguments, we’ll use a simple data object. For routes with arguments, we’ll use a data class. Both need to be annotated with @Serializable:

//ROUTES
@Serializable
data object HomeRoute

@Serializable
data class DetailRoute(
val item: CustomType
)

---

@Serializable
data class CustomType(
val id: String,
val name: String,
val description: String
)

Be sure to add the annotation to your custom types too if you are using them as arguments.

Creating Views

Next, let’s create two simple views, one for each route:

@Composable
fun HomeView(navController: NavHostController) {
// Home view
Scaffold { paddingValues ->
Column(
modifier = Modifier.padding(paddingValues).fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Home view")
Button(
onClick = {
navController.navigate(DetailRoute(item = CustomType("1", "Item 1", "Description 1")))
}
) {
Text("Go to Item 1")
}
Button(
onClick = {
navController.navigate(DetailRoute(item = CustomType("2", "Item 2", "Description 2")))
}
) {
Text("Go to Item 2")
}
}

}
}

@Composable
fun DetailView(navController: NavHostController, item: CustomType) {
// Detail view
Scaffold { paddingValues ->
Column(
modifier = Modifier.padding(paddingValues).fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Detail view")
Text("Item id: ${item.id}")
Text("Item name: ${item.name}")
Text("Item description: ${item.description}")
Button(
onClick = {
navController.popBackStack()
}
) {
Text("Go back")
}
}

}
}

Creating the Navigation Graph

Now that we have created our routes and our views, we need to create a NavGraph to bind them together. I like to use a simple function to build it in order to keep everything tidier.

@Composable
fun createNavGraph(navController: NavHostController) {
NavHost(navController = navController, startDestination = HomeRoute) {
homeRoute(navController)
detailRoute(navController)
}
}


fun NavGraphBuilder.homeRoute(navController: NavHostController) {
composable<HomeRoute> {
HomeView(navController = navController)
}
}

fun NavGraphBuilder.detailRoute(navController: NavHostController) {
composable<DetailRoute> {
val item = it.toRoute<DetailRoute>().item
DetailView(navController = navController, item = item)
}
}

Handling Custom Types in Navigation

When attempting to run your app at this point, you may encounter the following error:

java.lang.IllegalArgumentException: Cannot cast item of type org.gntechonomy.gnmobilenetsuite.Item to a NavType. Make sure to provide custom NavType for this argument.

This error indicates that we need to implement a custom NavType to pass our custom types between views. The process is more complex than simply implementing the functions suggested by NavType type.

To resolve this issue:

  1. We need to override the serializeAsValue function.
  2. We must URI-encode our JSON-encoded string, as that’s what NavType expects in return.

Here’s an example implementation of a custom NavType:

val CustomNavType = object : NavType<CustomType>(
isNullableAllowed = false,
) {
override fun get(bundle: Bundle, key: String): CustomType? {
return Json.decodeFromString(bundle.getString(key) ?: return null)
}

override fun parseValue(value: String): CustomType {
return Json.decodeFromString(UriCodec.decode(value))
}

override fun put(bundle: Bundle, key: String, value: CustomType) {
bundle.putString(key, Json.encodeToString(value))
}

override fun serializeAsValue(value: CustomType): String {
return UriCodec.encode(Json.encodeToString(value))
}
}

Next, we need to create a mapper from our custom type to our custom NavType. The NavGraphBuilder provides an easy way to implement this. Let’s update our detail route composable in the navigation graph with a typeMap property:

fun NavGraphBuilder.detailRoute(navController: NavHostController) {
composable<DetailRoute>(
//like this
typeMap = mapOf(typeOf<CustomType>() to CustomNavType),
) {
val item = it.toRoute<DetailRoute>().item
DetailView(navController = navController, item = item)
}
}

Conclusion

You now have a fully working Compose Navigation implementation in your KMP application, capable of passing custom types between views!

--

--