Illustrations by Pavlo Stavytskyi

Adjusting Compose Google Map While Bottom Sheet Moves

To resize or not to resize…

Published in
7 min readJun 5, 2024

--

Maps and bottom sheets are a popular combination in Android apps because they work well together. Maps show you where things are, and bottom sheets give you extra details about those places. This saves screen space while keeping the map easy to see. Users can quickly switch between seeing the big picture on the map and getting more information from the bottom sheet.

When implementing a map and bottom sheet combination, there are a few considerations to keep in mind.

  • Map controls position. Ensure map UI controls (e.g. zoom buttons, Google logo, etc) move along with the bottom sheet to prevent them from being covered.
  • Map camera position. Adjust the camera focus when the map’s visible center changes due to bottom sheet movement. This ensures the previous center point remains centered after the bottom sheet’s state changes.

You might be wondering how to implement this functionality. Should the map be resized alongside the bottom sheet, as the title suggests, or is there a more efficient approach?

In a previous blog post, I detailed a custom bottom sheet implementation and how it overcomes limitations found in the official Material 3 version. At Turo, we utilize this enhanced bottom sheet to streamline the parking location experience for both hosts and guests at airport locations.

In this story, I’ll focus on the specifics of implementing interactions between maps and bottom sheets, particularly focusing on Google Maps.

UI design by Yifan Zhao

You can find the sample source code in the advanced-bottomsheet-compose repository on GitHub.

Map controls position

When the bottom sheet moves and changes state, it’s crucial to adjust the Google Map’s UI overlay components (e.g., zoom buttons, Google logo) to prevent them from being obscured. This ensures compliance with Google Maps’ terms of service, which require the logo to remain visible. In the demo above, the Google logo is part of the map, so it moves along with the rest of the map content.

Let’s explore how to implement this behavior in practice. First, I’ll demonstrate how to achieve this using the standard Material 3 bottom sheet. Then, you’ll see a simpler approach using the advanced bottom sheet I described in the previous blog post.

Original Material 3 bottom sheet

Let’s start with the Material 3 bottom sheet. First, initialize SheetState and BottomSheetScaffoldState.

val sheetState = rememberStandardBottomSheetState(
initialValue = SheetValue.PartiallyExpanded
)
val scaffoldState = rememberBottomSheetScaffoldState(sheetState)

Then create a composable hierarchy including BottomSheetScaffold and GoogleMap.

val density = LocalDensity.current

// --(1)--
BoxWithConstraints {
val layoutHeightPx = constraints.maxHeight

// --(2)--
val sheetHeight by remember(layoutHeightPx) {
derivedStateOf {
val sheetVisibleHeightPx = layoutHeightPx - sheetState.requireOffset()
with(density) { sheetVisibleHeightPx.roundToInt().toDp() }
}
}

// --(3)--
val paddingValues by remember {
derivedStateOf { PaddingValues(bottom = sheetHeight) }
}

BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetContent = {
BottomSheetContent()
},
content = {
val camera = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(LatLng(...), 13f)
}
// --(4)--
GoogleMap(
modifier = modifier.fillMaxSize(),
cameraPositionState = camera,
contentPadding = paddingValues
)
}
)
}
  1. We wrap everything with BoxWithConstraints so that we can measure the layout height of a screen.
  2. Now for the crucial part — calculating the visible height of the bottom sheet. By wrapping this calculation in derivedStateOf, the value will update in real-time whenever the bottom sheet's offset changes.
  3. To prevent the bottom sheet from covering the map UI controls, we need to add padding to the bottom of the map equal to the visible height of the bottom sheet. We achieve this by wrapping the calculated height value within PaddingValues, which is the required format for the GoogleMap composable.
  4. Lastly, apply the calculated bottom padding to the GoogleMap composable using its contentPadding parameter.
UI design by Yifan Zhao

In the image on the right, the bottom sheet is invisible for better demonstration purposes. Notice that when padding is applied to a GoogleMap, it affects only the UI overlay, not the entire map view. This is beneficial for performance, as the padding frequently changes during bottom sheet dragging. By repositioning only the UI overlay, we avoid the costly operation of resizing the entire map.

Advanced bottom sheet

In the previous blog post, you’ll find an advanced bottom sheet implementation that offers a more flexible API and overcomes the limitations of the original Material 3 bottom sheet. It also simplifies the retrieval of the visible height.

To begin, initialize both a BottomSheetState and a BottomSheetScaffoldState.

enum class SheetValue { PartiallyExpanded, Expanded }

val sheetState = rememberBottomSheetState(
initialValue = SheetValue.PartiallyExpanded,
defineValues = {
SheetValue.Collapsed at height(100.dp)
SheetValue.PartiallyExpanded at offset(percent = 60)
}
)
val scaffoldState = rememberBottomSheetScaffoldState(sheetState)

In this case, you can directly retrieve requireSheetVisibleHeightDp from the BottomSheetState instead of using BoxWithConstraints.

// --(1)--
val sheetHeight by remember {
derivedStateOf { sheetState.requireSheetVisibleHeightDp() }
}

