Illustrations by Pavlo Stavytskyi

Advanced Bottom Sheet With Flexible Configuration for Compose

Using AnchoredDraggable and SubcomposeLayout

Published in
9 min readJun 5, 2024

--

Turo empowers users to rent cars for a wide variety of trips, from quick weekend getaways to epic cross-country adventures. Among such diverse journeys, those that originate at airport locations are particularly significant, representing approximately 36% of Turo’s total trip revenue.

Given the significant impact of airport trips, Turo has launched a major initiative to streamline airport pickups and returns, with a focus on enhancing the experience to make every step of the process seamless and stress-free. The feature is coming soon, so stay tuned for updates!

In the redesigned airport trip experience, bottom sheets play a crucial role in guiding Turo guests and hosts through the parking process. Although Google offers a Material 3 bottom sheet implementation for Jetpack Compose on Android, we encountered limitations that prevented us from fully realizing our design vision.

  • More than 2 expanded states. We needed 3 expanded states which is not supported by the original bottom sheet implementation as it offers a maximum of 2.
  • Dynamic state changes. We need to be able to dynamically change the number of bottom sheet states and their height while it is being used.

In this blog post, I’ll demonstrate a custom bottom sheet built for Compose. While leveraging the foundation of the original Material 3 implementation, it enhances functionality and flexibility, addressing the constraints above. We’ll explore its versatile API and the inner workings, which rely on low-level Compose components like AnchoredDraggable and SubcomposeLayout.

If you’d like to see the full source code from this post and use it in your project, check out the advanced-bottomsheet-compose repository on GitHub.

Overview

More than 2 expanded states

Below is an example of a bottom sheet with 3 states. We use it to display parking location details to Turo guests.

UI design by Yifan Zhao

The API is very similar to the original BottomSheetScaffold but adds more flexibility during the configuration. Notably, it lifts the restriction of a maximum of 2 expanded states.

First, we declare an enum that represents each state of a bottom sheet.

enum class SheetValue { Collapsed, PartiallyExpanded, Expanded }

Bottom sheet state and value are used interchangeably in the context of this blog post.

Then we use a rememberBottomSheetState function to map bottom sheet states to their positions on the screen.

val sheetState = rememberBottomSheetState(
initialValue = SheetValue.PartiallyExpanded,
defineValues = {
// Bottom sheet height is 100 dp.
SheetValue.Collapsed at height(100.dp)
// Bottom sheet offset is 60% meaning it takes 40% of the screen height.
SheetValue.PartiallyExpanded at offset(percent = 60)
// Bottom sheet wraps its content when expanded.
SheetValue.Expanded at contentHeight
}
)

Each bottom sheet state can be configured either by declaring its height or setting an offset from the top of the screen. It is allowed to use pixels, dp, or percentages during the configuration.

height(dp = 200.dp)
height(px = 550f)
height(percent = 45)

offset(dp = 200.dp)
offset(px = 550f)
offset(percent = 45)

contentHeight

In addition, there is a special contentHeight value that allows the bottom sheet to wrap its content.

The final step is identical to the one used with the original BottomSheetScaffold.

val scaffoldState = rememberBottomSheetScaffoldState(sheetState)

BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetContent = {
// Composable bottom sheet content.
},
content = {
// Composable screen content.
},
)

Adding or removing the bottom sheet states

A more advanced use case involves dynamically reconfiguring the bottom sheet while the user interacts with it, such as adding or removing states while it’s being dragged. Consider the example below.

// --(1)--
var isInitialState by remember { mutableStateOf(true) }

val state = rememberBottomSheetState(
initialValue = SheetValue.PartiallyExpanded,
defineValues = {
SheetValue.Collapsed at height(200.dp)
// --(2)--
if (isInitialState) {
SheetValue.PartiallyExpanded at height(percent = 50)
}
SheetValue.Expanded at contentHeight
},
confirmValueChange {
if (isInitialState) {
isInitialState = false
// --(3)--
refreshValues()
}
true
}
)
  1. We declare a mutable variable to determine when to include or exclude a specific bottom sheet state.
  2. Then, add or skip a particular bottom sheet state based on the variable.
  3. Finally, we call refreshValues to invoke defineValues lambda again and reconfigure the bottom sheet when needed. The refreshValues is a member function of a BottomSheetState.

In the code snippet above, the bottom sheet initially has 3 states and is set to PartiallyExpanded, but only the Collapsed or Expanded states are available once dragged by the user.

Changing the height of existing states.

While Turo guests can use the bottom sheet to view the vehicle parking location, hosts would use the bottom sheet to configure it. Consider the example below.

UI design by Yifan Zhao
  • Images 1 and 3 show the same PartiallyExpanded bottom sheet state. In image 1 the sheet occupies 40% of the screen while in image 3 it wraps the content height. The red banner in the UI is optional and may not always be present. Depending on the specific business logic, there can be at least 4 distinct states with varying heights.
  • Image 2 illustrates the Expanded bottom sheet state, designed to be nearly full-screen with a slight offset at the top to provide a view of the map.

Upon a screen state change, we call BottomSheetState.refreshValues, which invokes the defineValues lambda and provides the updated dimensions of the bottom sheet content. These updated dimensions allow the height of the PartiallyExpanded state to be adjusted.

Implementation

Now, let’s talk about the technical details. If your primary goal is to use the bottom sheet in your project, refer to advanced-bottomsheet-compose GitHub repository as it contains the source code and the instructions for integrating it in your project. If you’d like to understand how it works under the hood, feel free to continue reading.

The core concept behind any bottom sheet implementation is straightforward. We manipulate the offset of its layout from the top of the screen. The larger the offset, the less of the bottom sheet is visible, and vice versa. Its height remains unchanged, while the visible portion of the bottom sheet’s layout depends on the offset.

The bottom sheet’s offset changes as the user drags it. However, instead of dragging freely, it snaps to specific positions corresponding to the defined bottom sheet states.

AnchoredDraggable

AnchoredDraggable — is a low-level component for building draggable components with anchored states in Compose. This is precisely what we need to leverage for implementing the concepts described above.

The code for the bottom sheet is pretty straightforward and comprises several steps. First, declare an enum that represents the states of a bottom sheet.

enum class SheetValue { Collapsed, PartiallyExpanded, Expanded }

Initialize AnchoredDraggableState that uses SheetValue as its T.

val state = remember {
AnchoredDraggableState<SheetValue>(
initialValue = SheetValue.PartiallyExpanded,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
animationSpec = SpringSpec(),
)
}

Finally, define a composable hierarchy that implements a bottom sheet in Compose.

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

// --(2)--
ScreenContent()

Surface(
modifier = Modifier
.fillMaxWidth()
// --(3)--
.offset {
val sheetOffsetY = state.requireOffset().toInt()
IntOffset(x = 0, y = sheetOffsetY)
}
// --(4)--
.anchoredDraggable(state, orientation = Orientation.Vertical)
// --(5)--
.onSizeChanged { sheetSize ->
val sheetHeight = sheetSize.height
val newAnchors = DraggableAnchors {
with(density) {
// Bottom sheet height is 56 dp.
SheetValue.Collapsed at (layoutHeight - 56.dp.toPx())
// Offset is 60% meaning the bottom sheet takes 40% of the screen height.
SheetValue.PartiallyExpanded at (layoutHeight * 0.6f)
// Bottom sheet height is equal to the height of its content.
// If the height of the content is bigger than the screen - fill the entire screen.
SheetValue.Expanded at maxOf(layoutHeight - sheetHeight, 0).toFloat()
}
}
state.updateAnchors(newAnchors, state.targetValue)
},
) {
// --(6)--
BottomSheetContent()
}
}

Here is what’s going on in the code above.

  1. We wrap everything with BoxWithConstraints so that we can measure the layoutHeight of a screen. This will be handy when calculating the positions of bottom sheet states further.
  2. Render the ScreenContent behind the bottom sheet.
  3. Apply the current bottom sheet offset to its layout using the AnchoredDraggableState.
  4. Enable bottom sheet dragging by using anchoredDraggable modifier. This will update the offset as soon as the bottom sheet is dragged.
  5. The crucial step is configuring the bottom sheet states and applying them to the AnchoredDraggableState. We do this in onSizeChanged, which conveniently provides the bottom sheet layout's height, essential for subsequent calculations. Compose calls onSizeChanged both when the screen first opens and whenever the bottom sheet content's size changes.
  6. Finally, we render the actual BottomSheetContent.

This comprises most of the code required to implement a fully functional bottom sheet. The remaining step is to enable nested scrolling, which is detailed below. Everything else pertains to improvements for code organization and the public API.

Nested scrolling

If the content within your bottom sheet exceeds its height, you’ll need to implement nested scrolling. I won’t cover this in detail here, but we’re using a BottomSheetNestedScrollConnection, which is a slightly modified version of the code from the original Material 3 bottom sheet implementation.

// --(1)--
val scope = rememberCoroutineScope()

// --(2)--
val nestedScrollConnection = remember(state) {
BottomSheetNestedScrollConnection(
state = state,
orientation = Orientation.Vertical,
onFling = { velocity ->
scope.launch { state.settle(velocity) }
}
)
}

Surface(
modifier = Modifier
// --(3)--
.nestedScroll(nestedScrollConnection)
.offset {...}
.anchoredDraggable(...)
.onSizeChanged {...}
) {
...
}

Enabling nested scrolling in the bottom sheet requires the following steps.

  1. Create a coroutine scope instance.
  2. Instantiate a BottomSheetNestedScrollConnection. Use the coroutine scope to help the anchored draggable settle into specific bottom sheet states during nested scrolling
  3. Apply the nestedScroll modifier to the bottom sheet layout.

The code within the offset, anchoredDraggable, and onSizeChanged modifiers remains unchanged from the previous section.

SubcomposeLayout

SubcomposeLayout — is a layout in Compose that allows for the measurement of its child elements before using that data to compose other children. This makes it particularly useful for handling dynamic content, where the size of elements might not be known beforehand.

To be completely honest, SubcomposeLayout is not strictly necessary for the bottom sheet’s functionality. You can likely implement it without this component. However, it offers advantages in terms of code organization and overall scalability

@Composable
fun BottomSheetScaffold(
state: AnchoredDraggableState<SheetValue>,
sheetContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
// --(1)--
SubcomposeLayout { constraints ->
val layoutWidth = constraints.maxWidth
val layoutHeight = constraints.maxHeight

// --(2)--
val sheetPlaceable = subcompose(slotId = "sheet") {
Surface(
modifier = Modifier
.anchoredDraggable(...)
.onSizeChanged {...}
.nestedScroll(...)
) {
sheetContent()
}
}[0].measure(constraints)

val bodyPlaceable = subcompose(slotId = "body") {
Surface(modifier = modifier) {
content()
}
}[0].measure(constraints)

// --(3)--
layout(layoutWidth, layoutHeight) {
val sheetOffsetY = state.requireOffset().roundToInt()
val sheetOffsetX = 0

bodyPlaceable.placeRelative(x = 0, y = 0)
sheetPlaceable.placeRelative(sheetOffsetX, sheetOffsetY)
}
}
}

Here is how to implement a bottom sheet with a SubcomposeLayout.

  1. Wrap everything with a SubcomposeLayout composable.
  2. We measure the sheet content and screen content in their respective subcompose slots. Although subcompose returns a list of Measurable elements, we are using only a single top-level composable within each subcompose call. Therefore, we measure only the first element in each list, accessed with the index [0].
  3. Place composables using the latest offset. The lambda in the layout function will be called every time the bottom sheet offset changes.

In the full implementation of a bottom sheet, the SubcomposeLayout manages the screen content, sheet content, top bar, and snackbar host. Using SubcomposeLayout in this scenario enables efficient measurement and placement of these 4 elements. The code for this implementation can be found here.

Organizing code

The final step involves organizing the code into distinct composable functions internally to achieve better separation of concerns, improve scalability, and provide more customizable public properties. The API of the resulting composables is designed to closely resemble the original BottomSheetScaffold implementation and can be found here.

You can access the bottom sheet’s source code and a sample project demonstrating the steps outlined in this article in the advanced-bottomsheet-compose GitHub repository.

--

--

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