skip to content
LogoJ-log

Bottom Navigation Bar with nested Scaffolds

On the Android developer documentation there is a guide on how to integrate the bottom navigation bar into your app, which uses a top level Scaffold and a NavHost as its content.

The problem

The issue I encountered with this setup is the fact that it’s hard to nest material 3 scaffolds in the NavHost as they might end up with weird paddings around the edges, especially when managing insets yourself with WindowCompat.setDecorFitsSystemWindows(window, false) in your activity.

Nesting scaffolds is often necessary when you need to add different top app bars or floation action buttons depending on the screen, and moving that conditional logic to the main scaffold often requires view models filled with ui properties that shouldn’t be there.

My approach

I’ve tried multiple solutions, and the one that worked best for me was to replace to top level Scaffold with a simple Column and add the NavHost and BottomAppBar to it!

Code

@Composable
fun BottomAppBarScreen() {
    val navController = rememberNavController()
    val pages = listOf(BottomAppBarPage.Feed, BottomAppBarPage.Account, BottomAppBarPage.Settings)
    var selectedPage by remember { mutableStateOf(BottomAppBarPage.Account.route) }

    Column(
        verticalArrangement = Arrangement.Bottom,
        modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
    ) {
        NavHost(
            navController = navController,
            startDestination = BottomAppBarPage.Account.route,
            modifier = Modifier.weight(1f)
        ) {
            composable(BottomAppBarPage.Feed.route) {
                Text(text = "Feed")
            }

            composable(BottomAppBarPage.Account.route) {
                AccountPage()
            }

            composable(BottomAppBarPage.Settings.route) {
                Text(text = "Settings")
            }
        }

        CustomBottomAppBar(
            pages = pages,
            selectedPage = selectedPage,
            onPageClicked = {
                selectedPage = it
                navController.navigate(it) {
                    popUpTo(navController.graph.findStartDestination().id) {
                        saveState = true
                    }
                    launchSingleTop = true
                    restoreState = true
                }
            }
        )
    }
}

The BottomAppBarPage is a simple sealed class which code you can find here, while the CustomBottomAppBar code is here.

Then, a possible page could look like this:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountPage() {
    Scaffold(
        topBar = {}, // a custom top app bar...
        floatingActionButton = {}, // a custom fab...
        // This removes the system navigation bar padding which we have already handled in the top level screen
        contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(NavigationBarDefaults.windowInsets)
    ) { paddingValues ->  
        LazyColumn(
            modifier = Modifier.fillMaxWidth().padding(paddingValues),
            verticalArrangement = Arrangement.spacedBy(
                space = 4.dp
            )
        ) {
            items(50) {
                Text(text = "Item $it")
            }
        }
    }
}

The trick here is telling the Scaffold to ignore the navigation bar window insets, these do not refer to the bottom app bar but to the system navigation bar.
If we don’t tell that to the Scaffold, it will think that we didn’t handle those insets and will add a bottom padding as high as the system navigation bar.

You can see that I preserved the default Scaffold insets and removed just the ones we don’t need, the navigation bar insets.

contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(NavigationBarDefaults.windowInsets)

Demo & code

BottomNavigationBar demo

You can find the full code for the demo in this article on my GitHub repo

## Bonus - animations After publishing this article I realised animations were missing, or didn’t work well with the default settings.

The NavHost now has animations support, so I changed each composable enter and exit animations to match its position in the Bottom Navigation Bar.

Here is the updated NavHost

NavHost(
    navController = navController,
    startDestination = BottomAppBarPage.Account.route,
    modifier = Modifier.weight(1f)
) {
    composable(
        route = BottomAppBarPage.Feed.route,
        enterTransition = {
            slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right)
        },
        exitTransition = {
            slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left)
        }
    ) {
        Text(text = "Feed")
    }

    composable(
        route = BottomAppBarPage.Account.route,
        enterTransition = {
            if (initialState.destination.route == BottomAppBarPage.Settings.route) {
                slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right)
            } else {
                slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left)
            }
        },
        exitTransition = {
            if (targetState.destination.route == BottomAppBarPage.Settings.route) {
                slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left)
            } else {
                slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right)
            }
        }
    ) {
        AccountPage()
    }

    composable(
        route = BottomAppBarPage.Settings.route,
        enterTransition = {
            slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left)
        },
        exitTransition = {
            slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right)
        }
    ) {
        Text(text = "Settings")
    }
}

Happy coding ;)