/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.material3
import android.content.Context
import android.graphics.PixelFormat
import android.view.Gravity
import android.view.KeyEvent
import android.view.View
import android.view.ViewTreeObserver
import android.view.WindowManager
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.SheetValue.Expanded
import androidx.compose.material3.SheetValue.Hidden
import androidx.compose.material3.SheetValue.PartiallyExpanded
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.ViewRootForInspector
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.collapse
import androidx.compose.ui.semantics.dismiss
import androidx.compose.ui.semantics.expand
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.popup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import java.util.UUID
import kotlin.math.max
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Material Design modal bottom sheet.
*
* Modal bottom sheets are used as an alternative to inline menus or simple dialogs on mobile,
* especially when offering a long list of action items, or when items require longer descriptions
* and icons. Like dialogs, modal bottom sheets appear in front of app content, disabling all other
* app functionality when they appear, and remaining on screen until confirmed, dismissed, or a
* required action has been taken.
*
* 
*
* A simple example of a modal bottom sheet looks like this:
*
* @sample androidx.compose.material3.samples.ModalBottomSheetSample
*
* @param onDismissRequest Executes when the user clicks outside of the bottom sheet, after sheet
* animates to [Hidden].
* @param modifier Optional [Modifier] for the bottom sheet.
* @param sheetState The state of the bottom sheet.
* @param shape The shape of the bottom sheet.
* @param containerColor The color used for the background of this bottom sheet
* @param contentColor The preferred color for content inside this bottom sheet. Defaults to either
* the matching content color for [containerColor], or to the current [LocalContentColor] if
* [containerColor] is not a color from the theme.
* @param tonalElevation The tonal elevation of this bottom sheet.
* @param scrimColor Color of the scrim that obscures content when the bottom sheet is open.
* @param dragHandle Optional visual marker to swipe the bottom sheet.
* @param windowInsets window insets to be passed to the bottom sheet window via [PaddingValues]
* params.
* @param content The content to be displayed inside the bottom sheet.
*/
@Composable
@ExperimentalMaterial3Api
fun ModalBottomSheet(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
content: @Composable ColumnScope.() -> Unit,
) {
val scope = rememberCoroutineScope()
val animateToDismiss: () -> Unit = {
if (sheetState.swipeableState.confirmValueChange(Hidden)) {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) {
onDismissRequest()
}
}
}
}
val settleToDismiss: (velocity: Float) -> Unit = {
scope.launch { sheetState.settle(it) }.invokeOnCompletion {
if (!sheetState.isVisible) onDismissRequest()
}
}
// Callback that is invoked when the anchors have changed.
val anchorChangeHandler = remember(sheetState, scope) {
ModalBottomSheetAnchorChangeHandler(
state = sheetState,
animateTo = { target, velocity ->
scope.launch { sheetState.animateTo(target, velocity = velocity) }
},
snapTo = { target ->
val didSnapImmediately = sheetState.trySnapTo(target)
if (!didSnapImmediately) {
scope.launch { sheetState.snapTo(target) }
}
}
)
}
ModalBottomSheetPopup(
onDismissRequest = {
if (sheetState.currentValue == Expanded && sheetState.hasPartiallyExpandedState) {
scope.launch { sheetState.partialExpand() }
} else { // Is expanded without collapsed state or is collapsed.
scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() }
}
},
windowInsets = windowInsets,
) {
BoxWithConstraints(Modifier.fillMaxSize()) {
val fullHeight = constraints.maxHeight
Scrim(
color = scrimColor,
onDismissRequest = animateToDismiss,
visible = sheetState.targetValue != Hidden
)
val bottomSheetPaneTitle = getString(string = Strings.BottomSheetPaneTitle)
Surface(
modifier = modifier
.widthIn(max = BottomSheetMaxWidth)
.fillMaxWidth()
.align(Alignment.TopCenter)
.semantics { paneTitle = bottomSheetPaneTitle }
.offset {
IntOffset(
0,
sheetState
.requireOffset()
.toInt()
)
}
.nestedScroll(
remember(sheetState) {
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState = sheetState,
orientation = Orientation.Vertical,
onFling = settleToDismiss
)
}
)
.modalBottomSheetSwipeable(
sheetState = sheetState,
anchorChangeHandler = anchorChangeHandler,
screenHeight = fullHeight.toFloat(),
onDragStopped = {
settleToDismiss(it)
},
),
shape = shape,
color = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
) {
Column(Modifier.fillMaxWidth()) {
if (dragHandle != null) {
val collapseActionLabel =
getString(Strings.BottomSheetPartialExpandDescription)
val dismissActionLabel = getString(Strings.BottomSheetDismissDescription)
val expandActionLabel = getString(Strings.BottomSheetExpandDescription)
Box(
Modifier
.align(Alignment.CenterHorizontally)
.semantics(mergeDescendants = true) {
// Provides semantics to interact with the bottomsheet based on its
// current value.
with(sheetState) {
dismiss(dismissActionLabel) {
animateToDismiss()
true
}
if (currentValue == PartiallyExpanded) {
expand(expandActionLabel) {
if (swipeableState.confirmValueChange(Expanded)) {
scope.launch { sheetState.expand() }
}
true
}
} else if (hasPartiallyExpandedState) {
collapse(collapseActionLabel) {
if (
swipeableState.confirmValueChange(
PartiallyExpanded
)
) {
scope.launch { partialExpand() }
}
true
}
}
}
}
) {
dragHandle()
}
}
content()
}
}
}
}
if (sheetState.hasExpandedState) {
LaunchedEffect(sheetState) {
sheetState.show()
}
}
}
/**
* Create and [remember] a [SheetState] for [ModalBottomSheet].
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is tall enough,
* should be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
* [Hidden] state when hiding the sheet, either programmatically or by user interaction.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
@ExperimentalMaterial3Api
fun rememberModalBottomSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
) = rememberSheetState(skipPartiallyExpanded, confirmValueChange, Hidden)
@Composable
private fun Scrim(
color: Color,
onDismissRequest: () -> Unit,
visible: Boolean
) {
if (color.isSpecified) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = TweenSpec()
)
val dismissSheet = if (visible) {
Modifier
.pointerInput(onDismissRequest) {
detectTapGestures {
onDismissRequest()
}
}
.clearAndSetSemantics {}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissSheet)
) {
drawRect(color = color, alpha = alpha)
}
}
}
@ExperimentalMaterial3Api
private fun Modifier.modalBottomSheetSwipeable(
sheetState: SheetState,
anchorChangeHandler: AnchorChangeHandler,
screenHeight: Float,
onDragStopped: CoroutineScope.(velocity: Float) -> Unit,
) = draggable(
state = sheetState.swipeableState.swipeDraggableState,
orientation = Orientation.Vertical,
enabled = sheetState.isVisible,
startDragImmediately = sheetState.swipeableState.isAnimationRunning,
onDragStopped = onDragStopped
)
.swipeAnchors(
state = sheetState.swipeableState,
anchorChangeHandler = anchorChangeHandler,
possibleValues = setOf(Hidden, PartiallyExpanded, Expanded),
) { value, sheetSize ->
when (value) {
Hidden -> screenHeight
PartiallyExpanded -> when {
sheetSize.height < screenHeight / 2 -> null
sheetState.skipPartiallyExpanded -> null
else -> screenHeight / 2f
}
Expanded -> if (sheetSize.height != 0) {
max(0f, screenHeight - sheetSize.height)
} else null
}
}
@ExperimentalMaterial3Api
private fun ModalBottomSheetAnchorChangeHandler(
state: SheetState,
animateTo: (target: SheetValue, velocity: Float) -> Unit,
snapTo: (target: SheetValue) -> Unit,
) = AnchorChangeHandler { previousTarget, previousAnchors, newAnchors ->
val previousTargetOffset = previousAnchors[previousTarget]
val newTarget = when (previousTarget) {
Hidden -> Hidden
PartiallyExpanded, Expanded -> {
val hasPartiallyExpandedState = newAnchors.containsKey(PartiallyExpanded)
val newTarget = if (hasPartiallyExpandedState) PartiallyExpanded
else if (newAnchors.containsKey(Expanded)) Expanded else Hidden
newTarget
}
}
val newTargetOffset = newAnchors.getValue(newTarget)
if (newTargetOffset != previousTargetOffset) {
if (state.swipeableState.isAnimationRunning || previousAnchors.isEmpty()) {
// Re-target the animation to the new offset if it changed
animateTo(newTarget, state.swipeableState.lastVelocity)
} else {
// Snap to the new offset value of the target if no animation was running
snapTo(newTarget)
}
}
}
/**
* Popup specific for modal bottom sheet.
*/
@Composable
internal fun ModalBottomSheetPopup(
onDismissRequest: () -> Unit,
windowInsets: WindowInsets,
content: @Composable () -> Unit,
) {
val view = LocalView.current
val id = rememberSaveable { UUID.randomUUID() }
val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
val modalBottomSheetWindow = remember {
ModalBottomSheetWindow(
onDismissRequest = onDismissRequest,
composeView = view,
saveId = id
).apply {
setCustomContent(
parent = parentComposition,
content = {
Box(
Modifier
.semantics { this.popup() }
.windowInsetsPadding(windowInsets)
.imePadding()
) {
currentContent()
}
}
)
}
}
DisposableEffect(modalBottomSheetWindow) {
modalBottomSheetWindow.show()
onDispose {
modalBottomSheetWindow.disposeComposition()
modalBottomSheetWindow.dismiss()
}
}
}
/** Custom compose view for [ModalBottomSheet] */
private class ModalBottomSheetWindow(
private var onDismissRequest: () -> Unit,
private val composeView: View,
saveId: UUID,
) :
AbstractComposeView(composeView.context),
ViewTreeObserver.OnGlobalLayoutListener,
ViewRootForInspector {
init {
id = android.R.id.content
// Set up view owners
setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner())
setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Popup:$saveId")
// Enable children to draw their shadow by not clipping them
clipChildren = false
}
private val windowManager =
composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val displayWidth: Int
get() {
val density = context.resources.displayMetrics.density
return (context.resources.configuration.screenWidthDp * density).roundToInt()
}
private val params: WindowManager.LayoutParams =
WindowManager.LayoutParams().apply {
// Position bottom sheet from the bottom of the screen
gravity = Gravity.BOTTOM or Gravity.START
// Application panel window
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
// Fill up the entire app view
width = displayWidth
height = WindowManager.LayoutParams.MATCH_PARENT
// Format of screen pixels
format = PixelFormat.TRANSLUCENT
// Title used as fallback for a11y services
// TODO: Provide bottom sheet window resource
title = composeView.context.resources.getString(
androidx.compose.ui.R.string.default_popup_window_title
)
// Get the Window token from the parent view
token = composeView.applicationWindowToken
// Flags specific to modal bottom sheet.
flags = flags and (
WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
).inv()
flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
}
private var content: @Composable () -> Unit by mutableStateOf({})
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
private set
@Composable
override fun Content() {
content()
}
fun setCustomContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit
) {
parent?.let { setParentCompositionContext(it) }
this.content = content
shouldCreateCompositionOnAttachedToWindow = true
}
fun show() {
windowManager.addView(this, params)
}
fun dismiss() {
setViewTreeLifecycleOwner(null)
setViewTreeSavedStateRegistryOwner(null)
composeView.viewTreeObserver.removeOnGlobalLayoutListener(this)
windowManager.removeViewImmediate(this)
}
/**
* Taken from PopupWindow. Calls [onDismissRequest] when back button is pressed.
*/
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode == KeyEvent.KEYCODE_BACK) {
if (keyDispatcherState == null) {
return super.dispatchKeyEvent(event)
}
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val state = keyDispatcherState
state?.startTracking(event, this)
return true
} else if (event.action == KeyEvent.ACTION_UP) {
val state = keyDispatcherState
if (state != null && state.isTracking(event) && !event.isCanceled) {
onDismissRequest()
return true
}
}
}
return super.dispatchKeyEvent(event)
}
override fun onGlobalLayout() {
// No-op
}
}