How to Implement Official Type-Safe Navigation With Jetpack Compose & Custom Types

by | Oct 2, 2024

The declarative UI code that Jetpack Compose has introduced has been a fantastic development. Creating and reviewing UI code has never been easier! With all the positives it came with, there was one area where I felt that Jetpack Compose went a bit backward – and that was how it handled navigation…

What did we have with Fragment Navigation?

First, let’s review what we had: Type-safe navigation offered by the Safe Args Gradle plugin that simplified passing arguments between destinations. I was grateful not to have to manually define a type at the source destination and then recast the type at the destination, which can quickly introduce some bugs. The plugin also generated classes representing the routes we defined in our nav_graph.xml. Sure, it was in XML, but at least we defined it once, and then we could use the generated code further.

With the release of Compose, the news quickly spread that it relied on string-based navigation routes, similar to that of web URLs. We now needed to place extra care when defining and using routes; passing arguments also became a bit tricker, sometimes even questionable when passing custom types.  This was enough for some developers to continue to rely on Fragment navigation and only use Compose to render their UI. Luckily, Compose supports interop like this, and it was a viable solution. That, however, is no longer necessary.

Simple Type-Safe Navigation

We’ve had type-safe navigation for compose in beta and alpha for a while, and now we have a stable version that was recently released. Using this stable version, I will walk you through how we can navigate using Compose in a type-safe manner.

We will build an application with two screens:

  1. DogListScreen
  2. DogDetailScreen

A standard list and detail app. DogListScreen displays a list of dogs with their name, breed, and size, and DogDetailScreen displays the details of the dog tapped on the previous screen. Let’s see how we can pass this data around!

First, we’ll need some dependencies. Make sure to target at least version 2.8.0, as it is the first stable release to support the type-safe navigation we are looking for. The Kotlin Serialization plugin is required when defining our routes, which we will create soon.

// libs.versions.toml
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.6.3" }
androidx-compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version="2.8.0" }

// app build.gradle.kts
plugin {
    // ...
    alias(libs.plugins.jetbrains.kotlin.serialization)
}

dependencies {
    // ...
    implementation(libs.androidx.compose.navigation)
    implementation(libs.kotlinx.serialization.json)
}

Next, we have our Composable screens.

@Composable
fun DogListScreen(
    onDogClick: (Dog) -> Unit,
) {
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
    ) {
        items(SAMPLE_DATA_DOGS.keys.toList()) { dog ->
            Text(
                text = dog.toString(),
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable {
                        onDogClick(dog)
                    }
                    .padding(16.dp)
            )
        }
    }
}

@Composable
fun DogDetailScreen(
    modifier: Modifier = Modifier,
    dog: Dog,
) {
    Surface(modifier) {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Text("Id: ${dog.id} and breed: ${dog.breed}")
        }
    }
}

Our data source will be SAMPLE_DATA_DOGS, a map of Dog to BreedSize enum. We won’t use the enum for now, but we will by the end of this article.

data class Dog(val id: Int, val breed: String)

enum class BreedSize {
    SMALL,
    MEDIUM,
    LARGE,
}

val SAMPLE_DATA_DOGS = mapOf(
    Dog(1, "Golden Retriever") to BreedSize.LARGE,
    Dog(2, "Labrador Retriever") to BreedSize.LARGE,
    Dog(3, "German Shepherd") to BreedSize.LARGE,
    Dog(4, "Poodle") to BreedSize.MEDIUM,
    Dog(5, "Bulldog") to BreedSize.MEDIUM,
    Dog(6, "Rottweiler") to BreedSize.LARGE,
    Dog(7, "Beagle") to BreedSize.SMALL,
    Dog(8, "Dachshund") to BreedSize.SMALL,
    Dog(9, "Boxer") to BreedSize.LARGE,
    Dog(10, "Yorkshire Terrier") to BreedSize.SMALL,
    Dog(11, "Siberian Husky") to BreedSize.LARGE,
    Dog(12, "Australian Shepherd") to BreedSize.MEDIUM,
    Dog(13, "Doberman Pinscher") to BreedSize.LARGE,
    Dog(14, "Bernese Mountain Dog") to BreedSize.LARGE,
    Dog(15, "French Bulldog") to BreedSize.SMALL,
    Dog(16, "Golden Doodle") to BreedSize.MEDIUM,
    Dog(17, "Pomeranian") to BreedSize.SMALL,
    Dog(18, "Shih Tzu") to BreedSize.SMALL,
    Dog(19, "Great Dane") to BreedSize.LARGE,
    Dog(20, "Border Collie") to BreedSize.MEDIUM,
)

Great! Our setup is complete. Now, let’s define the type-safe navigation routes for our Composable screens.

@Serializable
object DogsList

@Serializable
data class DogDetail(val id: Int, val breed: String)

It is as simple as that—no more strings! We are keeping the routes simple for now with primitive types because defining a custom data type requires additional steps, which we will review in a bit. Keep reading to learn how to use custom data types as navigation arguments!

Let’s now set up our navigation graph with the destinations and routes we’ve defined above.

// Place this in your Activity's onCreate function inside the setContent {} lambda
val navController = rememberNavController()
NavHost(
    navController = navController,
    startDestination = DogsList,
) {
    composable<DogsList> {
        DogListScreen(
            onDogClick = { dog -> 
	        navController.navigate(DogDetail(dog.id, dog.breed)) 
            }
        )
    }
    composable<DogDetail> { backStackEntry ->
        val dogDetail = requireNotNull(backStackEntry.toRoute<DogDetail>())
        DogDetailScreen(
            dog = Dog(
                id = dogDetail.id,
                breed = dogDetail.breed
            ),
        )
    }
}

