Post

Curious case of missing Bottom Sheet Previews

While working on a Jetpack Compose project, I noticed my modal bottom sheet wasn’t appearing in Android Studio previews. I could preview the sheet’s contents by extracting them into a separate composable, but the full bottom sheet, including its scrim and drag handle, was invisible. This was an issue because I needed to verify the entire UI for design and screenshot tests.

Why Full Previews Matter

Previewing the entire bottom sheet is essential to verify:

  • Scrim Color and Opacity: Ensures the overlay behind the sheet has the correct appearance.
  • Drag Handle Appearance: Confirms the drag handle is styled and positioned correctly.
  • Overlay Behavior: Verifies how the sheet appears and how the content being overlaid looks

Full functioning previews also mostly imply compatibility with screenshot tests

The Issue

The problem stems from rememberModalBottomSheetState, which initializes the bottom sheet to a Hidden state. The ModalBottomSheet composable uses a LaunchedEffect to call sheetState.show() and make the sheet visible:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
@ExperimentalMaterial3Api
fun ModalBottomSheet(
    onDismissRequest: () -> Unit,
    modifier: Modifier = Modifier,
    sheetState: SheetState = rememberModalBottomSheetState(),
    // ... other parameters ...
) {
    // ... implementation ...
    if (sheetState.hasExpandedState) {
        LaunchedEffect(sheetState) { sheetState.show() }
    }
    // ... rest of the implementation ...
}

Since previews don’t execute LaunchedEffect, the sheet remains hidden. The rememberModalBottomSheetState function sets the initial state to Hidden and doesn’t allow customization:

1
2
3
4
5
6
7
8
9
10
11
@Composable
@ExperimentalMaterial3Api
fun rememberModalBottomSheetState(
    skipPartiallyExpanded: Boolean = false,
    confirmValueChange: (SheetValue) -> Boolean = { true }
) = rememberSheetState(
    skipPartiallyExpanded = skipPartiallyExpanded,
    confirmValueChange = confirmValueChange,
    initialValue = Hidden
)

The rememberSheetState function it calls is internal, so we can’t modify the initial state directly:

1
2
3
4
5
6
7
8
9
10
11
@Composable
@ExperimentalMaterial3Api
internal fun rememberSheetState(
    skipPartiallyExpanded: Boolean = false,
    confirmValueChange: (SheetValue) -> Boolean = { true },
    initialValue: SheetValue = Hidden,
    skipHiddenState: Boolean = false,
    positionalThreshold: Dp = BottomSheetDefaults.PositionalThreshold,
    velocityThreshold: Dp = BottomSheetDefaults.VelocityThreshold,
): SheetState

The Solution

For previews, use rememberStandardBottomSheetState, which initializes the sheet to a PartiallyExpanded state, making it visible without relying on side effects:

1
2
3
4
5
6
7
8
9
10
11
12
@Composable
@ExperimentalMaterial3Api
fun rememberStandardBottomSheetState(
    initialValue: SheetValue = PartiallyExpanded,
    confirmValueChange: (SheetValue) -> Boolean = { true },
    skipHiddenState: Boolean = true,
) = rememberSheetState(
    confirmValueChange = confirmValueChange,
    initialValue = initialValue,
    skipHiddenState = skipHiddenState
)

If your sheet is tall, the you can specify the initialValue as SheetValue.Expanded to get the full sheet in preview.

So instead of relying on separating the contents of the bottom sheet to its own composable, just for previews, we can structure the composable to accept a sheetState parameter, defaulting to rememberModalBottomSheetState for production, but using rememberStandardBottomSheetState in previews:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Composable
fun MyBottomSheet(
    onDismiss: () -> Unit,
    sheetState: SheetState = rememberModalBottomSheetState()
) {
    ModalBottomSheet(
        onDismissRequest = onDismiss,
        sheetState = sheetState,
        // other parameters...
    ) {
        // content...
    }
}

@Preview
@Composable
fun MyBottomSheetPreview() {
    MyBottomSheet(
        onDismiss = {},
        sheetState = rememberStandardBottomSheetState()
    )
}

Alternatively, you could create a custom SheetState object:

1
2
3
4
5
6
7
8
9
class SheetState(
    internal val skipPartiallyExpanded: Boolean,
    positionalThreshold: () -> Float,
    velocityThreshold: () -> Float,
    initialValue: SheetValue = Hidden,
    internal val confirmValueChange: (SheetValue) -> Boolean = { true },
    internal val skipHiddenState: Boolean = false,
)

However, rememberStandardBottomSheetState is simpler (personal opinion) and sufficient for previews.

Why Not Use rememberStandardBottomSheetState in Production?

Using rememberStandardBottomSheetState in production is problematic because it starts the sheet in a PartiallyExpanded state, making it visible immediately. Modal bottom sheets should start hidden and animate in for a better UX. rememberModalBottomSheetState ensures this by initializing the sheet to Hidden and then animating the sheet in via sheetState.show().

Conclusion

To render bottom sheet previews in Jetpack Compose, use rememberStandardBottomSheetState to make the sheet visible in Android Studio previews and screenshot tests. In production, use rememberModalBottomSheetState for a hidden initial state and smooth animations. This approach ensures accurate design verification and reliable screenshot tests, capturing the full UI as users will experience it.

This post is licensed under CC BY 4.0 by the author.