A Story About Composable BottomSheet
TLDR; `BottomSheetScaffold` is not capable of covering the background content, this article explains a possible workaround.
The other day I had to build a screen with two buttons, which had to show a BottomSheetDialog when clicked. Simple Android stuff, right?
Looking at the documentation I figured out that there was already a Composable for this job, which is a Scaffold
that has BottomSheet
capabilities. So I started declaring a Composable which accepts the content to render:
@ExperimentalMaterialApi
@Composable
fun BottomSheet(
modifier: Modifier = Modifier,
scope: CoroutineScope = rememberCoroutineScope(),
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = BottomSheetState(initialValue = BottomSheetValue.Collapsed)
),
content: @Composable () -> Unit
) {
BottomSheetScaffold(
modifier = modifier,
sheetContent = { BottomSheetContent() },
scaffoldState = scaffoldState,
sheetPeekHeight = 0.dp,
sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
) {
content()
}
}
The BottomSheetContent()
may be whatever content you would like, i just used a Column()
with some text and two Buttons:
@Composable
private fun BottomSheetContent(modifier: Modifier = Modifier) {
Column(
modifier = Modifier.padding(horizontal = 20.dp)
) {
Spacer(modifier = Modifier.height(20.dp))
Text(text = "Title")
Spacer(modifier = Modifier.height(20.dp))
Text(text = "A long warning description to make sure that the user knows what is going on")
Spacer(modifier = Modifier.height(20.dp))
Column(modifier = modifier.fillMaxWidth()) {
TextButton(
modifier = modifier.fillMaxWidth(),
onClick = { /*TODO*/ }
) {
Text(text = "OK")
}
Spacer(modifier = Modifier.height(10.dp))
TextButton(
modifier = modifier.fillMaxWidth(),
onClick = { /*TODO*/ }
) {
Text(text = "CANCEL")
}
Spacer(modifier = Modifier.height(80.dp))
}
}
}
So far so good. But looking at the result I noticed that when the BottomSheet
was expanded the background content color did not change!
It was clear that I was missing something, but the BottomSheetScaffold
constructor did not seem to accept any parameters which might gave me the expected behavior. After further documentation reading I noticed another Composable with a sexy name, ModalBottomSheetLayout
. Looking at the constructor of ModalBottomSheetLayout
, I thought that finally I have found what I was looking for:
@Composable
@ExperimentalMaterialApi
fun ModalBottomSheetLayout(
sheetContent: ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
sheetState: ModalBottomSheetState = rememberModalBottomSheetState(Hidden),
sheetShape: Shape = MaterialTheme.shapes.large,
sheetElevation: Dp = ModalBottomSheetDefaults.Elevation,
sheetBackgroundColor: Color = MaterialTheme.colors.surface,
sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
scrimColor: Color = ModalBottomSheetDefaults.scrimColor,
content: () -> Unit
): @Composable @ExperimentalMaterialApi Unit
But as you can quickly notice this is not a Scaffold
which is capable of hosting a TopBar
, a Drawer
or a FloatingActionButton
. Moreover, it is not clear how to decide the sheetPeekHeight
as it is in BottomSheetScaffold
.
What is beautiful about Jetpack Compose is that you can read the Composable source code right in the Android Studio with a click! If you jump into the ModalBottomSheetLayout
implementation:
BoxWithConstraints(modifier) {
val fullHeight = constraints.maxHeight.toFloat()
val sheetHeightState = remember { mutableStateOf<Float?>(null) }
Box(Modifier.fillMaxSize()) {
content()
Scrim(
color = scrimColor,
onDismiss = {
if (sheetState.confirmStateChange(Hidden)) {
scope.launch { sheetState.hide() }
}
},
visible = sheetState.targetValue != Hidden
)
}
/* Other Composables...*/
}
you can see another interesting Composable Scrim
, which provides us what we want. But where is the catch? It is a private Composable defined in ModalBottomSheet.kt
! Is it a problem though? Of course not, with the power of copy-paste we can have our public Scrim
to use wherever we want!
So our Scrim
Composable looks like this:
@Composable
fun Scrim(
color: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
onDismiss: () -> Unit,
visible: Boolean
) {
if (color.isSpecified) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = TweenSpec()
)
val closeSheet = "2"
val dismissModifier = if (visible) {
Modifier
.pointerInput(onDismiss) { detectTapGestures { onDismiss() } }
.semantics(mergeDescendants = true) {
contentDescription = closeSheet
onClick { onDismiss(); true }
}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissModifier)
) {
drawRect(color = color, alpha = alpha)
}
}
}
This is the same that is used in the ModalBottomSheet
except with a difference: we should define val closeSheet = "2"
as a hard-coded String as the original one defines it val closeSheet = getString(Strings.CloseSheet)
with the Strings.CloseSheet
being an internal component!
You might be saying "Ok, we got it. But how should we use this information in real life?" and that is the point where we compose them all! (:partyparrot:)
Our Composable looks like this after we put it all together:
@ExperimentalMaterialApi
@Composable
fun BottomSheet(
modifier: Modifier = Modifier,
scope: CoroutineScope = rememberCoroutineScope(),
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = BottomSheetState(initialValue = BottomSheetValue.Collapsed)
),
content: @Composable () -> Unit
) {
BottomSheetScaffold(
modifier = modifier,
sheetContent = { BottomSheetContent() },
scaffoldState = scaffoldState,
sheetPeekHeight = 0.dp,
sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
) {
BoxWithConstraints(modifier) {
Box(Modifier.fillMaxSize()) {
content()
Scrim(
onDismiss = {
if (scaffoldState.bottomSheetState.targetValue == BottomSheetValue.Collapsed) {
scope.launch { scaffoldState.bottomSheetState.collapse() }
}
},
visible = scaffoldState.bottomSheetState.targetValue != BottomSheetValue.Collapsed
)
}
}
}
}
You might be asking where did the BoxWithConstraints
came from? If you remember the ModalBottomSheetLayout
implementation it uses that as the root element in order to coordinate the Scrim
with the content
. In fact, if we read the BoxWithConstraints
documentation we may confirm this information:
When the content has more than one layout child the layout children will be stacked one on top of the other in the composition order.
I hope you enjoyed reading this article and it may be a reference if you ever have to suffer like I did! I would be grateful to hear from you, so please do not hesitate to give feedback! The source code might be found here.