Building a Google Maps Style Bottom Sheet with Jetpack Compose

skydovesJaewoong Eum (skydoves)||11 min read

Building a Google Maps Style Bottom Sheet with Jetpack Compose

Google Maps popularized a bottom sheet pattern that most Android developers recognize immediately: a small panel peeking from the bottom of the screen, expandable to a mid height for quick details, and draggable to full screen for comprehensive information. The user interacts with the map behind the sheet at all times. This pattern looks simple on the surface, but implementing it correctly requires solving several problems: multi state anchoring, non modal interaction, dynamic content adaptation, and nested scroll coordination. Jetpack Compose's standard ModalBottomSheet only supports two states (expanded and hidden) and blocks background interaction with a scrim, making it unsuitable for this use case.

In this article, you'll explore how to build a Google Maps style bottom sheet using FlexibleBottomSheet, covering how to configure three expansion states with custom height ratios, how to enable non modal mode so users can interact with the content behind the sheet, how to adapt your UI dynamically based on the sheet's current state, how to control state transitions programmatically, how to handle nested scrolling inside the sheet, and how to wrap content dynamically for variable height sheets.

Why ModalBottomSheet falls short

Consider the standard Material 3 bottom sheet:

@Composable
fun StandardBottomSheet() {
    ModalBottomSheet(
        onDismissRequest = { /* dismiss */ },
        sheetState = rememberModalBottomSheetState(),
    ) {
        Text("Content here")
    }
}

This gives you two states: expanded and hidden. The sheet covers the background with a scrim, blocking all interaction behind it. For a confirmation dialog or action menu, this is fine. But for a Google Maps style experience, you need:

  1. Three visible states: A peek height showing a summary, a mid height for details, and a full height for comprehensive content.
  2. No scrim: The map behind the sheet must remain fully interactive.
  3. Dynamic content: The content should adapt based on the current expansion state.
  4. Nested scrolling: Scrollable content inside the fully expanded sheet should scroll naturally, and dragging down from the top of the scroll should collapse the sheet.

FlexibleBottomSheet addresses all of these.

Setting up a three state bottom sheet

The core of the Google Maps pattern is three distinct expansion levels. FlexibleBottomSheet models these with the FlexibleSheetValue enum:

  • SlightlyExpanded: The peek state, a small panel at the bottom showing a summary.
  • IntermediatelyExpanded: The mid height state for quick details.
  • FullyExpanded: The full height state for comprehensive content.
  • Hidden: The sheet is not visible.

To enable all three visible states, configure rememberFlexibleBottomSheetState with skipSlightlyExpanded = false (it defaults to true) and define size ratios through FlexibleSheetSize:

@Composable
fun GoogleMapsSheet(onDismissRequest: () -> Unit) {
    FlexibleBottomSheet(
        onDismissRequest = onDismissRequest,
        sheetState = rememberFlexibleBottomSheetState(
            flexibleSheetSize = FlexibleSheetSize(
                fullyExpanded = 0.9f,
                intermediatelyExpanded = 0.5f,
                slightlyExpanded = 0.18f,
            ),
            isModal = false,
            skipSlightlyExpanded = false,
        ),
        containerColor = Color.Black,
    ) {
        Text("Sheet content", color = Color.White)
    }
}

Each FlexibleSheetSize value represents a fraction of the screen height. Setting fullyExpanded = 0.9f means the sheet occupies 90% of the screen at full expansion, leaving a sliver of the map visible at the top. Setting slightlyExpanded = 0.18f creates a small peek panel at 18% of the screen.

The isModal = false flag is what makes this behave like Google Maps. Without it, the sheet renders a scrim over the background and blocks touch events.

image

Customizing expansion heights

The default FlexibleSheetSize uses ratios of 1.0, 0.5, and 0.25. For a Google Maps style experience, you'll want to tune these to match your content.

A few common configurations:

// Google Maps style: peek, half, almost full
FlexibleSheetSize(
    fullyExpanded = 0.9f,
    intermediatelyExpanded = 0.5f,
    slightlyExpanded = 0.18f,
)

// Music player style: small bar, half, full
FlexibleSheetSize(
    fullyExpanded = 1.0f,
    intermediatelyExpanded = 0.5f,
    slightlyExpanded = 0.1f,
)

// Two states only (skip the intermediate)
FlexibleSheetSize(
    fullyExpanded = 0.85f,
    slightlyExpanded = 0.15f,
)

image

For the two state configuration, set skipIntermediatelyExpanded = true so the sheet transitions directly between peek and full:

rememberFlexibleBottomSheetState(
    flexibleSheetSize = FlexibleSheetSize(
        fullyExpanded = 0.85f,
        slightlyExpanded = 0.15f,
    ),
    isModal = false,
    skipSlightlyExpanded = false,
    skipIntermediatelyExpanded = true,
)

You can also skip the hidden state entirely with skipHiddenState = true, which keeps the sheet always visible. This is useful when the sheet is a permanent part of the UI, like a map detail panel that can never be fully dismissed.