// --(2)--
val paddingValues by remember {
derivedStateOf { PaddingValues(bottom = sheetHeight) }
}

BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetContent = {
BottomSheetContent()
},
content = {
val camera = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(LatLng(...), 13f)
}
// --(3)--
GoogleMap(
modifier = modifier.fillMaxSize(),
cameraPositionState = camera,
contentPadding = paddingValues
)
}
)
  1. Unlike the original Material 3 bottom sheet, you can directly obtain the visible height from the BottomSheetState. Wrapping this value in derivedStateOf ensures that sheetHeight stays updated as the bottom sheet is dragged.
  2. Next, we encapsulate the calculated bottom padding value within PaddingValues, which is the required format for the GoogleMap composable.
  3. Lastly, we apply the calculated bottom padding to the GoogleMap composable.

Map camera position

Another feature you might want to implement is automatically refocusing the map’s camera to the center after the bottom sheet is dragged. Here is how it looks.

UI design by Yifan Zhao

We’ll build upon the code from the previous section, omitting a few details for brevity. Assume these omitted parts are implemented exactly as described earlier.

val sheetState = ...
val scaffoldState = ...
val paddingValues = ...

// --(1)--
val isBottomSheetMoving by remember {
derivedStateOf { sheetState.currentValue != sheetState.targetValue }
}

BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetContent = {
BottomSheetContent()
},
content = {
// --(2)--
val camera = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(LatLng(...), 13f)
}

// --(3)--
AdjustedCameraPositionEffect(
camera = camera,
isBottomSheetMoving = isBottomSheetMoving,
bottomPadding = paddingValues.calculateBottomPadding(),
)

GoogleMap(
modifier = modifier.fillMaxSize(),
cameraPositionState = camera,
contentPadding = paddingValues
)
}
)
  1. To determine if the bottom sheet is currently being dragged, we can compare its target state with its current state. If they differ, it indicates that the bottom sheet is in motion. Utilizing derivedStateOf guarantees real-time updates of this value, allowing us to track the bottom sheet's movement accurately
  2. Next, initialize a CameraPositionState instance to adjust the camera focus later.
  3. Finally, let’s dive into the core logic. The AdjustedCameraPositionEffect is a custom composable created for demonstration purposes, and its code will be explained further in this post.

The CameraPositionState instance relies on a target location that is at the center of the camera view. When you update the bottom padding of the map, the CameraPositionState will adjust to the new location as if the bottom part of the map does not exist. Because this new location is shifted due to the bottom sheet movement, we need to revert to the previous target location.

Therefore, the core idea behind the camera updates is simple. Before the bottom sheet starts moving, we check the camera’s target location. Once the bottom sheet has finished moving, we update the camera back to the previous location, restoring the original center.

@Composable
fun AdjustedCameraPositionEffect(
camera: CameraPositionState,
isBottomSheetMoving: Boolean,
bottomPadding: Dp,
) {
val density = LocalDensity.current

// --(1)--
var cameraLocation by remember { mutableStateOf(camera.position.target) }
LaunchedEffect(camera.isMoving, camera.cameraMoveStartedReason) {
if (!camera.isMoving && camera.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) {
cameraLocation = camera.position.target
}
}

// --(2)--
var isCameraInitialized by remember { mutableStateOf(false) }
LaunchedEffect(isBottomSheetMoving) {
if (isBottomSheetMoving) return@LaunchedEffect

if (!isCameraInitialized) {
// --(3)--
isCameraInitialized = true
val verticalShiftPx = with(density) { bottomPadding.toPx() / 2 }
val update = CameraUpdateFactory.scrollBy(0f, verticalShiftPx)
camera.animate(update)
} else {
// --(4)--
val update = CameraUpdateFactory.newLatLng(cameraLocation)
camera.animate(update)
}
}
}
  1. This precondition ensures we track the latest location on the map, which may have been changed by the user’s manual gestures. This is independent of any bottom sheet movement and simply helps the camera stay updated with the user’s chosen focus point.
  2. This is the main block containing the logic. Every time the bottom sheet stops moving, we execute the camera adjustment logic detailed below.
  3. First, we need to consider a workaround for a specific case when the GoogleMap composable is being initialized. At this stage, the camera focus will be set as if there is no bottom padding, even if you set it. To address this, we need to manually adjust the camera to account for the padding and ensure the target location is correctly positioned.
  4. Finally, for the general case. After the bottom sheet moves, we animate the camera back to the last target location from before the bottom sheet started moving.

You can refer to the illustration for a visual representation of what was described above.

On a side note, if you know the height of the smallest bottom sheet state, you can set it as a static minimum bottom padding for the entire GoogleMap composable. This is represented with a blue dashed line in the diagram and helps to avoid stretching the map into areas of the screen that will never be visible to the user.

You can check the source code with full samples in the advanced-bottomsheet-compose repository on GitHub.

--

--

Staff Software Engineer at Meta • Google Developer Expert for Android, Kotlin