The release of Jetpack Navigation 2.8.0 brings type-safe navigation APIs to Android developers, making it easier to define navigation destinations and pass data between them. This upgrade enhances the safety and simplicity of handling navigation in Compose-based UIs by offering compile-time safety and structured argument passing.
Why Type-Safe Navigation Matters
Previously, developers needed to manage routes using string-based arguments, leading to potential runtime errors if arguments were missing or incorrectly passed. With type-safe navigation, the @Serializable
annotation and Kotlin’s powerful type system come together to ensure arguments are correctly defined and passed between destinations.
Benefits of Type-Safe Navigation
- Compile-Time Safety: Navigation errors are caught at compile time, reducing runtime crashes.
- Structured Data: Arguments are passed as Kotlin types, making it easier to work with complex data structures.
- Cleaner Code: Less boilerplate and string manipulation means easier-to-read and maintainable code.
1. Setting Up Your Project
To get started, you need to add the Jetpack Navigation library and the Kotlin serialization plugin to your project:
// build.gradle.kts (Project Level) plugins { kotlin("plugin.serialization") version "1.8.0" } // build.gradle.kts (App Level) dependencies { implementation("androidx.navigation:navigation-compose:2.8.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0") }
Now, you can use the @Serializable
annotation to create serializable data classes for your routes.
2. Defining Destinations and Passing Data
With type-safe navigation, you define routes using Kotlin classes. For example, consider an app where the Home screen displays a list of articles, and clicking an article takes you to an Article screen with detailed information.
Creating the Routes
First, create a data class for the Article route:
@Serializable data class Article(val id: String, val title: String)
In your navigation graph, define the Article screen as a composable destination:
NavHost(navController = navController, startDestination = Home) { composable { HomeScreen(onArticleClick = { id, title -> navController.navigate(route = Article(id = id, title = title)) }) } composable { backStackEntry -> val article: Article = backStackEntry.toRoute() ArticleScreen(article) } }
In this example, Home
is the starting destination, and Article
is a route that accepts an Article
data class. You can then use the backStackEntry.toRoute()
function to retrieve the Article data for rendering the ArticleScreen
.
3. Deep Linking to Destinations
Deep links allow users to navigate directly to a specific screen within your app. Here’s how you add a deep link to the Article
screen:
composable( deepLinks = listOf( navDeepLink( basePath = "www.example.com/article" ) ) ) { // Composable content here }
The generated URL for deep linking would be www.example.com/article/{id}
, and you can test this using ADB:
adb shell am start -a android.intent.action.VIEW -d "https://www.example.com/article/ABC" com.example.yourapp
Deep Link Testing Tip
To debug your deep links, you can print the NavBackStackEntry.destination.route
in your composable function to view the generated URL in logcat.
4. Using Enums and Nullable Types
You can also use nullable types and enums as navigation arguments. Enums are particularly useful for passing a set of predefined values.
@Serializable data class Article(val id: String, val category: Category?) enum class Category { TECH, LIFESTYLE, EDUCATION }
When passing nullable types or enums, you need to ensure that the class is marked with @Keep
to prevent removal during minified builds:
@Keep enum class Category { TECH, LIFESTYLE, EDUCATION }
Then, navigating to the Article route with an enum looks like this:
navController.navigate(route = Article(id = "ABC", category = Category.TECH))
5. Testing Your Navigation Code
Testing navigation in Compose is usually done using instrumented tests to simulate user actions. For example, the following test ensures that clicking an article in the Home screen navigates correctly to the Article screen:
@RunWith(AndroidJUnit4::class) class NavigationTest { @get:Rule val composeTestRule = createAndroidComposeRule() @Test fun onHomeScreen_whenArticleIsTapped_thenArticleScreenIsDisplayed() { composeTestRule.apply { onNodeWithText("View details about Article ABC").performClick() onNodeWithText("Article details for ABC").assertExists() } } }
Debugging Tip
If you need to pause your test for debugging, you can use:
composeTestRule.waitUntil(timeoutMillis = 3_600_000) { false }
This will pause the test execution, allowing you to interact with the app and inspect its state.
6. Migrating from String-Based Routes
If you are currently using string-based routes for navigation, migrating to type-safe routes involves:
- Creating a serializable class for each route.
- Replacing string-based route definitions with type-safe composable routes.
- Using
toRoute()
to retrieve the route arguments.
// Before const val ARTICLE_ID_KEY = "id" const val ARTICLE_ROUTE = "article/{$ARTICLE_ID_KEY}" // After @Serializable data class Article(val id: String) composable { backStackEntry -> val article: Article = backStackEntry.toRoute() }
7. Top-Level Navigation UI
If your app has top-level navigation (like a bottom navigation bar or navigation drawer), use the new hasRoute()
extension function to identify the current destination.
@Serializable data object Home @Serializable data object Account val TOP_LEVEL_ROUTES = listOf( TopLevelRoute(route = Home, icon = Icons.Default.Home), TopLevelRoute(route = Account, icon = Icons.Default.AccountCircle) ) NavigationSuiteScaffold( navigationSuiteItems = { TOP_LEVEL_ROUTES.forEach { topLevelRoute -> item( selected = currentDestination?.hierarchy?.any { it.hasRoute(route = topLevelRoute.route::class) } == true, onClick = { navController.navigate(route = topLevelRoute.route) } ) } } ) { NavHost(…) }
8. Best Practices and Debugging Tips
- Avoid Duplicate Destinations: Use unique route classes for each destination to prevent ambiguity.
- Handle “null” Strings Carefully: When using String arguments, ensure the value is not the literal “null” to prevent crashes.
- Keep Routes Small: Avoid using large objects as routes to prevent
TransactionTooLargeException
. Use a unique ID and fetch the data at the destination if needed.
Conclusion
The new type-safe navigation APIs in Jetpack Navigation 2.8.0 bring improved safety, maintainability, and cleaner code for Android developers using Compose. By leveraging Kotlin’s type system, you can avoid runtime errors, create structured navigation, and enhance the developer experience.
Further Reading
- Jetpack Compose Navigation Official Docs
- Kotlin Serialization Guide
- https://medium.com/androiddevelopers/type-safe-navigation-for-compose-105325a97657
The article says this way removes boilerplate, but the migration guide from string paths adds more code. Also, does this way mean we have to sanitize our argument strings for “null” edge cases?
No, declare the variables as null but do not forget to fill them because they come from the serialization class and can be misleading.