When migrating to Compose I found that the ModalBottomSheetLayout was implemented a bit differently in Compose than I was expecting. This caused some problems for me with nested screens in modules and tab navigation. If you have a similar navigation setup to mine, then maybe this will help 😊
TL;DR; This is a very app-specific problem that may only apply to you if your app uses tabs with nested
Scaffoldsfrom which you wish to control the bottom sheet. The image below shows my problem on the left and my desired outcome on the right. Continue reading to know more 😉

BottomSheet wrapping inner screen vs covering tabs as well
BC
In the olden days (before Compose/BC), we could create a bottom sheet as a Dialog using the BottomSheetDialogFragment class. We could keep the UI logic inside the BottomSheetDialogFragment apart from the Fragment it was launched from. And the bottom sheet and parent fragment could share a ViewModel when necessary. This was at least how I implemented it in my apps. I wanted to migrate this flow to Compose but hit a snag in the road.
Bottom sheet modal in Compose
While Compose has support for Dialogs in a way I would have liked to use, the ModalBottomSheetLayout is instead meant to be used as a wrapper around your screen (Scaffold). A dialog can be used like this:
@Composable
fun Screen1() {
var showDialog by remember { mutableStateOf(false) }
Scaffold {
// Screen UI
showDialog = true
)
if (showDialog) {
Dialog(...)
}
}
While the ModalBottomSheetLayout is implemented like this:
@Composable
fun Screen1() {
val scope = rememberCoroutineScope()
val bottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
ModalBottomSheetLayout(
sheetState = bottomSheetState,
sheetContent = {
// Bottomsheet UI
},
content = {
Scaffold {
// Screen UI
scope.launch { bottomSheetState.show() }
}
},
)
}
When opened, both of these will cover the Scaffold content with either a Dialog or a modal bottom sheet.

Bottom sheet is opened via Toolbar actions when the Scaffold covers the entire screen
It’s pretty easy to use, but what happens when your screen is used inside tab navigation? Then your code becomes more nested and might look something more like this:
@Composable
fun TabsScreen() {
Scaffold(
bottomBar = {
NavigationBar(...) // or BottomNavigation in Material2
}
) {
NavHost(...) {
composable(...) {
Screen1()
}
composable(...) {
Screen2()
}
}
}
}
If you used a Dialog inside Screen1/2 it would still show up covering the entire screen (even the tabs). But, if you went with a bottom sheet, the bottom sheet won’t cover the entire screen, but only the inner Scaffold (ex. Screen1, Screen2, etc above)

The bottom sheet is opened via Toolbar actions when the Scaffold now sits inside another Scaffold with tabs
To me, this doesn’t feel right as I want a modal bottom sheet to cover the entire screen. I.e. the user should only be able to interact with the bottom sheet and not the tabs at the same time.
The way I went about solving this was to move the ModalBottomSheetLayout code up around the Scaffold containing the tabs. This wouldn’t have been much of a problem if my app was a single-module app. But in my case, each inner screen lives in its own module and is connected via the top-level app module containing the tabs navigation scaffold.
So, I needed a way to control the bottom sheet from the sub-modules. 🤔
Job Offers
:app
│
├──► :home
│
├──► :rounds
│
├──► :players
│
└──► :menu
Hacky solutions to the rescue 🎉
I created a typealias and placed it in a common submodule so all modules above could access it.
typealias SheetContent = @Composable ColumnScope.() -> Unit
Then I added 2 functions to each inner screen Composable:
showBottomSheet: (SheetContent) -> Unit, hideBottomSheet: () -> Unit,
In the top-level Scaffold, I added the implementation for these 2 functions:
val showBottomSheet: (SheetContent) -> Unit = { content: SheetContent ->
bottomSheetContent = content
scope.launch { bottomSheetState.show() }
}
val hideBottomSheet: () -> Unit = {
scope.launch {
bottomSheetState.hide()
bottomSheetContent = null
}
}
And implemented the bottom sheet content like so:
@Composable
fun TabsScreen() {
val bottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
var bottomSheetContent: SheetContent? by remember { mutableStateOf(null) }
Scaffold(
sheetContent = {
bottomSheetContent?.invoke(this)
},
sheetState = bottomSheetState,
bottomBar = {
NavigationBar(...) // or BottomNavigation in Material2
}
...
With this, I’m now able to control (show/hide) the bottom sheet from each inner screen, while keeping the bottom sheet Composable logic contained next to the parent Composable inside each sub-module. The complete logic looks like this:
@Composable
fun TabsScreen() {
val bottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
var bottomSheetContent: SheetContent? by remember { mutableStateOf(null) }
val showBottomSheet: (SheetContent) -> Unit = { content: SheetContent ->
bottomSheetContent = content
scope.launch { bottomSheetState.show() }
}
val hideBottomSheet: () -> Unit = {
scope.launch {
bottomSheetState.hide()
bottomSheetContent = null
}
}
BackHandler(bottomSheetContent != null) {
hideBottomSheet()
}
Scaffold(
sheetContent = {
bottomSheetContent?.invoke(this)
},
sheetState = bottomSheetState,
bottomBar = {
NavigationBar(...) // or BottomNavigation in Material2
}
) {
NavHost(...) {
composable(...) {
Screen1(
showBottomSheet = showBottomSheet,
hideBottomSheet = hideBottomSheet,
)
}
composable(...) {
Screen2(
showBottomSheet = showBottomSheet,
hideBottomSheet = hideBottomSheet,
)
}
...
}
}
}
@Composable
fun Screen1(
showBottomSheet: (SheetContent) -> Unit,
hideBottomSheet: () -> Unit,
) {
Scaffold {
// Screen UI
...
showBottomSheet {
// Bottomsheet UI
}
}
}
To hack or not to hack
So why is this a hack you may ask? We pass composable around all the time, right? Sure, we pass Composables down into nested Composables, but in this case, we’re passing it up to another Composable. To be honest, I’m not sure that is a problem at all. This works (for me at least) and I’ve seen no immediate issues with the solution. But at the same time, I’ve not seen anyone suggesting this in any example, so I have a suspicion this may come back to bite me at some point and may be considered a code smell. With that said, that’s my only bit of warning.
Use at your own discretion ¯\_(ツ)_/¯
Thanks for reading!
I’ve you’ve come this far, I’m really happy. Maybe I’m not the only one with this problem 😅.
Please clap and share
Sources
This article was originally published on proandroiddev.com


