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!

BottomSheet.gif

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
                )
            }
        }
    }
}

Talk is cheap, show me the gif.gif

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.

Please note that when this article was written, the latest Jetpack Compose version was 1.0.0. All this stuff may be fixed in the future, making this article a funny memory.