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
Scaffolds
from 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