androidandroid-jetpack-composeandroid-jetpackjetpack-compose-navigation

Jetpack compose navigation: why I have wrong hierarchy of a destination?


Problem:

I have an issue with the hierarchy of my Compose Navigation. The version of compose-navigation I am using is: androidx.navigation:navigation-compose:2.7.7.

Background:

I am trying to implement simple navigation with bottom navigation and three tabs. Each tab hosts its own nested navigation, consisting of two screens: the main tab content and a details screen, which is reused across nested graphs.

Main Activity with NavHost for the Tabs:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            TestNavigationTheme {
                val tabsController = rememberNavController()
                Scaffold(
                    bottomBar = {
                        NavigationBar {
                            val navBackStackEntry by tabsController.currentBackStackEntryAsState()
                            val currentDestination = navBackStackEntry?.destination
                            BottomTabs.items.forEach { navigationItem ->
                                NavigationBarItem(
                                    label = { Text(navigationItem.title) },
                                    selected = currentDestination
                                        ?.hierarchy
                                        ?.any { it.route == navigationItem.route } == true,
                                    icon = { Icon(navigationItem.icon, navigationItem.title) },
                                    onClick = {
                                        tabsController.navigate(navigationItem.route) {
                                            popUpTo(tabsController.graph.findStartDestination().id) {
                                                saveState = true
                                            }
                                            launchSingleTop = true
                                            restoreState = true
                                        }
                                    }
                                )
                            }
                        }
                    }
                ) { paddingValues ->
                    NavHost(
                        navController = tabsController,
                        startDestination = BottomTabs.Feed.route,
                        modifier = Modifier.padding(paddingValues = paddingValues)
                    ) {
                        navigation(
                            route = BottomTabs.Feed.route,
                            startDestination = "feed"
                        ) {
                            composable(route = "feed") {
                                FeedScreen {
                                    tabsController.navigate("details/feed")
                                }
                            }
                            details()
                        }

                        navigation(
                            route = BottomTabs.Search.route,
                            startDestination = "search"
                        ) {
                            composable(route = "search") {
                                SearchScreen {
                                    tabsController.navigate("details/search")
                                }
                            }
                            details()
                        }
                        navigation(
                            route = BottomTabs.Favourites.route,
                            startDestination = "fav"
                        ) {
                            composable(route = "fav") {
                                FavouritesScreen {
                                    tabsController.navigate("details/fav")
                                }
                            }
                            details()
                        }
                    }
                }
            }
        }
    }

    private fun NavGraphBuilder.details() {
        composable(
            route = "details/{argument}",
            arguments = listOf(
                navArgument("argument") { type = NavType.StringType }
            )
        ) { backStackEntry ->
            DetailsScreen(
                argument = backStackEntry
                    .arguments
                    ?.getString("argument")
                    ?: "Couldn't obtain the argument"
            )
        }
    }
}

BottomTabs:

sealed class BottomTabs(val route: String, val title: String, val icon: ImageVector) {
    data object Feed : BottomTabs("feed-tab", "Feed", Icons.Default.Home)
    data object Search : BottomTabs("search-tab", "Search", Icons.Default.Search)
    data object Favourites : BottomTabs("fav-tab", "Favourites", Icons.Default.Favorite)

    companion object {
        val items = listOf(Feed, Search, Favourites)
    }

}

Tab Screens:

@Composable
fun FeedScreen(navigateToDetails: () -> Unit) {
    Column(
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize()
    ) {
        Text("Hi, I'm Feed screen")
        Spacer(modifier = Modifier.size(40.dp))
        Button(onClick = navigateToDetails) {
            Text(text = "Go to details")
        }
    }
}

@Composable
fun SearchScreen(navigateToDetails: () -> Unit) {
    Column(
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize()
    ) {
        Text("Hi, I'm Search screen")
        Spacer(modifier = Modifier.size(40.dp))
        Button(onClick = navigateToDetails) {
            Text(text = "Go to details")
        }
    }
}

@Composable
fun FavouritesScreen(navigateToDetails: () -> Unit) {
    Column(
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize()
    ) {
        Text("Hi, I'm Favourites screen")
        Spacer(modifier = Modifier.size(40.dp))
        Button(onClick = navigateToDetails) {
            Text(text = "Go to details")
        }
    }
}

Details Screen:

@Composable
fun DetailsScreen(argument: String) {
    Column(
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize()
    ) {
        Text("I'm details screen")
        Text("Your argument is: $argument")
    }
}

Issue:

You can find this code inside NavigationBarItem, which I took from Google's guide to navigation:

selected = currentDestination
            ?.hierarchy
            ?.any { it.route == navigationItem.route } == true

When I switch to the third tab ("fav-tab") and press a button to open the details screen, the details screen opens correctly within the fav-tab. However, the navigation bar highlights the feed-tab as selected instead of the fav-tab.

What have I tried:

I tried debugging the navigation recomposition cycle by placing a breakpoint in NavigationBarItem. Here is the hierarchy:

enter image description here

I don't understand why the feed-tab appears as the root of the hierarchy. In my opinion, it should be:

"details/{argument}"
"fav-tab"

because I navigated to the details screen from the fav-tab, not the feed-tab.

What am I doing wrong in my implementation? Any thoughts or advice would be greatly appreciated!


Solution

  • The cause of the issue was the route of the details screen. It was the same across all three navigation graphs.

    According to the documentation, "Route uniquely identifies a destination and any data required by it."

    So, I added prefixes to the details() composable, and now everything works fine.