Skip to content

Instantly share code, notes, and snippets.

@alexvanyo
Last active September 17, 2024 01:48

Revisions

  1. alexvanyo revised this gist Sep 17, 2024. 1 changed file with 0 additions and 2 deletions.
    2 changes: 0 additions & 2 deletions PredictiveBackStateHolder.kt
    Original file line number Diff line number Diff line change
    @@ -16,8 +16,6 @@

    @file:Suppress("MatchingDeclarationName", "Filename")

    package com.alexvanyo.composelife.ui.util

    import androidx.compose.runtime.Composable
    import androidx.compose.runtime.getValue
    import androidx.compose.runtime.mutableStateOf
  2. alexvanyo revised this gist Sep 17, 2024. 4 changed files with 64 additions and 45 deletions.
    2 changes: 1 addition & 1 deletion EdgeToEdgeAlertDialog.kt
    Original file line number Diff line number Diff line change
    @@ -409,4 +409,4 @@ internal fun EdgeToEdgeAlertDialogPreview() {
    },
    )
    }
    }
    }
    1 change: 1 addition & 0 deletions EdgeToEdgeDialog.kt
    Original file line number Diff line number Diff line change
    @@ -326,6 +326,7 @@ fun PlatformEdgeToEdgeDialog(
    }
    }

    @Suppress("LongParameterList")
    private class DialogWrapper(
    private var onDismissRequest: () -> Unit,
    private var properties: DialogProperties,
    6 changes: 3 additions & 3 deletions EdgeToEdgeModalBottomSheet.kt
    Original file line number Diff line number Diff line change
    @@ -388,7 +388,7 @@ internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
    ): NestedScrollConnection = object : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
    val delta = available.toFloat()
    return if (delta < 0 && source == NestedScrollSource.Drag) {
    return if (delta < 0 && source == NestedScrollSource.UserInput) {
    sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
    } else {
    Offset.Zero
    @@ -400,7 +400,7 @@ internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
    available: Offset,
    source: NestedScrollSource,
    ): Offset {
    return if (source == NestedScrollSource.Drag) {
    return if (source == NestedScrollSource.UserInput) {
    sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
    } else {
    Offset.Zero
    @@ -750,4 +750,4 @@ internal fun EdgeToEdgeModalBottomSheetPreview() {
    }
    }
    }
    }
    }
    100 changes: 59 additions & 41 deletions PredictiveBackStateHolder.kt
    Original file line number Diff line number Diff line change
    @@ -1,5 +1,5 @@
    /*
    * Copyright 2024 The Android Open Source Project
    * Copyright 2023 The Android Open Source Project
    *
    * Licensed under the Apache License, Version 2.0 (the "License");
    * you may not use this file except in compliance with the License.
    @@ -14,16 +14,42 @@
    * limitations under the License.
    */

    import androidx.activity.BackEventCompat
    import androidx.activity.compose.PredictiveBackHandler
    @file:Suppress("MatchingDeclarationName", "Filename")

    package com.alexvanyo.composelife.ui.util

    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 repeatable back state, with use in a [RepeatablePredictiveBackHandler].
    *
    * Because the back handler can be used repeatedly, there are only two states that [RepeatablePredictiveBackState] can
    * be in:
    *
    * - [NotRunning], which will always be the case on API 33 and below
    * - [Running], which can happen on API 34 and above if a predictive back is in progress.
    */
    sealed interface RepeatablePredictiveBackState {
    /**
    * There is no predictive back ongoing. On API 33 and below, this will always be the case.
    */
    data object NotRunning : RepeatablePredictiveBackState

    /**
    * 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,
    ) : RepeatablePredictiveBackState
    }

    /**
    * The state describing a one-shot back state, with use in a [CompletablePredictiveBackStateHandler].
    *
    @@ -56,6 +82,32 @@ sealed interface CompletablePredictiveBackState {
    data object Completed : CompletablePredictiveBackState
    }

    sealed interface SwipeEdge {
    data object Left : SwipeEdge
    data object Right : SwipeEdge
    }

    @Composable
    fun rememberRepeatablePredictiveBackStateHolder(): RepeatablePredictiveBackStateHolder =
    remember {
    RepeatablePredictiveBackStateHolderImpl()
    }

    sealed interface RepeatablePredictiveBackStateHolder {
    val value: RepeatablePredictiveBackState
    }

    internal class RepeatablePredictiveBackStateHolderImpl : RepeatablePredictiveBackStateHolder {
    override var value: RepeatablePredictiveBackState by mutableStateOf(RepeatablePredictiveBackState.NotRunning)
    }

    @Composable
    expect fun RepeatablePredictiveBackHandler(
    repeatablePredictiveBackStateHolder: RepeatablePredictiveBackStateHolder,
    enabled: Boolean = true,
    onBack: () -> Unit,
    )

    @Composable
    fun rememberCompletablePredictiveBackStateHolder(): CompletablePredictiveBackStateHolder =
    remember {
    @@ -71,42 +123,8 @@ internal class CompletablePredictiveBackStateHolderImpl : CompletablePredictiveB
    }

    @Composable
    CompletablePredictiveBackStateHandler(
    expect fun 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
    }
    }
    }
    }
    )
  3. alexvanyo revised this gist Jan 30, 2024. 2 changed files with 18 additions and 22 deletions.
    33 changes: 11 additions & 22 deletions EdgeToEdgeDialog.kt
    Original file line number Diff line number Diff line change
    @@ -38,6 +38,7 @@ 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
    @@ -101,7 +102,8 @@ import java.util.UUID
    * 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]. For the most simple implementation of this that acts like a platform dialog, use
    * 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
    @@ -114,6 +116,7 @@ import java.util.UUID
    fun EdgeToEdgeDialog(
    onDismissRequest: () -> Unit,
    properties: DialogProperties = DialogProperties(),
    windowTheme: Int = R.style.EdgeToEdgeFloatingDialogWindowTheme,
    content: @Composable (CompletablePredictiveBackStateHolder) -> Unit,
    ) {
    val view = LocalView.current
    @@ -130,6 +133,7 @@ fun EdgeToEdgeDialog(
    onDismissRequest = onDismissRequest,
    properties = properties,
    composeView = view,
    windowTheme = windowTheme,
    layoutDirection = layoutDirection,
    density = density,
    dialogId = dialogId,
    @@ -188,8 +192,7 @@ fun PlatformEdgeToEdgeDialog(
    onDismissRequest: () -> Unit,
    properties: DialogProperties = DialogProperties(),
    scrim: @Composable () -> Unit = {
    val scrimColor = Color.Black.copy(alpha = 0.6f)
    Canvas(
    Spacer(
    modifier = Modifier
    .fillMaxSize()
    .then(
    @@ -201,17 +204,12 @@ fun PlatformEdgeToEdgeDialog(
    Modifier
    },
    ),
    ) {
    drawRect(
    color = scrimColor,
    topLeft = -Offset(size.width, size.height),
    size = size * 3f,
    )
    }
    )
    },
    content: @Composable () -> Unit,
    ) = EdgeToEdgeDialog(
    onDismissRequest = onDismissRequest,
    windowTheme = R.style.PlatformEdgeToEdgeFloatingDialogWindowTheme,
    properties = properties,
    ) { predictiveBackStateHolder ->
    Box(
    @@ -332,20 +330,11 @@ private class DialogWrapper(
    private var onDismissRequest: () -> Unit,
    private var properties: DialogProperties,
    private val composeView: View,
    windowTheme: Int,
    layoutDirection: LayoutDirection,
    density: Density,
    dialogId: UUID,
    ) : ComponentDialog(
    /**
    * [Window.setClipToOutline] is only available from 22+, but the style attribute exists on 21.
    * So use a wrapped context that sets this attribute for compatibility back to 21.
    */
    ContextThemeWrapper(
    composeView.context,
    R.style.EdgeToEdgeFloatingDialogWindowTheme,
    ),
    ),
    ViewRootForInspector {
    ) : ComponentDialog(ContextThemeWrapper(composeView.context, windowTheme)), ViewRootForInspector {

    private val dialogLayout: DialogLayout

    @@ -641,4 +630,4 @@ internal fun EdgeToEdgeDialogPreview() {
    }
    }
    }
    }
    }
    7 changes: 7 additions & 0 deletions styles.xml
    Original file line number Diff line number Diff line change
    @@ -26,5 +26,12 @@
    <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>
  4. alexvanyo revised this gist Jan 29, 2024. 3 changed files with 561 additions and 317 deletions.
    4 changes: 2 additions & 2 deletions EdgeToEdgeAlertDialog.kt
    Original file line number Diff line number Diff line change
    @@ -352,7 +352,7 @@ private val ButtonsCrossAxisSpacing = 12.dp

    @Preview
    @Composable
    fun EdgeToEdgeAlertDialogPreview() {
    internal fun EdgeToEdgeAlertDialogPreview() {
    var showEdgeToEdgeAlertDialog by rememberSaveable { mutableStateOf(false) }
    var showAlertDialog by rememberSaveable { mutableStateOf(false) }

    @@ -409,4 +409,4 @@ fun EdgeToEdgeAlertDialogPreview() {
    },
    )
    }
    }
    }
    830 changes: 531 additions & 299 deletions EdgeToEdgeDialog.kt
    Original file line number Diff line number Diff line change
    @@ -14,347 +14,535 @@
    * 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.PaddingValues
    import androidx.compose.foundation.layout.fillMaxSize
    import androidx.compose.foundation.layout.fillMaxWidth
    import androidx.compose.foundation.layout.height
    import androidx.compose.foundation.layout.padding
    import androidx.compose.foundation.layout.sizeIn
    import androidx.compose.foundation.layout.safeDrawingPadding
    import androidx.compose.foundation.layout.widthIn
    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.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.Shape
    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.layout.Placeable
    import androidx.compose.ui.platform.AbstractComposeView
    import androidx.compose.ui.platform.LocalConfiguration
    import androidx.compose.ui.platform.LocalContext
    import androidx.compose.ui.semantics.paneTitle
    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.text.TextStyle
    import androidx.compose.ui.tooling.preview.Preview
    import androidx.compose.ui.unit.Dp
    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.fastForEachIndexed
    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 kotlin.math.max
    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

    @Suppress("LongParameterList", "LongMethod")
    /**
    * 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]. 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 EdgeToEdgeAlertDialog(
    fun EdgeToEdgeDialog(
    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,
    content: @Composable (CompletablePredictiveBackStateHolder) -> Unit,
    ) {
    AlertDialogContent(
    buttons = {
    AlertDialogFlowRow(
    mainAxisSpacing = ButtonsMainAxisSpacing,
    crossAxisSpacing = ButtonsCrossAxisSpacing,
    ) {
    dismissButton?.invoke()
    confirmButton()
    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,
    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)
    }
    }
    },
    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,
    )
    }
    }

    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 EdgeToEdgeBasicAlertDialog(
    fun PlatformEdgeToEdgeDialog(
    onDismissRequest: () -> Unit,
    modifier: Modifier = Modifier,
    properties: DialogProperties = DialogProperties(),
    scrim: @Composable () -> Unit = {
    val scrimColor = Color.Black.copy(alpha = 0.6f)
    Canvas(
    modifier = Modifier
    .fillMaxSize()
    .then(
    if (properties.dismissOnClickOutside) {
    Modifier.pointerInput(Unit) {
    detectTapGestures { onDismissRequest() }
    }
    } else {
    Modifier
    },
    ),
    ) {
    drawRect(
    color = scrimColor,
    topLeft = -Offset(size.width, size.height),
    size = size * 3f,
    )
    }
    },
    content: @Composable () -> Unit,
    ) {
    PlatformEdgeToEdgeDialog(
    onDismissRequest = onDismissRequest,
    properties = properties,
    ) = EdgeToEdgeDialog(
    onDismissRequest = onDismissRequest,
    properties = properties,
    ) { predictiveBackStateHolder ->
    Box(
    modifier = Modifier.fillMaxSize(),
    contentAlignment = Alignment.Center,
    ) {
    val dialogPaneDescription = "dialog" // TODO: getString(string = Strings.BottomSheetPaneTitle)
    Box(
    modifier = modifier
    .sizeIn(minWidth = DialogMinWidth, maxWidth = DialogMaxWidth)
    .then(Modifier.semantics { paneTitle = dialogPaneDescription }),
    propagateMinConstraints = true,
    ) {
    content()
    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
    }
    }
    }

    @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()
    }
    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
    }
    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()
    }
    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
    }
    }
    }
    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()
    },
    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
    }
    }
    }
    Box(modifier = Modifier.align(Alignment.End)) {
    val textStyle =
    MaterialTheme.typography.labelLarge
    ProvideContentColorTextStyle(
    contentColor = buttonContentColor,
    textStyle = textStyle,
    content = buttons,
    )
    }
    },
    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()
    }
    }
    }

    /**
    * 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,
    )
    }
    private class DialogWrapper(
    private var onDismissRequest: () -> Unit,
    private var properties: DialogProperties,
    private val composeView: View,
    layoutDirection: LayoutDirection,
    density: Density,
    dialogId: UUID,
    ) : ComponentDialog(
    /**
    * [Window.setClipToOutline] is only available from 22+, but the style attribute exists on 21.
    * So use a wrapped context that sets this attribute for compatibility back to 21.
    */
    ContextThemeWrapper(
    composeView.context,
    R.style.EdgeToEdgeFloatingDialogWindowTheme,
    ),
    ),
    ViewRootForInspector {

    /**
    * 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
    }
    private val dialogLayout: DialogLayout

    measurables.fastForEach { measurable ->
    // Ask the child for its preferred size.
    val placeable = measurable.measure(constraints)
    // 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

    // Start a new sequence if there is not enough space.
    if (!canAddToCurrentSequence(placeable)) startNewSequence()
    override val subCompositionView: AbstractComposeView get() = dialogLayout

    // Add the child to the current sequence.
    if (currentSequence.isNotEmpty()) {
    currentMainAxisSize += mainAxisSpacing.roundToPx()
    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
    }
    }
    currentSequence.add(placeable)
    currentMainAxisSize += placeable.width
    currentCrossAxisSize = max(currentCrossAxisSize, placeable.height)
    }

    if (currentSequence.isNotEmpty()) startNewSequence()
    /**
    * 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()
    }
    }

    val mainAxisLayoutSize = max(mainAxisSpace, constraints.minWidth)
    // Turn of all clipping so shadows can be drawn outside the window
    (window.decorView as? ViewGroup)?.disableClipping()

    val crossAxisLayoutSize = max(crossAxisSpace, constraints.minHeight)
    WindowCompat.getInsetsController(window, window.decorView).apply {
    isAppearanceLightStatusBars = false
    isAppearanceLightNavigationBars = false
    }

    val layoutWidth = mainAxisLayoutSize
    setContentView(dialogLayout)
    dialogLayout.setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
    dialogLayout.setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
    dialogLayout.setViewTreeSavedStateRegistryOwner(
    composeView.findViewTreeSavedStateRegistryOwner(),
    )

    val layoutHeight = crossAxisLayoutSize
    // Initial setup
    updateParameters(onDismissRequest, properties, layoutDirection)
    }

    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],
    )
    }
    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
    }
    }

    internal val DialogMinWidth = 280.dp
    internal val DialogMaxWidth = 560.dp
    @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()
    }
    }

    // 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)
    @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) }
    }
    }
    }

    private val ButtonsMainAxisSpacing = 8.dp
    private val ButtonsCrossAxisSpacing = 12.dp
    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
    fun EdgeToEdgeAlertDialogPreview() {
    var showEdgeToEdgeAlertDialog by rememberSaveable { mutableStateOf(false) }
    var showAlertDialog by rememberSaveable { mutableStateOf(false) }
    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 {
    @@ -369,44 +557,88 @@ fun EdgeToEdgeAlertDialogPreview() {
    BasicText(
    modifier = Modifier
    .height(64.dp)
    .clickable { showEdgeToEdgeAlertDialog = true },
    text = "Show edge-to-edge alert dialog",
    .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 { showAlertDialog = true },
    text = "Show built-in alert dialog",
    .clickable { showPlatformEdgeToEdgeDialog = true },
    text = "Show platform edge-to-edge dialog",
    )
    }

    if (showEdgeToEdgeAlertDialog) {
    val onDismissRequest = { showEdgeToEdgeAlertDialog = false }
    EdgeToEdgeAlertDialog(
    onDismissRequest = onDismissRequest,
    text = {
    Text("This is an alert dialog")
    },
    confirmButton = {
    Button(onClick = onDismissRequest) {
    Text("OK")
    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 (showAlertDialog) {
    val onDismissRequest = { showAlertDialog = false }
    AlertDialog(
    onDismissRequest = onDismissRequest,
    text = {
    Text("This is an alert dialog")
    },
    confirmButton = {
    Button(onClick = onDismissRequest) {
    Text("OK")
    }
    },
    )
    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")
    }
    }
    }
    }
    44 changes: 28 additions & 16 deletions EdgeToEdgeModalBottomSheet.kt
    Original file line number Diff line number Diff line change
    @@ -64,6 +64,7 @@ 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
    @@ -118,9 +119,15 @@ fun EdgeToEdgeModalBottomSheet(
    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() },
    @@ -157,16 +164,10 @@ fun EdgeToEdgeModalBottomSheet(
    .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() }
    }
    scope.launch { sheetState.hide() }
    },
    ) { predictiveBackStateHolder ->
    Box(
    @@ -181,14 +182,25 @@ fun EdgeToEdgeModalBottomSheet(

    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(
    WindowInsets.safeDrawing.only(
    WindowInsetsSides.Horizontal + WindowInsetsSides.Top,
    ),
    )
    .windowInsetsPadding(sheetWindowInsets())
    .wrapContentSize()
    .widthIn(max = sheetMaxWidth)
    .fillMaxWidth()
    @@ -270,7 +282,7 @@ fun EdgeToEdgeModalBottomSheet(
    when (predictiveBackState) {
    CompletablePredictiveBackState.NotRunning -> 0f
    is CompletablePredictiveBackState.Running -> predictiveBackState.progress
    CompletablePredictiveBackState.Completed -> 1f
    CompletablePredictiveBackState.Completed -> if (lastRunningValue == null) 0f else 1f
    },
    )
    scaleX = scale
    @@ -290,7 +302,7 @@ fun EdgeToEdgeModalBottomSheet(
    Column(
    Modifier
    .fillMaxWidth()
    .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
    .windowInsetsPadding(contentWindowInsets()),
    ) {
    if (dragHandle != null) {
    val collapseActionLabel =
    @@ -681,7 +693,7 @@ fun rememberModalBottomSheetState(
    @OptIn(ExperimentalMaterial3Api::class)
    @Preview
    @Composable
    fun EdgeToEdgeModalBottomSheetPreview() {
    internal fun EdgeToEdgeModalBottomSheetPreview() {
    var showEdgeToEdgeModalBottomSheet by rememberSaveable { mutableStateOf(false) }
    var showModalBottomSheet by rememberSaveable { mutableStateOf(false) }

    @@ -738,4 +750,4 @@ fun EdgeToEdgeModalBottomSheetPreview() {
    }
    }
    }
    }
    }
  5. alexvanyo revised this gist Jan 27, 2024. 4 changed files with 806 additions and 522 deletions.
    412 changes: 412 additions & 0 deletions EdgeToEdgeAlertDialog.kt
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,412 @@
    /*
    * 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")
    }
    },
    )
    }
    }
    767 changes: 306 additions & 461 deletions EdgeToEdgeDialog.kt
    Original file line number Diff line number Diff line change
    @@ -14,463 +14,352 @@
    * 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.ComponentDialog
    import androidx.compose.foundation.Canvas
    import androidx.compose.foundation.background
    import androidx.activity.ComponentActivity
    import androidx.activity.enableEdgeToEdge
    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.PaddingValues
    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.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.CompositionContext
    import androidx.compose.runtime.DisposableEffect
    import androidx.compose.runtime.CompositionLocalProvider
    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.graphicsLayer
    import androidx.compose.ui.input.pointer.pointerInput
    import androidx.compose.ui.graphics.Shape
    import androidx.compose.ui.layout.Layout
    import androidx.compose.ui.platform.AbstractComposeView
    import androidx.compose.ui.platform.LocalConfiguration
    import androidx.compose.ui.layout.Placeable
    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.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.Density
    import androidx.compose.ui.unit.LayoutDirection
    import androidx.compose.ui.unit.Dp
    import androidx.compose.ui.unit.dp
    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.util.fastForEachIndexed
    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
    import kotlin.math.max

    /**
    * 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]. 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 [PredictiveBackStateHolder] that encapsulates the predictive back state if
    * [DialogProperties.dismissOnBackPress] is true.
    */
    @Suppress("LongParameterList", "LongMethod")
    @Composable
    fun EdgeToEdgeDialog(
    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(),
    content: @Composable (PredictiveBackStateHolder) -> Unit
    ) = EdgeToEdgeBasicAlertDialog(
    onDismissRequest = onDismissRequest,
    modifier = modifier,
    properties = properties,
    ) {
    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,
    layoutDirection = layoutDirection,
    density = density,
    dialogId = dialogId
    ).apply {
    setContent(composition) {
    val predictiveBackStateHolder = rememberPredictiveBackStateHolder()

    PredictiveBackHandler(
    predictiveBackStateHolder = predictiveBackStateHolder,
    enabled = currentDismissOnBackPress,
    onBack = { currentOnDismissRequest() },
    )

    DialogLayout(
    Modifier.semantics { dialog() },
    ) {
    currentContent(predictiveBackStateHolder)
    }
    AlertDialogContent(
    buttons = {
    AlertDialogFlowRow(
    mainAxisSpacing = ButtonsMainAxisSpacing,
    crossAxisSpacing = ButtonsCrossAxisSpacing,
    ) {
    dismissButton?.invoke()
    confirmButton()
    }
    }
    }

    DisposableEffect(dialog) {
    dialog.show()

    onDispose {
    dialog.dismiss()
    dialog.disposeComposition()
    }
    }

    SideEffect {
    dialog.updateParameters(
    onDismissRequest = onDismissRequest,
    properties = properties,
    layoutDirection = layoutDirection
    )
    }
    },
    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,
    )
    }

    /**
    * 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")
    @Composable
    fun PlatformEdgeToEdgeDialog(
    fun EdgeToEdgeBasicAlertDialog(
    onDismissRequest: () -> Unit,
    modifier: Modifier = Modifier,
    properties: DialogProperties = DialogProperties(),
    scrim: @Composable () -> Unit = {
    val scrimColor = Color.Black.copy(alpha = 0.6f)
    Canvas(
    modifier = Modifier
    .fillMaxSize()
    .then(
    if (properties.dismissOnClickOutside) {
    Modifier.pointerInput(Unit) {
    detectTapGestures { onDismissRequest() }
    }
    } else {
    Modifier
    }
    )
    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,
    ) {
    drawRect(
    color = scrimColor,
    topLeft = -Offset(size.width, size.height),
    size = size * 3f
    )
    content()
    }
    },
    content: @Composable () -> Unit
    ) = EdgeToEdgeDialog(
    onDismissRequest = onDismissRequest,
    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)
    @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()
    }
    }
    }
    when (typedValue.type) {
    TypedValue.TYPE_DIMENSION -> {
    Modifier.widthIn(
    min = with(LocalDensity.current) {
    typedValue.getDimension(context.resources.displayMetrics).toDp()
    }
    )
    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()
    }
    }
    TypedValue.TYPE_FRACTION ->
    Modifier.fillMaxWidth(fraction = typedValue.getFraction(1f,1f))
    else -> Modifier
    }
    } else {
    Modifier
    }

    Box(
    modifier = Modifier
    .safeDrawingPadding()
    .graphicsLayer {
    val predictiveBackState = predictiveBackStateHolder.value
    val scale = lerp(
    1f,
    0.9f,
    when (predictiveBackState) {
    PredictiveBackState.NotRunning -> 0f
    is PredictiveBackState.Running -> predictiveBackState.progress
    }
    )
    scaleX = scale
    scaleY = scale
    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()
    }
    }
    .then(sizeModifier)
    ) {
    content()
    }
    Box(modifier = Modifier.align(Alignment.End)) {
    val textStyle =
    MaterialTheme.typography.labelLarge
    ProvideContentColorTextStyle(
    contentColor = buttonContentColor,
    textStyle = textStyle,
    content = buttons,
    )
    }
    }
    }
    }

    private class DialogWrapper(
    private var onDismissRequest: () -> Unit,
    private var properties: DialogProperties,
    private val composeView: View,
    layoutDirection: LayoutDirection,
    density: Density,
    dialogId: UUID
    ) : ComponentDialog(
    /**
    * [Window.setClipToOutline] is only available from 22+, but the style attribute exists on 21.
    * So use a wrapped context that sets this attribute for compatibility back to 21.
    */
    ContextThemeWrapper(
    composeView.context,
    R.style.EdgeToEdgeFloatingDialogWindowTheme,
    /**
    * 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,
    )
    ), 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
    }
    /**
    * 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
    }

    /**
    * 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()
    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)
    }

    // Turn of all clipping so shadows can be drawn outside the window
    (window.decorView as? ViewGroup)?.disableClipping()
    setContentView(dialogLayout)
    dialogLayout.setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
    dialogLayout.setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
    dialogLayout.setViewTreeSavedStateRegistryOwner(
    composeView.findViewTreeSavedStateRegistryOwner()
    )
    if (currentSequence.isNotEmpty()) startNewSequence()

    // Initial setup
    updateParameters(onDismissRequest, properties, layoutDirection)
    }
    val mainAxisLayoutSize = max(mainAxisSpace, constraints.minWidth)

    private fun setLayoutDirection(layoutDirection: LayoutDirection) {
    dialogLayout.layoutDirection = when (layoutDirection) {
    LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
    LayoutDirection.Rtl -> android.util.LayoutDirection.RTL
    }
    }
    val crossAxisLayoutSize = max(crossAxisSpace, constraints.minHeight)

    fun setContent(
    parentComposition: CompositionContext,
    children: @Composable () -> Unit,
    ) {
    dialogLayout.setContent(parentComposition, children)
    }
    val layoutWidth = mainAxisLayoutSize

    private fun setSecurePolicy(securePolicy: SecureFlagPolicy) {
    val secureFlagEnabled =
    when (securePolicy) {
    SecureFlagPolicy.SecureOff -> false
    SecureFlagPolicy.SecureOn -> true
    SecureFlagPolicy.Inherit -> composeView.isFlagSecureEnabled()
    }
    window!!.setFlags(
    if (secureFlagEnabled) {
    WindowManager.LayoutParams.FLAG_SECURE
    } else {
    WindowManager.LayoutParams.FLAG_SECURE.inv()
    },
    WindowManager.LayoutParams.FLAG_SECURE
    )
    }
    val layoutHeight = crossAxisLayoutSize

    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
    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],
    )
    }
    }
    )
    }

    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({})
    internal val DialogMinWidth = 280.dp
    internal val DialogMaxWidth = 560.dp

    override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
    private set

    fun setContent(parent: CompositionContext, content: @Composable () -> Unit) {
    setParentCompositionContext(parent)
    this.content = content
    shouldCreateCompositionOnAttachedToWindow = true
    createComposition()
    }
    // 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)

    @Composable
    override fun Content() {
    content()
    }
    }
    private val ButtonsMainAxisSpacing = 8.dp
    private val ButtonsCrossAxisSpacing = 12.dp

    @Preview
    @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) }
    }
    }
    }
    fun EdgeToEdgeAlertDialogPreview() {
    var showEdgeToEdgeAlertDialog by rememberSaveable { mutableStateOf(false) }
    var showAlertDialog by rememberSaveable { mutableStateOf(false) }

    internal fun View.isFlagSecureEnabled(): Boolean {
    val windowParams = rootView.layoutParams as? WindowManager.LayoutParams
    if (windowParams != null) {
    return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0
    val context = LocalContext.current
    SideEffect {
    (context as? ComponentActivity)?.enableEdgeToEdge()
    }
    return false
    }

    @Preview
    @Composable
    fun EdgeToEdgeDialogPreview() {
    var showEdgeToEdgeDialog by remember { mutableStateOf(false) }
    var showBuiltInDialog by remember { mutableStateOf(false) }
    var showPlatformEdgeToEdgeDialog by remember { mutableStateOf(false) }

    Column(
    modifier = Modifier.fillMaxSize(),
    @@ -480,88 +369,44 @@ fun EdgeToEdgeDialogPreview() {
    BasicText(
    modifier = Modifier
    .height(64.dp)
    .clickable { showEdgeToEdgeDialog = true },
    text = "Show edge-to-edge dialog"
    .clickable { showEdgeToEdgeAlertDialog = true },
    text = "Show edge-to-edge alert 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"
    .clickable { showAlertDialog = true },
    text = "Show built-in alert 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 (showEdgeToEdgeAlertDialog) {
    val onDismissRequest = { showEdgeToEdgeAlertDialog = false }
    EdgeToEdgeAlertDialog(
    onDismissRequest = onDismissRequest,
    text = {
    Text("This is an alert dialog")
    },
    confirmButton = {
    Button(onClick = onDismissRequest) {
    Text("OK")
    }
    }
    }
    }

    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")
    }
    }
    if (showAlertDialog) {
    val onDismissRequest = { showAlertDialog = false }
    AlertDialog(
    onDismissRequest = onDismissRequest,
    text = {
    Text("This is an alert dialog")
    },
    confirmButton = {
    Button(onClick = onDismissRequest) {
    Text("OK")
    }
    },
    )
    }
    }
    85 changes: 50 additions & 35 deletions EdgeToEdgeModalBottomSheet.kt
    Original file line number Diff line number Diff line change
    @@ -14,8 +14,11 @@
    * 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
    @@ -25,6 +28,7 @@ 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
    @@ -81,6 +85,7 @@ 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
    @@ -106,6 +111,7 @@ import kotlinx.coroutines.launch
    import kotlin.math.max

    @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
    @Suppress("LongParameterList", "CyclomaticComplexMethod", "LongMethod")
    @Composable
    fun EdgeToEdgeModalBottomSheet(
    onDismissRequest: () -> Unit,
    @@ -164,12 +170,12 @@ fun EdgeToEdgeModalBottomSheet(
    },
    ) { predictiveBackStateHolder ->
    Box(
    Modifier.fillMaxSize()
    Modifier.fillMaxSize(),
    ) {
    Scrim(
    color = scrimColor,
    onDismissRequest = animateToDismiss,
    visible = sheetState.targetValue != Hidden
    visible = sheetState.targetValue != Hidden,
    )
    val bottomSheetPaneTitle = "bottom sheet" // TODO: getString(string = Strings.BottomSheetPaneTitle)

    @@ -180,8 +186,8 @@ fun EdgeToEdgeModalBottomSheet(
    .fillMaxSize()
    .windowInsetsPadding(
    WindowInsets.safeDrawing.only(
    WindowInsetsSides.Horizontal + WindowInsetsSides.Top
    )
    WindowInsetsSides.Horizontal + WindowInsetsSides.Top,
    ),
    )
    .wrapContentSize()
    .widthIn(max = sheetMaxWidth)
    @@ -213,6 +219,7 @@ fun EdgeToEdgeModalBottomSheet(
    } else {
    Hidden
    }

    Expanded -> if (newAnchors.hasAnchorFor(Expanded)) {
    Expanded
    } else if (newAnchors.hasAnchorFor(PartiallyExpanded)) {
    @@ -236,18 +243,18 @@ fun EdgeToEdgeModalBottomSheet(
    0,
    sheetState
    .requireOffset()
    .toInt()
    )
    .toInt(),
    ),
    )
    }
    .nestedScroll(
    remember(sheetState) {
    ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
    sheetState = sheetState,
    orientation = Orientation.Vertical,
    onFling = settleToDismiss
    onFling = settleToDismiss,
    )
    }
    },
    )
    .anchoredDraggable(
    state = sheetState.anchoredDraggableState,
    @@ -261,17 +268,18 @@ fun EdgeToEdgeModalBottomSheet(
    1f,
    1f - (48.dp.toPx() / fullWidth),
    when (predictiveBackState) {
    PredictiveBackState.NotRunning -> 0f
    is PredictiveBackState.Running -> predictiveBackState.progress
    }
    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
    (size.height - sheetState.requireOffset()) / size.height,
    )
    },
    shape = shape,
    @@ -282,7 +290,7 @@ fun EdgeToEdgeModalBottomSheet(
    Column(
    Modifier
    .fillMaxWidth()
    .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))
    .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
    ) {
    if (dragHandle != null) {
    val collapseActionLabel =
    @@ -316,7 +324,7 @@ fun EdgeToEdgeModalBottomSheet(
    }
    }
    }
    }
    },
    ) {
    dragHandle()
    }
    @@ -332,12 +340,12 @@ fun EdgeToEdgeModalBottomSheet(
    private fun Scrim(
    color: Color,
    onDismissRequest: () -> Unit,
    visible: Boolean
    visible: Boolean,
    ) {
    if (color.isSpecified) {
    val alpha by animateFloatAsState(
    targetValue = if (visible) 1f else 0f,
    animationSpec = TweenSpec()
    animationSpec = TweenSpec(),
    )
    val dismissSheet = if (visible) {
    Modifier
    @@ -353,7 +361,7 @@ private fun Scrim(
    Canvas(
    Modifier
    .fillMaxSize()
    .then(dismissSheet)
    .then(dismissSheet),
    ) {
    drawRect(color = color, alpha = alpha)
    }
    @@ -364,7 +372,7 @@ private fun Scrim(
    internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
    sheetState: SheetState,
    orientation: Orientation,
    onFling: (velocity: Float) -> Unit
    onFling: (velocity: Float) -> Unit,
    ): NestedScrollConnection = object : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
    val delta = available.toFloat()
    @@ -378,7 +386,7 @@ internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
    override fun onPostScroll(
    consumed: Offset,
    available: Offset,
    source: NestedScrollSource
    source: NestedScrollSource,
    ): Offset {
    return if (source == NestedScrollSource.Drag) {
    sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
    @@ -407,7 +415,7 @@ internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(

    private fun Float.toOffset(): Offset = Offset(
    x = if (orientation == Orientation.Horizontal) this else 0f,
    y = if (orientation == Orientation.Vertical) this else 0f
    y = if (orientation == Orientation.Vertical) this else 0f,
    )

    @JvmName("velocityToFloat")
    @@ -448,7 +456,7 @@ class SheetState(
    if (skipPartiallyExpanded) {
    require(initialValue != PartiallyExpanded) {
    "The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
    "is set to true."
    "is set to true."
    }
    }
    if (skipHiddenState) {
    @@ -532,7 +540,7 @@ class SheetState(
    suspend fun partialExpand() {
    check(!skipPartiallyExpanded) {
    "Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
    " skipPartiallyExpanded to false to use this function."
    " skipPartiallyExpanded to false to use this function."
    }
    animateTo(PartiallyExpanded)
    }
    @@ -558,7 +566,7 @@ class SheetState(
    suspend fun hide() {
    check(!skipHiddenState) {
    "Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
    " to false to use this function."
    " to false to use this function."
    }
    animateTo(Hidden)
    }
    @@ -575,9 +583,9 @@ class SheetState(
    */
    internal suspend fun animateTo(
    targetValue: SheetValue,
    velocity: Float = anchoredDraggableState.lastVelocity
    velocity: Float = anchoredDraggableState.lastVelocity,
    ) {
    anchoredDraggableState.animateTo(targetValue, velocity)
    anchoredDraggableState.animateToWithDecay(targetValue, velocity)
    }

    /**
    @@ -601,10 +609,11 @@ class SheetState(

    internal var anchoredDraggableState = AnchoredDraggableState(
    initialValue = initialValue,
    animationSpec = spring(),
    snapAnimationSpec = spring(),
    decayAnimationSpec = exponentialDecay(),
    confirmValueChange = confirmValueChange,
    positionalThreshold = { with(density) { 56.dp.toPx() } },
    velocityThreshold = { with(density) { 125.dp.toPx() } }
    velocityThreshold = { with(density) { 125.dp.toPx() } },
    )

    internal val offset: Float get() = anchoredDraggableState.offset
    @@ -621,7 +630,7 @@ class SheetState(
    save = { it.currentValue },
    restore = { savedValue ->
    SheetState(skipPartiallyExpanded, density, savedValue, confirmValueChange)
    }
    },
    )
    }
    }
    @@ -636,19 +645,20 @@ internal fun rememberSheetState(
    ): SheetState {
    val density = LocalDensity.current
    return rememberSaveable(
    skipPartiallyExpanded, confirmValueChange,
    skipPartiallyExpanded,
    confirmValueChange,
    saver = SheetState.Saver(
    skipPartiallyExpanded = skipPartiallyExpanded,
    confirmValueChange = confirmValueChange,
    density = density,
    )
    ),
    ) {
    SheetState(
    skipPartiallyExpanded,
    density,
    initialValue,
    confirmValueChange,
    skipHiddenState
    skipHiddenState,
    )
    }
    }
    @@ -675,6 +685,11 @@ 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,
    @@ -684,13 +699,13 @@ fun EdgeToEdgeModalBottomSheetPreview() {
    modifier = Modifier
    .height(64.dp)
    .clickable { showEdgeToEdgeModalBottomSheet = true },
    text = "Show edge-to-edge modal bottom sheet"
    text = "Show edge-to-edge modal bottom sheet",
    )
    BasicText(
    modifier = Modifier
    .height(64.dp)
    .clickable { showModalBottomSheet = true },
    text = "Show built-in modal bottom sheet"
    text = "Show built-in modal bottom sheet",
    )
    }

    @@ -699,7 +714,7 @@ fun EdgeToEdgeModalBottomSheetPreview() {
    onDismissRequest = { showEdgeToEdgeModalBottomSheet = false },
    ) {
    LazyColumn {
    items(100) {
    items(100) { _ ->
    var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
    mutableStateOf(TextFieldValue())
    }
    @@ -714,7 +729,7 @@ fun EdgeToEdgeModalBottomSheetPreview() {
    onDismissRequest = { showModalBottomSheet = false },
    ) {
    LazyColumn {
    items(100) {
    items(100) { _ ->
    var textFieldValue by remember {
    mutableStateOf(TextFieldValue())
    }
    64 changes: 38 additions & 26 deletions PredictiveBackStateHolder.kt
    Original file line number Diff line number Diff line change
    @@ -25,13 +25,20 @@ import androidx.compose.runtime.rememberUpdatedState
    import androidx.compose.runtime.setValue

    /**
    * The state describing an in-progress predictive back animation.
    * 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 PredictiveBackState {
    sealed interface CompletablePredictiveBackState {
    /**
    * There is no predictive back ongoing. On API 33 and below, this will always be the case.
    * There is no predictive back ongoing, and the back has not been completed.
    */
    data object NotRunning : PredictiveBackState
    data object NotRunning : CompletablePredictiveBackState

    /**
    * There is an ongoing predictive back animation, with the given [progress].
    @@ -41,59 +48,64 @@ sealed interface PredictiveBackState {
    val touchY: Float,
    val progress: Float,
    val swipeEdge: SwipeEdge,
    ) : PredictiveBackState
    }
    ) : CompletablePredictiveBackState

    sealed interface SwipeEdge {
    data object Left : SwipeEdge
    data object Right : SwipeEdge
    /**
    * The back has completed.
    */
    data object Completed : CompletablePredictiveBackState
    }

    @Composable
    fun rememberPredictiveBackStateHolder(): PredictiveBackStateHolder =
    fun rememberCompletablePredictiveBackStateHolder(): CompletablePredictiveBackStateHolder =
    remember {
    PredictiveBackStateHolderImpl()
    CompletablePredictiveBackStateHolderImpl()
    }

    sealed interface PredictiveBackStateHolder {
    val value: PredictiveBackState
    sealed interface CompletablePredictiveBackStateHolder {
    val value: CompletablePredictiveBackState
    }

    internal class PredictiveBackStateHolderImpl : PredictiveBackStateHolder {
    override var value: PredictiveBackState by mutableStateOf(PredictiveBackState.NotRunning)
    internal class CompletablePredictiveBackStateHolderImpl : CompletablePredictiveBackStateHolder {
    override var value: CompletablePredictiveBackState by mutableStateOf(CompletablePredictiveBackState.NotRunning)
    }

    @Composable
    fun PredictiveBackHandler(
    predictiveBackStateHolder: PredictiveBackStateHolder,
    enabled: Boolean,
    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(predictiveBackStateHolder) {
    when (predictiveBackStateHolder) {
    is PredictiveBackStateHolderImpl -> Unit
    key(completablePredictiveBackStateHolder) {
    when (completablePredictiveBackStateHolder) {
    is CompletablePredictiveBackStateHolderImpl -> Unit
    }
    PredictiveBackHandler(enabled = enabled) { progress ->
    PredictiveBackHandler(
    enabled = enabled &&
    completablePredictiveBackStateHolder.value !is CompletablePredictiveBackState.Completed,
    ) { progress ->
    try {
    progress.collect { backEvent ->
    backEvent.swipeEdge
    predictiveBackStateHolder.value = PredictiveBackState.Running(
    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()
    } finally {
    predictiveBackStateHolder.value = PredictiveBackState.NotRunning
    } catch (cancellationException: CancellationException) {
    completablePredictiveBackStateHolder.value = CompletablePredictiveBackState.NotRunning
    throw cancellationException
    }
    }
    }
  6. alexvanyo renamed this gist Jan 13, 2024. 1 changed file with 0 additions and 0 deletions.
    File renamed without changes.
  7. alexvanyo created this gist Jan 13, 2024.
    567 changes: 567 additions & 0 deletions EdgeToEdgeDialog.kt
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,567 @@
    /*
    * 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.ComponentDialog
    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.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.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.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]. 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 [PredictiveBackStateHolder] that encapsulates the predictive back state if
    * [DialogProperties.dismissOnBackPress] is true.
    */
    @Composable
    fun EdgeToEdgeDialog(
    onDismissRequest: () -> Unit,
    properties: DialogProperties = DialogProperties(),
    content: @Composable (PredictiveBackStateHolder) -> 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,
    layoutDirection = layoutDirection,
    density = density,
    dialogId = dialogId
    ).apply {
    setContent(composition) {
    val predictiveBackStateHolder = rememberPredictiveBackStateHolder()

    PredictiveBackHandler(
    predictiveBackStateHolder = predictiveBackStateHolder,
    enabled = currentDismissOnBackPress,
    onBack = { currentOnDismissRequest() },
    )

    DialogLayout(
    Modifier.semantics { dialog() },
    ) {
    currentContent(predictiveBackStateHolder)
    }
    }
    }
    }

    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")
    @Composable
    fun PlatformEdgeToEdgeDialog(
    onDismissRequest: () -> Unit,
    properties: DialogProperties = DialogProperties(),
    scrim: @Composable () -> Unit = {
    val scrimColor = Color.Black.copy(alpha = 0.6f)
    Canvas(
    modifier = Modifier
    .fillMaxSize()
    .then(
    if (properties.dismissOnClickOutside) {
    Modifier.pointerInput(Unit) {
    detectTapGestures { onDismissRequest() }
    }
    } else {
    Modifier
    }
    )
    ) {
    drawRect(
    color = scrimColor,
    topLeft = -Offset(size.width, size.height),
    size = size * 3f
    )
    }
    },
    content: @Composable () -> Unit
    ) = EdgeToEdgeDialog(
    onDismissRequest = onDismissRequest,
    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
    }

    Box(
    modifier = Modifier
    .safeDrawingPadding()
    .graphicsLayer {
    val predictiveBackState = predictiveBackStateHolder.value
    val scale = lerp(
    1f,
    0.9f,
    when (predictiveBackState) {
    PredictiveBackState.NotRunning -> 0f
    is PredictiveBackState.Running -> predictiveBackState.progress
    }
    )
    scaleX = scale
    scaleY = scale
    }
    .then(sizeModifier)
    ) {
    content()
    }
    }
    }

    private class DialogWrapper(
    private var onDismissRequest: () -> Unit,
    private var properties: DialogProperties,
    private val composeView: View,
    layoutDirection: LayoutDirection,
    density: Density,
    dialogId: UUID
    ) : ComponentDialog(
    /**
    * [Window.setClipToOutline] is only available from 22+, but the style attribute exists on 21.
    * So use a wrapped context that sets this attribute for compatibility back to 21.
    */
    ContextThemeWrapper(
    composeView.context,
    R.style.EdgeToEdgeFloatingDialogWindowTheme,
    )
    ), 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()
    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()
    }
    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()
    }

    @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
    }

    @Preview
    @Composable
    fun EdgeToEdgeDialogPreview() {
    var showEdgeToEdgeDialog by remember { mutableStateOf(false) }
    var showBuiltInDialog by remember { mutableStateOf(false) }
    var showPlatformEdgeToEdgeDialog by remember { mutableStateOf(false) }

    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")
    }
    }
    }
    }
    726 changes: 726 additions & 0 deletions EdgeToEdgeModalBottomSheet.kt
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,726 @@
    /*
    * 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.compose.animation.core.TweenSpec
    import androidx.compose.animation.core.animateFloatAsState
    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.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.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)
    @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) {
    PredictiveBackState.NotRunning -> 0f
    is PredictiveBackState.Running -> predictiveBackState.progress
    }
    )
    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.animateTo(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,
    animationSpec = spring(),
    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) }

    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 })
    }
    }
    }
    }
    }
    100 changes: 100 additions & 0 deletions PredictiveBackStateHolder
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,100 @@
    /*
    * 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 an in-progress predictive back animation.
    */
    sealed interface PredictiveBackState {
    /**
    * There is no predictive back ongoing. On API 33 and below, this will always be the case.
    */
    data object NotRunning : PredictiveBackState

    /**
    * 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,
    ) : PredictiveBackState
    }

    sealed interface SwipeEdge {
    data object Left : SwipeEdge
    data object Right : SwipeEdge
    }

    @Composable
    fun rememberPredictiveBackStateHolder(): PredictiveBackStateHolder =
    remember {
    PredictiveBackStateHolderImpl()
    }

    sealed interface PredictiveBackStateHolder {
    val value: PredictiveBackState
    }

    internal class PredictiveBackStateHolderImpl : PredictiveBackStateHolder {
    override var value: PredictiveBackState by mutableStateOf(PredictiveBackState.NotRunning)
    }

    @Composable
    fun PredictiveBackHandler(
    predictiveBackStateHolder: PredictiveBackStateHolder,
    enabled: Boolean,
    onBack: () -> Unit,
    ) {
    // Safely update the current `onBack` lambda when a new one is provided
    val currentOnBack by rememberUpdatedState(onBack)

    key(predictiveBackStateHolder) {
    when (predictiveBackStateHolder) {
    is PredictiveBackStateHolderImpl -> Unit
    }
    PredictiveBackHandler(enabled = enabled) { progress ->
    try {
    progress.collect { backEvent ->
    backEvent.swipeEdge
    predictiveBackStateHolder.value = PredictiveBackState.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")
    }
    )
    }
    currentOnBack()
    } finally {
    predictiveBackStateHolder.value = PredictiveBackState.NotRunning
    }
    }
    }
    }
    19 changes: 19 additions & 0 deletions ids.xml
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,19 @@
    <?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>
    30 changes: 30 additions & 0 deletions styles.xml
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,30 @@
    <?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>