We just launched Compose Examples featuring over 150+ components! Check it out →

Bottom Sheets that... just work

by @alexstyl
#compose
#open-source
#composables-core
#bottom-sheets

Bottom Sheets in Jetpack Compose is a tricky business. The Material Compose ones are not customizable and people keep bumping into issues (soft-keyboards anyone?). There are 3rd party libraries but they are practically patching the issues of the Material Compose one. Except one...

Introducing the Composables Core Bottom Sheets – a unstyled Bottom Sheet component you can customize to your heart's content and build any kind of sheet you need for your apps.

Bottom Sheet live demo

Here is an interactive demo of the bottom sheet we will build in this tutorial, powered by Compose Web.

Did I mention that Composables Core is a Compose Multiplatform library yet? Components built with it can be use on any platform Compose can run on (Android, iOS, Desktop and Web):

Go ahead and drag that bottom sheet around and see how it feels:

How is Composables Core different to other Compose libraries

All components that come with Composables Core are unstyled. This means that they render nothing on the screen by design. Think of it like a library that brings in the pattern of a specific component, instead of the components itself.

Instead of giving you a Bottom Sheet component with a fixed look that you will eventually need to change anyway (as app design styles change over time), it gives you the API to build the sheets of your dreams.

All the hard work of dealing with dragging interactions, animations, dynamic content sizing, accessibility is done for you. All you need to do is decide how the sheet needs to look like, as if it was a normal foundation component (such as a Box()).

How to install Composables Core

Add the latest dependency in your app module. You can find the latest version in the official documentation.

// build.gradle.kts

dependencies {
    implementation("com.composables:core:1.11.2")
}

How to build a Bottom Sheet using Composables Core

Bottom Sheet Core concepts

The BottomSheet() component is the main component to use. Create a state object using the rememberBottomSheetState() function.

The initialDetent specifies the initial detent of the sheet, which controls where the sheet should stop when it is resting.

de·tent /dəˈtent/ noun
a catch in a machine which prevents motion until released.
"a system of detents is used to ensure that gears are held in whatever position is selected"

Here is an unstyled example of how to setup a bottom sheet. It will show nothing on the screen but it's good to understand the core concepts:

val sheetState = rememberBottomSheetState(
    initialDetent = SheetDetent.FullyExpanded,
)

BottomSheet(state = sheetState) {
    DragIndication()
}

This will cause the bottom sheet to be fully expanded, revealing its full contents, if it fits the screen.

To hide the sheet simply pass the new detent to your state by calling sheetState.currentDetent = SheetDetent.Hidden.

Last but not least, I strongly suggest to use the DragIndication() component within the contents of your sheet. Bottom sheets are not really accessible by design. They only allow for dragging interactions, making them hard to navigate to using a keyboard or screen reader. The DragIndication() fixes this issue by being a clickable element that when clicked it toggles the state's detents, which causes the sheet to expand or collapse.

How to peek the sheet (aka custom bottom sheet detents)

A common bottom sheet ux pattern is 'peeking' the sheet's contents by default. This is handy because you show to the user that the bottom sheet is there, without blocking the full screen.

Creating a custom detent is dead simple. In fact, it's as simple as create a new Kotlin object:

val Peek = SheetDetent("peek") { containerHeight, sheetHeight ->
    containerHeight * 0.6f
}

You need a name (which works as an id - also handy for debugging reasons) and a lambda which defines the detent.

This lambda can be called multiple times so make sure it returns FAST. For convenience, you have access to the sheet's container height (the parent composable the BottomSheet is placed in), and the sheet's height.

The above example shows how to create a detent which peeks the bottom sheet by 60% of the container's height.

By default, there are two detents out of the box: Hidden and FullyExpanded. You can override those detents via the rememberBottomSheetState() function:

val Peek = SheetDetent("peek") { containerHeight, sheetHeight ->
    containerHeight * 0.6f
}

@Composable
fun App() {
    val sheetState = rememberBottomSheetState(
        initialDetent = Peek,
        detents = listOf(SheetDetent.Hidden, Peek, SheetDetent.FullyExpanded)
    )
}

That's all. Now the bottom sheet has 3 different detents to stop at while resting.

Working with a soft-keyboard

One of the most miserable things in the life of an Android developer used to be handling soft keyboards in their bottom sheets. Not any more.

Composables Core's Sheets works great with soft-keyboards. You just need to use the imePadding() modifier to 'lift' the contents of your sheet when the soft-keyboard is visible.

