Skip to content

Instantly share code, notes, and snippets.

@alexvanyo
Last active July 27, 2024 09:57
Show Gist options
  • Save alexvanyo/594abce742ecd9f973cb1162ec49df12 to your computer and use it in GitHub Desktop.
Save alexvanyo/594abce742ecd9f973cb1162ec49df12 to your computer and use it in GitHub Desktop.
EdgeToEdgeDialogs
/*
* Copyright 2024 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
*
* https://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.
*/
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.layout.Layout
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
import androidx.compose.ui.window.DialogProperties
import kotlin.math.max
@Suppress("LongParameterList", "LongMethod")
@Composable
fun EdgeToEdgeAlertDialog(
onDismissRequest: () -> Unit,
confirmButton: @Composable () -> Unit,
modifier: Modifier = Modifier,
dismissButton: @Composable (() -> Unit)? = null,
icon: @Composable (() -> Unit)? = null,
title: @Composable (() -> Unit)? = null,
text: @Composable (() -> Unit)? = null,
shape: Shape = AlertDialogDefaults.shape,
containerColor: Color = AlertDialogDefaults.containerColor,
iconContentColor: Color = AlertDialogDefaults.iconContentColor,
titleContentColor: Color = AlertDialogDefaults.titleContentColor,
textContentColor: Color = AlertDialogDefaults.textContentColor,
tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
properties: DialogProperties = DialogProperties(),
) = EdgeToEdgeBasicAlertDialog(
onDismissRequest = onDismissRequest,
modifier = modifier,
properties = properties,
) {
AlertDialogContent(
buttons = {
AlertDialogFlowRow(
mainAxisSpacing = ButtonsMainAxisSpacing,
crossAxisSpacing = ButtonsCrossAxisSpacing,
) {
dismissButton?.invoke()
confirmButton()
}
},
icon = icon,
title = title,
text = text,
shape = shape,
containerColor = containerColor,
tonalElevation = tonalElevation,
// Note that a button content color is provided here from the dialog's token, but in
// most cases, TextButtons should be used for dismiss and confirm buttons.
// TextButtons will not consume this provided content color value, and will used their
// own defined or default colors.
buttonContentColor = MaterialTheme.colorScheme.primary,
iconContentColor = iconContentColor,
titleContentColor = titleContentColor,
textContentColor = textContentColor,
)
}
@Composable
fun EdgeToEdgeBasicAlertDialog(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
properties: DialogProperties = DialogProperties(),
content: @Composable () -> Unit,
) {
PlatformEdgeToEdgeDialog(
onDismissRequest = onDismissRequest,
properties = properties,
) {
val dialogPaneDescription = "dialog" // TODO: getString(string = Strings.BottomSheetPaneTitle)
Box(
modifier = modifier
.sizeIn(minWidth = DialogMinWidth, maxWidth = DialogMaxWidth)
.then(Modifier.semantics { paneTitle = dialogPaneDescription }),
propagateMinConstraints = true,
) {
content()
}
}
}
@Suppress("ComposeParameterOrder", "LongParameterList", "LongMethod")
@Composable
internal fun AlertDialogContent(
buttons: @Composable () -> Unit,
modifier: Modifier = Modifier,
icon: (@Composable () -> Unit)?,
title: (@Composable () -> Unit)?,
text: @Composable (() -> Unit)?,
shape: Shape,
containerColor: Color,
tonalElevation: Dp,
buttonContentColor: Color,
iconContentColor: Color,
titleContentColor: Color,
textContentColor: Color,
) {
Surface(
modifier = modifier,
shape = shape,
color = containerColor,
tonalElevation = tonalElevation,
) {
Column(
modifier = Modifier.padding(DialogPadding),
) {
icon?.let {
CompositionLocalProvider(LocalContentColor provides iconContentColor) {
Box(
Modifier
.padding(IconPadding)
.align(Alignment.CenterHorizontally),
) {
icon()
}
}
}
title?.let {
ProvideContentColorTextStyle(
contentColor = titleContentColor,
textStyle = MaterialTheme.typography.headlineSmall,
) {
Box(
// Align the title to the center when an icon is present.
Modifier
.padding(TitlePadding)
.align(
if (icon == null) {
Alignment.Start
} else {
Alignment.CenterHorizontally
},
),
) {
title()
}
}
}
text?.let {
val textStyle = MaterialTheme.typography.bodyMedium
ProvideContentColorTextStyle(
contentColor = textContentColor,
textStyle = textStyle,
) {
Box(
Modifier
.weight(weight = 1f, fill = false)
.padding(TextPadding)
.align(Alignment.Start),
) {
text()
}
}
}
Box(modifier = Modifier.align(Alignment.End)) {
val textStyle =
MaterialTheme.typography.labelLarge
ProvideContentColorTextStyle(
contentColor = buttonContentColor,
textStyle = textStyle,
content = buttons,
)
}
}
}
}
/**
* ProvideContentColorTextStyle
*
* A convenience method to provide values to both LocalContentColor and LocalTextStyle in
* one call. This is less expensive than nesting calls to CompositionLocalProvider.
*
* Text styles will be merged with the current value of LocalTextStyle.
*/
@Composable
internal fun ProvideContentColorTextStyle(
contentColor: Color,
textStyle: TextStyle,
content: @Composable () -> Unit,
) {
val mergedStyle = LocalTextStyle.current.merge(textStyle)
CompositionLocalProvider(
LocalContentColor provides contentColor,
LocalTextStyle provides mergedStyle,
content = content,
)
}
/**
* Simple clone of FlowRow that arranges its children in a horizontal flow with limited
* customization.
*/
@Composable
internal fun AlertDialogFlowRow(
mainAxisSpacing: Dp,
crossAxisSpacing: Dp,
content: @Composable () -> Unit,
) {
Layout(content) { measurables, constraints ->
val sequences = mutableListOf<List<Placeable>>()
val crossAxisSizes = mutableListOf<Int>()
val crossAxisPositions = mutableListOf<Int>()
var mainAxisSpace = 0
var crossAxisSpace = 0
val currentSequence = mutableListOf<Placeable>()
var currentMainAxisSize = 0
var currentCrossAxisSize = 0
// Return whether the placeable can be added to the current sequence.
fun canAddToCurrentSequence(placeable: Placeable) =
currentSequence.isEmpty() || currentMainAxisSize + mainAxisSpacing.roundToPx() +
placeable.width <= constraints.maxWidth
// Store current sequence information and start a new sequence.
fun startNewSequence() {
if (sequences.isNotEmpty()) {
crossAxisSpace += crossAxisSpacing.roundToPx()
}
// Ensures that confirming actions appear above dismissive actions.
@Suppress("ListIterator")
sequences.add(0, currentSequence.toList())
crossAxisSizes += currentCrossAxisSize
crossAxisPositions += crossAxisSpace
crossAxisSpace += currentCrossAxisSize
mainAxisSpace = max(mainAxisSpace, currentMainAxisSize)
currentSequence.clear()
currentMainAxisSize = 0
currentCrossAxisSize = 0
}
measurables.fastForEach { measurable ->
// Ask the child for its preferred size.
val placeable = measurable.measure(constraints)
// Start a new sequence if there is not enough space.
if (!canAddToCurrentSequence(placeable)) startNewSequence()
// Add the child to the current sequence.
if (currentSequence.isNotEmpty()) {
currentMainAxisSize += mainAxisSpacing.roundToPx()
}
currentSequence.add(placeable)
currentMainAxisSize += placeable.width
currentCrossAxisSize = max(currentCrossAxisSize, placeable.height)
}
if (currentSequence.isNotEmpty()) startNewSequence()
val mainAxisLayoutSize = max(mainAxisSpace, constraints.minWidth)
val crossAxisLayoutSize = max(crossAxisSpace, constraints.minHeight)
val layoutWidth = mainAxisLayoutSize
val layoutHeight = crossAxisLayoutSize
layout(layoutWidth, layoutHeight) {
sequences.fastForEachIndexed { i, placeables ->
val childrenMainAxisSizes = IntArray(placeables.size) { j ->
placeables[j].width +
if (j < placeables.lastIndex) mainAxisSpacing.roundToPx() else 0
}
val arrangement = Arrangement.End
val mainAxisPositions = IntArray(childrenMainAxisSizes.size) { 0 }
with(arrangement) {
arrange(
mainAxisLayoutSize,
childrenMainAxisSizes,
layoutDirection,
mainAxisPositions,
)
}
placeables.fastForEachIndexed { j, placeable ->
placeable.place(
x = mainAxisPositions[j],
y = crossAxisPositions[i],
)
}
}
}
}
}
internal val DialogMinWidth = 280.dp
internal val DialogMaxWidth = 560.dp
// Paddings for each of the dialog's parts.
private val DialogPadding = PaddingValues(all = 24.dp)
private val IconPadding = PaddingValues(bottom = 16.dp)
private val TitlePadding = PaddingValues(bottom = 16.dp)
private val TextPadding = PaddingValues(bottom = 24.dp)
private val ButtonsMainAxisSpacing = 8.dp
private val ButtonsCrossAxisSpacing = 12.dp
@Preview
@Composable
internal fun EdgeToEdgeAlertDialogPreview() {
var showEdgeToEdgeAlertDialog by rememberSaveable { mutableStateOf(false) }
var showAlertDialog by rememberSaveable { mutableStateOf(false) }
val context = LocalContext.current
SideEffect {
(context as? ComponentActivity)?.enableEdgeToEdge()
}
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
BasicText(
modifier = Modifier
.height(64.dp)
.clickable { showEdgeToEdgeAlertDialog = true },
text = "Show edge-to-edge alert dialog",
)
BasicText(
modifier = Modifier
.height(64.dp)
.clickable { showAlertDialog = true },
text = "Show built-in alert dialog",
)
}
if (showEdgeToEdgeAlertDialog) {
val onDismissRequest = { showEdgeToEdgeAlertDialog = false }
EdgeToEdgeAlertDialog(
onDismissRequest = onDismissRequest,
text = {
Text("This is an alert dialog")
},
confirmButton = {
Button(onClick = onDismissRequest) {
Text("OK")
}
},
)
}
if (showAlertDialog) {
val onDismissRequest = { showAlertDialog = false }
AlertDialog(
onDismissRequest = onDismissRequest,
text = {
Text("This is an alert dialog")
},
confirmButton = {
Button(onClick = onDismissRequest) {
Text("OK")
}
},
)
}
}
/*
* Copyright 2024 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
*
* https://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.
*/
import android.content.Context
import android.content.res.Configuration
import android.graphics.Outline
import android.os.Build
import android.util.TypedValue
import android.view.ContextThemeWrapper
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.view.Window
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.ComponentDialog
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.ViewRootForInspector
import androidx.compose.ui.semantics.dialog
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.util.fastMaxBy
import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.core.view.WindowCompat
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
/**
* A [Dialog] that is _always_ edge-to-edge. This is intended to be the underlying backbone for more complicated and
* opinionated dialogs.
*
* The [content] will fill the entire window, going entirely edge-to-edge.
*
* This [EdgeToEdgeDialog] provides no scrim or dismissing when the scrim is pressed: if this is desired, it must be
* implemented by the [content] or supplied by enabling background dim on the dialog's window.
* For the most simple implementation of this that acts like a platform dialog, use
* [PlatformEdgeToEdgeDialog].
*
* [DialogProperties] will be respected, but [DialogProperties.decorFitsSystemWindows] and
* [DialogProperties.usePlatformDefaultWidth] are ignored.
*
* The [content] will be passed a [CompletablePredictiveBackStateHolder] that encapsulates the predictive back state if
* [DialogProperties.dismissOnBackPress] is true.
*/
@Composable
fun EdgeToEdgeDialog(
onDismissRequest: () -> Unit,
properties: DialogProperties = DialogProperties(),
windowTheme: Int = R.style.EdgeToEdgeFloatingDialogWindowTheme,
content: @Composable (CompletablePredictiveBackStateHolder) -> Unit,
) {
val view = LocalView.current
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val composition = rememberCompositionContext()
val dialogId = rememberSaveable { UUID.randomUUID() }
val currentOnDismissRequest by rememberUpdatedState(onDismissRequest)
val currentDismissOnBackPress by rememberUpdatedState(properties.dismissOnBackPress)
val currentContent by rememberUpdatedState(content)
val dialog = remember(view, density) {
DialogWrapper(
onDismissRequest = onDismissRequest,
properties = properties,
composeView = view,
windowTheme = windowTheme,
layoutDirection = layoutDirection,
density = density,
dialogId = dialogId,
).apply {
setContent(composition) {
val completablePredictiveBackStateHolder = rememberCompletablePredictiveBackStateHolder()
CompletablePredictiveBackStateHandler(
completablePredictiveBackStateHolder = completablePredictiveBackStateHolder,
enabled = currentDismissOnBackPress,
onBack = { currentOnDismissRequest() },
)
DialogLayout(
Modifier.semantics { dialog() },
) {
currentContent(completablePredictiveBackStateHolder)
}
}
}
}
DisposableEffect(dialog) {
dialog.show()
onDispose {
dialog.dismiss()
dialog.disposeComposition()
}
}
SideEffect {
dialog.updateParameters(
onDismissRequest = onDismissRequest,
properties = properties,
layoutDirection = layoutDirection,
)
}
}
/**
* A [Dialog] based on [EdgeToEdgeDialog] that provides a more opinionated dialog that is closer to the default
* [Dialog].
*
* The [scrim] is rendered behind the content. The default scrim will request to dismiss the dialog if
* [DialogProperties.dismissOnClickOutside] is true.
*
* The [content] of the dialog can be arbitrarily sized, and can fill the entire window if desired.
*
* If [DialogProperties.dismissOnBackPress] is true, the [content] will automatically start to animate out with a
* predictive back gestures from the dialog.
*/
@Suppress("ComposeModifierMissing", "LongMethod", "CyclomaticComplexMethod")
@Composable
fun PlatformEdgeToEdgeDialog(
onDismissRequest: () -> Unit,
properties: DialogProperties = DialogProperties(),
scrim: @Composable () -> Unit = {
Spacer(
modifier = Modifier
.fillMaxSize()
.then(
if (properties.dismissOnClickOutside) {
Modifier.pointerInput(Unit) {
detectTapGestures { onDismissRequest() }
}
} else {
Modifier
},
),
)
},
content: @Composable () -> Unit,
) = EdgeToEdgeDialog(
onDismissRequest = onDismissRequest,
windowTheme = R.style.PlatformEdgeToEdgeFloatingDialogWindowTheme,
properties = properties,
) { predictiveBackStateHolder ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
scrim()
val sizeModifier = if (properties.usePlatformDefaultWidth) {
// This is a reimplementation of the intrinsic logic from
// https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/com/android/internal/policy/DecorView.java;l=757;drc=e41472bd05b233b5946b30b3d862f043c30f54c7
val context = LocalContext.current
val widthResource = if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) {
android.R.dimen.dialog_min_width_minor
} else {
android.R.dimen.dialog_min_width_major
}
val typedValue = TypedValue().also {
context.resources.getValue(widthResource, it, true)
}
when (typedValue.type) {
TypedValue.TYPE_DIMENSION -> {
Modifier.widthIn(
min = with(LocalDensity.current) {
typedValue.getDimension(context.resources.displayMetrics).toDp()
},
)
}
TypedValue.TYPE_FRACTION ->
Modifier.fillMaxWidth(fraction = typedValue.getFraction(1f, 1f))
else -> Modifier
}
} else {
Modifier
}
val predictiveBackState = predictiveBackStateHolder.value
val lastRunningValue by remember {
mutableStateOf<CompletablePredictiveBackState.Running?>(null)
}.apply {
when (predictiveBackState) {
CompletablePredictiveBackState.NotRunning -> value = null
is CompletablePredictiveBackState.Running -> if (predictiveBackState.progress >= 0.01f) {
// Only save that we were disappearing if the progress is at least 1% along
value = predictiveBackState
}
CompletablePredictiveBackState.Completed -> Unit
}
}
val scale by animateFloatAsState(
targetValue = when (predictiveBackState) {
CompletablePredictiveBackState.NotRunning -> 1f
is CompletablePredictiveBackState.Running -> lerp(1f, 0.9f, predictiveBackState.progress)
CompletablePredictiveBackState.Completed -> 0.9f
},
label = "scale",
) {
}
val translationX by animateDpAsState(
targetValue = when (predictiveBackState) {
CompletablePredictiveBackState.NotRunning -> 0.dp
is CompletablePredictiveBackState.Running -> lerp(
0.dp,
8.dp,
predictiveBackState.progress,
) * when (predictiveBackState.swipeEdge) {
SwipeEdge.Left -> -1f
SwipeEdge.Right -> 1f
}
CompletablePredictiveBackState.Completed -> {
8.dp * when (lastRunningValue?.swipeEdge) {
null -> 0f
SwipeEdge.Left -> -1f
SwipeEdge.Right -> 1f
}
}
},
label = "translationX",
)
val pivotFractionX by animateFloatAsState(
targetValue = when (predictiveBackState) {
CompletablePredictiveBackState.NotRunning -> 0.5f
is CompletablePredictiveBackState.Running -> when (predictiveBackState.swipeEdge) {
SwipeEdge.Left -> 1f
SwipeEdge.Right -> 0f
}
CompletablePredictiveBackState.Completed -> {
when (lastRunningValue?.swipeEdge) {
null -> 0.5f
SwipeEdge.Left -> 1f
SwipeEdge.Right -> 0f
}
}
},
label = "pivotFractionX",
)
Box(
modifier = Modifier
.safeDrawingPadding()
.graphicsLayer {
this.translationX = translationX.toPx()
this.alpha = alpha
this.scaleX = scale
this.scaleY = scale
this.transformOrigin = TransformOrigin(pivotFractionX, 0.5f)
}
.then(sizeModifier),
contentAlignment = Alignment.Center,
) {
content()
}
}
}
private class DialogWrapper(
private var onDismissRequest: () -> Unit,
private var properties: DialogProperties,
private val composeView: View,
windowTheme: Int,
layoutDirection: LayoutDirection,
density: Density,
dialogId: UUID,
) : ComponentDialog(ContextThemeWrapper(composeView.context, windowTheme)), ViewRootForInspector {
private val dialogLayout: DialogLayout
// On systems older than Android S, there is a bug in the surface insets matrix math used by
// elevation, so high values of maxSupportedElevation break accessibility services: b/232788477.
private val maxSupportedElevation = 8.dp
override val subCompositionView: AbstractComposeView get() = dialogLayout
init {
val window = window ?: error("Dialog has no window")
WindowCompat.setDecorFitsSystemWindows(window, false)
dialogLayout = DialogLayout(context, window).apply {
// Set unique id for AbstractComposeView. This allows state restoration for the state
// defined inside the Dialog via rememberSaveable()
setTag(R.id.compose_view_saveable_id_tag, "Dialog:$dialogId")
// Enable children to draw their shadow by not clipping them
clipChildren = false
// Allocate space for elevation
with(density) { elevation = maxSupportedElevation.toPx() }
// Simple outline to force window manager to allocate space for shadow.
// Note that the outline affects clickable area for the dismiss listener. In case of
// shapes like circle the area for dismiss might be to small (rectangular outline
// consuming clicks outside of the circle).
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, result: Outline) {
result.setRect(0, 0, view.width, view.height)
// We set alpha to 0 to hide the view's shadow and let the composable to draw
// its own shadow. This still enables us to get the extra space needed in the
// surface.
result.alpha = 0f
}
}
}
/**
* Disables clipping for [this] and all its descendant [ViewGroup]s until we reach a
* [DialogLayout] (the [ViewGroup] containing the Compose hierarchy).
*/
fun ViewGroup.disableClipping() {
clipChildren = false
if (this is DialogLayout) return
for (i in 0 until childCount) {
(getChildAt(i) as? ViewGroup)?.disableClipping()
}
}
// Turn of all clipping so shadows can be drawn outside the window
(window.decorView as? ViewGroup)?.disableClipping()
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = false
isAppearanceLightNavigationBars = false
}
setContentView(dialogLayout)
dialogLayout.setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
dialogLayout.setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
dialogLayout.setViewTreeSavedStateRegistryOwner(
composeView.findViewTreeSavedStateRegistryOwner(),
)
// Initial setup
updateParameters(onDismissRequest, properties, layoutDirection)
}
private fun setLayoutDirection(layoutDirection: LayoutDirection) {
dialogLayout.layoutDirection = when (layoutDirection) {
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
LayoutDirection.Rtl -> android.util.LayoutDirection.RTL
}
}
fun setContent(
parentComposition: CompositionContext,
children: @Composable () -> Unit,
) {
dialogLayout.setContent(parentComposition, children)
}
private fun setSecurePolicy(securePolicy: SecureFlagPolicy) {
val secureFlagEnabled =
when (securePolicy) {
SecureFlagPolicy.SecureOff -> false
SecureFlagPolicy.SecureOn -> true
SecureFlagPolicy.Inherit -> composeView.isFlagSecureEnabled()
}
checkNotNull(window).setFlags(
if (secureFlagEnabled) {
WindowManager.LayoutParams.FLAG_SECURE
} else {
WindowManager.LayoutParams.FLAG_SECURE.inv()
},
WindowManager.LayoutParams.FLAG_SECURE,
)
}
fun updateParameters(
onDismissRequest: () -> Unit,
properties: DialogProperties,
layoutDirection: LayoutDirection,
) {
this.onDismissRequest = onDismissRequest
this.properties = properties
setSecurePolicy(properties.securePolicy)
setLayoutDirection(layoutDirection)
window?.setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT,
)
window?.setSoftInputMode(
if (Build.VERSION.SDK_INT >= 30) {
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING
} else {
@Suppress("DEPRECATION")
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
},
)
}
fun disposeComposition() {
dialogLayout.disposeComposition()
}
override fun onTouchEvent(event: MotionEvent): Boolean {
val result = super.onTouchEvent(event)
if (result && properties.dismissOnClickOutside) {
onDismissRequest()
}
return result
}
override fun cancel() {
// Prevents the dialog from dismissing itself
return
}
}
@Suppress("ViewConstructor")
private class DialogLayout(
context: Context,
override val window: Window,
) : AbstractComposeView(context), DialogWindowProvider {
private var content: @Composable () -> Unit by mutableStateOf({})
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
private set
fun setContent(parent: CompositionContext, content: @Composable () -> Unit) {
setParentCompositionContext(parent)
this.content = content
shouldCreateCompositionOnAttachedToWindow = true
createComposition()
}
@Suppress("ComposeUnstableReceiver")
@Composable
override fun Content() {
content()
}
}
@Composable
private fun DialogLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Layout(
content = content,
modifier = modifier,
) { measurables, constraints ->
val placeables = measurables.fastMap { it.measure(constraints) }
val width = placeables.fastMaxBy { it.width }?.width ?: constraints.minWidth
val height = placeables.fastMaxBy { it.height }?.height ?: constraints.minHeight
layout(width, height) {
placeables.fastForEach { it.placeRelative(0, 0) }
}
}
}
internal fun View.isFlagSecureEnabled(): Boolean {
val windowParams = rootView.layoutParams as? WindowManager.LayoutParams
if (windowParams != null) {
return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0
}
return false
}
@Suppress("LongMethod")
@Preview
@Composable
internal fun EdgeToEdgeDialogPreview() {
var showEdgeToEdgeDialog by remember { mutableStateOf(false) }
var showBuiltInDialog by remember { mutableStateOf(false) }
var showPlatformEdgeToEdgeDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
SideEffect {
(context as? ComponentActivity)?.enableEdgeToEdge()
}
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
BasicText(
modifier = Modifier
.height(64.dp)
.clickable { showEdgeToEdgeDialog = true },
text = "Show edge-to-edge dialog",
)
BasicText(
modifier = Modifier
.height(64.dp)
.clickable { showBuiltInDialog = true },
text = "Show built-in dialog",
)
BasicText(
modifier = Modifier
.height(64.dp)
.clickable { showPlatformEdgeToEdgeDialog = true },
text = "Show platform edge-to-edge dialog",
)
}
val hideEdgeToEdgeDialog = { showEdgeToEdgeDialog = false }
if (showEdgeToEdgeDialog) {
EdgeToEdgeDialog(
onDismissRequest = hideEdgeToEdgeDialog,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
val scrimColor = Color.Black.copy(alpha = 0.6f)
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures { hideEdgeToEdgeDialog() }
},
) {
drawRect(
color = scrimColor,
topLeft = -Offset(size.width, size.height),
size = size * 3f,
)
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Red)
.pointerInput(Unit) {},
contentAlignment = Alignment.Center,
) {
BasicText("Full screen")
}
}
}
}
if (showBuiltInDialog) {
Dialog(
onDismissRequest = { showBuiltInDialog = false },
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Red),
contentAlignment = Alignment.Center,
) {
BasicText("Full screen")
}
}
}
if (showPlatformEdgeToEdgeDialog) {
PlatformEdgeToEdgeDialog(
onDismissRequest = { showPlatformEdgeToEdgeDialog = false },
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Red)
.pointerInput(Unit) {},
contentAlignment = Alignment.Center,
) {
BasicText("Full screen")
}
}
}
}
/*
* Copyright 2024 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
*
* https://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.
*/
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.animateToWithDecay
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetValue
import androidx.compose.material3.SheetValue.Expanded
import androidx.compose.material3.SheetValue.Hidden
import androidx.compose.material3.SheetValue.PartiallyExpanded
import androidx.compose.material3.Surface
import androidx.compose.material3.TextField
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
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.semantics
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlin.math.max
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Suppress("LongParameterList", "CyclomaticComplexMethod", "LongMethod")
@Composable
fun EdgeToEdgeModalBottomSheet(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
sheetWindowInsets: @Composable () -> WindowInsets = {
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)
},
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
contentWindowInsets: @Composable () -> WindowInsets = {
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
},
tonalElevation: Dp = BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
properties: DialogProperties = DialogProperties(),
content: @Composable ColumnScope.() -> Unit,
) {
// b/291735717 Remove this once deprecated methods without density are removed
val density = LocalDensity.current
SideEffect {
sheetState.density = density
}
val scope = rememberCoroutineScope()
val animateToDismiss: () -> Unit = {
if (sheetState.confirmValueChange(Hidden)) {
scope.launch { sheetState.hide() }
}
}
val settleToDismiss: (velocity: Float) -> Unit = {
scope.launch { sheetState.settle(it) }
}
val currentOnDismissRequest by rememberUpdatedState(onDismissRequest)
LaunchedEffect(Unit) {
sheetState.animateTo(if (sheetState.skipPartiallyExpanded) Expanded else PartiallyExpanded)
snapshotFlow {
!sheetState.anchoredDraggableState.isAnimationRunning && !sheetState.isVisible
}
.filter { it }
.onEach {
currentOnDismissRequest()
}
.collect()
}
EdgeToEdgeDialog(
properties = properties,
onDismissRequest = {
scope.launch { sheetState.hide() }
},
) { predictiveBackStateHolder ->
Box(
Modifier.fillMaxSize(),
) {
Scrim(
color = scrimColor,
onDismissRequest = animateToDismiss,
visible = sheetState.targetValue != Hidden,
)
val bottomSheetPaneTitle = "bottom sheet" // TODO: getString(string = Strings.BottomSheetPaneTitle)
val ime = WindowInsets.ime
val lastRunningValue by remember {
mutableStateOf<CompletablePredictiveBackState.Running?>(null)
}.apply {
when (val predictiveBackState = predictiveBackStateHolder.value) {
CompletablePredictiveBackState.NotRunning -> value = null
is CompletablePredictiveBackState.Running -> if (predictiveBackState.progress >= 0.01f) {
// Only save that we were disappearing if the progress is at least 1% along
value = predictiveBackState
}
CompletablePredictiveBackState.Completed -> Unit
}
}
var fullWidth by remember { mutableIntStateOf(0) }
Surface(
modifier = modifier
.fillMaxSize()
.windowInsetsPadding(sheetWindowInsets())
.wrapContentSize()
.widthIn(max = sheetMaxWidth)
.fillMaxWidth()
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val fullHeight = constraints.maxHeight.toFloat()
fullWidth = constraints.maxWidth
val sheetSize = IntSize(placeable.width, placeable.height)
val partiallyExpandedOffset = max(0f, fullHeight / 2f - ime.getBottom(density))
val newAnchors = DraggableAnchors {
Hidden at fullHeight
if (sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded) {
PartiallyExpanded at partiallyExpandedOffset
}
if (sheetSize.height != 0) {
Expanded at max(0f, fullHeight - sheetSize.height)
}
}
val newTarget = when (sheetState.anchoredDraggableState.targetValue) {
Hidden -> Hidden
PartiallyExpanded -> if (newAnchors.hasAnchorFor(PartiallyExpanded)) {
PartiallyExpanded
} else if (newAnchors.hasAnchorFor(Expanded)) {
Expanded
} else {
Hidden
}
Expanded -> if (newAnchors.hasAnchorFor(Expanded)) {
Expanded
} else if (newAnchors.hasAnchorFor(PartiallyExpanded)) {
PartiallyExpanded
} else {
Hidden
}
}
sheetState.anchoredDraggableState.updateAnchors(newAnchors, newTarget)
layout(placeable.width, placeable.height) {
placeable.placeRelative(0, 0)
}
}
.semantics { paneTitle = bottomSheetPaneTitle }
.offset {
IntOffset(
0,
max(
0,
sheetState
.requireOffset()
.toInt(),
),
)
}
.nestedScroll(
remember(sheetState) {
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState = sheetState,
orientation = Orientation.Vertical,
onFling = settleToDismiss,
)
},
)
.anchoredDraggable(
state = sheetState.anchoredDraggableState,
orientation = Orientation.Vertical,
enabled = sheetState.isVisible,
startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
)
.graphicsLayer {
val predictiveBackState = predictiveBackStateHolder.value
val scale = lerp(
1f,
1f - (48.dp.toPx() / fullWidth),
when (predictiveBackState) {
CompletablePredictiveBackState.NotRunning -> 0f
is CompletablePredictiveBackState.Running -> predictiveBackState.progress
CompletablePredictiveBackState.Completed -> if (lastRunningValue == null) 0f else 1f
},
)
scaleX = scale
scaleY = scale
// Set the transform origin to be at the point of the sheet that is at the bottom
// edge of the screen
transformOrigin = TransformOrigin(
0.5f,
(size.height - sheetState.requireOffset()) / size.height,
)
},
shape = shape,
color = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
) {
Column(
Modifier
.fillMaxWidth()
.windowInsetsPadding(contentWindowInsets()),
) {
if (dragHandle != null) {
val collapseActionLabel =
"collapse" // TODO: getString(Strings.BottomSheetPartialExpandDescription)
val dismissActionLabel = "dismiss" // TODO: getString(Strings.BottomSheetDismissDescription)
val expandActionLabel = "expand" // TODO: 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 (sheetState.confirmValueChange(Expanded)) {
scope.launch { sheetState.expand() }
}
true
}
} else if (hasPartiallyExpandedState) {
collapse(collapseActionLabel) {
if (sheetState.confirmValueChange(PartiallyExpanded)) {
scope.launch { partialExpand() }
}
true
}
}
}
},
) {
dragHandle()
}
}
content()
}
}
}
}
}
@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)
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState: SheetState,
orientation: Orientation,
onFling: (velocity: Float) -> Unit,
): NestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) {
sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
return if (source == NestedScrollSource.Drag) {
sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = available.toFloat()
val currentOffset = sheetState.requireOffset()
val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor()
return if (toFling < 0 && currentOffset > minAnchor) {
onFling(toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
onFling(available.toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(
x = if (orientation == Orientation.Horizontal) this else 0f,
y = if (orientation == Orientation.Vertical) this else 0f,
)
@JvmName("velocityToFloat")
private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
@JvmName("offsetToFloat")
private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
}
/**
* State of a sheet composable, such as [ModalBottomSheet]
*
* Contains states relating to its swipe position as well as animations between state values.
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
* interaction.
* @param density The density that this state can use to convert values to and from dp.
* @param initialValue The initial value of the state.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
* programmatically or by user interaction.
*/
@Stable
@ExperimentalMaterial3Api
@OptIn(ExperimentalFoundationApi::class)
class SheetState(
internal val skipPartiallyExpanded: Boolean,
internal var density: Density,
initialValue: SheetValue = Hidden,
internal val confirmValueChange: (SheetValue) -> Boolean = { true },
internal val skipHiddenState: Boolean = false,
) {
init {
if (skipPartiallyExpanded) {
require(initialValue != PartiallyExpanded) {
"The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
"is set to true."
}
}
if (skipHiddenState) {
require(initialValue != Hidden) {
"The initial value must not be set to Hidden if skipHiddenState is set to true."
}
}
}
/**
* The current value of the state.
*
* If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
* currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
* was in before the swipe or animation started.
*/
val currentValue: SheetValue get() = anchoredDraggableState.currentValue
/**
* The target value of the bottom sheet state.
*
* If a swipe is in progress, this is the value that the sheet would animate to if the
* swipe finishes. If an animation is running, this is the target value of that animation.
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
*/
val targetValue: SheetValue get() = anchoredDraggableState.targetValue
/**
* Whether the modal bottom sheet is visible.
*/
val isVisible: Boolean
get() = anchoredDraggableState.currentValue != Hidden
/**
* Require the current offset (in pixels) of the bottom sheet.
*
* The offset will be initialized during the first measurement phase of the provided sheet
* content.
*
* These are the phases:
* Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
*
* During the first composition, an [IllegalStateException] is thrown. In subsequent
* compositions, the offset will be derived from the anchors of the previous pass. Always prefer
* accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next
* frame, after layout.
*
* @throws IllegalStateException If the offset has not been initialized yet
*/
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
/**
* Whether the sheet has an expanded state defined.
*/
val hasExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(Expanded)
/**
* Whether the modal bottom sheet has a partially expanded state defined.
*/
val hasPartiallyExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(PartiallyExpanded)
/**
* Fully expand the bottom sheet with animation and suspend until it is fully expanded or
* animation has been cancelled.
* *
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun expand() {
anchoredDraggableState.animateTo(Expanded)
}
/**
* Animate the bottom sheet and suspend until it is partially expanded or animation has been
* cancelled.
* @throws [CancellationException] if the animation is interrupted
* @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true
*/
suspend fun partialExpand() {
check(!skipPartiallyExpanded) {
"Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
" skipPartiallyExpanded to false to use this function."
}
animateTo(PartiallyExpanded)
}
/**
* Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined
* else [Expanded].
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun show() {
val targetValue = when {
hasPartiallyExpandedState -> PartiallyExpanded
else -> Expanded
}
animateTo(targetValue)
}
/**
* Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
* been cancelled.
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun hide() {
check(!skipHiddenState) {
"Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
" to false to use this function."
}
animateTo(Hidden)
}
/**
* Animate to a [targetValue].
* If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
* [targetValue] without updating the offset.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
internal suspend fun animateTo(
targetValue: SheetValue,
velocity: Float = anchoredDraggableState.lastVelocity,
) {
anchoredDraggableState.animateToWithDecay(targetValue, velocity)
}
/**
* Snap to a [targetValue] without any animation.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
internal suspend fun snapTo(targetValue: SheetValue) {
anchoredDraggableState.snapTo(targetValue)
}
/**
* Find the closest anchor taking into account the velocity and settle at it with an animation.
*/
internal suspend fun settle(velocity: Float) {
anchoredDraggableState.settle(velocity)
}
internal var anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue,
snapAnimationSpec = spring(),
decayAnimationSpec = exponentialDecay(),
confirmValueChange = confirmValueChange,
positionalThreshold = { with(density) { 56.dp.toPx() } },
velocityThreshold = { with(density) { 125.dp.toPx() } },
)
internal val offset: Float get() = anchoredDraggableState.offset
companion object {
/**
* The default [Saver] implementation for [SheetState].
*/
fun Saver(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean,
density: Density,
) = Saver<SheetState, SheetValue>(
save = { it.currentValue },
restore = { savedValue ->
SheetState(skipPartiallyExpanded, density, savedValue, confirmValueChange)
},
)
}
}
@Composable
@ExperimentalMaterial3Api
internal fun rememberSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
initialValue: SheetValue = Hidden,
skipHiddenState: Boolean = false,
): SheetState {
val density = LocalDensity.current
return rememberSaveable(
skipPartiallyExpanded,
confirmValueChange,
saver = SheetState.Saver(
skipPartiallyExpanded = skipPartiallyExpanded,
confirmValueChange = confirmValueChange,
density = density,
),
) {
SheetState(
skipPartiallyExpanded,
density,
initialValue,
confirmValueChange,
skipHiddenState,
)
}
}
/**
* 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)
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
internal fun EdgeToEdgeModalBottomSheetPreview() {
var showEdgeToEdgeModalBottomSheet by rememberSaveable { mutableStateOf(false) }
var showModalBottomSheet by rememberSaveable { mutableStateOf(false) }
val context = LocalContext.current
SideEffect {
(context as? ComponentActivity)?.enableEdgeToEdge()
}
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
BasicText(
modifier = Modifier
.height(64.dp)
.clickable { showEdgeToEdgeModalBottomSheet = true },
text = "Show edge-to-edge modal bottom sheet",
)
BasicText(
modifier = Modifier
.height(64.dp)
.clickable { showModalBottomSheet = true },
text = "Show built-in modal bottom sheet",
)
}
if (showEdgeToEdgeModalBottomSheet) {
EdgeToEdgeModalBottomSheet(
onDismissRequest = { showEdgeToEdgeModalBottomSheet = false },
) {
LazyColumn {
items(100) { _ ->
var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue())
}
TextField(value = textFieldValue, onValueChange = { textFieldValue = it })
}
}
}
}
if (showModalBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showModalBottomSheet = false },
) {
LazyColumn {
items(100) { _ ->
var textFieldValue by remember {
mutableStateOf(TextFieldValue())
}
TextField(value = textFieldValue, onValueChange = { textFieldValue = it })
}
}
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024 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.
-->
<resources>
<item name="compose_view_saveable_id_tag" type="id" />
</resources>
/*
* Copyright 2024 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
*
* https://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.
*/
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
/**
* The state describing a one-shot back state, with use in a [CompletablePredictiveBackStateHandler].
*
* Because the back handler can only be used once there are three states that [CompletablePredictiveBackState] can
* be in:
*
* - [NotRunning]
* - [Running], which can happen on API 34 and above if a predictive back is in progress.
* - [Completed]
*/
sealed interface CompletablePredictiveBackState {
/**
* There is no predictive back ongoing, and the back has not been completed.
*/
data object NotRunning : CompletablePredictiveBackState
/**
* There is an ongoing predictive back animation, with the given [progress].
*/
data class Running(
val touchX: Float,
val touchY: Float,
val progress: Float,
val swipeEdge: SwipeEdge,
) : CompletablePredictiveBackState
/**
* The back has completed.
*/
data object Completed : CompletablePredictiveBackState
}
@Composable
fun rememberCompletablePredictiveBackStateHolder(): CompletablePredictiveBackStateHolder =
remember {
CompletablePredictiveBackStateHolderImpl()
}
sealed interface CompletablePredictiveBackStateHolder {
val value: CompletablePredictiveBackState
}
internal class CompletablePredictiveBackStateHolderImpl : CompletablePredictiveBackStateHolder {
override var value: CompletablePredictiveBackState by mutableStateOf(CompletablePredictiveBackState.NotRunning)
}
@Composable
CompletablePredictiveBackStateHandler(
completablePredictiveBackStateHolder: CompletablePredictiveBackStateHolder,
enabled: Boolean = true,
onBack: () -> Unit,
) {
// Safely update the current `onBack` lambda when a new one is provided
val currentOnBack by rememberUpdatedState(onBack)
key(completablePredictiveBackStateHolder) {
when (completablePredictiveBackStateHolder) {
is CompletablePredictiveBackStateHolderImpl -> Unit
}
PredictiveBackHandler(
enabled = enabled &&
completablePredictiveBackStateHolder.value !is CompletablePredictiveBackState.Completed,
) { progress ->
try {
progress.collect { backEvent ->
backEvent.swipeEdge
completablePredictiveBackStateHolder.value = CompletablePredictiveBackState.Running(
backEvent.touchX,
backEvent.touchY,
backEvent.progress,
when (backEvent.swipeEdge) {
BackEventCompat.EDGE_LEFT -> SwipeEdge.Left
BackEventCompat.EDGE_RIGHT -> SwipeEdge.Right
else -> error("Unknown swipe edge")
},
)
}
completablePredictiveBackStateHolder.value = CompletablePredictiveBackState.Completed
currentOnBack()
} catch (cancellationException: CancellationException) {
completablePredictiveBackStateHolder.value = CompletablePredictiveBackState.NotRunning
throw cancellationException
}
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024 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.
-->
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="EdgeToEdgeFloatingDialogWindowTheme">
<item name="android:dialogTheme">@style/EdgeToEdgeFloatingDialogTheme</item>
</style>
<style name="EdgeToEdgeFloatingDialogTheme" parent="android:Theme.DeviceDefault.Dialog">
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="27">always</item>
<item name="android:windowClipToOutline">false</item>
<item name="android:windowIsFloating">false</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowElevation">0dp</item>
</style>
<style name="PlatformEdgeToEdgeFloatingDialogWindowTheme">
<item name="android:dialogTheme">@style/PlatformEdgeToEdgeFloatingDialogTheme</item>
</style>
<style name="PlatformEdgeToEdgeFloatingDialogTheme" parent="EdgeToEdgeFloatingDialogTheme">
<item name="android:backgroundDimEnabled">true</item>
</style>
</resources>
@alexvanyo
Copy link
Author

This is an experimental re-implementation of Dialog, AlertDialog and ModalBottomSheet in Compose.

The base component, EdgeToEdgeDialog is always edge-to-edge, and gives the content the full screen to render.
This allows building components on top of it with more flexibility, including drawing the scrim in Compose, handling size logic fully in Compose, and handling predictive back behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment