When building apps with tabbed navigation in Jetpack Compose, it's common to face the challenge of mixing screens with and without bottom tabs. Traditionally, we'd solve this by creating two separate navigation graphs - one for the outer navigation (without tabs) and another for the inner navigation (within tabs). However, this approach can lead to several issues, particularly when working with Compose Web, where URL mapping doesn't properly reflect the internal navigation graph (hopefully we will have an upcoming post about Compose Web).
In this post, we'll explore a simpler, flatter approach that avoids these complications while maintaining clean navigation.
The traditional approach
The nested graphs approach worked but created several pain points. Nested navigation made deep linking complicated, URL mapping in Compose Web didn't reflect the internal tab structure, sharing data between the two navigation graphs required workarounds, and back navigation often behaved unpredictably.
The flatter approach
Instead of nested graphs, we now propose using a flat navigation structure with conditional tab visibility.
sealed class TabScreen(
val route: Any,
val title: StringResource,
val icon: DrawableResource
) {
data object Tab1 : TabScreen(...)
data object Tab2 : TabScreen(...)
}
val tabs = listOf(
TabScreen.Tab1,
TabScreen.Tab2,
)
@Composable
fun MainComponent(navController: NavHostController = rememberNavController()) {
ApplicationTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface
) {
Scaffold(
bottomBar = {
BottomNavBar(navController)
}
) { paddingValues ->
Column(
modifier = Modifier.fillMaxSize()
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
) {
MainNavigation(navController)
}
}
}
}
}
@Composable
private fun BottomNavBar(
navController: NavHostController,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val showBottomNav = tabs.any { tab ->
currentDestination?.hierarchy?.any { it.hasRoute(tab.route::class) } == true
}
if (!showBottomNav) return
Column {
HorizontalDivider()
NavigationBar(
containerColor = Color.White
) {
tabs.forEach { screen ->
val isSelected = currentDestination?.hierarchy
?.any { it.hasRoute(tab.route::class) } == true
NavigationBarItem(
icon = {
Icon(
painter = painterResource(screen.icon),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
},
label = { Text(stringResource(screen.title) },
selected = isSelected,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of
// the graph to
// avoid building up a large stack
// of destinations
// on the back stack as users select items
popUpTo(TabHostFlow) {
if (!isSelected) {
saveState = true
}
}
// Avoid multiple copies of
// the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting
// a previously selected item
restoreState = true
}
},
)
}
}
}
}
This flatter approach offers several advantages. With a flat navigation structure, URLs in Compose Web properly reflect all destinations, making routing more predictable. The showBottomNav
variable checks whether the current destination should display tabs - if not, the bottom navigation simply doesn't render. Individual tabs can be conditionally shown or hidden based on application state through parameters. Additionally, with a single navigation graph, back navigation behaves more intuitively and predictably, while deep linking becomes straightforward since all destinations exist at the same level in the navigation hierarchy.
The navigation host
Here's how the navigation host should be structured for the above code to work:
@Composable
fun MainNavigation(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = OnboardingFlow.Start,
) {
// Non-tabbed flows
authenticationGraph(navController = navController)
onboardingGraph(navController = navController)
// Wrap tab screens in a navigation block to preserve their state
navigation<TabHostFlow>(startDestination = HomeFlow.Start) {
homeGraph(navController = navController)
myProfileGraph(navController = navController)
}
}
}
The important detail that's crucial for proper state preservation when switching between tabs is wrapping the tab screen graphs in a navigation
block. This ensures that the state of each tab is properly maintained when users switch between them. Without this wrapper, you might find that your tab states are reset when navigating between them, leading to a poor user experience.
Conclusion
By using a flat navigation structure with conditional tab visibility, we've simplified our navigation architecture while making it more flexible and web-friendly. This approach solves the URL mapping issues in Compose Web and provides a more intuitive user experience.
Happy coding!
Top comments (0)