Jetpack Compose Navigation: Embracing Type Safety and Simplifying Parcelable Handling with NavHelper library
Resources
Full video tutorial: YouTube
At the above video you can watch how to embrace the new type safety provided by the new navigation libary, utilizing DataClass
to pass parameters, and how to fully add Parcelables
. Then the video shows a simplification using the NavHelper
library to pass your parcelables with almost no effort, almost as if Parcelables were primitives.
NavHelper Library at GitHub
Old way passing Parcellables through BackStack: GIST
New way passing Parcellables through BackStack: GIST
Demo Project at GitHub
Introduction
A crucial aspect of crafting seamless user experiences in Jetpack Compose is navigation, and with the release of Jetpack Compose Navigation 2.8.0-alpha08, we’ve witnessed a significant shift towards a more robust and type-safe approach, as announced at Google I/O ‘24.
In this article, we’ll delve into the world of type-safe navigation in Jetpack Compose, with a focus on passing Parcelables
, and introduce a handy helper library, NavHelper
, designed to simplify your navigation code.
The Landscape Before Type Safety
In the pre-type-safe era of Jetpack Compose Navigation, passing complex data like Parcelable
objects between composables posed a significant challenge. Developers often found themselves caught between two less-than-ideal approaches:
- Leveraging the Backstack for Parcelables: The backstack, a core component of navigation, served as a conduit for data transfer. However, it wasn’t designed to directly handle complex objects like
Parcelable
s. To work around this, developers would typically store these objects in theSavedStateHandle
associated with the backstack entry. While functional, this method came with caveats. It tightly coupled navigation logic to theSavedStateHandle
, making it less flexible and potentially leading to unexpected behavior if not managed carefully. Additionally, retrieving the data on the destination screen required extra steps and introduced some potential for errors if the key or type didn't match.
Take a look at this GIST that applies this approach. - Manual JSON Wrestling: When dealing with custom objects like
Parcelable
s, developers frequently turned to manual JSON serialization using libraries like Gson or kotlinx.serialization. This involved manually converting objects into JSON strings, passing them along, and then painstakingly deserializing them on the destination screen to. While flexible, this approach was cumbersome, required external libraries, and introduced the risk of runtime errors if the JSON structure wasn't perfectly aligned. The code itself became verbose, riddled with potential points of failure.
Both of these approaches, while functional, lacked the elegance and robustness that modern developers crave. They often led to code that was harder to read, maintain, and debug.
Enter Type Safety: A Paradigm Shift
Jetpack Compose Navigation 2.8.0-alpha08 introduced a paradigm shift with the concept of type-safe navigation. This marked a major step forward in making navigation not only safer but also more intuitive. Let’s break down the key elements of this new approach:
- Data Classes as First-Class Citizens: Kotlin’s data classes, with their concise syntax and built-in functionality, are now the preferred way to represent navigation arguments. This means that instead of passing raw strings or manually serialized JSON, you can now pass instances of your data classes directly to your navigation functions.
- Type Safety for All: One of the most significant advantages of this new approach is that type safety isn’t limited to complex objects. You can now pass primitive types like
Int
,String
, and even custom enums with the guarantee that their types will be preserved throughout the navigation process. This eliminates the risk of type-related errors that could occur at runtime. - Automatic Serialization: Gone are the days of manually serializing and deserializing objects. The navigation library now handles this process automatically, taking a significant burden off your shoulders. This means less boilerplate code and a reduced risk of errors in your navigation logic.
Code Comparison: Old vs. New
To truly appreciate the transformative power of type-safe navigation, let’s compare the old and new approaches side-by-side using concrete code examples.
Old Way (Passing a Person Object through JSON)
Manually serializing a Person instance to JSON and passing it as string, and then deserializing it at the second screen to create back a Person instance.
@Parcelize
data class Person(
val id: Int,
val section: Int,
val name: String,
val imageUrl: String,
val landingPage: String
) : Parcelable
@Composable
fun NavigationRootOld() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "main_screen") {
// SCREEN_A
composable("main_screen") {
val viewModel = viewModel<MainScreenViewModel>()
MainScreen(viewModel, onItemClick = { person ->
// Serialize the Person object to JSON
val personJson = Json.encodeToString(person)
navController.navigate("detail_screen/$personJson") // Pass it in the route
})
}
// SCREEN_B
composable("detail_screen/{personJson}") { backStackEntry ->
// Extract and deserialize the Person object from the route
val personJson = backStackEntry.arguments?.getString("personJson")
val person = personJson?.let { Json.decodeFromString<Person>(it) }
person?.let {
DetailScreen(person, onBackPress = {
navController.popBackStack()
})
}
}
}
}
Old Way (Passing a Person Object through the Backstack)
Put a Person instance at the Backstack of SCREEN_A, and then when you move to SCREEN_B, take the person from the previous Backstack (that is SCREEN_A’s Backstack entry) the stored Person instance.
@Parcelize
data class Person(
val id: Int,
val section: Int,
val name: String,
val imageUrl: String,
val landingPage: String
) : Parcelable
@Composable
fun NavigationRootOld() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "main_screen") {
// SCREEN_A
composable("main_screen") {
val viewModel = viewModel<MainScreenViewModel>()
MainScreen(viewModel, onItemClick = { person ->
// put Person object inside the savedStateHandle of backstack
navController.currentBackStackEntry?.savedStateHandle?.set("person", person)
navController.navigate("detail_screen")
})
}
// SCREEN_B
composable("detail_screen") {
// retrieve Person from inside the previous backstack's savedStateHandle
val person = navController.previousBackStackEntry?.savedStateHandle?.get<Person>("person")
person?.let {
DetailScreen(person, onBackPress = {
navController.popBackStack()
})
}
}
}
}
New Way (with type safety and NavHelper library)
Here the routes are not strings anymore but classes that can contain any kind of instance variables. So we pass entire objects and can retrieve the variables they contain as a payload.
Serialization will happen for Parcelables automatically by the navigation library, so we need to mark it as @Serializable
, and with the help of NavHelper
library we only need to mark strings that might contain escape characters that could mess the serialized object (such as URLs) with @Serializable(with=StringSanitizer::class)
and with a single line typemap=mapOf(typeOf<Person>() to NavType.fromCustom<Person>())
t we can let navigation library know how to treat the non-primitive custom type Person
// Just mark Serializable your Parcellable
// and JSON serialization will happen behind the scenes for you
@Serializable
@Parcelize
data class Person(
val id: Int,
val section: Int,
val name: String,
@Serializable(with = StringSanitizer::class)
val imageUrl: String,
@Serializable(with = StringSanitizer::class)
val landingPage: String
) : Parcelable
// Navigation Routes are now described as classes
sealed class NavRoute {
@Serializable
object MainScreen
@Serializable
data class DetailScreen(
val person: Person
val someNumber: Int
)
}
@Composable
fun NavigationRootNew() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = NavRoute.MainScreen) {
// SCREEN_A
composable<NavRoute.MainScreen>() {
val viewModel = viewModel<MainScreenViewModel>()
MainScreen(viewModel, onItemClick = { person ->
// Pass an instance of "DetailScreen"
val detail = DetailScreen(person, 20)
navController.navigate(NavRoute.DetailScreen(person))
})
}
// SCREEN_B
composable<NavRoute.DetailScreen>(
// For non primitives ask the NavHelper to provide support using this line
typeMap = mapOf(typeOf<Person>() to NavType.fromCustom<Person>())
) {
// obtain the payload of "DetailScreen" instance
val args = it.toRoute<NavRoute.DetailScreen>()
val person = args.person // custom type
val someNumber = args.someNumber // primitive type
DetailScreen(person, onBackPress = {
navController.popBackStack()
})
}
}
}
NavHelper: Your Type-Safe Companion
While the new type-safe navigation system is a major leap forward, it still leaves room for improvement, particularly when dealing with Parcelable
objects and strings with special characters. This is where NavHelper
, the library I created, comes to the rescue.
Simplifying Parcelable Handling
NavHelper
seamlessly integrates with the new type-safe navigation system by providing a streamlined way to handle Parcelable
objects. Working with the new type-safe version of the navigation component with Parcelable
s involved writing boilerplate code for describing how to serialize and deserialize custom types as it can be seen here. NavHelper
eliminates this burden by automatically generating the necessary NavType
for your Parcelable
classes.
With just a few lines of code, NavHelper
takes care of the complex serialization process, allowing you to focus on the core logic of your application.
Safeguarding Strings with Special Characters
Another challenge in navigation arises when dealing with strings containing special characters, such as URLs. These characters can cause issues during serialization and deserialization. NavHelper
addresses this problem with its StringSanitizer
, which ensures that such strings are properly encoded and decoded, preventing errors in your navigation parameters.
By annotating the relevant fields in your data class with @Serializable(with = StringSanitizer::class)
, you can rest assured that these strings will be handled correctly during navigation.
The Road Ahead: Embracing Type Safety
The adoption of type-safe navigation in Jetpack Compose is a significant step towards a more robust and reliable navigation experience. By leveraging data classes, automatic serialization, and tools like the NavHelper
library, you can write cleaner, more maintainable, and less error-prone navigation code.
I encourage you to explore the possibilities of type-safe navigation and to adopt the NavHelper
library in your projects. It's a small but powerful tool that can make a big difference in the quality of your code.
As the Android ecosystem continues to evolve, we can expect further enhancements to the navigation system. Embracing type safety is not just a trend; it’s a fundamental shift towards a more reliable and enjoyable development experience. By staying ahead of the curve and adopting these best practices, you’ll be well-equipped to build the next generation of Android applications.