From c8093a146650509f91f8fae940e187bf64bf2e16 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Mon, 1 Sep 2025 16:06:41 +0200 Subject: [PATCH 1/6] Implement enter end exit animation for Dialog --- .../compose/ui/animation/Animation.skiko.kt | 8 ++++ .../ui/scene/ComposeSceneLayer.skiko.kt | 5 -- .../compose/ui/window/Dialog.skiko.kt | 47 ++++++++++++++++++- .../androidx/compose/ui/window/Popup.skiko.kt | 7 +++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/animation/Animation.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/animation/Animation.skiko.kt index 15ed32f8a53a3..a1ffafdba1c95 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/animation/Animation.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/animation/Animation.skiko.kt @@ -27,6 +27,14 @@ internal fun easeInOutTimingFunction(progress: Float): Float = if (progress < 0. (-2f * progress * progress) + (4f * progress) - 1f } +internal fun easeOutTimingFunction(progress: Float): Float { + return -progress * (progress - 2f) +} + +internal fun easeInTimingFunction(progress: Float): Float { + return progress * progress +} + internal suspend fun withAnimationProgress( duration: Duration, timingFunction: (Float) -> Float = ::easeInOutTimingFunction, diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeSceneLayer.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeSceneLayer.skiko.kt index a324f6361977b..9ee37c19acd4d 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeSceneLayer.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeSceneLayer.skiko.kt @@ -196,11 +196,6 @@ internal fun rememberComposeSceneLayer( layer.density = density layer.layoutDirection = layoutDirection - DisposableEffect(Unit) { - onDispose { - layer.close() - } - } return layer } diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt index 6ab550d411eae..f520c7124ad8a 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt @@ -17,14 +17,23 @@ package androidx.compose.ui.window import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.animation.easeInTimingFunction +import androidx.compose.ui.animation.easeOutTimingFunction +import androidx.compose.ui.animation.withAnimationProgress import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType @@ -33,6 +42,7 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalPlatformWindowInsets import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.PlatformInsets @@ -46,12 +56,19 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.center +import androidx.compose.ui.unit.dp +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * The default scrim opacity. */ private const val DefaultScrimOpacity = 0.6f private val DefaultScrimColor = Color.Black.copy(alpha = DefaultScrimOpacity) +private const val AnimatedLayerAppearanceOffsetDp = 16f +private const val AnimatedLayerDisappearanceOffsetDp = 8f +private const val AnimatedLayerInitialAlphaProgress = 0.6f /** * Properties used to customize the behavior of a [Dialog]. @@ -169,14 +186,24 @@ private fun DialogLayout( content: @Composable () -> Unit ) { val currentContent by rememberUpdatedState(content) + val compositionContext = rememberCompositionContext() + var layerAlpha by remember { mutableStateOf(0f) } + var layerTranslation by remember { mutableStateOf(0.dp) } val layer = rememberComposeSceneLayer( focusable = true ) - layer.scrimColor = properties.scrimColor layer.setKeyEventListener(onPreviewKeyEvent, onKeyEvent) layer.setOutsidePointerEventListener(onOutsidePointerEvent) layer.Content { + val density = LocalDensity.current + LaunchedEffect(Unit) { + withAnimationProgress(0.15.seconds, timingFunction = ::easeOutTimingFunction) { + layerAlpha = AnimatedLayerInitialAlphaProgress + it * (1f - AnimatedLayerInitialAlphaProgress) + layer.scrimColor = properties.scrimColor.copy(properties.scrimColor.alpha * layerAlpha) + layerTranslation = (AnimatedLayerAppearanceOffsetDp * (1f - it)).dp + } + } val platformInsets = properties.platformInsets val containerSize = LocalWindowInfo.current.containerSize val measurePolicy = rememberDialogMeasurePolicy( @@ -192,11 +219,27 @@ private fun DialogLayout( ) { Layout( content = currentContent, - modifier = modifier, + modifier = Modifier.graphicsLayer { + alpha = layerAlpha + translationY = with(density) { layerTranslation.toPx() } + }.then(modifier), measurePolicy = measurePolicy ) } } + + DisposableEffect(Unit) { + onDispose { + CoroutineScope(compositionContext.effectCoroutineContext).launch { + withAnimationProgress(0.10.seconds, timingFunction = ::easeInTimingFunction) { + layerAlpha = 1f - it * AnimatedLayerInitialAlphaProgress + layer.scrimColor = properties.scrimColor.copy(properties.scrimColor.alpha * layerAlpha) + layerTranslation = (-AnimatedLayerDisappearanceOffsetDp * it).dp + } + layer.close() + } + } + } } private val DialogProperties.platformInsets: PlatformInsets diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Popup.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Popup.skiko.kt index c79037d56af54..018e52c7681bd 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Popup.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Popup.skiko.kt @@ -17,6 +17,7 @@ package androidx.compose.ui.window import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -452,6 +453,12 @@ private fun PopupLayout( ) } } + + DisposableEffect(Unit) { + onDispose { + layer.close() + } + } } private val PopupProperties.platformInsets: PlatformInsets From 50fac7b8bc0c81148431f163b6938b527a2c77fa Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Mon, 1 Sep 2025 16:15:10 +0200 Subject: [PATCH 2/6] Make scrim darker --- .../skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt index f520c7124ad8a..faba1e725537f 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt @@ -64,7 +64,7 @@ import kotlinx.coroutines.launch /** * The default scrim opacity. */ -private const val DefaultScrimOpacity = 0.6f +private const val DefaultScrimOpacity = 0.78f private val DefaultScrimColor = Color.Black.copy(alpha = DefaultScrimOpacity) private const val AnimatedLayerAppearanceOffsetDp = 16f private const val AnimatedLayerDisappearanceOffsetDp = 8f From c3d09497760f3fcc127881c7c52c5d2d957387fe Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Mon, 1 Sep 2025 16:25:46 +0200 Subject: [PATCH 3/6] Revert scrim color --- .../skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt index faba1e725537f..f520c7124ad8a 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt @@ -64,7 +64,7 @@ import kotlinx.coroutines.launch /** * The default scrim opacity. */ -private const val DefaultScrimOpacity = 0.78f +private const val DefaultScrimOpacity = 0.6f private val DefaultScrimColor = Color.Black.copy(alpha = DefaultScrimOpacity) private const val AnimatedLayerAppearanceOffsetDp = 16f private const val AnimatedLayerDisappearanceOffsetDp = 8f From a4c8084a510102c07c4f10713e2b3766fc27a436 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 2 Sep 2025 09:29:10 +0200 Subject: [PATCH 4/6] Add dialog animation --- .../compose/ui/window/Dialog.skiko.kt | 123 ++++++++++++++---- 1 file changed, 101 insertions(+), 22 deletions(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt index f520c7124ad8a..a0bd9c23e0ce5 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt @@ -28,11 +28,11 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.animation.easeInTimingFunction import androidx.compose.ui.animation.easeOutTimingFunction import androidx.compose.ui.animation.withAnimationProgress import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.GraphicsLayerScope import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent @@ -42,7 +42,6 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.layout.Layout -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalPlatformWindowInsets import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.PlatformInsets @@ -56,7 +55,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.center -import androidx.compose.ui.unit.dp import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -64,12 +62,44 @@ import kotlinx.coroutines.launch /** * The default scrim opacity. */ -private const val DefaultScrimOpacity = 0.6f +private const val DefaultScrimOpacity = 0.78f private val DefaultScrimColor = Color.Black.copy(alpha = DefaultScrimOpacity) private const val AnimatedLayerAppearanceOffsetDp = 16f private const val AnimatedLayerDisappearanceOffsetDp = 8f private const val AnimatedLayerInitialAlphaProgress = 0.6f +/** + * Represents an animation scope for dialogs, allowing customization of dialog animations + * and associated visual properties. + * + * This interface provides methods to apply transformations and modify visual properties + * during dialog animations. + * + * Note: This API is experimental and may change in the future. + */ +@ExperimentalComposeUiApi +@Immutable +interface DialogAnimationScope { + /** + * Applies graphics layer transformations and customizations within the specified [GraphicsLayerScope]. + * This method can be used to modify visual properties like rotation, scale, translation, + * shadow, and other effects. + * + * @param modify A lambda receiver of [GraphicsLayerScope] allowing customization of graphics layer properties. + */ + fun graphicsLayer(modify: GraphicsLayerScope.() -> Unit) + + /** + * Defines the color of the scrim used during dialog animations. + * + * The scrim is a semi-transparent layer displayed behind the dialog to + * focus the user's attention on the foreground content. This property + * allows customization of the scrim's appearance to match desired visual + * aesthetics or themes. + */ + var scrimColor: Color +} + /** * Properties used to customize the behavior of a [Dialog]. * @@ -85,6 +115,8 @@ private const val AnimatedLayerInitialAlphaProgress = 0.6f * @property useSoftwareKeyboardInset Whether the size of the dialog's content should be limited by * software keyboard inset. * @property scrimColor Color of background fill. + * @property onAppearEffect The effect to be applied when the dialog appears. + * @property onDisappearEffect The effect to be applied when the dialog disappears. */ @Immutable actual class DialogProperties @ExperimentalComposeUiApi constructor( @@ -94,6 +126,10 @@ actual class DialogProperties @ExperimentalComposeUiApi constructor( val usePlatformInsets: Boolean = true, val useSoftwareKeyboardInset: Boolean = true, val scrimColor: Color = DefaultScrimColor, + val onAppearEffect: suspend DialogAnimationScope.() -> Unit = + DialogAnimationScope::defaultDialogAppearEffect, + val onDisappearEffect: suspend DialogAnimationScope.() -> Unit = + DialogAnimationScope::defaultDialogDisappearEffect, ) { actual constructor( dismissOnBackPress: Boolean, @@ -108,6 +144,25 @@ actual class DialogProperties @ExperimentalComposeUiApi constructor( scrimColor = DefaultScrimColor, ) + @ExperimentalComposeUiApi + constructor( + dismissOnBackPress: Boolean = true, + dismissOnClickOutside: Boolean = true, + usePlatformDefaultWidth: Boolean = true, + usePlatformInsets: Boolean = true, + useSoftwareKeyboardInset: Boolean = true, + scrimColor: Color = DefaultScrimColor, + ) : this( + dismissOnBackPress = dismissOnBackPress, + dismissOnClickOutside = dismissOnClickOutside, + usePlatformDefaultWidth = usePlatformDefaultWidth, + usePlatformInsets = usePlatformInsets, + useSoftwareKeyboardInset = useSoftwareKeyboardInset, + scrimColor = scrimColor, + onAppearEffect = DialogAnimationScope::defaultDialogAppearEffect, + onDisappearEffect = DialogAnimationScope::defaultDialogDisappearEffect, + ) + override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is DialogProperties) return false @@ -187,22 +242,28 @@ private fun DialogLayout( ) { val currentContent by rememberUpdatedState(content) val compositionContext = rememberCompositionContext() - var layerAlpha by remember { mutableStateOf(0f) } - var layerTranslation by remember { mutableStateOf(0.dp) } - + var graphicsLayerScopeUpdate by remember { mutableStateOf Unit>({}) } val layer = rememberComposeSceneLayer( focusable = true ) layer.setKeyEventListener(onPreviewKeyEvent, onKeyEvent) layer.setOutsidePointerEventListener(onOutsidePointerEvent) + val dialogAnimationScope = remember { + object : DialogAnimationScope { + override fun graphicsLayer(modify: GraphicsLayerScope.() -> Unit) { + graphicsLayerScopeUpdate = modify + } + + override var scrimColor: Color + get() = layer.scrimColor ?: properties.scrimColor + set(value) { layer.scrimColor = value } + } + } layer.Content { - val density = LocalDensity.current LaunchedEffect(Unit) { - withAnimationProgress(0.15.seconds, timingFunction = ::easeOutTimingFunction) { - layerAlpha = AnimatedLayerInitialAlphaProgress + it * (1f - AnimatedLayerInitialAlphaProgress) - layer.scrimColor = properties.scrimColor.copy(properties.scrimColor.alpha * layerAlpha) - layerTranslation = (AnimatedLayerAppearanceOffsetDp * (1f - it)).dp - } + properties.onAppearEffect(dialogAnimationScope) + graphicsLayerScopeUpdate = {} + layer.scrimColor = properties.scrimColor } val platformInsets = properties.platformInsets val containerSize = LocalWindowInfo.current.containerSize @@ -219,10 +280,7 @@ private fun DialogLayout( ) { Layout( content = currentContent, - modifier = Modifier.graphicsLayer { - alpha = layerAlpha - translationY = with(density) { layerTranslation.toPx() } - }.then(modifier), + modifier = Modifier.graphicsLayer(graphicsLayerScopeUpdate).then(modifier), measurePolicy = measurePolicy ) } @@ -231,17 +289,38 @@ private fun DialogLayout( DisposableEffect(Unit) { onDispose { CoroutineScope(compositionContext.effectCoroutineContext).launch { - withAnimationProgress(0.10.seconds, timingFunction = ::easeInTimingFunction) { - layerAlpha = 1f - it * AnimatedLayerInitialAlphaProgress - layer.scrimColor = properties.scrimColor.copy(properties.scrimColor.alpha * layerAlpha) - layerTranslation = (-AnimatedLayerDisappearanceOffsetDp * it).dp - } + properties.onDisappearEffect(dialogAnimationScope) layer.close() } } } } +private suspend fun DialogAnimationScope.defaultDialogAppearEffect() { + val initialScrimColor = this.scrimColor + withAnimationProgress(0.15.seconds, timingFunction = ::easeOutTimingFunction) { progress -> + val animatedAlpha = + AnimatedLayerInitialAlphaProgress + progress * (1f - AnimatedLayerInitialAlphaProgress) + this.scrimColor = initialScrimColor.copy(initialScrimColor.alpha * animatedAlpha) + this.graphicsLayer { + this.alpha = animatedAlpha + this.translationY = (AnimatedLayerAppearanceOffsetDp * (1f - progress)) * density + } + } +} + +private suspend fun DialogAnimationScope.defaultDialogDisappearEffect() { + val initialScrimColor = this.scrimColor + withAnimationProgress(0.10.seconds, timingFunction = ::easeOutTimingFunction) { progress -> + val animatedAlpha = 1f - progress * AnimatedLayerInitialAlphaProgress + this.scrimColor = initialScrimColor.copy(initialScrimColor.alpha * animatedAlpha) + this.graphicsLayer { + this.alpha = animatedAlpha + this.translationY = -AnimatedLayerDisappearanceOffsetDp * progress * density + } + } +} + private val DialogProperties.platformInsets: PlatformInsets @Composable get() { val safeInsets = if (usePlatformInsets) { From c9793841a694f7e44d7a52c8d929ca474c25d1e7 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 2 Sep 2025 10:30:40 +0200 Subject: [PATCH 5/6] Update API dump --- compose/ui/ui/api/desktop/ui.api | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/ui/ui/api/desktop/ui.api b/compose/ui/ui/api/desktop/ui.api index 5f6956738cb3b..63d5dfd8e6d33 100644 --- a/compose/ui/ui/api/desktop/ui.api +++ b/compose/ui/ui/api/desktop/ui.api @@ -4332,6 +4332,7 @@ public final class androidx/compose/ui/window/DialogProperties { public fun (ZZZ)V public synthetic fun (ZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (ZZZZZJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ZZZZZJLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun equals (Ljava/lang/Object;)Z public final fun getDismissOnBackPress ()Z public final fun getDismissOnClickOutside ()Z From 6a9351b33f1a617f2e63f1293daeba5cd0e67758 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 2 Sep 2025 10:31:02 +0200 Subject: [PATCH 6/6] Add experimental attributes --- .../skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt index a0bd9c23e0ce5..1a99773e72091 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt @@ -126,8 +126,10 @@ actual class DialogProperties @ExperimentalComposeUiApi constructor( val usePlatformInsets: Boolean = true, val useSoftwareKeyboardInset: Boolean = true, val scrimColor: Color = DefaultScrimColor, + @property:ExperimentalComposeUiApi val onAppearEffect: suspend DialogAnimationScope.() -> Unit = DialogAnimationScope::defaultDialogAppearEffect, + @property:ExperimentalComposeUiApi val onDisappearEffect: suspend DialogAnimationScope.() -> Unit = DialogAnimationScope::defaultDialogDisappearEffect, ) {