# Bottom Sheets that... just work

> Create the bottom sheets of your dreams using Compose Unstyled

Published 17 September 2024 by Alex Styl.

Bottom Sheets in Jetpack Compose are 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 Compose Unstyled 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 Compose Unstyled 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:

```kotlin
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.composables.core.BottomSheet
import com.composables.core.SheetDetent
import com.composables.core.SheetDetent.Companion.FullyExpanded
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.Lucide
import com.composables.core.rememberBottomSheetState
import com.composables.uripainter.rememberUriPainter
import com.composeunstyled.Icon
import com.composeunstyled.Text

COMPOSE {
    // Exact header row height: 14 top + 52 cover + 8 bottom.
    val PeekHeight = 74.dp
    val Mini = SheetDetent("mini") { _, _ ->
        PeekHeight
    }
    val sheetState = rememberBottomSheetState(
        initialDetent = Mini,
        detents = listOf(Mini, FullyExpanded),
    )
    val coverUrl = "https://images.unsplash.com/photo-1499364615650-ec38552f4f34?q=80&w=512"
    val tracks = listOf(
        "Memory Leak",
        "Out of Context",
        "Fragments of Time",
        "Android Love",
        "Mr. Roboto",
        "Invalidate"
    )

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.White),
        contentAlignment = Alignment.Center
    ) {
        BottomSheet(
            state = sheetState,
            backgroundColor = Color.White,
            contentColor = Color.Black,
            shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
            modifier = Modifier
                .dropShadow(
                    shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
                    shadow = Shadow(
                        radius = 24.dp,
                        spread = 0.dp,
                        color = Color.Black.copy(alpha = 0.14f),
                        offset = DpOffset(x = 0.dp, y = (-3).dp)
                    )
                )
                .fillMaxWidth(),
        ) {
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(520.dp),
            ) {
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .clickable {
                            if (sheetState.currentDetent != FullyExpanded) {
                                sheetState.targetDetent = FullyExpanded
                            }
                        }
                        .padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 8.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Box(
                        modifier = Modifier
                            .size(52.dp)
                            .clip(RoundedCornerShape(10.dp))
                            .background(Color(0xFFE4E4E7))
                    ) {
                        Image(
                            painter = rememberUriPainter(coverUrl),
                            contentDescription = "Album cover",
                            contentScale = ContentScale.Crop,
                            modifier = Modifier.fillMaxSize()
                        )
                    }
                    Column(
                        modifier = Modifier
                            .weight(1f)
                            .padding(horizontal = 12.dp)
                    ) {
                        Text(
                            "Just hoist it!",
                            fontWeight = FontWeight.SemiBold,
                            fontSize = 15.sp,
                            maxLines = 1,
                            overflow = TextOverflow.Ellipsis
                        )
                        Text(
                            "The @Deprecated",
                            color = Color(0xFF71717A),
                            fontSize = 13.sp,
                            maxLines = 1,
                            overflow = TextOverflow.Ellipsis
                        )
                    }
                    Box(
                        modifier = Modifier
                            .size(38.dp)
                            .clip(RoundedCornerShape(100))
                            .background(Color.White.copy(alpha = 0.92f)),
                        contentAlignment = Alignment.Center
                    ) {
                        Icon(
                            Lucide.Play,
                            contentDescription = "Play",
                            tint = Color(0xFF18181B),
                            modifier = Modifier.size(18.dp)
                        )
                    }
                }
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(1.dp)
                        .background(Color(0xFFE4E4E7))
                )

                LazyColumn(
                    modifier = Modifier
                        .fillMaxWidth()
                        .weight(1f)
                ) {
                    items(count = tracks.size) { index ->
                        val title = tracks[index]
                        Row(
                            modifier = Modifier
                                .fillMaxWidth()
                                .defaultMinSize(minHeight = 56.dp)
                                .padding(horizontal = 16.dp),
                            verticalAlignment = Alignment.CenterVertically
                        ) {
                            Text(
                                "${index + 1}",
                                color = Color(0xFF71717A),
                                fontSize = 12.sp,
                                modifier = Modifier.padding(end = 12.dp)
                            )
                            Text(
                                title,
                                fontSize = 14.sp,
                                modifier = Modifier.weight(1f)
                            )
                            Text(
                                "3:${10 + index}",
                                color = Color(0xFF71717A),
                                fontSize = 12.sp
                            )
                        }
                    }
                }
            }
        }
    }
}
```

## How is Compose Unstyled different to other Compose libraries

All components that come with Compose Unstyled 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 Compose Unstyled

Add the latest dependency in your app module. You can find the latest version [in the official documentation](https://composablescore.com/).

```kotlin
// build.gradle.kts

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

## How to build a Bottom Sheet using Compose Unstyled

### 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.

<div class='border border-1 border-blue-500/50 rounded-xl p-4 flex flex-col unstyled-blog-callout'>
    <span class='titleLarge'>de·tent</span>
    <span class='bodyLarge'>/dəˈtent/</span>
    <span class='mt-4 text-gray-400 italic unstyled-blog-callout-muted'>noun</span>
    <div class='bodyMedium ml-6 unstyled-blog-callout-def'>a catch in a machine which prevents motion until released.</div>
    <div class='bodyMedium ml-6 mt-1 text-gray-400 unstyled-blog-callout-def unstyled-blog-callout-def-muted'>"a system of detents is used to ensure that gears are held in
        whatever position is selected"
    </div>
</div>

Here is an unstyled example of how to setup a bottom sheet. <span class='underline unstyled-blog-underline'>It will show nothing on the screen</span> but it's good to understand the core concepts:

```kotlin
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:

```kotlin
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:

```kotlin
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.

Compose Unstyled'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:

```kotlin
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](#demo) from earlier? Here is the full code to re-create it:

```kotlin
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 Compose Unstyled

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.

Compose Unstyled 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:

```kotlin
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, Compose Unstyled sheets good. Compose Unstyled sheets are super simple to customize that fit your app's design needs. It comes with two versions: regular and modal.
