From 945cfa26c0496e763c077619fd2ac10e6a4645fa Mon Sep 17 00:00:00 2001 From: Arkadii Ivanov Date: Tue, 13 Aug 2024 10:17:59 +0300 Subject: [PATCH] Fixed the predictive back gesture interrupted with the new animation API --- .../compose/experimental/stack/Utils.kt | 10 + .../stack/animation/DefaultStackAnimation.kt | 116 ++++-- .../compose/experimental/TestUtils.kt | 37 ++ .../experimental/stack/ChildStackTest.kt | 31 +- .../animation/PredictiveBackGestureTest.kt | 345 ++++++++++++++++++ 5 files changed, 497 insertions(+), 42 deletions(-) create mode 100644 extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/TestUtils.kt create mode 100644 extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackGestureTest.kt diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/Utils.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/Utils.kt index 434d4fa9f..45e0be980 100644 --- a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/Utils.kt +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/Utils.kt @@ -1,8 +1,18 @@ package com.arkivanov.decompose.extensions.compose.experimental.stack import com.arkivanov.decompose.router.stack.ChildStack +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch internal fun ChildStack.dropLast(): ChildStack = ChildStack(active = backStack.last(), backStack = backStack.dropLast(1)) internal val ChildStack<*, *>.size: Int get() = items.size + +internal suspend inline fun awaitAll(vararg jobs: suspend CoroutineScope.() -> Unit) { + coroutineScope { + jobs.map { launch(block = it) }.joinAll() + } +} 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 b8a18f399..c2a00ac03 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 @@ -21,6 +21,7 @@ import androidx.compose.ui.input.pointer.pointerInput import com.arkivanov.decompose.Child import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.experimental.stack.WithAnimatedVisibilityScope +import com.arkivanov.decompose.extensions.compose.experimental.stack.awaitAll import com.arkivanov.decompose.extensions.compose.experimental.stack.dropLast import com.arkivanov.decompose.extensions.compose.experimental.stack.size import com.arkivanov.decompose.extensions.compose.stack.animation.Direction @@ -30,7 +31,7 @@ import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.essenty.backhandler.BackCallback import com.arkivanov.essenty.backhandler.BackEvent import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.joinAll +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch @ExperimentalDecomposeApi @@ -48,12 +49,21 @@ internal class DefaultStackAnimation( ) { var currentStack by remember { mutableStateOf(stack) } var items by remember { mutableStateOf(getAnimationItems(newStack = currentStack)) } + val stackKeys = remember(stack) { stack.items.map { it.key } } + val currentStackKeys = remember(currentStack) { currentStack.items.map { it.key } } - if (stack.active.key != currentStack.active.key) { + if (stackKeys != currentStackKeys) { val oldStack = currentStack currentStack = stack - if ((items.size == 1) && (items.keys.single() != currentStack.active.key)) { + val updateItems = + if (stack.active.key == oldStack.active.key) { + items.keys.singleOrNull() != stack.active.key + } else { + items.keys.toList() != stackKeys + } + + if (updateItems) { items = getAnimationItems(newStack = currentStack, oldStack = oldStack) } } @@ -83,7 +93,7 @@ internal class DefaultStackAnimation( } if ((predictiveBackParams != null) && currentStack.backStack.isNotEmpty()) { - key(items.keys) { + key(currentStackKeys) { PredictiveBackController( stack = currentStack, predictiveBackParams = predictiveBackParams, @@ -179,7 +189,11 @@ internal class DefaultStackAnimation( DisposableEffect(predictiveBackParams.backHandler, callback) { predictiveBackParams.backHandler.register(callback) - onDispose { predictiveBackParams.backHandler.unregister(callback) } + + onDispose { + scope.cancel() // Ensure the scope is cancelled before unregistering the callback + predictiveBackParams.backHandler.unregister(callback) + } } } @@ -204,81 +218,103 @@ internal class DefaultStackAnimation( private val setItems: (Map>) -> Unit, ) : BackCallback() { private val exitChild = stack.active - private val exitTransitionState = SeekableTransitionState(initialState = EnterExitState.Visible) private val enterChild = stack.backStack.last() - private val enterTransitionState = SeekableTransitionState(initialState = EnterExitState.PreEnter) - private var animatable: PredictiveBackAnimatable? = null + private var animationHandler: AnimationHandler? = null override fun onBackStarted(backEvent: BackEvent) { - animatable = predictiveBackParams.animatableSelector(backEvent, exitChild, enterChild) + val animationHandler = AnimationHandler(animatable = predictiveBackParams.animatableSelector(backEvent, exitChild, enterChild)) + this.animationHandler = animationHandler setItems( keyedItemsOf( AnimationItem( child = enterChild, direction = Direction.ENTER_BACK, - transitionState = enterTransitionState, + transitionState = animationHandler.enterTransitionState, otherChild = exitChild, - predictiveBackAnimator = animatable?.let { anim -> SimpleStackAnimator { anim.enterModifier } }, + predictiveBackAnimator = animationHandler.animatable?.let { anim -> SimpleStackAnimator { anim.enterModifier } }, ), AnimationItem( child = exitChild, direction = Direction.EXIT_FRONT, - transitionState = exitTransitionState, + transitionState = animationHandler.exitTransitionState, otherChild = enterChild, - predictiveBackAnimator = animatable?.let { anim -> SimpleStackAnimator { anim.exitModifier } }, + predictiveBackAnimator = animationHandler.animatable?.let { anim -> SimpleStackAnimator { anim.exitModifier } }, ), ) ) scope.launch { - joinAll( - launch { exitTransitionState.seekTo(fraction = backEvent.progress, targetState = EnterExitState.PostExit) }, - launch { enterTransitionState.seekTo(fraction = backEvent.progress, targetState = EnterExitState.Visible) }, - launch { animatable?.animate(backEvent) }, - ) + animationHandler.start(backEvent) } } override fun onBackProgressed(backEvent: BackEvent) { scope.launch { - animatable?.run { - animate(backEvent) - return@launch // Don't animate transition states on back progress if there is PredictiveBackAnimatable - } - - joinAll( - launch { exitTransitionState.seekTo(fraction = backEvent.progress, targetState = EnterExitState.PostExit) }, - launch { enterTransitionState.seekTo(fraction = backEvent.progress, targetState = EnterExitState.Visible) }, - ) + animationHandler?.progress(backEvent) } } override fun onBackCancelled() { scope.launch { - joinAll( - launch { exitTransitionState.snapTo(EnterExitState.Visible) }, - launch { enterTransitionState.snapTo(EnterExitState.PreEnter) }, - launch { animatable?.cancel() }, - ) - + animationHandler?.cancel() + animationHandler = null setItems(getAnimationItems(newStack = stack)) } } override fun onBack() { scope.launch { - joinAll( - launch { exitTransitionState.animateTo(EnterExitState.PostExit) }, - launch { enterTransitionState.animateTo(EnterExitState.Visible) }, - launch { animatable?.finish() } - ) - + animationHandler?.finish() + animationHandler = null setItems(getAnimationItems(newStack = stack.dropLast())) predictiveBackParams.onBack() } } } + + private class AnimationHandler( + val animatable: PredictiveBackAnimatable?, + ) { + val exitTransitionState: SeekableTransitionState = SeekableTransitionState(EnterExitState.Visible) + val enterTransitionState: SeekableTransitionState = SeekableTransitionState(EnterExitState.PreEnter) + + suspend fun start(backEvent: BackEvent) { + awaitAll( + { exitTransitionState.seekTo(fraction = backEvent.progress, targetState = EnterExitState.PostExit) }, + { enterTransitionState.seekTo(fraction = backEvent.progress, targetState = EnterExitState.Visible) }, + { animatable?.animate(backEvent) }, + ) + } + + suspend fun progress(backEvent: BackEvent) { + animatable?.run { + animate(backEvent) + return@progress // Don't animate transition states on back progress if there is PredictiveBackAnimatable + } + + awaitAll( + { exitTransitionState.seekTo(fraction = backEvent.progress, targetState = EnterExitState.PostExit) }, + { enterTransitionState.seekTo(fraction = backEvent.progress, targetState = EnterExitState.Visible) }, + ) + } + + suspend fun cancel() { + awaitAll( + { exitTransitionState.snapTo(EnterExitState.Visible) }, + { enterTransitionState.snapTo(EnterExitState.PreEnter) }, + { animatable?.cancel() }, + ) + } + + suspend fun finish() { + awaitAll( + { exitTransitionState.animateTo(EnterExitState.PostExit) }, + { enterTransitionState.animateTo(EnterExitState.Visible) }, + { animatable?.finish() }, + ) + } + } } @Composable @@ -291,7 +327,7 @@ private fun Overlay(modifier: Modifier) { event.changes.forEach { it.consume() } } } - } + }, ) } 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 new file mode 100644 index 000000000..e22b552ed --- /dev/null +++ b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/TestUtils.kt @@ -0,0 +1,37 @@ +package com.arkivanov.decompose.extensions.compose.experimental + +import androidx.compose.ui.semantics.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsNodeInteraction +import kotlin.test.fail + +internal fun SemanticsNodeInteraction.assertTestTagToRootExists(testTag: String) { + val count = collectTestTagsToRoot().filter { it == testTag }.size + if (count != 1) { + fail("Expected to have one node with the specified test tag \"$testTag\", but was $count") + } +} + +internal fun SemanticsNodeInteraction.assertTestTagToRootDoesNotExist(matcher: (String) -> Boolean) { + val count = collectTestTagsToRoot().filter(matcher).size + if (count != 0) { + fail("Expected not to have a node with a test tag matching the specified predicate, but was $count") + } +} + +private fun SemanticsNodeInteraction.collectTestTagsToRoot(): List = + collectSemanticsFromRoot() + .mapNotNull { it.getOrNull(SemanticsProperties.TestTag) } + +private fun SemanticsNodeInteraction.collectSemanticsFromRoot(): List { + val list = ArrayList() + var node: SemanticsNode? = fetchSemanticsNode() + while (node != null) { + list += node.config + node = node.parent + } + + return list +} 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 1434cd170..8ca3ba2c6 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 @@ -15,13 +15,16 @@ 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.extensions.compose.experimental.stack.animation.PredictiveBackParams import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.StackAnimation import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.fade import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.plus import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.scale import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.slide import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.stackAnimation +import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.materialPredictiveBackAnimatable import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.essenty.backhandler.BackDispatcher import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -232,15 +235,39 @@ class ChildStackTest( fun parameters(): List> = getParameters().map { arrayOf(it) } - private fun getParameters(): List?> = - listOf( + private fun getParameters(): List?> { + val predictiveBackParams1 = + PredictiveBackParams( + backHandler = BackDispatcher(), + onBack = {}, + ) + + val predictiveBackParams2 = + PredictiveBackParams( + backHandler = BackDispatcher(), + onBack = {}, + animatableSelector = { initialBackEvent, _, _ -> materialPredictiveBackAnimatable(initialBackEvent) }, + ) + + return listOf( null, stackAnimation { _, _, _ -> null }, stackAnimation { _, _, _ -> scale() }, stackAnimation { _, _, _ -> fade() }, stackAnimation { _, _, _ -> slide() }, stackAnimation { _, _, _ -> scale() + fade() + slide() }, + stackAnimation(predictiveBackParams = predictiveBackParams1) { _, _, _ -> null }, + stackAnimation(predictiveBackParams = predictiveBackParams1) { _, _, _ -> scale() }, + stackAnimation(predictiveBackParams = predictiveBackParams1) { _, _, _ -> fade() }, + stackAnimation(predictiveBackParams = predictiveBackParams1) { _, _, _ -> slide() }, + stackAnimation(predictiveBackParams = predictiveBackParams1) { _, _, _ -> scale() + fade() + slide() }, + stackAnimation(predictiveBackParams = predictiveBackParams2) { _, _, _ -> null }, + stackAnimation(predictiveBackParams = predictiveBackParams2) { _, _, _ -> scale() }, + stackAnimation(predictiveBackParams = predictiveBackParams2) { _, _, _ -> fade() }, + stackAnimation(predictiveBackParams = predictiveBackParams2) { _, _, _ -> slide() }, + stackAnimation(predictiveBackParams = predictiveBackParams2) { _, _, _ -> scale() + fade() + slide() }, ) + } } // Can be enum, workaround https://issuetracker.google.com/issues/195185633 diff --git a/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackGestureTest.kt b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackGestureTest.kt new file mode 100644 index 000000000..c23277410 --- /dev/null +++ b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackGestureTest.kt @@ -0,0 +1,345 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack.animation + +import androidx.compose.material.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.experimental.assertTestTagToRootDoesNotExist +import com.arkivanov.decompose.extensions.compose.experimental.assertTestTagToRootExists +import com.arkivanov.decompose.extensions.compose.experimental.stack.dropLast +import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.PredictiveBackAnimatable +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.essenty.backhandler.BackDispatcher +import com.arkivanov.essenty.backhandler.BackEvent +import org.junit.Rule +import kotlin.test.Test +import kotlin.test.assertEquals + +@Suppress("TestFunctionName") +@OptIn(ExperimentalDecomposeApi::class) +class PredictiveBackGestureTest { + + @get:Rule + val composeRule = createComposeRule() + + private val backDispatcher = BackDispatcher() + + @Test + fun WHEN_gesture_not_started_THEN_active_child_shown_without_progress() { + var stack by mutableStateOf(stack("1", "2")) + val animation = DefaultStackAnimation(onBack = { stack = stack.dropLast() }) + + composeRule.setContent { + animation(stack, Modifier) { + Text(text = it.configuration) + } + } + + composeRule.onNodeWithText("1").assertDoesNotExist() + composeRule.onNodeWithText("2").assertExists() + composeRule.onNodeWithText("2").assertTestTagToRootDoesNotExist { it.startsWith(TEST_TAG_PREFIX) } + } + + @Test + fun WHEN_startPredictiveBack_THEN_gesture_started() { + var stack by mutableStateOf(stack("1", "2")) + val animation = DefaultStackAnimation(onBack = { stack = stack.dropLast() }) + + composeRule.setContent { + animation(stack, Modifier) { + Text(text = it.configuration) + } + } + + backDispatcher.startPredictiveBack(BackEvent(progress = 0F)) + composeRule.waitForIdle() + + composeRule.onNodeWithText("1").assertTestTagToRootExists(enterTestTag(progress = 0F)) + composeRule.onNodeWithText("2").assertTestTagToRootExists(exitTestTag(progress = 0F)) + } + + @Test + fun GIVEN_gesture_started_WHEN_progressPredictiveBack_THEN_gesture_progressed() { + var stack by mutableStateOf(stack("1", "2")) + val animation = DefaultStackAnimation(onBack = { stack = stack.dropLast() }) + + composeRule.setContent { + animation(stack, Modifier) { + Text(text = it.configuration) + } + } + + backDispatcher.startPredictiveBack(BackEvent(progress = 0F)) + composeRule.waitForIdle() + + backDispatcher.progressPredictiveBack(BackEvent(progress = 0.5F)) + composeRule.waitForIdle() + + composeRule.onNodeWithText("1").assertTestTagToRootExists(enterTestTag(progress = 0.5F)) + composeRule.onNodeWithText("2").assertTestTagToRootExists(exitTestTag(progress = 0.5F)) + } + + @Test + fun GIVEN_gesture_started_WHEN_back_THEN_gesture_finished_and_stack_popped() { + var stack by mutableStateOf(stack("1", "2")) + val animation = DefaultStackAnimation(onBack = { stack = stack.dropLast() }) + + composeRule.setContent { + animation(stack, Modifier) { + Text(text = it.configuration) + } + } + + backDispatcher.startPredictiveBack(BackEvent(progress = 0F)) + composeRule.waitForIdle() + backDispatcher.progressPredictiveBack(BackEvent(progress = 0.5F)) + composeRule.waitForIdle() + + backDispatcher.back() + composeRule.waitForIdle() + + assertEquals(stack("1"), stack) + composeRule.onNodeWithText("1").assertExists() + composeRule.onNodeWithText("1").assertTestTagToRootDoesNotExist { it.startsWith(TEST_TAG_PREFIX) } + composeRule.onNodeWithText("2").assertDoesNotExist() + } + + @Test + fun GIVEN_gesture_started_WHEN_cancelPredictiveBack_THEN_gesture_finished_and_stack_not_changed() { + var stack by mutableStateOf(stack("1", "2")) + val animation = DefaultStackAnimation(onBack = { stack = stack.dropLast() }) + + composeRule.setContent { + animation(stack, Modifier) { + Text(text = it.configuration) + } + } + + backDispatcher.startPredictiveBack(BackEvent(progress = 0F)) + composeRule.waitForIdle() + backDispatcher.progressPredictiveBack(BackEvent(progress = 0.5F)) + composeRule.waitForIdle() + + backDispatcher.cancelPredictiveBack() + composeRule.waitForIdle() + + assertEquals(stack("1", "2"), stack) + composeRule.onNodeWithText("1").assertDoesNotExist() + composeRule.onNodeWithText("2").assertExists() + composeRule.onNodeWithText("2").assertTestTagToRootDoesNotExist { it.startsWith(TEST_TAG_PREFIX) } + } + + @Test + fun GIVEN_gesture_started_WHEN_stack_popped_THEN_child_popped_and_gesture_finished() { + var stack by mutableStateOf(stack("1", "2")) + val animation = DefaultStackAnimation(onBack = { stack = stack.dropLast() }) + + composeRule.setContent { + animation(stack, Modifier) { + Text(text = it.configuration) + } + } + + backDispatcher.startPredictiveBack(BackEvent(progress = 0F)) + composeRule.waitForIdle() + + stack = stack.dropLast() + composeRule.waitForIdle() + + assertEquals(stack("1"), stack) + composeRule.onNodeWithText("1").assertExists() + composeRule.onNodeWithText("1").assertTestTagToRootDoesNotExist { it.startsWith(TEST_TAG_PREFIX) } + composeRule.onNodeWithText("2").assertDoesNotExist() + } + + @Test + fun GIVEN_gesture_started_WHEN_stack_pushed_THEN_child_pushed_and_gesture_finished() { + var stack by mutableStateOf(stack("1", "2")) + val animation = DefaultStackAnimation(onBack = { stack = stack.dropLast() }) + + composeRule.setContent { + animation(stack, Modifier) { + Text(text = it.configuration) + } + } + + backDispatcher.startPredictiveBack(BackEvent(progress = 0F)) + composeRule.waitForIdle() + + stack = stack("1", "2", "3") + composeRule.waitForIdle() + + assertEquals(stack("1", "2", "3"), stack) + composeRule.onNodeWithText("2").assertDoesNotExist() + composeRule.onNodeWithText("3").assertExists() + composeRule.onNodeWithText("3").assertTestTagToRootDoesNotExist { it.startsWith(TEST_TAG_PREFIX) } + } + + @Test + fun GIVEN_gesture_started_WHEN_stack_popped_and_back_THEN_previous_child_popped_and_gesture_finished() { + var stack by mutableStateOf(stack("1", "2", "3")) + val animation = DefaultStackAnimation(onBack = { stack = stack.dropLast() }) + + composeRule.setContent { + animation(stack, Modifier) { + Text(text = it.configuration) + } + } + + backDispatcher.startPredictiveBack(BackEvent(progress = 0F)) + composeRule.waitForIdle() + + stack = stack.dropLast() + composeRule.waitForIdle() + backDispatcher.back() + composeRule.waitForIdle() + + assertEquals(stack("1"), stack) + composeRule.onNodeWithText("1").assertExists() + composeRule.onNodeWithText("1").assertTestTagToRootDoesNotExist { it.startsWith(TEST_TAG_PREFIX) } + composeRule.onNodeWithText("2").assertDoesNotExist() + composeRule.onNodeWithText("3").assertDoesNotExist() + } + + @Test + fun GIVEN_gesture_started_WHEN_stack_pushed_and_back_THEN_new_child_popped_and_gesture_finished() { + var stack by mutableStateOf(stack("1", "2")) + val animation = DefaultStackAnimation(onBack = { stack = stack.dropLast() }) + + composeRule.setContent { + animation(stack, Modifier) { + Text(text = it.configuration) + } + } + + backDispatcher.startPredictiveBack(BackEvent(progress = 0F)) + composeRule.waitForIdle() + + stack = stack("1", "2", "3") + composeRule.waitForIdle() + backDispatcher.back() + composeRule.waitForIdle() + + assertEquals(stack("1", "2"), stack) + composeRule.onNodeWithText("2").assertExists() + composeRule.onNodeWithText("2").assertTestTagToRootDoesNotExist { it.startsWith(TEST_TAG_PREFIX) } + composeRule.onNodeWithText("3").assertDoesNotExist() + } + + @Test + fun GIVEN_gesture_started_WHEN_stack_popped_and_cancelPredictiveBack_THEN_child_popped_and_gesture_finished() { + var stack by mutableStateOf(stack("1", "2", "3")) + val animation = DefaultStackAnimation(onBack = { stack = stack.dropLast() }) + + composeRule.setContent { + animation(stack, Modifier) { + Text(text = it.configuration) + } + } + + backDispatcher.startPredictiveBack(BackEvent(progress = 0F)) + composeRule.waitForIdle() + + stack = stack.dropLast() + composeRule.waitForIdle() + backDispatcher.cancelPredictiveBack() + composeRule.waitForIdle() + + assertEquals(stack("1", "2"), stack) + composeRule.onNodeWithText("2").assertExists() + composeRule.onNodeWithText("2").assertTestTagToRootDoesNotExist { it.startsWith(TEST_TAG_PREFIX) } + composeRule.onNodeWithText("3").assertDoesNotExist() + } + + @Test + fun GIVEN_gesture_started_WHEN_stack_pushed_and_back_THEN_child_pushed_and_gesture_finished() { + var stack by mutableStateOf(stack("1", "2")) + val animation = DefaultStackAnimation(onBack = { stack = stack.dropLast() }) + + composeRule.setContent { + animation(stack, Modifier) { + Text(text = it.configuration) + } + } + + backDispatcher.startPredictiveBack(BackEvent(progress = 0F)) + composeRule.waitForIdle() + + stack = stack("1", "2", "3") + composeRule.waitForIdle() + backDispatcher.cancelPredictiveBack() + composeRule.waitForIdle() + + assertEquals(stack("1", "2", "3"), stack) + composeRule.onNodeWithText("2").assertDoesNotExist() + composeRule.onNodeWithText("3").assertExists() + composeRule.onNodeWithText("3").assertTestTagToRootDoesNotExist { it.startsWith(TEST_TAG_PREFIX) } + } + + private fun DefaultStackAnimation(onBack: () -> Unit): DefaultStackAnimation = + DefaultStackAnimation( + disableInputDuringAnimation = false, + predictiveBackParams = PredictiveBackParams( + backHandler = backDispatcher, + onBack = onBack, + animatableSelector = { initialBackEvent, _, _ -> + TestAnimatable(initialBackEvent) + }, + ), + selector = { _, _, _ -> null }, + ) + + private fun stack(configs: List): ChildStack = + ChildStack( + active = child(configs.last()), + backStack = configs.dropLast(1).map(::child), + ) + + private fun stack(vararg configs: String): ChildStack = + stack(configs.asList()) + + private fun child(config: String): Child.Created = + Child.Created(configuration = config, instance = config) + + private companion object { + private const val TEST_TAG_PREFIX = "TestTag" + private const val TEST_TAG_PREFIX_ENTER = TEST_TAG_PREFIX + "Enter" + private const val TEST_TAG_PREFIX_EXIT = TEST_TAG_PREFIX + "Exit" + + private fun enterTestTag(progress: Float): String = + testTag(prefix = TEST_TAG_PREFIX_ENTER, progress = progress) + + private fun exitTestTag(progress: Float): String = + testTag(prefix = TEST_TAG_PREFIX_EXIT, progress = progress) + + private fun testTag(prefix: String, progress: Float): String = + "$prefix{progress=$progress}" + } + + private class TestAnimatable( + initialBackEvent: BackEvent, + ) : PredictiveBackAnimatable { + private var progress by mutableStateOf(initialBackEvent.progress) + + override val exitModifier: Modifier get() = Modifier.testTag(exitTestTag(progress = progress)) + override val enterModifier: Modifier get() = Modifier.testTag(enterTestTag(progress = progress)) + + override suspend fun animate(event: BackEvent) { + progress = event.progress + } + + override suspend fun finish() { + progress = 1F + } + + override suspend fun cancel() { + progress = 0F + } + } +}