Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -94,7 +96,7 @@ class ImageComposeSceneTest {

@Test
fun `run dialog in center`() {
val image = renderComposeScene(
val image = renderComposeSceneOnIdle(
width = 80,
height = 40,
) {
Expand Down Expand Up @@ -130,4 +132,17 @@ 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
).renderOnIdle()
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -272,4 +275,18 @@ internal inline fun <R> ImageComposeScene.useInUiThread(
crossinline block: (ImageComposeScene) -> R
): R = runBlocking(MainUIDispatcher) {
use(block)
}

@OptIn(ExperimentalTime::class)
internal fun ImageComposeScene.renderOnIdle(
initialTime: Duration = Duration.ZERO,
frameDuration: Duration = 16.milliseconds
): org.jetbrains.skia.Image {
var time = initialTime
var frame = render(time)
while (hasInvalidations()) {
time += frameDuration
frame = render(time)
}
return frame
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,22 @@ 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,
update: (Float) -> Unit
) {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,6 @@ internal fun rememberComposeSceneLayer(
layer.density = density
layer.layoutDirection = layoutDirection

DisposableEffect(Unit) {
onDispose {
layer.close()
}
}
return layer
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,23 @@ 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.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
Expand All @@ -44,12 +53,23 @@ 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.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].
Expand All @@ -68,7 +88,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,
Expand Down Expand Up @@ -166,13 +186,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 {
DialogAnimator(layer = layer, coroutineContext = compositionContext.effectCoroutineContext)
}
animator.scrimColor = properties.scrimColor

layer.Content {
LaunchedEffect(Unit) {
animator.showDialog()
}

val platformInsets = properties.platformInsets
val containerSize = LocalWindowInfo.current.containerSize
val measurePolicy = rememberDialogMeasurePolicy(
Expand All @@ -188,11 +215,100 @@ private fun DialogLayout(
) {
Layout(
content = currentContent,
modifier = modifier,
modifier = animator.modifier.value.then(modifier),
measurePolicy = measurePolicy
)
}
}

DisposableEffect(Unit) {
onDispose {
animator.hideDialog()
}
}
}

private class DialogAnimator(
private val layer: ComposeSceneLayer,
private val coroutineContext: CoroutineContext,
) {
private val appearanceProgress = mutableStateOf(0f)
private var appearAnimationJob: Job? = null
val modifier = mutableStateOf<Modifier>(Modifier)
var scrimColor: Color = Color.Transparent
set(value) {
field = value
updateScrimLayerColor()
}

init {
if (ComposeUiFlags.isDialogAnimationEnabled) {
modifier.value = Modifier.animationLayerTransform(appearanceProgress)
} else {
appearanceProgress.value = 1f
}
}

fun showDialog() {
if (ComposeUiFlags.isDialogAnimationEnabled) {
appearAnimationJob =
CoroutineScope(coroutineContext).launch(start = CoroutineStart.UNDISPATCHED) {
withAnimationProgress(
duration = (durationScale() * AnimatedLayerAppearanceDuration).seconds,
timingFunction = ::easeOutTimingFunction
) { progress ->
appearanceProgress.value = progress
updateScrimLayerColor()
}

modifier.value = Modifier
layer.scrimColor = scrimColor
}
}
}

fun hideDialog() {
appearAnimationJob?.cancel()
if (ComposeUiFlags.isDialogAnimationEnabled) {
CoroutineScope(coroutineContext).launch(start = CoroutineStart.UNDISPATCHED) {
val initialProgress = appearanceProgress.value
val duration =
durationScale() * initialProgress * AnimatedLayerDisappearanceDuration
modifier.value = Modifier.animationLayerTransform(appearanceProgress)

withAnimationProgress(
duration = duration.seconds,
timingFunction = ::easeOutTimingFunction
) { progress ->
appearanceProgress.value = (1f - progress) * initialProgress
updateScrimLayerColor()
}

layer.close()
}
} else {
layer.close()
}
}

fun updateScrimLayerColor() {
val scrimAlpha = contentAlpha(appearanceProgress.value)
layer.scrimColor = scrimColor.copy(scrimColor.alpha * scrimAlpha)
}

private fun contentAlpha(progress: Float): Float =
AnimatedLayerInitialAlpha + (1f - AnimatedLayerInitialAlpha) * progress

private fun durationScale(): Float = coroutineContext[MotionDurationScale]?.scaleFactor ?: 1f

private fun Modifier.animationLayerTransform(progress: State<Float>): 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 val DialogProperties.platformInsets: PlatformInsets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,12 @@ private fun PopupLayout(
)
}
}

DisposableEffect(Unit) {
onDispose {
layer.close()
}
}
}

private val PopupProperties.platformInsets: PlatformInsets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ 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
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
Expand Down Expand Up @@ -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 }
}
}
Loading