Understanding Type-Safe Compose Library: Multiple Approaches to Navigation, Data Transfer, Backstack Management, NavType, and Deep Link Patterns

Vinod Kumar G
11 min readNov 19, 2024

--

The Type-safe Compose Navigation library (version >= 2.8.0) for Android simplifies navigation between screens by using objects (types/values) instead of String patterns, which were used in previous versions. This new approach not only makes navigation more straightforward but also enhances the process of passing data among destinations.

Scope

This article explores the various methods for navigating to different destinations and the techniques for sending or receiving data across those destinations.

Preliminary Understanding: Key Terms to Know Before Reading This Article

NavDestination: A node in the navigation graph. When the user navigates to this node, the host displays its content.
NovHost: A UI element that contains the current navigation destination. That is, when a user navigates through an app, the app essentially swaps destinations in and out of the navigation host.
NavGraph: A data structure that defines all the navigation destinations within the app and how they connect together.
NavController: The central coordinator for managing navigation between destinations. The controller offers methods for navigating between destinations, handling deep links, managing the back stack, and more.
Route: Uniquely identifies a destination and any data required by it. You can navigate using routes. Routes take you to destinations.

Compose Type-Safe Navigation Setup:

Dependency: For type-safe Compose navigation, ensure you use the latest versions in your code.

gradle/libs.versions.toml

navVersion = "2.8.3"
kotlinSerialization = "1.7.3"

...........................
...........................

androidx-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navVersion" }
kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinSerialization" }

app:build.gradle.kts

plugins {
...................
alias(libs.plugins.kotlin.serialization)
...................
}


dependencies {
..............................
..............................
implementation(libs.androidx.navigation)
implementation(libs.kotlinx.serialization)

}

The following code demonstrates how to create a simple HomeScreen.

@Serializable
object Home


@Composable
fun NavUi(navController: NavHostController = rememberNavController()) {
NavHost(startDestination = Home, navController = navController) {
composable<Home> { navBackStackEntry ->
Column {
Text("Home")
Button(onClick = {
}) {
Text("First")
}
}
}
}
}

rememberNavController(): This function returns an instance of NavController.
Before setting a starting destination, you should create a serializable class (data/object).
startDestination: This property specifies the initial route node for navigation graphs.
composable<T>: This is an extension function for NavGraphBuilder (a Kotlin DSL for constructing a new NavGraph). It is used to add a node/destination to the navigation graph.
In the above code, the Home destination is added and identified using the Home route. Essentially, the route for this destination is defined by the Home class.

Let’s Discuss the Following Regularly Used Scenarios

  1. Simple Navigation: without passing any data(path/query parameters)
  2. Sending and Receiving Path and Query Parameters
  3. Sending and Receiving Custom Objects
  4. Pop Backstack to Specific Screen
  5. Navigate to Screen Before Popping Up To Designed Destination
  6. Sending Data to Existing Screen in the Backstack
  7. Handling Deeplink Patterns

1. Simple Navigation

Now, if you want to navigate from the HomeScreen to the FirstScreen. Create a class for the new destination, which is FirstScreen.

@Serializable
object First

Add FirstScreen to the navigation graph using the code below.

composable<First> { navBackStackEntry ->
FirstScreen(navBackStackEntry)
}

..........................


@Composable
fun FirstScreen(backStackEntry: NavBackStackEntry) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("First screen")
}
}

The Navigation library creates a route for the destination using First::class.java.name (which includes the package name and class name).
NavBackStackEntry: This class will be discussed in the upcoming sections.
Update the HomeScreen as shown below for navigation from the HomeScreen to FirstScreen.

composable<Home> { navBackStackEntry ->
Column {
Text("Home")
Button(onClick = {
navController.navigate(First)
}) {
Text("First")
}
}
}

Clicking on the `Home` button (from HomeScreen) navigates to FirstScreen. There is another way to navigate to FirstScreen. We can use the code below to navigate from Home to FirstScreen.

navController.navigate(First::class.java.name)

The navigate() function handles both objects and strings as routes for navigation. Type-safe Compose navigation converts a First object into a string internally and navigates to the specified destination.

How can we navigate back to the HomeScreen from the FirstScreen?

Use the code below to clear the current destination(FirstScreen) from the backstack:

navController.navigateUp() //recommended
(or)
navController.popBackStack()

navigateUp(): If the current destination is the same as the starting destination, there will be no change in navigation. This means it cannot clear its current destination.

2. Sending and Receiving Path and Query Parameters

In earlier versions of Compose (prior to version 2.8.0), passing values to the next screen required using query or path parameters, which involved additional steps. However, type-safe navigation simplifies the process of passing data from one screen to another. Add the following code to SecondScreen to pass data from FirstScreen:

@Serializable
data class Second(
val path: String,
val arg1: String = "",
val arg2: String = "argument2"
)

path: Since it does not have optional parameters, the Compose type-safe navigation library treats this as a path variable in the route.
arg1 & arg2: These are optional parameters, so the type-safe navigation library treats these parameters as query parameters in the route.
Add the following code to the navigation graph.

composable<Second> { navBackStackEntry ->
SecondScreen(navBackStackEntry.toRoute<Second>()) {
}
}


--------------------------------------------
--------------------------------------------


@Composable
fun SecondScreen(
receivedObject: Second,
onNext: () -> Unit
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
"Second screen\n from First: $receivedObject}",
style = MaterialTheme.typography.bodyMedium
)
Button(onClick = onNext) {
Text("Third")
}
}
}

The type-safe navigation component generates the following route for SecondScreen.
“${Second::class.java.name}/{path}?arg1={arg1}&arg2={arg2}”

Sending parameters from FirstScreen

By using type-safe navigation:

navController.navigate(Second(path = "path_variable_1", arg1 = "123456789"))

We can also pass parameters using another method:

navController.navigate("${Second::class.java.name}/path_variable_1?arg1=123456789")

However, the above method is discouraged for type-safe navigation because parameters (both path and query) sometimes need to be encoded to avoid issues with extracting argument values

Receiving parameters in SecondScreen:

Using backStackEntry.toRoute<T>(): It recreates an object of T.

composable<Second> { navBackStackEntry ->
SecondScreen(navBackStackEntry.toRoute<Second>()) {
}
}

Where, navBackStackEntry.toRoute<Second>() returns “Second(path=path_variable_1, arg1=123456789, arg2=argument2)” object.

Using backStackEntry.arguments: We can use the arguments method to retrieve all the values in the Second object with the following code snippet.

navBackStackEntry.arguments?.getString("path") // -> path_variable_1
navBackStackEntry.arguments?.getString("arg1") // -> 123456789
navBackStackEntry.arguments?.getString("arg2") // -> argument2

Using SavedStateHandle: The ViewModel of SecondScreen can receive an object from FirstScreen using the following code snippet

class SecondViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val receivedObject = savedStateHandle.toRoute<Second>()
}

toRoute() is an important extension function in the type-safe compose-navigation library to provide an object.

3. Sending and Receiving Custom Objects

How can we send custom objects?
Compose navigation allows passing custom objects by extending the NavType<T> abstract class. NavType denotes the type that can be used in arguments, with built-in NavTypes for primitive types such as int, long, boolean, float, and strings. For custom objects, you need to create a class that extends the NavType class.

Defining CustomObject:

@Serializable
data class Third(
val arg: String = "arg1",
val customObject: CustomObject = CustomObject()
)

@Serializable
data class CustomObject(val y: Int = 12)

Note: Every custom object must be serializable using annotations.

Creating a Generic Custom NavType:

inline fun <reified T> customNavType(
isNullableAllowed: Boolean = false,
) = object : NavType<T>(isNullableAllowed = isNullableAllowed) {
override fun get(bundle: Bundle, key: String): T? = null
override fun parseValue(value: String): T = Json.decodeFromString(Uri.decode(value))
override fun put(bundle: Bundle, key: String, value: T) = Unit
}
inline fun <reified T> typeMap(): Map<KType, NavType<T>> {
return mapOf(typeOf<T>() to customNavType<T>())
}


fun <T, U> Map<KType, NavType<T>>.plus(on: Map<KType, NavType<U>>): MutableMap<KType, NavType<*>> {
return mutableMapOf<KType, NavType<*>>().apply {
putAll(this@plus)
putAll(on)
}
}

parseValue(value:String): Method for parsing the string form of object (Implement only this method for following examples)
isNullableAllowed: This property indicates whether an argument of this type can accept a null value.
typeMap<T>() : This function converts T (for custom types) into a Map<KType, NavType<T>>, which is essential for the compose{} DSL function.
plus(): This extension function combines multiple Map<KType, NavType<T>> into a single Map<KType, NavType<T>>

composable<Third>(
typeMap = typeMap<CustomObject>()
) { navBackStackEntry ->
ThirdScreen(
receivedObject = navBackStackEntry.toRoute<Third>()
)
}

.....................................................

@Composable
fun ThirdScreen(
receivedObject: Third,
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Third screen $receivedObject", style = MaterialTheme.typography.headlineMedium)
}
}

The typeMap:Map<KType, NavType<*>> property is necessary for the navigation library to handle the serialization and deserialization of custom class types (e.g., CustomObject).

Sending CustomObject:

navController.navigate(Third(customObject = CustomObject(2000)))