Here is an example of a bottom sheet with a simple text field component that stays above the IME while typing:

val sheetState = rememberBottomSheetState(
    initialDetent = SheetDetent.FullyExpanded,
)

BottomSheet(
    state = sheetState,
    modifier = Modifier.imePadding().background(Color.White).fillMaxWidth(),
) {
    var value by remember { mutableStateOf("") }

    Box(Modifier.fillMaxWidth().navigationBarsPadding()) {
        BasicTextField(
            value = value,
            onValueChange = { value = it },
            modifier = Modifier.border(2.dp, Color.Black).fillMaxWidth().padding(4.dp)
        )
    }
}

Styling the Bottom Sheet

Now that you are aware of the core concepts of a bottom sheet, styling is straight forward. There is no magic here or special styling API. It works the same way you would style a simple Box().

Remember the interactive demo from earlier? Here is the full code to re-create it:

val Peek = SheetDetent("peek") { containerHeight, sheetHeight ->
    containerHeight * 0.6f
}

@Composable
fun BottomSheetDemo() {
    BoxWithConstraints(
        modifier = Modifier
            .fillMaxSize()
            .background(Brush.linearGradient(listOf(Color(0xFF800080), Color(0xFFDA70D6)))),
    ) {
        val isCompact = maxWidth < 600.dp

        val sheetState = rememberBottomSheetState(
            initialDetent = Peek,
            detents = listOf(Hidden, Peek, FullyExpanded)
        )

        Box(
            modifier = Modifier
                .align(Alignment.Center)
                .padding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal).asPaddingValues())
                .clip(RoundedCornerShape(6.dp))
                .clickable(role = Role.Button) { sheetState.currentDetent = Peek }
                .background(Color.White)
                .padding(horizontal = 14.dp, vertical = 10.dp)
        ) {
            BasicText("Show Sheet", style = TextStyle.Default.copy(fontWeight = FontWeight(500)))
        }

        BottomSheet(
            state = sheetState,
            modifier = Modifier
                .padding(top = 12.dp)
                .let { if (isCompact) it else it.padding(horizontal = 56.dp) }
                .statusBarsPadding()
                .padding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal).asPaddingValues())
                .shadow(4.dp, RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp))
                .clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp))
                .background(Color.White)
                .widthIn(max = 640.dp)
                .fillMaxWidth()
                .imePadding(),
        ) {
            Box(Modifier.fillMaxWidth().height(600.dp), contentAlignment = Alignment.TopCenter) {
                DragIndication(
                    modifier = Modifier.padding(top = 22.dp)
                        .background(Color.Black.copy(0.4f), RoundedCornerShape(100))
                        .width(32.dp)
                        .height(4.dp)
                )
            }
        }
    }
}

How to create a Modal Bottom Sheet using Composables Core

Bottom Sheets are great, but there's a good chance you don't want them to be part of your screen's layout, similar to dialogs. They are useful when you need to prompt the user to make an important decision.

Composables Core brings a ModalBottomSheet() component for such scenario. The API is very similar to the BottomSheet() component. You still control a state and you can still customize the detents as you wish.

The difference is that you have two extra components at your disposal to build your Modal Bottom Sheet with.

Here is an unstyled example of how the API looks like and talk about it right after:

val modalSheetState = rememberModalBottomSheetState(
    initialDetent = SheetDetent.FullyExpanded,
)

ModalBottomSheet(state = modalSheetState) {
    Scrim()
    Sheet {
        DragIndication()
    }
}

The ModalBottomSheet() component accepts no Modifier and it is cannot be styled. It works as the Modal (screen layer) that will hold the bottom sheet instead.

The Scrim() is a common UX pattern which dims the screen, so that the user can focus on the bottom sheet instead. This is optional and its looks and animation are fully customizable.

The Sheet() component is the actual sheet that can be dragged within the ModalBottomSheet() area. Styling the Sheet() component by passing a Modifier is like customizing the BottomSheet() component in the non-modal example.

Conclusion

tl;dr: Material Compose sheets bad, Composables Core sheets good. Composables Core sheets are super simple to customize that fit your app's design needs. It comes with two versions: regular and modal.

Styled Bottom Sheets examples

If you want to support open-source development, while getting a serious Jetpack Compose productivity boost for you or your team, checkout Compose Examples.

It's a collection of over 150+ production ready component & screen examples (including bottom sheets) you can use straight away in your apps, instead of Googling, ping-ponging ChatGPT or trying to hack Material Compose in order to style it.

Compose Examples

by @alexstyl