At first glance, this looks similar to how we’ve defined routes using Compose in the past. But take note that our startDestination is set to our DogList object. We also use our routes as the generic type when calling the composable functions.

When we want to navigate, we simply pass a new instance of our DogDetail class to the navController.navigate() function. The parameters of the DogDetail class will automatically be passed as arguments, which we will retrieve at the destination.

We can retrieve those arguments using a new function called toRoute on our NavBackStackEntry, which we get from the content lambda block of the composable function. We define the type of our route when we call toRoute. We have then successfully retrieved an instance of our route class with all its properties in a type-safe manner!

We now have a working application using type-safe navigation with Compose! That is great already, but I’ve only shown you how to use routes with zero arguments (with an object) or that contain primitive types. But I know that often, we find ourselves in a situation where we want to pass a custom data type or even a simple enum type.

Type-Safe Navigation with Custom Types

Let’s first update our DogDetail class to take only two values: our Dog class and an enum type called BreedSize. After doing so, your compiler will complain that you need to mark Dog with @Serializable as well, so go ahead and do that.

@Serializable
data class DogDetail(val dog: Dog, val breedSize: BreedSize)

Usually, I would place the enum inside the Dog class, but for demonstration purposes, we will pass it separately to show you how to do this. We should also now mark our Dog class with @Serializable so that we can easily encode it to a JSON string and then decode it back again.

Next, let’s update our DogListScreen to support passing through the BreedSize of the tapped dog.

@Composable
fun DogListScreen(
    onDogClick: (Dog, BreedSize) -> Unit // <-- Include BreedSize
) { 
    LazyColumn(/* ... */) {
        items(/* ... */) { dog ->
	    Text(
	      /* ... */,
	      modifier = Modifier
	        .clickable {
	            onDogClick(dog, SAMPLE_DATA_DOGS[dog]!!) // <-- Pass BreedSize
	        }
	    )
        }
    }
}

And our DogDetailScreen to accept and render the BreedSize :

@Composable
fun DogDetailScreen(
    modifier: Modifier = Modifier,
    dog: Dog,
    breedSize: BreedSize,
) {
    Surface(modifier) {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Text("Id: ${dog.id} and breed: ${dog.breed} with size: $breedSize")
        }
    }
}

Next, we need to update our navigation calls:

composable<DogsList> {
    DogListScreen(
        onDogClick = { dog, breedSize ->
            navController.navigate(DogDetail(dog, breedSize)) 
        }
    )
}

composable<DogDetail> { backStackEntry ->
    val dogDetail = requireNotNull(backStackEntry.toRoute<DogDetail>())
    DogDetailScreen(
        dog = dogDetail.dog,
	breedSize = dogDetail.breedSize
    )
}

This code will now pass our entire custom type and an enum to our destination. At the destination, we access those using the same method as before by calling toRoute on our NavBackStackEntry and passing that to our DogDetailScreen.

We’ve updated everything! But it would crash if you had to run it now. This is because we didn’t tell Compose how to convert our custom and enum types to strings and back. Let’s do that now:

object CustomNavType {

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

        override fun parseValue(value: String): Dog {
            return Json.decodeFromString(Uri.decode(value))
        }

        override fun serializeAsValue(value: Dog): String {
            return Uri.encode(Json.encodeToString(value))
        }

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

A lot is happening here, but it looks worse than it is. First, what does this class do? We are defining an instance of a class called NavType from the AndroidX Navigation library that we imported earlier. With this instance, we can define how we want to convert our class to a string and then back again to our class. Compose will use this instance to do the conversions when navigating between destinations. With this class:

  1. We can optionally mark this type as nullable using the isNullableAllowed property.
  2. The get and put functions make use of a Bundle to save the converted string
  3. The serializeAsValue and parseValue functions work together to convert your type to and from a string. Note, here is where we benefit from the @Serializable annotation on our Dog class mentioned earlier.

I use Uri.encode() and Uri.decode() because these values are still used as parameters in a URI (comparable to a website URL) where we must replace some characters with special Unicode characters.

A way to look at these functions we are overriding is to see them as two sets of pairs that work together. One pair uses serialization for conversion to and from a string, and the other uses Bundles. I am not 100% sure why we need both, but it may be due to compatibility with Fragment navigation. For Compose, though, we need both pairs of functions.

Finally, we need to let Compose know of our custom NavType :

composable<DogDetail>(
    typeMap = mapOf(
        typeOf<Dog>() to CustomNavType.DogType,
        typeOf<BreedSize>() to NavType.EnumType(BreedSize::class.java)
    )
) { /* ... */ }

In this step,we

  1. pass in a map of custom types that our DogDetail route to the typeMap property of the composable function.
  2. pass in our custom implementation we just created for our Dog class to the map
  3. and then we pass in a NavType.EnumType instance of our BreedSize enum type. This is a built-in generic NavType specifically for enums, which allows us to define our enum and be all set.

And that’s it! You can now navigate using custom types and enums with Compose code. You can also check out the video I made on this topic below on Compose Type-Safe Navigation. Thank you for reading this article, and look forward to more Android, Compose, and Kotlin content!

The Essentials of Industry-Level Android App Development

Master Building Large-Scale Native Android Apps

Take this 23h online course to learn everything it takes to become an industry-ready native Android developer. Learn about multi-module architecture, Gradle, Jetpack Compose, authentication, offline-first development and more.

Read More