Implementation of DeepLinks with Type-Safe Navigation Compose APIs

kenken
4 min readAug 2, 2024

--

As you might already know, type-safe APIs are now available in Navigation Compose from Navigation 2.8.0-alpha08.

There is a brief reference to DeepLinks at the end of the above article. However, I couldn’t find any specific information on how to implement them, so I read the library code to figure out how we should use the APIs.
The library version at that time is Navigation 2.8.0-bata05.

Besides support for all of the Kotlin DSL builders we support (…), it also includes other APIs you might find interesting like the navDeepLink API that takes a Serializable class and a prefix that allows you to easily connect external links to the same type safe APIs.

Prerequisite Knowledge: How to Use Type-Safe Routes

ℹ️ If you have already used it before, you can skip this section.

For details, it would be better to refer to the above article and the official documentation, but I think you should be fine if you know the following points.

  • Need to use them with Kotlin Serialization
    — Add @Serializable annotation to all route classes/objects
  • Need to use Parcelable classes when you need your own data types
    — Using the kotlin-parcelize plugin makes their implementation easier
     — Custom Nav Type implementation is also required

The following is an example of how to use a type-safe route for your own data type.

/**
* Define your own data type
* You also need to annotate it with `@Serializable` to pass it to the route class
*/
@Parcelize
@Serializable
data class SearchParameters(
val searchQuery: String,
val filters: List<String>,
): Parcelable

/**
* Route with your own data type as an argument
*/
@Serializable
data class Search(
val parameters: Search Parameters,
)

/**
* Generic function for creating a Custom Nav Type
*/
@OptIn(InternalSerializationApi::class)
inline fun <reified T : Parcelable> createCustomNavType(
isNullableAllowed: Boolean = false,
) = object : NavType<T>(isNullableAllowed) {
override fun get(bundle: Bundle , key: String): T? {
return BundleCompat.getParcelable(bundle, key, T::class.java)
}

override fun put(bundle: Bundle, key: String, value: T) {
bundle.putParcelable(key, value)
}

/**
* Note that this method also needs to be overridden because the parent class has the following description:
* This method can be override for custom serialization implementation on types such custom NavType classes.
*/
override fun serializeAsValue(value: T): String {
return Json.encodeToString(T::class.serializer(), value)
}

override fun parseValue(value: String): T {
return Json.decodeFromString(T::class.serializer(), value)
}
}

/**
* Custom Nav Type corresponding to your own data type
*/
val SearchParametersType = createCustomNavType<SearchParameters>()

/**
* Pass the Custom Nav Type defined above to the `typeMap` of `composable()`
*/
composable<Search>(
typeMap = mapOf(typeOf<SearchParameters>() to SearchParametersType),
) {…}

Main Topic: How to Use Type-Safe Routes in DeepLinks Implementation

The following FullName route is used as an example of how Type-Safe routes should be implemented for DeepLinks.

@Serializable
data class FullName(
val firstName: String,
val middleName: String? = null,
val lastName: String,
)

Looking at the Type-Safe navDeepLink() API, the following information is required at least.

  • T: Type information of the route to extract arguments
  • basePath: Base URI to add arguments to
  • (typeMap: Mapping of custom nav type to own data type)
public inline fun <reified T : Any> navDeepLink(
basePath: String,
typeMap: Map<KType, @JvmSuppressWildcards NavType<*>> = emptyMap (),
noinline deepLinkBuilder: NavDeepLinkDslBuilder.() -> Unit = {}
): NavDeepLink = navDeepLink(basePath, T::class, typeMap, deepLinkBuilder)

If the FullName route is implemented as follows, the corresponding URI pattern used for matching becomes example://full_name/{firstName}/{lastName}?middleName={middleName}.

composable<FullName>(
deepLinks = listOf(
navDeepLink<FullName>(
basePath = "example://full_name",
),
)
) {…}

From the above, we can see that the URI pattern is generated based on the information passed to the navDeepLink() API according to the following rules.

  • Information about arguments corresponding to properties without default values is added to the URI pattern’s path, and information about arguments corresponding to properties with default values is added to the URI pattern’s query
     — {firstName} and {lastName} are added to its path, and middleName={middleName} is added to its query
  • The order in which argument information is added to the path and query and matches that of the properties declared in the route class
  • The name of each property is used as a placeholder

The last point, the name of each property is used as a placeholder, sounded tricky and may have brought us anxieties about migrating, but it turned out that the placeholder can be renamed by using Kotlin Serialization’s @SerialName.
For example, if each property name is annotated as follows, the URI pattern used for matching changes example://full_name/{first_name}/{last_name}?middle_name={middle_name}.

@Serializable
data class FullName(
@SerialName("first_name")
val firstName: String,
@SerialName("middle_name")
val middleName: String? = null,
@SerialName("last_name")
val lastName: String,
)

Side Note: Things That Caught My Attention

A smooth migration seems possible for the simple cases above, but it may be difficult in the following cases. Let me leave things that caught my attention as a side note.

In cases where a fixed path is required after the placeholder

It looks difficult to build DeepLinks such as example://full_name/{first_name}/{last_name}/edit with this implementation (I am not sure if there are actually such cases though…).

In cases where a route class has its own data type as an argument

DeepLinks could be built and handled in such cases, but it is complicated and not that practical because operations such as encoding JSON data are required. For example, if the FullName route has the following data type, the app can navigate us to the mapped screen by decoding the encoded JSON data being passed in the {parameters} part of example://full_name/{parameters}. However, in this case, it would be better to replace it with a flat argument as explained in the first example.

/*
* A Custom Nav Type implementation is also required, but let me omit it here
*/
@Parcelize
@Serializable
data class FullNameParameters(
@SerialName("first_name")
val firstName: String,
@SerialName("middle_name")
val middleName: String? = null,
@SerialName("last_name")
val lastName: String,
) : Parcelable

@Serializable
data class FullName(
val parameters: FullNameParameters,
)

--

--