diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/Utils.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/Utils.kt index b4dc69e0d..39f6d3091 100644 --- a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/Utils.kt +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/Utils.kt @@ -6,3 +6,13 @@ import androidx.compose.runtime.remember @Composable internal fun rememberLazy(key: Any, provider: () -> T): Lazy = remember(key) { lazy(provider) } + +internal fun List.takeSorted(comparator: Comparator): List = + takeWhileIndexed { index, item -> + (index == 0) || (comparator.compare(item, get(index - 1)) >= 0) + } + +internal fun Iterable.takeWhileIndexed(predicate: (Int, T) -> Boolean): List = + withIndex() + .takeWhile { (index, item) -> predicate(index, item) } + .map { it.value } diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimation.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimation.kt index b4bed2a0b..127a1bfc0 100644 --- a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimation.kt +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimation.kt @@ -49,22 +49,31 @@ internal class DefaultStackAnimation( ) { var currentStack by remember { mutableStateOf(stack) } var items by remember { mutableStateOf(getAnimationItems(newStack = currentStack)) } + var nextItems: Map>? by remember { mutableStateOf(null) } val stackKeys = remember(stack) { stack.items.map { it.key } } val currentStackKeys = remember(currentStack) { currentStack.items.map { it.key } } - if (stackKeys != currentStackKeys) { + if (stack != currentStack) { val oldStack = currentStack currentStack = stack val updateItems = when { - stack.active.key == oldStack.active.key -> items.keys.singleOrNull() != stack.active.key + stack.active.key == oldStack.active.key -> + (items.keys.singleOrNull() != stack.active.key) || + (items.values.singleOrNull()?.child?.instance != stack.active.instance) + items.size == 1 -> items.keys.single() != stack.active.key else -> items.keys.toList() != stackKeys } if (updateItems) { - items = getAnimationItems(newStack = currentStack, oldStack = oldStack) + val newItems = getAnimationItems(newStack = currentStack, oldStack = oldStack) + if (items.size == 1) { + items = newItems + } else { + nextItems = newItems + } } } @@ -82,12 +91,21 @@ internal class DefaultStackAnimation( }, content = content, ) + + if (item.direction.isExit) { + DisposableEffect(Unit) { + onDispose { + nextItems?.also { items = it } + nextItems = null + } + } + } } } // A workaround until https://issuetracker.google.com/issues/214231672. // Normally only the exiting child should be disabled. - if (disableInputDuringAnimation && (items.size > 1)) { + if (disableInputDuringAnimation && ((items.size > 1) || (nextItems != null))) { Overlay(modifier = Modifier.matchParentSize()) } } @@ -129,7 +147,7 @@ internal class DefaultStackAnimation( private fun getAnimationItems(newStack: ChildStack, oldStack: ChildStack? = null): Map> = when { - oldStack == null -> + (oldStack == null) || (newStack.active.key == oldStack.active.key) -> keyedItemsOf( AnimationItem( child = newStack.active, diff --git a/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/TestUtils.kt b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/TestUtils.kt index 045c4ba64..3c8a45184 100644 --- a/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/TestUtils.kt +++ b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/TestUtils.kt @@ -1,6 +1,7 @@ package com.arkivanov.decompose.extensions.compose.experimental import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.AnimationConstants import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.Transition import androidx.compose.animation.core.animateFloat @@ -15,8 +16,8 @@ import androidx.compose.ui.test.SemanticsNodeInteraction import kotlin.test.fail @Composable -internal fun Transition.animateFloat(): State = - animateFloat(transitionSpec = { tween(easing = LinearEasing) }) { state -> +internal fun Transition.animateFloat(durationMillis: Int = AnimationConstants.DefaultDurationMillis): State = + animateFloat(transitionSpec = { tween(easing = LinearEasing, durationMillis = durationMillis) }) { state -> when (state) { EnterExitState.PreEnter -> 0F EnterExitState.Visible -> 1F diff --git a/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/ChildStackTest.kt b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/ChildStackTest.kt index 5b8554453..ef3a35899 100644 --- a/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/ChildStackTest.kt +++ b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/ChildStackTest.kt @@ -1,5 +1,6 @@ package com.arkivanov.decompose.extensions.compose.experimental.stack +import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.foundation.clickable import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable @@ -29,12 +30,13 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized +import kotlin.test.assertSame @OptIn(ExperimentalDecomposeApi::class) @Suppress("TestFunctionName") @RunWith(Parameterized::class) class ChildStackTest( - private val animation: StackAnimation?, + private val animation: StackAnimation?, ) { @get:Rule @@ -92,6 +94,18 @@ class ChildStackTest( composeRule.onNodeWithText(text = "ChildA1", substring = true).assertExists() } + @Test + fun GIVEN_child_displayed_WHEN_new_child_instance_with_the_same_key_THEN_new_child_instance_displayed() { + val state = mutableStateOf(routerState(Child.Created(configuration = Config.A, instance = Any(), key = "A"))) + var lastInstance: Any? = null + setContent(state) { lastInstance = it.instance } + + val instance2 = Any() + state.setValueOnIdle(routerState(Child.Created(configuration = Config.A, instance = instance2, key = "A"))) + + assertSame(instance2, lastInstance) + } + @Test fun GIVEN_child_B_displayed_and_child_A_in_back_stack_WHEN_pop_child_B_THEN_state_restored_for_child_A() { val state = mutableStateOf(routerState(Config.A)) @@ -186,11 +200,14 @@ class ChildStackTest( composeRule.onNodeWithText(text = "ChildA2", substring = true).assertDoesNotExist() } - private fun setContent(state: State>) { + private fun setContent( + state: State>, + content: @Composable AnimatedVisibilityScope.(Child.Created) -> Unit = { + Child(name = it.key.toString()) + }, + ) { composeRule.setContent { - ChildStack(stack = state.value, animation = animation) { child -> - Child(name = child.key.toString()) - } + ChildStack(stack = state.value, animation = animation, content = content) } composeRule.runOnIdle {} @@ -211,6 +228,12 @@ class ChildStackTest( backStack = stack.dropLast(1).map { it.toChild() }, ) + private fun routerState(vararg stack: Child.Created): ChildStack = + ChildStack( + active = stack.last(), + backStack = stack.dropLast(1), + ) + private fun Pair.toChild(): Child.Created = Child.Created(configuration = second, instance = second, key = first) @@ -235,7 +258,7 @@ class ChildStackTest( fun parameters(): List> = getParameters().map { arrayOf(it) } - private fun getParameters(): List?> { + private fun getParameters(): List?> { val predictiveBackParams1 = PredictiveBackParams( backHandler = BackDispatcher(), diff --git a/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimationTest.kt b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimationTest.kt new file mode 100644 index 000000000..f5daa08e9 --- /dev/null +++ b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimationTest.kt @@ -0,0 +1,121 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack.animation + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.MainTestClock +import androidx.compose.ui.test.junit4.createComposeRule +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.extensions.compose.experimental.animateFloat +import com.arkivanov.decompose.extensions.compose.experimental.takeSorted +import com.arkivanov.decompose.router.stack.ChildStack +import org.junit.Rule +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@Suppress("TestFunctionName") +class DefaultStackAnimationTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun WHEN_animating_push_and_stack_popped_during_animation_THEN_animated_push_and_pop_fully() { + val anim = + DefaultStackAnimation( + disableInputDuringAnimation = true, + predictiveBackParams = { null }, + selector = { _, _, _ -> null }, + ) + + var stack by mutableStateOf(stack(1)) + val values1 = ArrayList>() + val values2 = ArrayList>() + + composeRule.setContent { + anim(stack = stack, modifier = Modifier) { + val value by transition.animateFloat(durationMillis = 1000) + val pair = composeRule.mainClock.currentTime to value + + when (it.key) { + 1 -> values1 += pair + 2 -> values2 += pair + } + } + } + + stack = stack(1, 2) + composeRule.mainClock.advanceFramesBy(millis = 500L) + stack = stack(1) + composeRule.waitForIdle() + + val v11 = values1.takeSorted(compareByDescending { it.second }) + val v21 = values2.takeSorted(compareBy { it.second }) + assertTrue(v11.size > 10) + assertTrue(v21.size > 10) + assertEquals(1F, v11.first().second) + assertEquals(0F, v11.last().second) + assertEquals(0F, v21.first().second) + assertEquals(1F, v21.last().second) + + val v12 = values1.drop(v11.size - 1).takeSorted(compareBy { it.second }) + val v22 = values2.drop(v21.size - 1).takeSorted(compareByDescending { it.second }) + assertTrue(v12.size > 10) + assertTrue(v22.size > 10) + assertEquals(0F, v12.first().second) + assertEquals(1F, v12.last().second) + assertEquals(1F, v22.first().second) + assertEquals(0F, v22.last().second) + } + + @Test + fun WHEN_animating_push_and_stack_popped_during_animation_THEN_first_child_restarted() { + val anim = + DefaultStackAnimation( + disableInputDuringAnimation = true, + predictiveBackParams = { null }, + selector = { _, _, _ -> null }, + ) + + var stack by mutableStateOf(stack(1)) + var counter = 0 + + composeRule.setContent { + anim(stack = stack, modifier = Modifier) { + transition.animateFloat(durationMillis = 1000) + + if (it.key == 1) { + LaunchedEffect(Unit) { + counter++ + } + } + } + } + + stack = stack(1, 2) + composeRule.mainClock.advanceFramesBy(millis = 500L) + stack = stack(1) + composeRule.waitForIdle() + + assertEquals(2, counter) + } + + private fun MainTestClock.advanceFramesBy(millis: Long) { + val endTime = currentTime + millis + while (currentTime < endTime) { + advanceTimeByFrame() + } + } + + private fun child(config: Int): Child.Created = + Child.Created(configuration = config, instance = Any()) + + private fun stack(vararg stack: Int): ChildStack = + ChildStack( + active = child(stack.last()), + backStack = stack.dropLast(1).map(::child), + ) +} diff --git a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/AbstractStackAnimation.kt b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/AbstractStackAnimation.kt index 5b079530b..43ed2cfba 100644 --- a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/AbstractStackAnimation.kt +++ b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/AbstractStackAnimation.kt @@ -2,6 +2,7 @@ package com.arkivanov.decompose.extensions.compose.stack.animation import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -29,11 +30,18 @@ internal abstract class AbstractStackAnimation( override operator fun invoke(stack: ChildStack, modifier: Modifier, content: @Composable (child: Child.Created) -> Unit) { var currentStack by remember { mutableStateOf(stack) } var items by remember { mutableStateOf(getAnimationItems(newStack = currentStack, oldStack = null)) } + var nextItems: Map>? by remember { mutableStateOf(null) } - if (stack.active.key != currentStack.active.key) { + if (stack.active.key != currentStack.active.key || stack.active.instance != currentStack.active.instance) { val oldStack = currentStack currentStack = stack - items = getAnimationItems(newStack = currentStack, oldStack = oldStack) + + val newItems = getAnimationItems(newStack = currentStack, oldStack = oldStack) + if (items.size == 1) { + items = newItems + } else { + nextItems = newItems + } } Box(modifier = modifier) { @@ -50,12 +58,21 @@ internal abstract class AbstractStackAnimation( }, content = content, ) + + if (item.direction.isExit) { + DisposableEffect(Unit) { + onDispose { + nextItems?.also { items = it } + nextItems = null + } + } + } } } // A workaround until https://issuetracker.google.com/issues/214231672. // Normally only the exiting child should be disabled. - if (disableInputDuringAnimation && (items.size > 1)) { + if (disableInputDuringAnimation && ((items.size > 1) || (nextItems != null))) { InputConsumingOverlay(modifier = Modifier.matchParentSize()) } } @@ -63,7 +80,7 @@ internal abstract class AbstractStackAnimation( private fun getAnimationItems(newStack: ChildStack, oldStack: ChildStack?): Map> = when { - oldStack == null -> + (oldStack == null) || (newStack.active.key == oldStack.active.key) -> listOf(AnimationItem(child = newStack.active, direction = Direction.ENTER_FRONT, isInitial = true)) (newStack.size < oldStack.size) && (newStack.active.key in oldStack.backStack) -> diff --git a/extensions-compose/src/commonTest/kotlin/com/arkivanov/decompose/extensions/compose/Utils.kt b/extensions-compose/src/commonTest/kotlin/com/arkivanov/decompose/extensions/compose/Utils.kt new file mode 100644 index 000000000..ed8a35afa --- /dev/null +++ b/extensions-compose/src/commonTest/kotlin/com/arkivanov/decompose/extensions/compose/Utils.kt @@ -0,0 +1,11 @@ +package com.arkivanov.decompose.extensions.compose + +internal fun List.takeSorted(comparator: Comparator): List = + takeWhileIndexed { index, item -> + (index == 0) || (comparator.compare(item, get(index - 1)) >= 0) + } + +internal fun Iterable.takeWhileIndexed(predicate: (Int, T) -> Boolean): List = + withIndex() + .takeWhile { (index, item) -> predicate(index, item) } + .map { it.value } diff --git a/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/ChildrenTest.kt b/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/ChildrenTest.kt index c39fa4ed1..b9c7b8303 100644 --- a/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/ChildrenTest.kt +++ b/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/ChildrenTest.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.arkivanov.decompose.Child -import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.FaultyDecomposeApi import com.arkivanov.decompose.extensions.compose.stack.animation.StackAnimation import com.arkivanov.decompose.extensions.compose.stack.animation.fade @@ -27,11 +26,12 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized +import kotlin.test.assertSame @Suppress("TestFunctionName") @RunWith(Parameterized::class) class ChildrenTest( - private val animation: StackAnimation?, + private val animation: StackAnimation?, ) { @get:Rule @@ -89,6 +89,18 @@ class ChildrenTest( composeRule.onNodeWithText(text = "ChildA1", substring = true).assertExists() } + @Test + fun GIVEN_child_displayed_WHEN_new_child_instance_with_the_same_key_THEN_new_child_instance_displayed() { + val state = mutableStateOf(routerState(Child.Created(configuration = Config.A, instance = Any(), key = "A"))) + var lastInstance: Any? = null + setContent(state) { lastInstance = it.instance } + + val instance2 = Any() + state.setValueOnIdle(routerState(Child.Created(configuration = Config.A, instance = instance2, key = "A"))) + + assertSame(instance2, lastInstance) + } + @Test fun GIVEN_child_B_displayed_and_child_A_in_back_stack_WHEN_pop_child_B_THEN_state_restored_for_child_A() { val state = mutableStateOf(routerState(Config.A)) @@ -183,11 +195,14 @@ class ChildrenTest( composeRule.onNodeWithText(text = "ChildA2", substring = true).assertDoesNotExist() } - private fun setContent(state: State>) { + private fun setContent( + state: State>, + content: @Composable (Child.Created) -> Unit = { + Child(name = it.key.toString()) + }, + ) { composeRule.setContent { - Children(stack = state.value, animation = animation) { child -> - Child(name = child.key.toString()) - } + Children(stack = state.value, animation = animation, content = content) } composeRule.runOnIdle {} @@ -208,6 +223,12 @@ class ChildrenTest( backStack = stack.dropLast(1).map { it.toChild() }, ) + private fun routerState(vararg stack: Child.Created): ChildStack = + ChildStack( + active = stack.last(), + backStack = stack.dropLast(1), + ) + private fun Pair.toChild(): Child.Created = Child.Created(configuration = second, instance = second, key = first) @@ -233,7 +254,7 @@ class ChildrenTest( getParameters().map { arrayOf(it) } @OptIn(FaultyDecomposeApi::class) - private fun getParameters(): List?> = + private fun getParameters(): List?> = listOf( null, stackAnimation { _, _, _ -> null }, diff --git a/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/SimpleStackAnimationTest.kt b/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/SimpleStackAnimationTest.kt new file mode 100644 index 000000000..f30d03167 --- /dev/null +++ b/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/SimpleStackAnimationTest.kt @@ -0,0 +1,143 @@ +package com.arkivanov.decompose.extensions.compose.stack.animation + +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.MainTestClock +import androidx.compose.ui.test.junit4.createComposeRule +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.extensions.compose.takeSorted +import com.arkivanov.decompose.router.stack.ChildStack +import org.junit.Rule +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@Suppress("TestFunctionName") +class SimpleStackAnimationTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun WHEN_animating_push_and_stack_popped_during_animation_THEN_animated_push_and_pop_fully() { + val anim = + SimpleStackAnimation( + disableInputDuringAnimation = true, + selector = { + stackAnimator(animationSpec = tween(durationMillis = 1000)) { factor, _, content -> + CompositionLocalProvider(LocalProgress provides factor) { + content(Modifier) + } + } + }, + ) + + var stack by mutableStateOf(stack(1)) + val values1 = ArrayList>() + val values2 = ArrayList>() + + composeRule.setContent { + anim(stack = stack, modifier = Modifier) { + val pair = composeRule.mainClock.currentTime to LocalProgress.current + when (it.key) { + 1 -> values1 += pair + 2 -> values2 += pair + } + } + } + + stack = stack(1, 2) + composeRule.mainClock.advanceFramesBy(millis = 500L) + stack = stack(1) + composeRule.waitForIdle() + + val v11 = values1.takeSorted(compareByDescending { it.second }) + val v21 = values2.takeSorted(compareByDescending { it.second }) + assertTrue(v11.size > 10) + assertTrue(v21.size > 10) + assertEquals(0F, v11.first().second, 0.01F) + assertEquals(-1F, v11.last().second, 0.01F) + assertEquals(1F, v21.first().second, 0.01F) + assertEquals(0F, v21.last().second, 0.01F) + + val v12 = values1.drop(v11.size - 1).takeSorted(compareBy { it.second }) + val v22 = values2.drop(v21.size - 1).takeSorted(compareBy { it.second }) + assertTrue(v12.size > 10) + assertTrue(v22.size > 10) + assertEquals(-1F, v12.first().second, 0.01F) + assertEquals(0F, v12.last().second, 0.01F) + assertEquals(0F, v22.first().second, 0.01F) + assertEquals(1F, v22.last().second, 0.01F) + } + + @Test + fun WHEN_animating_push_and_stack_popped_during_animation_THEN_first_child_restarted() { + val anim = + SimpleStackAnimation( + disableInputDuringAnimation = true, + selector = { fade(animationSpec = tween(durationMillis = 1000)) }, + ) + + var stack by mutableStateOf(stack(1)) + var counter = 0 + + composeRule.setContent { + anim(stack = stack, modifier = Modifier) { + if (it.key == 1) { + LaunchedEffect(Unit) { + counter++ + } + } + } + } + + stack = stack(1, 2) + composeRule.mainClock.advanceFramesBy(millis = 500L) + stack = stack(1) + composeRule.waitForIdle() + + assertEquals(counter, 2) + } + + private fun MainTestClock.advanceFramesBy(millis: Long) { + val endTime = currentTime + millis + while (currentTime < endTime) { + advanceTimeByFrame() + } + } + + @Composable + private fun Transition.animateFloat(durationMillis: Int): State = + animateFloat(transitionSpec = { tween(durationMillis = durationMillis, easing = LinearEasing) }) { state -> + when (state) { + EnterExitState.PreEnter -> 0F + EnterExitState.Visible -> 1F + EnterExitState.PostExit -> 0F + } + } + + private fun child(config: Int): Child.Created = + Child.Created(configuration = config, instance = Any()) + + private fun stack(vararg stack: Int): ChildStack = + ChildStack( + active = child(stack.last()), + backStack = stack.dropLast(1).map(::child), + ) + + private companion object { + private val LocalProgress = compositionLocalOf { 0F } + } +}