diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/ImageComposeSceneTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/ImageComposeSceneTest.kt index b6dc1094239cd..319ff58613b6f 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/ImageComposeSceneTest.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/ImageComposeSceneTest.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.ExtendedFloatingActionButton +import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -38,6 +39,7 @@ import androidx.compose.ui.window.Dialog import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import kotlinx.coroutines.runBlocking +import org.jetbrains.skia.Image import org.jetbrains.skiko.MainUIDispatcher import org.junit.Ignore import org.junit.Rule @@ -94,7 +96,7 @@ class ImageComposeSceneTest { @Test fun `run dialog in center`() { - val image = renderComposeScene( + val image = renderComposeSceneOnIdle( width = 80, height = 40, ) { @@ -130,4 +132,20 @@ class ImageComposeSceneTest { scene.close() } } +} + +@OptIn(ExperimentalTime::class) +private fun renderComposeSceneOnIdle( + width: Int, + height: Int, + density: Density = Density(1f), + content: @Composable () -> Unit +): Image = ImageComposeScene( + width = width, + height = height, + density = density, + content = content +).use { + val time = it.runUntilIdle() + it.render(time) } \ No newline at end of file diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/TestUtils.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/TestUtils.kt index da4e4ce67831a..8e36e022f29cf 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/TestUtils.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/TestUtils.kt @@ -41,6 +41,9 @@ import java.awt.image.MultiResolutionImage import java.text.AttributedString import javax.swing.Icon import javax.swing.ImageIcon +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield import org.jetbrains.skiko.MainUIDispatcher @@ -272,4 +275,18 @@ internal inline fun ImageComposeScene.useInUiThread( crossinline block: (ImageComposeScene) -> R ): R = runBlocking(MainUIDispatcher) { use(block) -} \ No newline at end of file +} + +@OptIn(ExperimentalTime::class) +internal fun ImageComposeScene.runUntilIdle( + initialTime: Duration = Duration.ZERO, + frameDuration: Duration = 16.milliseconds +): Duration { + var time = initialTime + render(time) + while (hasInvalidations()) { + time += frameDuration + render(time) + } + return time +} diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/ComposeUiFlags.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/ComposeUiFlags.skiko.kt index 69247cea7edde..6ed72d399d6bf 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/ComposeUiFlags.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/ComposeUiFlags.skiko.kt @@ -17,12 +17,15 @@ package androidx.compose.ui import kotlin.jvm.JvmField -import kotlin.jvm.JvmName internal object SkikoComposeUiFlags { @Suppress("MutableBareField") @JvmField var useLegacyRenderNodeLayers: Boolean = false + + @Suppress("MutableBareField") + @JvmField + var isDialogAnimationEnabled: Boolean = true } /** @@ -33,3 +36,11 @@ internal object SkikoComposeUiFlags { */ @ExperimentalComposeUiApi var ComposeUiFlags.useLegacyRenderNodeLayers by SkikoComposeUiFlags::useLegacyRenderNodeLayers + +/** + * When enabled the [androidx.compose.ui.window.Dialog] appear and disappear with animation. + * + * Note that it's a temporary flag, it will be removed in the future. + */ +@ExperimentalComposeUiApi +var ComposeUiFlags.isDialogAnimationEnabled by SkikoComposeUiFlags::isDialogAnimationEnabled \ No newline at end of file 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..13f9ebb6c240f 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,10 @@ 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 suspend fun withAnimationProgress( duration: Duration, timingFunction: (Float) -> Float = ::easeInOutTimingFunction, @@ -34,11 +38,11 @@ internal suspend fun withAnimationProgress( ) { update(0f) - var firstFrameTime = 0L + var firstFrameTime: Long? = null var progressDuration = Duration.ZERO while (progressDuration < duration) { withFrameNanos { frameTime -> - if (firstFrameTime == 0L) { + if (firstFrameTime == null) { firstFrameTime = frameTime } progressDuration = (frameTime - firstFrameTime).nanoseconds 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 deff13e81ff68..69608ef25e968 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 bcf9413ea920f..55bf62d1d77fb 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 @@ -21,14 +21,24 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.InternalComposeApi import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State 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.ComposeUiFlags import androidx.compose.ui.Modifier +import androidx.compose.ui.MotionDurationScale +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.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.isDialogAnimationEnabled import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalPlatformWindowInsets import androidx.compose.ui.platform.LocalWindowInfo @@ -44,12 +54,25 @@ 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 kotlin.coroutines.CoroutineContext +import kotlin.getValue +import kotlin.setValue +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +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 AnimatedLayerOffsetDp = 10f +private const val AnimatedLayerInitialAlpha = 0.2f +private const val AnimatedLayerScale = 0.05f +private const val AnimatedLayerAppearanceDuration = 0.2 +private const val AnimatedLayerDisappearanceDuration = 0.1 /** * Properties used to customize the behavior of a [Dialog]. @@ -68,7 +91,7 @@ private val DefaultScrimColor = Color.Black.copy(alpha = DefaultScrimOpacity) * @property scrimColor Color of background fill. */ @Immutable -actual class DialogProperties constructor( +actual class DialogProperties( actual val dismissOnBackPress: Boolean = true, actual val dismissOnClickOutside: Boolean = true, actual val usePlatformDefaultWidth: Boolean = true, @@ -166,13 +189,20 @@ private fun DialogLayout( content: @Composable () -> Unit ) { val currentContent by rememberUpdatedState(content) - - val layer = rememberComposeSceneLayer( - focusable = true - ) - layer.scrimColor = properties.scrimColor + val compositionContext = rememberCompositionContext() + val layer = rememberComposeSceneLayer(focusable = true) layer.setOutsidePointerEventListener(onOutsidePointerEvent) + + val animator = remember { + DialogAppearanceController(layer = layer, coroutineContext = compositionContext.effectCoroutineContext) + } + animator.scrimColor = properties.scrimColor + layer.Content { + LaunchedEffect(Unit) { + animator.onDialogShown() + } + val platformInsets = properties.platformInsets val containerSize = LocalWindowInfo.current.containerSize val measurePolicy = rememberDialogMeasurePolicy( @@ -188,11 +218,120 @@ private fun DialogLayout( ) { Layout( content = currentContent, - modifier = modifier, + modifier = animator.modifier.then(modifier), measurePolicy = measurePolicy ) } } + + DisposableEffect(Unit) { + onDispose { + animator.hideDialog() + } + } +} + +private interface DialogAppearanceController { + var scrimColor: Color? + val modifier: Modifier + fun onDialogShown() + fun hideDialog() +} + +private fun DialogAppearanceController( + layer: ComposeSceneLayer, + coroutineContext: CoroutineContext +): DialogAppearanceController = + if (ComposeUiFlags.isDialogAnimationEnabled) { + AnimatedDialogAppearanceController(layer, coroutineContext) + } else { + NonAnimatedDialogAppearanceController(layer) + } + +private class AnimatedDialogAppearanceController( + private val layer: ComposeSceneLayer, + private val coroutineContext: CoroutineContext +) : DialogAppearanceController { + private val appearanceProgress = mutableStateOf(0f) + private var appearAnimationJob: Job? = null + + override var modifier by mutableStateOf( + Modifier.animationLayerTransform(appearanceProgress) + ) + private set + + override var scrimColor: Color? = Color.Transparent + set(value) { + field = value + updateScrimLayerColor() + } + + override fun onDialogShown() { + appearAnimationJob = + CoroutineScope(coroutineContext).launch(start = CoroutineStart.UNDISPATCHED) { + withAnimationProgress( + duration = (durationScale() * AnimatedLayerAppearanceDuration).seconds, + timingFunction = ::easeOutTimingFunction + ) { progress -> + appearanceProgress.value = progress + updateScrimLayerColor() + } + + modifier = Modifier + layer.scrimColor = scrimColor + } + } + + override fun hideDialog() { + appearAnimationJob?.cancel() + CoroutineScope(coroutineContext).launch(start = CoroutineStart.UNDISPATCHED) { + val initialProgress = appearanceProgress.value + val duration = + durationScale() * initialProgress * AnimatedLayerDisappearanceDuration + modifier = Modifier.animationLayerTransform(appearanceProgress) + + withAnimationProgress( + duration = duration.seconds, + timingFunction = ::easeOutTimingFunction + ) { progress -> + appearanceProgress.value = (1f - progress) * initialProgress + updateScrimLayerColor() + } + + layer.close() + } + } + + private fun updateScrimLayerColor() { + layer.scrimColor = scrimColor?.let { + it.copy(it.alpha * contentAlpha(appearanceProgress.value)) + } + } + + private fun contentAlpha(progress: Float): Float = + AnimatedLayerInitialAlpha + (1f - AnimatedLayerInitialAlpha) * progress + + private fun durationScale(): Float = + coroutineContext[MotionDurationScale]?.scaleFactor ?: 1f + + private fun Modifier.animationLayerTransform(progress: State): Modifier = + graphicsLayer { + this.alpha = contentAlpha(progress.value) + val reversedProgress = 1f - progress.value + val scale = 1f - reversedProgress * AnimatedLayerScale + this.scaleX = scale + this.scaleY = scale + this.translationY = AnimatedLayerOffsetDp * reversedProgress * density + } +} + +private class NonAnimatedDialogAppearanceController( + private val layer: ComposeSceneLayer +) : DialogAppearanceController { + override var scrimColor: Color? by layer::scrimColor + override fun onDialogShown() {} + override fun hideDialog() = layer.close() + override val modifier = Modifier } 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 7ff6145a38404..43066c66f5f6d 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 @@ -472,6 +472,12 @@ private fun PopupLayout( ) } } + + DisposableEffect(Unit) { + onDispose { + layer.close() + } + } } private val PopupProperties.platformInsets: PlatformInsets diff --git a/compose/ui/ui/src/skikoTest/kotlin/androidx/compose/ui/window/DialogTest.kt b/compose/ui/ui/src/skikoTest/kotlin/androidx/compose/ui/window/DialogTest.kt index 71854c596a8b9..ae22a9df5ef85 100644 --- a/compose/ui/ui/src/skikoTest/kotlin/androidx/compose/ui/window/DialogTest.kt +++ b/compose/ui/ui/src/skikoTest/kotlin/androidx/compose/ui/window/DialogTest.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.testutils.assertPixels import androidx.compose.ui.DialogState import androidx.compose.ui.FillBox import androidx.compose.ui.Modifier @@ -30,6 +31,7 @@ import androidx.compose.ui.assertReceived import androidx.compose.ui.assertReceivedLast import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerButtons import androidx.compose.ui.input.pointer.PointerEventType @@ -308,4 +310,24 @@ class DialogTest { navEventInput.backCompleted() assertContentEquals(listOf(1, 1, 2, 1), eventList) } + + @Test + fun testDialogScrimColorChange() = runSkikoComposeUiTest( + size = Size(100f, 100f) + ) { + val scrimColor = mutableStateOf(Color.Red) + setContent { + Dialog( + onDismissRequest = {}, + properties = DialogProperties(scrimColor = scrimColor.value) + ) {} + } + + captureToImage().assertPixels { Color.Red } + + scrimColor.value = Color.Blue + waitForIdle() + + captureToImage().assertPixels { Color.Blue } + } } \ No newline at end of file