import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
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.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.composeunstyled.DragIndication
import com.composeunstyled.Scrim
import com.composeunstyled.Sheet
import com.composeunstyled.SheetDetent
import com.composeunstyled.SheetDetent.Companion.FullyExpanded
import com.composeunstyled.SheetDetent.Companion.Hidden
import com.composeunstyled.UnstyledModalBottomSheet
import com.composeunstyled.rememberModalBottomSheetState
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
@Composable
fun ModalBottomSheetDemo() {
val Peek = SheetDetent("peek") { containerHeight, _ ->
containerHeight * 0.6f
}
val modalSheetState = rememberModalBottomSheetState(
initialDetent = Peek,
detents = listOf(Hidden, Peek, FullyExpanded),
)
LaunchedEffect(
modalSheetState.isIdle,
modalSheetState.currentDetent,
) {
if (
modalSheetState.isIdle &&
modalSheetState.currentDetent == Hidden
) {
delay(1.seconds)
modalSheetState.targetDetent = Peek
}
}
UnstyledModalBottomSheet(
state = modalSheetState,
overlay = {
Scrim(
scrimColor = Color.Black.copy(0.3f),
enter = fadeIn(),
exit = fadeOut(),
)
},
) {
Box(
modifier = Modifier
.fillMaxWidth(),
contentAlignment = Alignment.TopCenter,
) {
Sheet(
modifier = Modifier
.widthIn(max = 640.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp))
.background(Color(0xFFF8FAFC))
.border(1.dp, Color(0xFFCACACA), RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)),
) {
Box(
modifier = Modifier.fillMaxWidth().height(600.dp),
contentAlignment = Alignment.TopCenter,
) {
DragIndication(
modifier = Modifier
.padding(top = 22.dp)
.background(Color(0xFFCACACA), RoundedCornerShape(100))
.size(32.dp, 4.dp),
indication = LocalIndication.current,
)
}
}
}
}
}Features
- Custom detents
- Custom overlay content
- Back press and outside click dismissal
- Dynamic content sizing
- Soft-keyboard support
- Scrollable content without fixed height
Installation
implementation("com.composables:composeunstyled-modal-bottom-sheet")
Anatomy
val sheetState = rememberModalBottomSheetState(
initialDetent = SheetDetent.Hidden,
)
UnstyledModalBottomSheet(state = sheetState) {
Sheet {
DragIndication()
}
}
Concepts
SheetDetentdefines a height where the sheet can rest.UnstyledModalBottomSheetrepresents the modal layer the sheet is rendered in.Sheetis the rendered bit of the sheet.DragIndicationadds an interactive handle for expand, collapse, and dismiss actions.
Accessibility
Use DragIndication when your sheet can move between multiple detents. It provides semantic expand, collapse, and dismiss actions so users can control the sheet without dragging.
Code Examples
Showing and hiding a modal bottom sheet
Use the targetDetent property to animate the modal sheet to a new detent:
val sheetState = rememberModalBottomSheetState(
initialDetent = SheetDetent.Hidden,
)
BasicText(
text = "Show sheet",
modifier = Modifier.clickable {
sheetState.targetDetent = SheetDetent.FullyExpanded
},
)
UnstyledModalBottomSheet(state = sheetState) {
Sheet {
BasicText(
text = "Hide sheet",
modifier = Modifier.clickable {
sheetState.targetDetent = SheetDetent.Hidden
},
)
}
}
Waiting for modal bottom sheet animations
Use the suspend animateTo() function to wait until the sheet animation is done:
val scope = rememberCoroutineScope()
BasicText(
text = "Show sheet",
modifier = Modifier.clickable {
scope.launch {
sheetState.animateTo(SheetDetent.FullyExpanded)
}
},
)
Opening a modal bottom sheet instantly
Use the jumpTo() function to move to a detent without animation:
BasicText(
text = "Open immediately",
modifier = Modifier.clickable {
sheetState.jumpTo(SheetDetent.FullyExpanded)
},
)
Adding an overlay behind a modal bottom sheet
Use the overlay parameter to render content behind the modal sheet. Scrim provides a ready-made overlay for modal bottom sheets.
UnstyledModalBottomSheet(
state = sheetState,
overlay = { Scrim() },
) {
Sheet {
BasicText("Sheet content")
}
}
Creating modal bottom sheets with custom detents
Use the SheetDetent constructor to create a new detent. Pass a unique identifier and a function that calculates the detent height. The calculated height cannot be smaller than 0.dp, taller than the container, or taller than the sheet content.
Keep this calculation fast. It runs during sheet measurement.
val Peek = SheetDetent("peek") { containerHeight, sheetHeight ->
minOf(containerHeight * 0.4f, sheetHeight)
}
val sheetState = rememberModalBottomSheetState(
initialDetent = SheetDetent.Hidden,
detents = listOf(SheetDetent.Hidden, Peek, SheetDetent.FullyExpanded),
)
Updating modal bottom sheet detents after layout changes
Use the invalidateDetents() function to recalculate sheet detents. This is useful when a custom detent reads a measured value that can change, such as a header height:
var peekHeight by remember { mutableStateOf(120.dp) }
val Peek = remember {
SheetDetent("peek") { _, _ -> peekHeight }
}
val sheetState = rememberModalBottomSheetState(
initialDetent = Peek,
detents = listOf(Peek, SheetDetent.FullyExpanded),
)
LaunchedEffect(peekHeight) {
sheetState.invalidateDetents()
}
Building modal bottom sheets with scrollable content
Use a scrollable layout inside the Sheet component to make content scroll within the current detent height:
Moving a modal bottom sheet above the soft keyboard
Use the offsetForIme parameter on ModalBottomSheetProperties to automatically move the sheet above the soft keyboard:
val textState = rememberTextFieldState()
UnstyledModalBottomSheet(
state = sheetState,
properties = ModalBottomSheetProperties(offsetForIme = true),
) {
Sheet {
BasicTextField(state = textState)
}
}
Disabling back press and outside click dismissal
Use the properties parameter to control how the modal sheet can be dismissed:
UnstyledModalBottomSheet(
state = sheetState,
properties = ModalBottomSheetProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false,
),
) {
Sheet {
BasicText("Sheet content")
}
}
Reacting to modal bottom sheet dismissal
Use the onDismiss parameter to run code when the sheet is dismissed:
UnstyledModalBottomSheet(
state = sheetState,
onDismiss = { selectedItem = null },
) {
Sheet {
BasicText("Sheet content")
}
}
Customizing modal bottom sheet animation between detents
Use the animationSpec parameter to customize the default animation between detents. Use the dismissAnimationSpec parameter to customize the animation used when the modal sheet is dismissed:
val sheetState = rememberModalBottomSheetState(
initialDetent = SheetDetent.Hidden,
animationSpec = tween(durationMillis = 300),
dismissAnimationSpec = tween(durationMillis = 180),
)
API Reference
rememberModalBottomSheetState
| Parameter | Type | Description |
|---|---|---|
initialDetent |
SheetDetent |
A SheetDetent which controls the height in which the sheet will be introduced within its container. |
detents |
List<SheetDetent> |
A list of SheetDetent which the sheet can be rested for dragging purposes. |
animationSpec |
AnimationSpec<Float> |
An AnimationSpec used when animating the sheet across the different sheetDetents. |
dismissAnimationSpec |
AnimationSpec<Float>? |
|
velocityThreshold |
() -> Dp |
The velocity threshold (in px per second) that the end velocity has to exceed in order to animate to the next state, even if the [positionalThreshold] has not been reached. |
positionalThreshold |
(totalDistance: Dp) -> Dp |
The positional threshold, in px, to be used when calculating the target state while a drag is in progress and when settling after the drag ends. This is the distance from the start of a transition. It will be, depending on the direction of the interaction, added or subtracted from/to the origin offset. It should always be a positive value. |
confirmDetentChange |
(SheetDetent) -> Boolean |
|
decayAnimationSpec |
DecayAnimationSpec<Float> |
ModalBottomSheetState
| Parameter | Type | Description |
|---|---|---|
bottomSheetState |
BottomSheetState |
|
modalState |
ModalState |
|
currentDetent |
SheetDetent |
The SheetDetent in which the sheet is currently rested on. Setting a new detent will cause the sheet to animate to that detent. |
targetDetent |
SheetDetent |
The SheetDetent in which the sheet is about to rest on, if it is being dragged or animated. |
isIdle |
Boolean |
Whether the sheet is currently resting at a specific detent. |
offset |
Float |
The current offset of the sheet. |
fun progress() |
(SheetDetent, SheetDetent) -> Float |
|
suspend fun animateTo() |
suspend (SheetDetent) -> Unit |
Animates the sheet to the given detent. This is a suspend function, which you can use to wait until the animation is complete. |
fun jumpTo() |
(SheetDetent) -> Unit |
Makes the sheet to immediately appear to the given detent without any animation. |
fun invalidateDetents() |
() -> Unit |
UnstyledModalBottomSheet
| Parameter | Type | Description |
|---|---|---|
state |
ModalBottomSheetState |
The ModalBottomSheetState for the component |
properties |
ModalBottomSheetProperties |
ModalSheetProperties that control whether the sheet needs to be dismissed on clicked outside, etc. |
onDismiss |
() -> Unit |
Called when the sheet is being dismissed either by tapping outside or by pressing Esc or Back. |
overlay |
(ModalBottomSheetOverlayScope.() -> Unit)? |
|
content |
ModalBottomSheetScope.() -> Unit |
The contents of the Modal Bottom Sheet. |
ModalBottomSheetOverlayScope.Scrim
| Parameter | Type | Description |
|---|---|---|
modifier |
Modifier |
The Modifier for the component |
scrimColor |
Color |
|
enter |
EnterTransition |
|
exit |
ExitTransition |
ModalBottomSheetScope.Sheet
| Parameter | Type | Description |
|---|---|---|
modifier |
Modifier |
The Modifier for the component |
content |
() -> Unit |
The contents of the sheet. |
ModalBottomSheetScope.DragIndication
| Parameter | Type | Description |
|---|---|---|
modifier |
Modifier |
The Modifier for the component |
indication |
Indication? |
|
interactionSource |
MutableInteractionSource? |