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:

  1. This sample does less so you can concentrate on dealing with the BottomBar and Snackbar only.

  2. I use a separate navigation graph for each navigation tab which I find to be often done in large production apps.

  3. 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

Previous
Previous

Slim down your Android components with LifecycleObserver

Next
Next

Compose by example: Intrinsics