Adapting content based on sheet state

Google Maps shows different content at each expansion level: a place name when peeked, photos and ratings when half expanded, and reviews and details when fully expanded. FlexibleBottomSheet supports this through the onTargetChanges callback:

@Composable
fun GoogleMapsSheet(onDismissRequest: () -> Unit) {
    var targetValue by remember {
        mutableStateOf(FlexibleSheetValue.IntermediatelyExpanded)
    }

    FlexibleBottomSheet(
        onDismissRequest = onDismissRequest,
        sheetState = rememberFlexibleBottomSheetState(
            flexibleSheetSize = FlexibleSheetSize(
                fullyExpanded = 0.9f,
                intermediatelyExpanded = 0.5f,
                slightlyExpanded = 0.18f,
            ),
            isModal = false,
            skipSlightlyExpanded = false,
        ),
        onTargetChanges = { targetValue = it },
        containerColor = Color.Black,
    ) {
        when (targetValue) {
            FlexibleSheetValue.SlightlyExpanded -> CompactSummary()
            FlexibleSheetValue.IntermediatelyExpanded -> DetailedInfo()
            FlexibleSheetValue.FullyExpanded -> FullContent()
            FlexibleSheetValue.Hidden -> {}
        }
    }
}

The callback fires when targetValue changes, not currentValue. The distinction matters: targetValue represents where the sheet is heading during a drag gesture or animation, while currentValue represents where it was before the gesture started. Using targetValue means the content updates as soon as the user begins dragging toward a new state, creating a responsive feel rather than waiting for the animation to complete.

You can also use targetValue for more subtle adaptations without swapping entire composables:

Text(
    text = "Central Park, New York",
    fontSize = 16.sp,
    fontWeight = FontWeight.Bold,
    maxLines = if (targetValue == FlexibleSheetValue.SlightlyExpanded) 1 else 3,
    overflow = TextOverflow.Ellipsis,
    color = Color.White,
)

When peeked, the title truncates to a single line. When expanded, it shows the full text. This pattern is lightweight and avoids layout shifts.

Controlling state programmatically

Beyond drag gestures, you can control the sheet state programmatically. The FlexibleSheetState provides suspend functions for each transition:

val sheetState = rememberFlexibleBottomSheetState(
    isModal = false,
    skipSlightlyExpanded = false,
)
val scope = rememberCoroutineScope()

// Expand to full height
Button(onClick = { scope.launch { sheetState.fullyExpand() } }) {
    Text("Expand")
}

// Collapse to peek state
Button(onClick = { scope.launch { sheetState.slightlyExpand() } }) {
    Text("Collapse")
}

// Hide completely
Button(onClick = { scope.launch { sheetState.hide() } }) {
    Text("Hide")
}

// Show at the best available state
Button(onClick = { scope.launch { sheetState.show() } }) {
    Text("Show")
}

The show() function is particularly useful. Without a target parameter, it selects the best available state automatically: IntermediatelyExpanded if available, then SlightlyExpanded, then FullyExpanded. You can also pass a specific target:

scope.launch { sheetState.show(FlexibleSheetValue.SlightlyExpanded) }

You can also read the current state to update other parts of your UI:

val isSheetVisible = sheetState.isVisible
val currentState = sheetState.currentValue
val targetState = sheetState.targetValue

Vetoing state changes

The confirmValueChange callback lets you prevent certain state transitions. For example, to prevent the sheet from being hidden:

rememberFlexibleBottomSheetState(
    confirmValueChange = { newValue ->
        newValue != FlexibleSheetValue.Hidden
    },
)

When the user tries to drag the sheet below the lowest anchor, the gesture is rejected and the sheet snaps back. This is useful for sheets that should always remain visible, similar to skipHiddenState = true but with the flexibility to allow programmatic hiding while blocking gesture based hiding.

Handling nested scrolling

When you place a LazyColumn or other scrollable content inside the sheet, FlexibleBottomSheet handles the scroll coordination automatically. Dragging up on the list expands the sheet first, and only after the sheet is fully expanded does the list start scrolling:

FlexibleBottomSheet(
    onDismissRequest = onDismissRequest,
    sheetState = rememberFlexibleBottomSheetState(
        flexibleSheetSize = FlexibleSheetSize(
            fullyExpanded = 0.9f,
            intermediatelyExpanded = 0.5f,
            slightlyExpanded = 0.18f,
        ),
        isModal = false,
        skipSlightlyExpanded = false,
        allowNestedScroll = true, // enabled by default
    ),
) {
    LazyVerticalGrid(
        modifier = Modifier.fillMaxSize(),
        columns = GridCells.Fixed(2),
    ) {
        items(items = posters, key = { it.name }) { poster ->
            PosterImage(poster = poster)
        }
    }
}

The allowNestedScroll parameter (defaulting to true) enables this coordination. When set to false, the inner content scrolls independently and the sheet can only be dragged by the drag handle or areas outside the scrollable content.

