Blog

Navigating Compose's Pathways: A Type-Safe Expedition

On Friday, Nov 8, 2024
post image

As Seen In - jetc.dev Newsletter Issue #240

Building modern Android applications often requires slicing up the user experience into distinct screens, each representing a unique state of the app. These screens are connected through a navigation system, allowing users to move seamlessly between different parts of the application.

A crucial aspect of this navigation process is the ability to pass relevant data from one screen to another. For example, when navigating from a list of items to the details of a specific item, we need to pass the item’s identifier (such as an ID or a unique identifier) to the details screen. This ensures that the correct information is displayed, enhancing the overall user experience and the app’s functionality. Historically, managing navigation in Android apps has involved a considerable amount of boilerplate code and type-checking.

Developers had to manually construct navigation routes using string-based URIs and handle the extraction and type-checking of arguments in the destination screens.

However, the recent introduction of Compose as a modern UI framework for Android, along with the introduction of Type-Safe navigation in Compose Navigation 2.8.0, has transformed the way we can build and optimize UIs, without having to worry about the compatibility of types between screens.

In this blog post, we’ll explore the power of Type-Safe navigation in Compose and how it can streamline the development of your Android applications. We’ll first look at the challenges faced with navigation before the introduction of this feature, and then dive into the benefits and implementation details of Type-Safe navigation.

The Navigation Challenge: A Practical Example

Imagine we have two screens in our app - a HomeScreen and a PostDetailsScreen. From the HomeScreen, we want to navigate to the PostDetailsScreen and pass along a post ID to retrieve the corresponding details.

Let’s define an object to centralize the routes and the arguments in the app and not handle them as string values directly in the navigation graph:


package co.wawand.composetypesafenavigation.presentation.navigation

import co.wawand.composetypesafenavigation.presentation.navigation.NavArguments.POST_ID

object NavDestinations {
    const val WELCOME_ROUTE = "welcome"
    const val HOME_ROUTE = "home"
    const val LOADING_ROUTE = "loading"


    private const val POST_DETAILS = "post_details"
    const val POST_DETAILS_ROUTE = "$POST_DETAILS/{$POST_ID}"

    // Helper function to create the route with parameter
    fun postDetailsRoute(postId: Long) = "$POST_DETAILS/$postId"
}

object NavArguments {
    const val POST_ID = "postId"
}

Also, we could centralize the navigation by defining a sealed class to represent all the possible navigation events belonging to the app and add a function to handle those events:


package co.wawand.composetypesafenavigation.presentation.navigation

import androidx.navigation.NavController
import co.wawand.composetypesafenavigation.presentation.navigation.NavDestinations.HOME_ROUTE

sealed class NavigationEvent {
    data object OnNavigateUp : NavigationEvent()
    data object NavigateToHome : NavigationEvent()
    data class OnNavigateToPostDetails(val postId: Long) : NavigationEvent()
}

fun handleNavigationEvent(navController: NavController, event: NavigationEvent) {
    when (event) {

        is NavigationEvent.OnNavigateUp -> navController::navigateUp

        is NavigationEvent.NavigateToHome -> {
            navController.navigate(HOME_ROUTE) {
                popUpTo(HOME_ROUTE) { inclusive = true }
            }
        }

        is NavigationEvent.OnNavigateToPostDetails -> {
            navController.navigate(NavDestinations.postDetailsRoute(event.postId))
        }
    }
}

Now, let’s define the navigation graph and how to link the HomeScreen to the PostDetailsScreen:


package co.wawand.composetypesafenavigation.presentation.navigation

import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument

@Composable
fun AppNavigation(navController: NavHostController) {

    NavHost(navController = navController, startDestination = NavDestinations.HOME_ROUTE) {

        composable(route = NavDestinations.HOME_ROUTE) {
            HomeScreen(
                onGoToDetails = { postId -> NavigationEvent.OnNavigateToPostDetails(postId) },
                // more parameters
            )
        }

        composable(
            route = NavDestinations.POST_DETAILS_ROUTE,
            arguments = listOf(
                navArgument(NavArguments.POST_ID) {
                    type = NavType.LongType
                }
            )
        ) { navBackStackEntry ->
            val postId = navBackStackEntry.arguments?.getLong(NavArguments.POST_ID) ?: 0L
            PostDetailsScreen(
                postId = postId,
                // more parameters
            )
        }
    }
}

In this example, we’re using the navigate() function from the Navigation component to pass the postId as part of the navigation route. On the PostDetailsScreen, we then need to retrieve this value from the NavBackStackEntry and handle the potential for a null value. This approach introduces several challenges:

  1. Route Composition: Constructing the navigation route by manually concatenating strings is error-prone and makes the code harder to read and maintain. If the route structure changes, we need to update multiple places in the codebase.
  2. Type-Safety: There’s no type-safety when passing arguments through the navigation route. We could accidentally pass the wrong type of data (e.g., an integer instead of a string) and encounter runtime crashes.
  3. Argument Handling: The PostDetailsScreen implementation needs to handle the potential for a null value when retrieving the postId argument, adding extra complexity to the code.
  4. Refactoring: If the argument names or types change, we need to update the route definition and the corresponding argument handling code in multiple places, increasing the chances of introducing bugs.

To address these issues, the Compose Navigation 2.8.0 release introduced Type-Safe navigation, let’s explore it and refresh the previous code to use it.

The new type-safe navigation APIs for Kotlin allow you to use any serializable type to define navigation destinations. To use them we need to

  • Set the Compose Navigation dependency to at least the 2.8.0 version.
  • Add the Kotlin serialization plugin
  • Add the Kotlin serialization dependency

Once done, and after synchronizing the dependencies and the plugins required, we can use the @Serializable annotation to automatically create serializable types. Let’s see how to implement it:


package co.wawand.composetypesafenavigation.presentation.navigation

import kotlinx.serialization.Serializable

sealed class NavDestinations {
    @Serializable
    data object Welcome : AppDestinations()

    @Serializable
    data object Home : AppDestinations()

    @Serializable
    data object Loading : AppDestinations()

    @Serializable
    data class PostDetail(val postId: Long) : AppDestinations()
}

The previous NavDestinations object is now a sealed class that contains all the app destinations, which are objects or data classes serializable. The data classes allow us to establish parameters, with the types required.

Now, let’s adjust the function to handle the navigation event to use the proper route definition:


fun handleNavigationEvent(navController: NavController, event: NavigationEvent) {
    when (event) {

        is NavigationEvent.OnNavigateUp -> navController.navigateUp()

        is NavigationEvent.NavigateToHome -> navController.navigate(AppDestinations.Home) {
            popUpTo(navController.graph.startDestinationId) { inclusive = true }
        }

        is NavigationEvent.OnNavigateToPostDetails -> navController.navigate(
            AppDestinations.PostDetail(event.postId)
        )
    }
}

And now, let’s dive into the navigation graph, where the magic appears:


package co.wawand.composetypesafenavigation.presentation.navigation

import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument

@Composable
fun AppNavigation(navController: NavHostController) {

    NavHost(navController = navController, startDestination = NavDestinations.Home) {

        composable<NavDestinations.Home> {
            HomeScreen(
                onGoToDetails = { postId -> NavigationEvent.OnNavigateToPostDetails(postId) },
                // more parameters
            )
        }

        composable<NavDestinations.PostDetail>{ navBackStackEntry ->
            val postDetail = navBackStackEntry.toRoute<NavDestinations.PostDetail>()
            PostDetailsScreen(
                postId = postDetail.postId,
                // more parameters
            )
        }
    }
}

And that’s it! Easy, right?

With the introduction of Type-Safe navigation, the process of navigating between screens and passing arguments becomes much more streamlined and robust. Here’s how it works:

  1. Defining Navigation Routes: Instead of using string-based routes, Type-Safe navigation allows you to define your navigation routes using sealed classes or data classes. This provides a clear, type-safe way to represent your application’s navigation structure.
  2. Navigating Between Screens: When navigating from one screen to another, you can use the type-safe route definitions instead of manually constructing string-based routes. This eliminates the risk of making mistakes in the route composition.
  3. Handling Arguments in Destination Screens: On the destination screen, you no longer need to manually extract and handle the arguments. The Navigation component will automatically handle the argument passing and type-checking, ensuring that the arguments are of the expected types.

This new approach to navigation in Compose represents a significant leap forward in terms of code quality and developer experience. By leveraging the power of Kotlin’s type system and sealed classes, we can create a more structured and maintainable navigation system. The transformation from string-based routes to type-safe navigation not only reduces the potential for errors but also provides a more intuitive way to manage the flow between screens in your application.

Let’s explore the key benefits that make Type-Safe navigation a game-changer for Android development with Compose:

  • Improved Code Readability and Maintainability: By using type-safe route definitions, the navigation code becomes more expressive and easier to understand. If the navigation structure changes, you only need to update the route definitions in a single place.
  • Elimination of Runtime Crashes: With type-safety, you can catch navigation-related errors at compile-time instead of encountering runtime crashes due to mismatched argument types.
  • Reduced Boilerplate and Complexity: The Navigation component handles the argument passing and extraction, eliminating the need for manual null-checks and type-casting.
  • Enhanced Refactoring Capabilities: If you need to change the argument names or types, you can do so in the route definitions, and the Navigation component will automatically update the corresponding code in your destination screens.
  • Improved Developer Experience: Type-Safe navigation in Compose provides a more intuitive and developer-friendly way to manage navigation within your Android applications.

Conclusion

Overall, the introduction of Type-Safe navigation in Compose 2.8.0 has not only streamlined the development process but has also elevated the quality and robustness of Android applications. By embracing this feature, you can build more intuitive, user-friendly, and maintainable apps that provide a seamless navigation experience for your users

As we continue to develop the Android applications with Compose, we can explore the power of Type-Safe navigation and unlock the full potential of this modern UI framework. With the improved developer experience and the elimination of common navigation-related issues, we can focus more on creating exceptional user experiences and delivering high-quality applications.

References

Share this post: