Last active
September 17, 2024 01:48
Revisions
-
alexvanyo revised this gist
Sep 17, 2024 . 1 changed file with 0 additions and 2 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -16,8 +16,6 @@ @file:Suppress("MatchingDeclarationName", "Filename") import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -
alexvanyo revised this gist
Sep 17, 2024 . 4 changed files with 64 additions and 45 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -409,4 +409,4 @@ internal fun EdgeToEdgeAlertDialogPreview() { }, ) } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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, This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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.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.UserInput) { sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset() } else { Offset.Zero @@ -750,4 +750,4 @@ internal fun EdgeToEdgeModalBottomSheetPreview() { } } } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -1,5 +1,5 @@ /* * 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. */ @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 import androidx.compose.runtime.remember 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 expect fun CompletablePredictiveBackStateHandler( completablePredictiveBackStateHolder: CompletablePredictiveBackStateHolder, enabled: Boolean = true, onBack: () -> Unit, ) -
alexvanyo revised this gist
Jan 30, 2024 . 2 changed files with 18 additions and 22 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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] 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 = { Spacer( modifier = Modifier .fillMaxSize() .then( @@ -201,17 +204,12 @@ fun PlatformEdgeToEdgeDialog( Modifier }, ), ) }, 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(ContextThemeWrapper(composeView.context, windowTheme)), ViewRootForInspector { private val dialogLayout: DialogLayout @@ -641,4 +630,4 @@ internal fun EdgeToEdgeDialogPreview() { } } } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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> -
alexvanyo revised this gist
Jan 29, 2024 . 3 changed files with 561 additions and 317 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -352,7 +352,7 @@ private val ButtonsCrossAxisSpacing = 12.dp @Preview @Composable internal fun EdgeToEdgeAlertDialogPreview() { var showEdgeToEdgeAlertDialog by rememberSaveable { mutableStateOf(false) } var showAlertDialog by rememberSaveable { mutableStateOf(false) } @@ -409,4 +409,4 @@ fun EdgeToEdgeAlertDialogPreview() { }, ) } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionContext import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCompositionContext import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.AbstractComposeView import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.ViewRootForInspector import androidx.compose.ui.semantics.dialog import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastMap import androidx.compose.ui.util.fastMaxBy import androidx.compose.ui.util.lerp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider import androidx.compose.ui.window.SecureFlagPolicy import androidx.core.view.WindowCompat import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.findViewTreeViewModelStoreOwner import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeViewModelStoreOwner import androidx.savedstate.findViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import java.util.UUID /** * A [Dialog] that is _always_ edge-to-edge. This is intended to be the underlying backbone for more complicated and * opinionated dialogs. * * The [content] will fill the entire window, going entirely edge-to-edge. * * This [EdgeToEdgeDialog] provides no scrim or dismissing when the scrim is pressed: if this is desired, it must be * implemented by the [content]. For the most simple implementation of this that acts like a platform dialog, use * [PlatformEdgeToEdgeDialog]. * * [DialogProperties] will be respected, but [DialogProperties.decorFitsSystemWindows] and * [DialogProperties.usePlatformDefaultWidth] are ignored. * * The [content] will be passed a [CompletablePredictiveBackStateHolder] that encapsulates the predictive back state if * [DialogProperties.dismissOnBackPress] is true. */ @Composable fun EdgeToEdgeDialog( onDismissRequest: () -> Unit, properties: DialogProperties = DialogProperties(), content: @Composable (CompletablePredictiveBackStateHolder) -> Unit, ) { val view = LocalView.current val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current val composition = rememberCompositionContext() val dialogId = rememberSaveable { UUID.randomUUID() } val currentOnDismissRequest by rememberUpdatedState(onDismissRequest) val currentDismissOnBackPress by rememberUpdatedState(properties.dismissOnBackPress) val currentContent by rememberUpdatedState(content) val dialog = remember(view, density) { DialogWrapper( onDismissRequest = onDismissRequest, properties = properties, composeView = view, layoutDirection = layoutDirection, density = density, dialogId = dialogId, ).apply { setContent(composition) { val completablePredictiveBackStateHolder = rememberCompletablePredictiveBackStateHolder() CompletablePredictiveBackStateHandler( completablePredictiveBackStateHolder = completablePredictiveBackStateHolder, enabled = currentDismissOnBackPress, onBack = { currentOnDismissRequest() }, ) DialogLayout( Modifier.semantics { dialog() }, ) { currentContent(completablePredictiveBackStateHolder) } } } } DisposableEffect(dialog) { dialog.show() onDispose { dialog.dismiss() dialog.disposeComposition() } } SideEffect { dialog.updateParameters( onDismissRequest = onDismissRequest, properties = properties, layoutDirection = layoutDirection, ) } } /** * A [Dialog] based on [EdgeToEdgeDialog] that provides a more opinionated dialog that is closer to the default * [Dialog]. * * The [scrim] is rendered behind the content. The default scrim will request to dismiss the dialog if * [DialogProperties.dismissOnClickOutside] is true. * * The [content] of the dialog can be arbitrarily sized, and can fill the entire window if desired. * * If [DialogProperties.dismissOnBackPress] is true, the [content] will automatically start to animate out with a * predictive back gestures from the dialog. */ @Suppress("ComposeModifierMissing", "LongMethod", "CyclomaticComplexMethod") @Composable fun PlatformEdgeToEdgeDialog( onDismissRequest: () -> Unit, properties: DialogProperties = DialogProperties(), scrim: @Composable () -> Unit = { 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 } val predictiveBackState = predictiveBackStateHolder.value val lastRunningValue by remember { mutableStateOf<CompletablePredictiveBackState.Running?>(null) }.apply { when (predictiveBackState) { CompletablePredictiveBackState.NotRunning -> value = null is CompletablePredictiveBackState.Running -> if (predictiveBackState.progress >= 0.01f) { // Only save that we were disappearing if the progress is at least 1% along value = predictiveBackState } CompletablePredictiveBackState.Completed -> Unit } } val scale by animateFloatAsState( targetValue = when (predictiveBackState) { CompletablePredictiveBackState.NotRunning -> 1f is CompletablePredictiveBackState.Running -> lerp(1f, 0.9f, predictiveBackState.progress) CompletablePredictiveBackState.Completed -> 0.9f }, label = "scale", ) { } val translationX by animateDpAsState( targetValue = when (predictiveBackState) { CompletablePredictiveBackState.NotRunning -> 0.dp is CompletablePredictiveBackState.Running -> lerp( 0.dp, 8.dp, predictiveBackState.progress, ) * when (predictiveBackState.swipeEdge) { SwipeEdge.Left -> -1f SwipeEdge.Right -> 1f } CompletablePredictiveBackState.Completed -> { 8.dp * when (lastRunningValue?.swipeEdge) { null -> 0f SwipeEdge.Left -> -1f SwipeEdge.Right -> 1f } } }, label = "translationX", ) val pivotFractionX by animateFloatAsState( targetValue = when (predictiveBackState) { CompletablePredictiveBackState.NotRunning -> 0.5f is CompletablePredictiveBackState.Running -> when (predictiveBackState.swipeEdge) { SwipeEdge.Left -> 1f SwipeEdge.Right -> 0f } CompletablePredictiveBackState.Completed -> { when (lastRunningValue?.swipeEdge) { null -> 0.5f SwipeEdge.Left -> 1f SwipeEdge.Right -> 0f } } }, label = "pivotFractionX", ) Box( modifier = Modifier .safeDrawingPadding() .graphicsLayer { this.translationX = translationX.toPx() this.alpha = alpha this.scaleX = scale this.scaleY = scale this.transformOrigin = TransformOrigin(pivotFractionX, 0.5f) } .then(sizeModifier), contentAlignment = Alignment.Center, ) { content() } } } private class DialogWrapper( private var onDismissRequest: () -> Unit, private var properties: DialogProperties, private val composeView: View, 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() WindowCompat.getInsetsController(window, window.decorView).apply { isAppearanceLightStatusBars = false isAppearanceLightNavigationBars = false } setContentView(dialogLayout) dialogLayout.setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner()) dialogLayout.setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner()) dialogLayout.setViewTreeSavedStateRegistryOwner( composeView.findViewTreeSavedStateRegistryOwner(), ) // Initial setup updateParameters(onDismissRequest, properties, layoutDirection) } private fun setLayoutDirection(layoutDirection: LayoutDirection) { dialogLayout.layoutDirection = when (layoutDirection) { LayoutDirection.Ltr -> android.util.LayoutDirection.LTR LayoutDirection.Rtl -> android.util.LayoutDirection.RTL } } fun setContent( parentComposition: CompositionContext, children: @Composable () -> Unit, ) { dialogLayout.setContent(parentComposition, children) } private fun setSecurePolicy(securePolicy: SecureFlagPolicy) { val secureFlagEnabled = when (securePolicy) { SecureFlagPolicy.SecureOff -> false SecureFlagPolicy.SecureOn -> true SecureFlagPolicy.Inherit -> composeView.isFlagSecureEnabled() } checkNotNull(window).setFlags( if (secureFlagEnabled) { WindowManager.LayoutParams.FLAG_SECURE } else { WindowManager.LayoutParams.FLAG_SECURE.inv() }, WindowManager.LayoutParams.FLAG_SECURE, ) } fun updateParameters( onDismissRequest: () -> Unit, properties: DialogProperties, layoutDirection: LayoutDirection, ) { this.onDismissRequest = onDismissRequest this.properties = properties setSecurePolicy(properties.securePolicy) setLayoutDirection(layoutDirection) window?.setLayout( WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT, ) window?.setSoftInputMode( if (Build.VERSION.SDK_INT >= 30) { WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING } else { @Suppress("DEPRECATION") WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE }, ) } fun disposeComposition() { dialogLayout.disposeComposition() } override fun onTouchEvent(event: MotionEvent): Boolean { val result = super.onTouchEvent(event) if (result && properties.dismissOnClickOutside) { onDismissRequest() } return result } override fun cancel() { // Prevents the dialog from dismissing itself return } } @Suppress("ViewConstructor") private class DialogLayout( context: Context, override val window: Window, ) : AbstractComposeView(context), DialogWindowProvider { private var content: @Composable () -> Unit by mutableStateOf({}) override var shouldCreateCompositionOnAttachedToWindow: Boolean = false private set fun setContent(parent: CompositionContext, content: @Composable () -> Unit) { setParentCompositionContext(parent) this.content = content shouldCreateCompositionOnAttachedToWindow = true createComposition() } @Suppress("ComposeUnstableReceiver") @Composable override fun Content() { content() } } @Composable private fun DialogLayout( modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { Layout( content = content, modifier = modifier, ) { measurables, constraints -> val placeables = measurables.fastMap { it.measure(constraints) } val width = placeables.fastMaxBy { it.width }?.width ?: constraints.minWidth val height = placeables.fastMaxBy { it.height }?.height ?: constraints.minHeight layout(width, height) { placeables.fastForEach { it.placeRelative(0, 0) } } } } internal fun View.isFlagSecureEnabled(): Boolean { val windowParams = rootView.layoutParams as? WindowManager.LayoutParams if (windowParams != null) { return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0 } return false } @Suppress("LongMethod") @Preview @Composable internal fun EdgeToEdgeDialogPreview() { var showEdgeToEdgeDialog by remember { mutableStateOf(false) } var showBuiltInDialog by remember { mutableStateOf(false) } var showPlatformEdgeToEdgeDialog by remember { mutableStateOf(false) } val context = LocalContext.current SideEffect { @@ -369,44 +557,88 @@ fun EdgeToEdgeAlertDialogPreview() { 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") } } } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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() } EdgeToEdgeDialog( properties = properties, onDismissRequest = { 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(sheetWindowInsets()) .wrapContentSize() .widthIn(max = sheetMaxWidth) .fillMaxWidth() @@ -270,7 +282,7 @@ fun EdgeToEdgeModalBottomSheet( when (predictiveBackState) { CompletablePredictiveBackState.NotRunning -> 0f is CompletablePredictiveBackState.Running -> predictiveBackState.progress CompletablePredictiveBackState.Completed -> if (lastRunningValue == null) 0f else 1f }, ) scaleX = scale @@ -290,7 +302,7 @@ fun EdgeToEdgeModalBottomSheet( Column( Modifier .fillMaxWidth() .windowInsetsPadding(contentWindowInsets()), ) { if (dragHandle != null) { val collapseActionLabel = @@ -681,7 +693,7 @@ fun rememberModalBottomSheetState( @OptIn(ExperimentalMaterial3Api::class) @Preview @Composable internal fun EdgeToEdgeModalBottomSheetPreview() { var showEdgeToEdgeModalBottomSheet by rememberSaveable { mutableStateOf(false) } var showModalBottomSheet by rememberSaveable { mutableStateOf(false) } @@ -738,4 +750,4 @@ fun EdgeToEdgeModalBottomSheetPreview() { } } } } -
alexvanyo revised this gist
Jan 27, 2024 . 4 changed files with 806 additions and 522 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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") } }, ) } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -14,463 +14,352 @@ * 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(), @@ -480,88 +369,44 @@ fun EdgeToEdgeDialogPreview() { BasicText( modifier = Modifier .height(64.dp) .clickable { showEdgeToEdgeAlertDialog = true }, text = "Show edge-to-edge alert dialog", ) BasicText( modifier = Modifier .height(64.dp) .clickable { showAlertDialog = true }, text = "Show built-in alert dialog", ) } if (showEdgeToEdgeAlertDialog) { val onDismissRequest = { showEdgeToEdgeAlertDialog = false } EdgeToEdgeAlertDialog( onDismissRequest = onDismissRequest, text = { Text("This is an alert dialog") }, confirmButton = { Button(onClick = onDismissRequest) { Text("OK") } }, ) } if (showAlertDialog) { val onDismissRequest = { showAlertDialog = false } AlertDialog( onDismissRequest = onDismissRequest, text = { Text("This is an alert dialog") }, confirmButton = { Button(onClick = onDismissRequest) { Text("OK") } }, ) } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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(), ) { Scrim( color = scrimColor, onDismissRequest = animateToDismiss, 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, ), ) .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(), ), ) } .nestedScroll( remember(sheetState) { ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( sheetState = sheetState, orientation = Orientation.Vertical, onFling = settleToDismiss, ) }, ) .anchoredDraggable( state = sheetState.anchoredDraggableState, @@ -261,17 +268,18 @@ fun EdgeToEdgeModalBottomSheet( 1f, 1f - (48.dp.toPx() / fullWidth), when (predictiveBackState) { CompletablePredictiveBackState.NotRunning -> 0f is CompletablePredictiveBackState.Running -> predictiveBackState.progress CompletablePredictiveBackState.Completed -> 1f }, ) scaleX = scale scaleY = scale // Set the transform origin to be at the point of the sheet that is at the bottom // edge of the screen transformOrigin = TransformOrigin( 0.5f, (size.height - sheetState.requireOffset()) / size.height, ) }, shape = shape, @@ -282,7 +290,7 @@ fun EdgeToEdgeModalBottomSheet( Column( Modifier .fillMaxWidth() .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, ) { if (color.isSpecified) { val alpha by animateFloatAsState( targetValue = if (visible) 1f else 0f, animationSpec = TweenSpec(), ) val dismissSheet = if (visible) { Modifier @@ -353,7 +361,7 @@ private fun Scrim( Canvas( Modifier .fillMaxSize() .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, ): 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, ): 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, ) @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." } } 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." } 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." } animateTo(Hidden) } @@ -575,9 +583,9 @@ class SheetState( */ internal suspend fun animateTo( targetValue: SheetValue, velocity: Float = anchoredDraggableState.lastVelocity, ) { anchoredDraggableState.animateToWithDecay(targetValue, velocity) } /** @@ -601,10 +609,11 @@ class SheetState( internal var anchoredDraggableState = AnchoredDraggableState( initialValue = initialValue, snapAnimationSpec = spring(), decayAnimationSpec = exponentialDecay(), confirmValueChange = confirmValueChange, positionalThreshold = { with(density) { 56.dp.toPx() } }, velocityThreshold = { with(density) { 125.dp.toPx() } }, ) internal val offset: Float get() = anchoredDraggableState.offset @@ -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, saver = SheetState.Saver( skipPartiallyExpanded = skipPartiallyExpanded, confirmValueChange = confirmValueChange, density = density, ), ) { SheetState( skipPartiallyExpanded, density, initialValue, confirmValueChange, 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", ) BasicText( modifier = Modifier .height(64.dp) .clickable { showModalBottomSheet = true }, text = "Show built-in modal bottom sheet", ) } @@ -699,7 +714,7 @@ fun EdgeToEdgeModalBottomSheetPreview() { onDismissRequest = { showEdgeToEdgeModalBottomSheet = false }, ) { LazyColumn { items(100) { _ -> var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } @@ -714,7 +729,7 @@ fun EdgeToEdgeModalBottomSheetPreview() { onDismissRequest = { showModalBottomSheet = false }, ) { LazyColumn { items(100) { _ -> var textFieldValue by remember { mutableStateOf(TextFieldValue()) } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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 a one-shot back state, with use in a [CompletablePredictiveBackStateHandler]. * * Because the back handler can only be used once there are three states that [CompletablePredictiveBackState] can * be in: * * - [NotRunning] * - [Running], which can happen on API 34 and above if a predictive back is in progress. * - [Completed] */ sealed interface CompletablePredictiveBackState { /** * There is no predictive back ongoing, and the back has not been completed. */ data object NotRunning : CompletablePredictiveBackState /** * There is an ongoing predictive back animation, with the given [progress]. @@ -41,59 +48,64 @@ sealed interface PredictiveBackState { val touchY: Float, val progress: Float, val swipeEdge: SwipeEdge, ) : CompletablePredictiveBackState /** * The back has completed. */ data object Completed : CompletablePredictiveBackState } @Composable fun rememberCompletablePredictiveBackStateHolder(): CompletablePredictiveBackStateHolder = remember { CompletablePredictiveBackStateHolderImpl() } sealed interface CompletablePredictiveBackStateHolder { val value: CompletablePredictiveBackState } internal class CompletablePredictiveBackStateHolderImpl : CompletablePredictiveBackStateHolder { override var value: CompletablePredictiveBackState by mutableStateOf(CompletablePredictiveBackState.NotRunning) } @Composable CompletablePredictiveBackStateHandler( completablePredictiveBackStateHolder: CompletablePredictiveBackStateHolder, enabled: Boolean = true, onBack: () -> Unit, ) { // Safely update the current `onBack` lambda when a new one is provided val currentOnBack by rememberUpdatedState(onBack) key(completablePredictiveBackStateHolder) { when (completablePredictiveBackStateHolder) { is CompletablePredictiveBackStateHolderImpl -> Unit } PredictiveBackHandler( enabled = enabled && completablePredictiveBackStateHolder.value !is CompletablePredictiveBackState.Completed, ) { progress -> try { progress.collect { backEvent -> backEvent.swipeEdge completablePredictiveBackStateHolder.value = CompletablePredictiveBackState.Running( backEvent.touchX, backEvent.touchY, backEvent.progress, when (backEvent.swipeEdge) { BackEventCompat.EDGE_LEFT -> SwipeEdge.Left BackEventCompat.EDGE_RIGHT -> SwipeEdge.Right else -> error("Unknown swipe edge") }, ) } completablePredictiveBackStateHolder.value = CompletablePredictiveBackState.Completed currentOnBack() } catch (cancellationException: CancellationException) { completablePredictiveBackStateHolder.value = CompletablePredictiveBackState.NotRunning throw cancellationException } } } -
alexvanyo renamed this gist
Jan 13, 2024 . 1 changed file with 0 additions and 0 deletions.There are no files selected for viewing
File renamed without changes. -
alexvanyo created this gist
Jan 13, 2024 .There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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") } } } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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 }) } } } } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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 } } } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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> This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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>