androidandroid-jetpack-composejetpack-compose-navigation

TypeSafe navigation in Jetpack Compose: Issue with serialization


I'm trying to use TypeSafe navigation arguments for my app, but i have issue with serialization.

I made sealed interface which contains all data classes for all screens within my app.

It looks like this:

sealed interface RouteData {
    val navigationVisible: Boolean

    @Serializable
    data class SplashScreenData(
        override val navigationVisible: Boolean = false
    ) : RouteData

    @Serializable
    data class FirstScreenData(
        override val navigationVisible: Boolean = true
    ) : RouteData

    @Serializable
    data class SecondScreenData(
        override val navigationVisible: Boolean = false,
        val id: Int,
        val name: String,
    ) : RouteData

    
    @Serializable
    data class ThirdScreenData(
        override val navigationVisible: Boolean = false,
        val item: String,
    ) : RouteData

    ...
}

And now I have NavigationBar which is listening to backStackEntry to collect data:

navController.currentBackStackEntryFlow.collect { backStackEntry ->
            val data = try {
                backStackEntry.toRoute<RouteData>()
            } catch (e: Exception) {
                Log.e("TabBar", "Error deserializing RouteData", e)
                null
            }

            data?.let {
                isTabBarVisible = it.navigationVisible
            }
} 

Issue is that I'm getting deserialization exception:

java.lang.IllegalArgumentException: Polymorphic value has not been read for class null

Solution

  • This approach is not supported - only concrete classes can be used with Navigation's APIs (e.g., composable or toRoute) as only those classes provide enough information via Serialization.

    Instead, you should separate the metadata about destinations from the data classes themselves. Besides being much, much more efficient in how savedInstanceState is being used (e.g., your navigationVisible is being stored in the Bundle for every instance), this also provides a separation of concerns between static data and data specific to an instance of your data class. This also encourages you to not use these kind of sealed interfaces, which do not scale as you split your destinations across multiple separate modules.

    So instead, your destinations can look like:

    @Serializable
    data object SplashScreenData
    
    @Serializable
    data object FirstScreenData
    
    @Serializable
    data class SecondScreenData(
        val id: Int,
        val name: String,
    )
    
    @Serializable
    data class ThirdScreenData(
        val item: String,
    )
    

    And you can centralize the logic around bottom nav visibility specifically around the place where you actually use that data - alongside your listener which can then use the hasRoute method to determine exactly what destination you are on:

    navController.currentBackStackEntryFlow.collect { backStackEntry ->
        isTabBarVisible = backStackEntry.destination.hasRoute<FirstScreenData>()
    } 
    

    Of course, if your isTabBarVisible is Compose State that you use to show and hide a bottom bar, you shouldn't be using currentBackStackEntryFlow at all (since that adds a one frame delay between collecting the value and the recomposition), so you'd instead want to read the state directly:

    val currentBackStackEntry by navController.currentBackStackEntryAsState()
    val currentDestination = currentBackStackEntry?.destination
    val isTabBarVisible = currentDestination?.hasRoute<FirstScreenData>() == true
    

    Of course, you can use the KClass version as well if you want to keep a set of classes:

    val bottomBarVisibleDestinations = listOf(FirstSreenData::class)
    val isTabBarVisible = bottomBarVisibleDestinations.any {
        currentDestination?.hasRoute(it) == true
    }