Compose Unstyled 2.0 is out! Check the official announcement blog ->

Modal Bottom Sheet

A dismissible modal bottom sheet with custom detents.

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

  • SheetDetent defines a height where the sheet can rest.
  • UnstyledModalBottomSheet represents the modal layer the sheet is rendered in.
  • Sheet is the rendered bit of the sheet.
  • DragIndication adds 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?