Show BottomBar conditionally in Compose
November 8, 2022
In most apps that use BottomBar
for bottom navigation, you’d want some screens to exclude the BottomBar
. Let’s look at how to accomplish this with Jetpack Compose.
This solution is based on the Jetsnack sample from Google but has some differences:
This sample does less so you can concentrate on dealing with the
BottomBar
andSnackbar
only.I use a separate navigation graph for each navigation tab which I find to be often done in large production apps.
The sample does not have Google’s fancy animated bottom bar which makes it much more useful to a significantly wider audience.
This is what we are trying to achieve (notice that one of the screens—home item detail screen—does not contain a BottomBar
):
Full source code for this example can be found at https://github.com/jshvarts/ComposeConditionalBottomNav
Gradle
First add latest Compose Navigation library dependency:
implementation("androidx.navigation:navigation-compose:2.6.0-alpha03")
AppState
Create a custom AppState
class which will handle all navigation requests including deciding if a particular navigation route requires a BottomBar
(note the shouldShowBottomBar
property)
@Stable class AppState( val navController: NavHostController ) { val bottomBarTabs = BottomBarTab.values() private val bottomBarRoutes = bottomBarTabs.map { it.route } val shouldShowBottomBar: Boolean @Composable get() = navController .currentBackStackEntryAsState().value?.destination?.route in bottomBarRoutes val currentRoute: String? get() = navController.currentDestination?.route fun upPress() { navController.navigateUp() } fun navigateToBottomBarRoute(route: String) { if (route != currentRoute) { navController.navigate(route) { launchSingleTop = true restoreState = true popUpTo(findStartDestination(navController.graph).id) { saveState = true } } } } } private fun NavBackStackEntry.lifecycleIsResumed() = this.lifecycle.currentState == Lifecycle.State.RESUMED private val NavGraph.startDestination: NavDestination? get() = findNode(startDestinationId) private tailrec fun findStartDestination(graph: NavDestination): NavDestination { return if (graph is NavGraph) findStartDestination(graph.startDestination!!) else graph }
Note: scroll down towards the bottom of the post to see how the AppState
is used to handle Snackbar
messages as well.
We decide whether to hide the bottom bar for a particular route based on presence of this route in BottomBarTab
enum.
Note that I use icons from drawable
folder since you are most likely to use custom SVG icons in production apps.
enum class BottomBarTab( @StringRes val title: Int, @DrawableRes val icon: Int, val route: String ) { HOME( R.string.home, R.drawable.ic_home, "$HOME_GRAPH/$HOME_ROUTE" ), CALENDAR( R.string.calendar, R.drawable.ic_calendar_today, "$CALENDAR_GRAPH/$CALENDAR_ROUTE" ), CHAT( R.string.chat, R.drawable.ic_chat_bubble, "$CHAT_GRAPH/$CHAT_ROUTE" ) }
The AppState
is used in the following manner:
class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { App() } } } @Composable fun App() { ConditionalBottomNavTheme { val appState = rememberAppState() Scaffold( bottomBar = { if (appState.shouldShowBottomBar) { BottomBar( tabs = appState.bottomBarTabs, currentRoute = appState.currentRoute!!, navigateToRoute = appState::navigateToBottomBarRoute ) } }, snackbarHost = { SnackbarHost( hostState = it, modifier = Modifier.systemBarsPadding(), snackbar = { snackbarData -> Snackbar(snackbarData) } ) }, scaffoldState = appState.scaffoldState ) { innerPaddingModifier -> NavHost( navController = appState.navController, startDestination = HOME_GRAPH, modifier = Modifier.padding(innerPaddingModifier) ) { navGraph( onHomeItemSelected = appState::navigateToHomeItemDetail, upPress = appState::upPress ) } } } } @Composable fun rememberAppState( navController: NavHostController = rememberNavController() ) { remember(navController) { AppState(navController) }
Note the check to decide whether it should be visible in the bottomBar
slot of the App Scaffold
:
Scaffold( bottomBar = { if (appState.shouldShowBottomBar) { BottomBar( tabs = appState.bottomBarTabs, currentRoute = appState.currentRoute!!, navigateToRoute = appState::navigateToBottomBarRoute ) } } ) { ... }
And here is the BottomBar
itself rendering BottomNavigation
(note: in the View system, we used BottomNavigationView
to build bottom navigation).
@Composable fun BottomBar( tabs: Array<BottomBarTab>, currentRoute: String, navigateToRoute: (String) -> Unit ) { BottomNavigation( backgroundColor = colorResource(id = R.color.white) ) { tabs.forEach { item -> BottomNavigationItem( icon = { Icon( painter = painterResource(id = item.icon), contentDescription = stringResource(id = item.title) ) }, label = { Text(text = stringResource(id = item.title)) }, selected = currentRoute == item.route, onClick = { navigateToRoute(item.route) } ) } } }
Nav Graphs
Here is what our Nav Graph setup look like:
fun NavGraphBuilder.navGraph() { navigation( route = HOME_GRAPH, startDestination = BottomBarTab.HOME.route ) { addHomeGraph() addCalendarGraph() addChatGraph() } } const val HOME_GRAPH = "home" object HomeDestinations { const val HOME_ROUTE = "root" } fun NavGraphBuilder.addHomeGraph() { composable(BottomBarTab.HOME.route) { HomeScreen() } } const val CALENDAR_GRAPH = "calendar" object CalendarDestinations { const val CALENDAR_ROUTE = "root" } fun NavGraphBuilder.addCalendarGraph() { composable(BottomBarTab.CALENDAR.route) { CalendarScreen() } } const val CHAT_GRAPH = "chat" object ChatDestinations { const val CHAT_ROUTE = "root" } fun NavGraphBuilder.addChatGraph() { composable(BottomBarTab.CHAT.route) { ChatScreen() } }
Whenever a new route needs to be added to a particular nav graph, you would do that in the corresponding *Destinations
object as well.
Navigating to a Detail screen
To add more navigation scenarios to the sample, let’s add an ability to navigate from a list of items on Home to an item Detail screen.
Here is how it’s accomplished with help of the AppState
.
Let’s pass two lambdas to our navGraph
setup:
@Composable fun App() { ConditionalBottomNavTheme { val appState = rememberAppState() Scaffold( bottomBar = { if (appState.shouldShowBottomBar) { BottomBar( tabs = appState.bottomBarTabs, currentRoute = appState.currentRoute!!, navigateToRoute = appState::navigateToBottomBarRoute ) } } ) { innerPaddingModifier -> NavHost( navController = appState.navController, startDestination = HOME_GRAPH, modifier = Modifier.padding(innerPaddingModifier) ) { navGraph( onHomeItemSelected = appState::navigateToHomeItemDetail, upPress = appState::upPress ) } } } }
Let’s update the AppState
to include the following:
fun navigateToHomeItemDetail(itemId: Int, from: NavBackStackEntry) { // In order to discard duplicated navigation events, we check the Lifecycle if (from.lifecycleIsResumed()) { navController.navigate("${HOME_ITEM_ROUTE}/$itemId") } }
And update the navGraph
to hoist the click events on Home.
fun NavGraphBuilder.navGraph( onHomeItemSelected: (Int, NavBackStackEntry) -> Unit, upPress: () -> Unit ) { navigation( route = HOME_GRAPH, startDestination = BottomBarTab.HOME.route ) { addHomeGraph(onHomeItemSelected, upPress) addCalendarGraph() addChatGraph() } }
Additionally, let’s update our HomeDestinations
to add home item related keys:
object HomeDestinations { const val HOME_ROUTE = "root" const val HOME_ITEM_ROUTE = "item" const val HOME_ITEM_ID_KEY = "itemId" }
And update the HomeGraph
setup to handle this navigation:
fun NavGraphBuilder.addHomeGraph( onHomeItemSelected: (Int, NavBackStackEntry) -> Unit, upPress: () -> Unit, modifier: Modifier = Modifier ) { composable(BottomBarTab.HOME.route) { from -> HomeScreen(onItemClick = { id -> onHomeItemSelected(id, from) }, modifier) } composable( route = "$HOME_ITEM_ROUTE/{${HOME_ITEM_ID_KEY}}", arguments = listOf(navArgument(HOME_ITEM_ID_KEY) { type = NavType.IntType }) ) { backStackEntry -> val arguments = requireNotNull(backStackEntry.arguments) val itemId = arguments.getInt(HOME_ITEM_ID_KEY) HomeItemDetail(itemId, upPress) } }
And, finally, this is what our HomeScreen
and HomeItemDetailScreen
look like:
@Composable fun HomeScreen( onItemClick: (Int) -> Unit, modifier: Modifier = Modifier ) { val homeItems = remember { HomeItemRepo.getItems() } LazyColumn( verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(24.dp), modifier = modifier ) { items(homeItems) { item -> HomeItemCard(item) { onItemClick(it.id) } } } } @Composable fun HomeItemDetail( itemId: Int, upPress: () -> Unit ) { val item = remember(itemId) { HomeItemRepo.getItem(itemId) } Scaffold( topBar = { TopAppBar( backgroundColor = Color.White, navigationIcon = { IconButton(onClick = upPress) { Icon( imageVector = Icons.Filled.Close, contentDescription = stringResource(id = R.string.close) ) } }, title = { Text( text = item.text, modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.h6 ) } ) } ) { innerPaddingModifier -> Box( contentAlignment = Alignment.Center, modifier = Modifier .padding(innerPaddingModifier) .fillMaxSize() ) { Text( text = "Home Item $itemId", style = MaterialTheme.typography.h6 ) } } }
Bonus: Handle Snackbar in AppState
The code below is for the most part lifted from the Jetsnack sample. Check out the diff in my repo.
First, update AppState
to include showing Snackbar
:
@Composable fun rememberAppState( scaffoldState: ScaffoldState = rememberScaffoldState(), navController: NavHostController = rememberNavController(), snackbarManager: SnackbarManager = SnackbarManager, resources: Resources = resources(), coroutineScope: CoroutineScope = rememberCoroutineScope() ) = remember(scaffoldState, navController, snackbarManager, resources, coroutineScope) { AppState(scaffoldState, navController, snackbarManager, resources, coroutineScope) } @Stable class AppState( val scaffoldState: ScaffoldState, val navController: NavHostController, private val snackbarManager: SnackbarManager, private val resources: Resources, coroutineScope: CoroutineScope ) { // Process snackbars coming from SnackbarManager init { coroutineScope.launch { snackbarManager.messages.collect { currentMessages -> if (currentMessages.isNotEmpty()) { val message = currentMessages[0] val text = resources.getText(message.messageId) // Display the snackbar on the screen. `showSnackbar` is a function // that suspends until the snackbar disappears from the screen scaffoldState.snackbarHostState.showSnackbar(text.toString()) // Once the snackbar is gone or dismissed, notify the SnackbarManager snackbarManager.setMessageShown(message.id) } } } } ...
And add to the application’s Scaffold
:
val appState = rememberAppState() Scaffold( bottomBar = { if (appState.shouldShowBottomBar) { BottomBar( tabs = appState.bottomBarTabs, currentRoute = appState.currentRoute!!, navigateToRoute = appState::navigateToBottomBarRoute ) } }, snackbarHost = { SnackbarHost( hostState = it, modifier = Modifier.systemBarsPadding(), snackbar = { snackbarData -> Snackbar(snackbarData) } ) }, scaffoldState = appState.scaffoldState ) { ... }
Where SnackbarManager
is as follows:
data class Message(val id: Long, @StringRes val messageId: Int) /** * Class responsible for managing Snackbar messages to show on the screen */ object SnackbarManager { private val _messages: MutableStateFlow<List<Message>> = MutableStateFlow(emptyList()) val messages: StateFlow<List<Message>> get() = _messages.asStateFlow() fun showMessage(@StringRes messageTextId: Int) { _messages.update { currentMessages -> currentMessages + Message( id = UUID.randomUUID().mostSignificantBits, messageId = messageTextId ) } } fun setMessageShown(messageId: Long) { _messages.update { currentMessages -> currentMessages.filterNot { it.id == messageId } } } }
Now we can make item lookup fail randomly to trigger the Snackbar
we just added:
if (ifRandomlyFailed()) { SnackbarManager.showMessage(R.string.item_lookup_failed_error) } else { Text( text = "Home Item $itemId", style = MaterialTheme.typography.h6 ) }
Full source code for this example can be found at https://github.com/jshvarts/ComposeConditionalBottomNav