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

navigation compose type-safe routing

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

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

Shares:
2 Comments
  • altair8800
    altair8800
    September 30, 2024 at 4:56 pm

    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?

    Reply
    • Görkem KARA
      September 30, 2024 at 4:58 pm

      No, declare the variables as null but do not forget to fill them because they come from the serialization class and can be misleading.

      Reply
Leave a Reply

Your email address will not be published. Required fields are marked *