The scroll priority works as follows: dragging up always expands the sheet first. Once fully expanded, upward drag scrolls the list. Dragging down on a non fully expanded sheet collapses it. Dragging down on a fully expanded sheet scrolls the list to the top first, then collapses the sheet.

Using wrap content mode

Sometimes you don't want fixed height ratios. For sheets with variable content, FlexibleSheetSize.WrapContent sizes the sheet to fit its content:

rememberFlexibleBottomSheetState(
    flexibleSheetSize = FlexibleSheetSize(
        fullyExpanded = FlexibleSheetSize.WrapContent,
        intermediatelyExpanded = 0.5f,
        slightlyExpanded = FlexibleSheetSize.WrapContent,
    ),
    isModal = false,
    skipSlightlyExpanded = false,
)

You can mix wrap content with fixed ratios. In this example, the peek and full states size to their content while the intermediate state stays at 50% of the screen. This is useful when the peek state shows a small summary of variable length and the full state shows complete details that vary by item.

The sheet will never exceed the screen height, even in wrap content mode. If the content is taller than the screen, the sheet caps at full screen height and the content scrolls inside.

Setting the initial state

By default, the sheet starts in FlexibleSheetValue.Hidden and animates to the first available state. You can change this by setting initialValue:

rememberFlexibleBottomSheetState(
    initialValue = FlexibleSheetValue.SlightlyExpanded,
    isModal = false,
    skipSlightlyExpanded = false,
)

The sheet will appear at the peek height immediately, without an entrance animation. This is useful when the sheet is an integral part of the screen rather than a response to a user action.

The initial value must be consistent with the skip flags. Setting initialValue = FlexibleSheetValue.SlightlyExpanded while skipSlightlyExpanded = true will throw an IllegalArgumentException at composition time, catching the misconfiguration early.

Putting it all together

Here's a complete Google Maps style implementation that combines all the concepts:

@Composable
fun PlaceDetailScreen() {
    var targetValue by remember {
        mutableStateOf(FlexibleSheetValue.SlightlyExpanded)
    }

    Box(modifier = Modifier.fillMaxSize()) {
        // Map content behind the sheet
        MapView(modifier = Modifier.fillMaxSize())

        // Non-modal bottom sheet with three states
        FlexibleBottomSheet(
            onDismissRequest = { /* handle dismiss */ },
            sheetState = rememberFlexibleBottomSheetState(
                flexibleSheetSize = FlexibleSheetSize(
                    fullyExpanded = 0.9f,
                    intermediatelyExpanded = 0.5f,
                    slightlyExpanded = 0.15f,
                ),
                isModal = false,
                skipSlightlyExpanded = false,
                skipHiddenState = true,
            ),
            onTargetChanges = { targetValue = it },
            containerColor = Color.White,
            shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
        ) {
            PlaceContent(targetValue = targetValue)
        }
    }
}

@Composable
private fun ColumnScope.PlaceContent(targetValue: FlexibleSheetValue) {
    // Header: always visible
    Row(
        modifier = Modifier.fillMaxWidth().padding(16.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Column(modifier = Modifier.weight(1f)) {
            Text(
                text = "Central Park",
                fontSize = 18.sp,
                fontWeight = FontWeight.Bold,
                maxLines = 1,
            )
            Text(
                text = "4.8 stars - Park",
                fontSize = 14.sp,
                color = Color.Gray,
            )
        }
    }

    // Details: visible when intermediately or fully expanded
    if (targetValue != FlexibleSheetValue.SlightlyExpanded) {
        ActionButtons()
        Spacer(modifier = Modifier.height(8.dp))
    }

    // Full content: scrollable list when fully expanded
    if (targetValue == FlexibleSheetValue.FullyExpanded) {
        LazyColumn(modifier = Modifier.fillMaxSize()) {
            item { PlacePhotos() }
            item { PlaceReviews() }
            item { PlaceInfo() }
        }
    }
}

The skipHiddenState = true keeps the sheet always visible, matching Google Maps where the place detail panel can be collapsed but never fully dismissed. The content adapts at each level: a compact header when peeked, action buttons when half expanded, and a full scrollable list of photos, reviews, and information when fully expanded.

Conclusion

In this article, you've explored how to build a Google Maps style multi state bottom sheet using FlexibleBottomSheet. The three key configuration parameters, FlexibleSheetSize for height ratios, isModal = false for background interactivity, and skipSlightlyExpanded = false for enabling the peek state, transform a standard bottom sheet into a flexible, multi stop panel. The onTargetChanges callback enables dynamic content adaptation, making the sheet feel responsive as users drag between states. And features like programmatic state control, nested scroll coordination, and wrap content mode cover the edge cases you'll encounter in production.

Whether you're building a map overlay, a media player with a persistent mini player, a ride sharing app with a collapsible trip panel, or any interface that needs a persistent, multi state panel over interactive content, FlexibleBottomSheet provides the foundation to implement these patterns with minimal configuration and a clean Compose API.

As always, happy coding!

— Jaewoong