Last active
September 17, 2024 01:48
-
-
Save alexvanyo/594abce742ecd9f973cb1162ec49df12 to your computer and use it in GitHub Desktop.
EdgeToEdgeDialogs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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 | |
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") | |
} | |
}, | |
) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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 | |
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") | |
} | |
}, | |
) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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.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, | |
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() }, | |
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() | |
} | |
var fullWidth by remember { mutableStateOf(0) } | |
EdgeToEdgeDialog( | |
properties = properties, | |
onDismissRequest = { | |
if (sheetState.currentValue == Expanded && sheetState.hasPartiallyExpandedState) { | |
scope.launch { sheetState.partialExpand() } | |
} else { // Is expanded without collapsed state or is collapsed. | |
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 | |
Surface( | |
modifier = modifier | |
.fillMaxSize() | |
.windowInsetsPadding( | |
WindowInsets.safeDrawing.only( | |
WindowInsetsSides.Horizontal + WindowInsetsSides.Top, | |
), | |
) | |
.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 -> 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(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), | |
) { | |
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 | |
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 }) | |
} | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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 | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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> | |
</style> | |
</resources> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is an experimental re-implementation of
Dialog
,AlertDialog
andModalBottomSheet
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.