Receiving CustomObject:

Using NavBackStackEntry:

navBackStackEntry.toRoute<Third>()

Note: navBackStackEntry.arguments?.getString(“customObject”) returns null because NavType<CustomObject> has not implemented all the required methods such as get(), put(), serializeAsValue()..etc in subclass of NavType.

Using SavedInstanceState in ViewModel;

savedStateHandle.toRoute<Third>(typeMap<CustomObject>())

If a third class contains multiple custom objects such as CustomObject1 and CustomObject2, the Compose code needs to be updated as follows

composable<Third>(
typeMap = typeMap<CustomObject1>() + typeMap<CustomObject2>()
) { navBackStackEntry ->
ThirdScreen(
receivedObject = navBackStackEntry.toRoute<Third>()
)

Additionally, the ViewModel code must be updated as shown below:

savedStateHandle.toRoute<Third>(typeMap<CustomObject1>()+typeMap<CustomObject2>())

Note: It is strongly advised not to pass around complex data objects when navigating, but instead pass the minimum necessary information, such as a unique identifier or other form of ID, as arguments when performing navigation actions

4. Pop Backstack to Specific Screen

(Assuming we are on ThirdScreen and the backstack includes multiple screens such as HomeScreen, FirstScreen, SecondScreen, and ThirdScreen, navigate back to FirstScreen.)

navController.popBackStack(First,false)
(or)
navController.popBackStack<First>(false)

popBackStack<T>(inclusive:Boolean): Attempts to pop the controller’s back stack to a specific destination. It returns true if the stack was successfully popped at least once and the user was navigated to a different destination; otherwise, it returns false.
inclusive: Whether the specified destination should also be removed from the stack.
If we want to navigate from ThirdScreen to SecondScreen, which includes path and query parameters, we need to specify the exact object.

navController.popBackStack(Second("path_variable_1",arg1 = "123456789"),false)//type safe

Alternatively, we can use different syntax to pop the backstack.

navController.popBackStack<Second>(false) //type-safe
(or)
navController.popBackStack(Second::class.java.name+"/path_variable_1",false)

5. Navigate to Screen Before Popping Up To Designed Destination

Compose navigation provides a NavOptions class with special options for navigation. Suppose we want to pop up the backstack (which has HomeScreen, FirstScreen, SecondScreen, and ThirdScreen) to FirstScreen before navigating to FourthScreen. We would use the following code:

navController.navigate(Fourth) {
popUpTo<First> {
inclusive = false
}
}

In this scenario, the backstack would have the destinations HomeScreen, FirstScreen and FourthScreen, while SecondScreen and ThirdScreen would be removed from the backstack.

navigate(route: T, builder: NavOptionsBuilder.() -> Unit)

builder: DSL for constructing a new NavOptions.
Using NavOptionsBuilder, we can control navigation and popup operations.
launchSingleTop property: This property determines whether a navigation action should launch as single-top. It functions similarly to how android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP works with activities.
popUpTo(): It navigates to a specified destination by first popping all non-matching routes from the back stack until it reaches the destination with a matching route.

popUpTo(route: T, popUpToBuilder: PopUpToBuilder.() -> Unit = {})

route : route from a Object for the destination
PopUpToBuilder: It is a builder used to construct a popUpTo operation. It has two parameters: inclusive and saveState.
inclusive: Determines whether the popUpTo destination should be removed from the backstack.

navController.navigate(Fourth) {
popUpTo<First> {
inclusive = false
}
}

The above code snippets are used to pop the backstack up to FirstScreen, and then add FourthScreen to the backstack. If inclusive is set to true, FirstScreen will also be removed from the backstack. By default, the inclusive parameter is set to false.

If we consider a scenario, Popping up the backstack to SecondScreen (inclusive = true), which includes path and query parameters, before navigating to FourthScreen:

navController.navigate(Fourth){
popUpTo(Second("path_variable_1", arg1 = "123456789")) {
inclusive = true
}
}

Alternatively, we can use the popUpTo() function without including path and query parameters.

navController.navigate(Fourth) {
popUpTo<Second> {
inclusive = true
}
}

6. Sending Data to Existing Screen in the Backstack

In some scenarios, data needs to be passed to the previous screen after popping the backstack. For example, assume the backstack has multiple destinations such as HomeScreen, FirstScreen, SecondScreen, and ThirdScreen, and you need to pass data from FourthScreen after popping up to FirstScreen. To send data from FourthScreen, you can use the following code:

navController.getBackStackEntry<First>().savedStateHandle["key"] =
"Value is received from Fourth Screen"
navController.popBackStack<First>(inclusive = false)

getBackStackEntry<First>: This method returns the NavBackStackEntry object of the FirstScreen
In the above code, a new key-value pair is added to FirstScreen’s navBackStackEntry. This allows you to pass data from FourthScreen to FirstScreen after popping the backstack. To receive the key-value pair in FirstScreen, you need to update FirstScreen with the following code:

LaunchedEffect(Unit) {
backStackEntry.savedStateHandle.getLiveData<String>("key").observeForever {
println("received value $it")
}
}

Note: An observer for LiveData has been added, but the removal of the observer object is not shown in the above code snippet. Once the data has been observed, it is necessary to remove the data (LiveData/Flow) that was added to the backStackEntry.

The backStackEntry.savedStateHandle provides a SavedStateHandle object that manages multiple values.

class SavedStateHandle {
private val regular = mutableMapOf<String, Any?>()
private val savedStateProviders = mutableMapOf<String, SavedStateRegistry.SavedStateProvider>()
private val liveDatas = mutableMapOf<String, SavingStateLiveData<*>>()
private val flows = mutableMapOf<String, MutableStateFlow<Any?>>()
private val savedStateProvider =
SavedStateRegistry.SavedStateProvider {......}
------------------------------------------
}

Internally, it uses maps to store key-value pairs, which can be exposed as a map, LiveData, or Flow objects.
getLiveData<String>(“key”): Returns a LiveData object for the given “key”.
getStateFlow(key = “key”, initialValue = “”): Returns a StateFlow object for the given “key” with an empty string as the initial value. This value can be observed using StateFlow collectors (e.g., collectLatest{}).
In the FirstScreen, a LiveData object has been created for the “key”. The function observeForever(observer) is used to observe the changes in the value for the given “key”.

7. Handling DeepLink Patterns:

Note: This article does not cover navigation using PendingIntent. This section focuses solely on the deep link pattern.

For example, assume a screen/destination (FifthScreen) needs to be opened based on a deep link/URI pattern.

Adding DeepLink to a Destination:

const val URI = "https://myapp"
@Serializable
data class Fifth(val path:Int, val received: String = "")

....................................................

composable<Fifth>(
deepLinks = listOf(
navDeepLink<Fifth>(basePath = URI)
)
) { backStackEntry ->
FifthScreen(backStackEntry)
}

...................................................


@Composable
fun FifthScreen(backStackEntry: NavBackStackEntry) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Fifth screen")
}
}

deepLinks: A list of deep links to associate with the destinations.
navDeeplink<Fifth>(basePath: String = URI): returns a NavDeepLink object. It extracts deep link arguments from the Fifth class and appends them to the basePath (URI). The basePath and the generated arguments together form the final URI pattern for the deep link.

The URI pattern for FifthScreen is : https://myapp/{path}?received={received}

Navigation and Sending Data Using URI Pattern

To send values for path and received arguments, use the following code:

val uri = Uri.parse("$URI/123?received=objectFromLink")
navController.navigate(uri)

This code adds the destination to the backstack and displays FifthScreen on the UI.

Receiving Data Using URI Pattern

Using backStackEntry in Compose:

val fifth = backStackEntry.toRoute<Fifth>()
(Or)
val path = backStackEntry.arguments?.getInt("path")
val received = backStackEntry.arguments?.getString("received")

Using savedStateHandle in ViewModel:

val fifth = savedStateHandle.toRoute<Fifth>()
(or)
val path = savedStateHandle.get<Int>("path")
val received = savedStateHandle.get<String>("received")

Note: Be cautious in production apps and ensure that classes (e.g., Fifth, Enum) are not removed during minified builds.

Migration

You can check the below PR to see how type-safe navigation has been migrated in Now-in-Android project.

PR : https://github.com/android/nowinandroid/pull/1413/files#diff-bbcd9ff3a5be72096922a84b290125441e34d8183492cd27536e343a903579a8R57

Disadvantage

Writing unit test cases for ViewModel requires additional effort. Since SavedStateHandle.toRoute() is an extension function that depends on the Android resource android.os.Bundle, it necessitates adding the Robolectric dependency to the project to facilitate unit testing for the ViewModel. Reference 1 Reference 2

Conclusion

One of the primary benefits of the new type-safe Compose navigation APIs is the compile-time safety achieved by using types for navigation arguments. This library provides multiple approaches for navigation, transferring data between destinations, handling the backstack, and managing deep links.

Don’t forget to follow me on LinkedIn and Medium for more updates.

You have any questions or doubts, feel free to leave a comment. If you enjoyed this article, please show your appreciation with a clap.

Happy Coding !!!

--

--

Vinod Kumar G
Vinod Kumar G

Written by Vinod Kumar G

Mobile application developer @Deloitte | Kotlin | Swift | Java | Spring boot | Machine Learning | Deep Learning

No responses yet