Type-Safe Navigation in Jetpack Compose: Passing Custom Classes

Jetpack Compose's Navigation library has introduced long-awaited type safety, making navigation between destinations more robust, intuitive, and of-course safe.

But what happens when you need to pass custom classes as arguments? Luckily, the library supports this functionality - with some additional setup. Here's a complete concise example to guide you.

Consider a CheckoutFlow sealed interface with a Calendar destination accepting an Offer object as an argument:

fun NavGraphBuilder.checkoutGraph(navController: NavHostController) {
    navigation<CheckoutFlow.Start>(
        startDestination = CheckoutFlow.Calendar(offer = null),
    ) {
        composable<CheckoutFlow.Calendar>(
            typeMap = mapOf(
                typeMapOf<Offer?>(),
            ),
        ) {
            val route: CheckoutFlow.Calendar = it.toRoute()

            CheckoutCalendarComponent(
                offer = route.offer,
            )
        }
    }
}

sealed interface CheckoutFlow {
    @Serializable
    data object Start : CheckoutFlow

    @Serializable
    data class Calendar(
        val offer: Offer?,
    ) : CheckoutFlow
}

@Serializable
data class Offer(
    @SerialName("description")
    val description: String,
    @SerialName("id")
    val id: Int
)

The Offer class, marked with @Serializable, requires additional work for navigation argument handling. Use the following custom NavType implementation for serialization and deserialization:

inline fun <reified T> serializableNavType(isNullableAllowed: Boolean = false) =
    object : NavType<T>(isNullableAllowed = isNullableAllowed) {
        override fun put(bundle: Bundle, key: String, value: T) {
            bundle.putString(key, serializeAsValue(value))
        }

        override fun get(bundle: Bundle, key: String): T? {
            return bundle.getString(key)?.let { parseValue(it) }
        }

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

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

        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            if (other !is NavType<*>) return false
            if (other::class.java != this::class.java) return false
            if (isNullableAllowed != other.isNullableAllowed) return false
            return true
        }
    }

inline fun <reified T> typeMapOf(): Pair<KType, NavType<T>> {
    val type = typeOf<T>()
    return type to serializableNavType<T>(isNullableAllowed = type.isMarkedNullable)
}

Serialization and deserialization are handled by the serializableNavType, which converts your class to and from a string representation.

Additionally, extremely important is the equals method that plays a crucial role in ensuring arguments are compared accurately during navigation.

By implementing these steps, you can seamlessly pass custom classes in a type-safe manner, unlocking the full potential of Jetpack Compose's Navigation library.

Happy coding!