diff --git a/extensions-compose-experimental/api/extensions-compose-experimental.klib.api b/extensions-compose-experimental/api/extensions-compose-experimental.klib.api index 76bd2f7dd..7584518f1 100644 --- a/extensions-compose-experimental/api/extensions-compose-experimental.klib.api +++ b/extensions-compose-experimental/api/extensions-compose-experimental.klib.api @@ -61,6 +61,10 @@ final val com.arkivanov.decompose.extensions.compose.experimental.panels/com_ark final val com.arkivanov.decompose.extensions.compose.experimental.stack.animation/LocalStackAnimationProvider // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/LocalStackAnimationProvider|{}LocalStackAnimationProvider[0] final fun (): androidx.compose.runtime/ProvidableCompositionLocal // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/LocalStackAnimationProvider.|(){}[0] final val com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation$stableprop // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation$stableprop|#static{}com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation$stableprop[0] +final val com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Finishing$stableprop // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Finishing$stableprop|#static{}com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Finishing$stableprop[0] +final val com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Idle$stableprop // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Idle$stableprop|#static{}com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Idle$stableprop[0] +final val com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Progress$stableprop // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Progress$stableprop|#static{}com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Progress$stableprop[0] +final val com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Started$stableprop // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Started$stableprop|#static{}com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Started$stableprop[0] final val com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimator$stableprop // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimator$stableprop|#static{}com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimator$stableprop[0] final val com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_PredictiveBackParams$stableprop // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_PredictiveBackParams$stableprop|#static{}com_arkivanov_decompose_extensions_compose_experimental_stack_animation_PredictiveBackParams$stableprop[0] final val com.arkivanov.decompose.extensions.compose.experimental/com_arkivanov_decompose_extensions_compose_experimental_BroadcastBackHandler$stableprop // com.arkivanov.decompose.extensions.compose.experimental/com_arkivanov_decompose_extensions_compose_experimental_BroadcastBackHandler$stableprop|#static{}com_arkivanov_decompose_extensions_compose_experimental_BroadcastBackHandler$stableprop[0] @@ -78,6 +82,10 @@ final fun <#A: kotlin/Any, #B: kotlin/Any> com.arkivanov.decompose.extensions.co final fun com.arkivanov.decompose.extensions.compose.experimental.panels/com_arkivanov_decompose_extensions_compose_experimental_panels_ChildPanelsAnimators$stableprop_getter(): kotlin/Int // com.arkivanov.decompose.extensions.compose.experimental.panels/com_arkivanov_decompose_extensions_compose_experimental_panels_ChildPanelsAnimators$stableprop_getter|com_arkivanov_decompose_extensions_compose_experimental_panels_ChildPanelsAnimators$stableprop_getter(){}[0] final fun com.arkivanov.decompose.extensions.compose.experimental.panels/com_arkivanov_decompose_extensions_compose_experimental_panels_HorizontalChildPanelsLayout$stableprop_getter(): kotlin/Int // com.arkivanov.decompose.extensions.compose.experimental.panels/com_arkivanov_decompose_extensions_compose_experimental_panels_HorizontalChildPanelsLayout$stableprop_getter|com_arkivanov_decompose_extensions_compose_experimental_panels_HorizontalChildPanelsLayout$stableprop_getter(){}[0] final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation$stableprop_getter(): kotlin/Int // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation$stableprop_getter|com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation$stableprop_getter(){}[0] +final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Finishing$stableprop_getter(): kotlin/Int // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Finishing$stableprop_getter|com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Finishing$stableprop_getter(){}[0] +final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Idle$stableprop_getter(): kotlin/Int // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Idle$stableprop_getter|com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Idle$stableprop_getter(){}[0] +final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Progress$stableprop_getter(): kotlin/Int // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Progress$stableprop_getter|com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Progress$stableprop_getter(){}[0] +final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Started$stableprop_getter(): kotlin/Int // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Started$stableprop_getter|com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation_State_Started$stableprop_getter(){}[0] final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimator$stableprop_getter(): kotlin/Int // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimator$stableprop_getter|com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimator$stableprop_getter(){}[0] final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_PredictiveBackParams$stableprop_getter(): kotlin/Int // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_PredictiveBackParams$stableprop_getter|com_arkivanov_decompose_extensions_compose_experimental_stack_animation_PredictiveBackParams$stableprop_getter(){}[0] final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/fade(androidx.compose.animation.core/FiniteAnimationSpec = ..., kotlin/Float = ...): com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimator // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/fade|fade(androidx.compose.animation.core.FiniteAnimationSpec;kotlin.Float){}[0] 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 7156a443a..6b0a52416 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 @@ -53,7 +53,7 @@ internal class DefaultStackAnimation( ) { var currentStack by remember { mutableStateOf(stack) } var items by remember { mutableStateOf(getAnimationItems(newStack = currentStack)) } - var nextItems: Map>? by remember { mutableStateOf(null) } + 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 } } @@ -150,7 +150,7 @@ internal class DefaultStackAnimation( } } - private fun getAnimationItems(newStack: ChildStack, oldStack: ChildStack? = null): Map> = + private fun getAnimationItems(newStack: ChildStack, oldStack: ChildStack? = null): Map> = when { (oldStack == null) || (newStack.active.key == oldStack.active.key) -> keyedItemsOf( @@ -199,7 +199,7 @@ internal class DefaultStackAnimation( private fun PredictiveBackController( stack: ChildStack, predictiveBackParams: PredictiveBackParams, - setItems: (Map>) -> Unit, + setItems: (Map>) -> Unit, ) { val scope = rememberCoroutineScope() @@ -242,29 +242,30 @@ internal class DefaultStackAnimation( private val stack: ChildStack, private val scope: CoroutineScope, private val predictiveBackParams: PredictiveBackParams, - private val setItems: (Map>) -> Unit, + private val setItems: (Map>) -> Unit, ) : BackCallback() { - private var animationHandler: AnimationHandler? = null - private var initialBackEvent: BackEvent? = null + private var state: State = State.Idle override fun onBackStarted(backEvent: BackEvent) { - initialBackEvent = backEvent + if (state is State.Idle) { + state = State.Started(backEvent) + } } override fun onBackProgressed(backEvent: BackEvent) { startIfNeeded() + val currentState = state as? State.Progress ?: return scope.launch { - animationHandler?.progress(backEvent) + currentState.animationHandler.progress(backEvent) } } private fun startIfNeeded() { - val backEvent = initialBackEvent ?: return - initialBackEvent = null - + val currentState = state as? State.Started ?: return + val backEvent = currentState.initialBackEvent val animationHandler = AnimationHandler(animatable = predictiveBackParams.animatable(backEvent)) - this.animationHandler = animationHandler + state = State.Progress(animationHandler) val exitChild = stack.active val enterChild = stack.backStack.last() @@ -295,32 +296,45 @@ internal class DefaultStackAnimation( } override fun onBackCancelled() { - initialBackEvent = null + val currentState = state + if (currentState is State.Progress) { + state = State.Finishing - scope.launch { - animationHandler?.also { handler -> - handler.cancel() - animationHandler = null + scope.launch { + currentState.animationHandler.cancel() + state = State.Idle setItems(getAnimationItems(newStack = stack)) } + } else if (currentState !is State.Finishing) { + state = State.Idle } } override fun onBack() { - initialBackEvent = null + val currentState = state + if (currentState is State.Progress) { + state = State.Finishing - scope.launch { - animationHandler?.also { handler -> - handler.finish() - animationHandler = null + scope.launch { + currentState.animationHandler.finish() + state = State.Idle setItems(getAnimationItems(newStack = stack.dropLast())) + predictiveBackParams.onBack() } - + } else if (currentState !is State.Finishing) { + state = State.Idle predictiveBackParams.onBack() } } } + private sealed interface State { + data object Idle : State + data class Started(val initialBackEvent: BackEvent) : State + data class Progress(val animationHandler: AnimationHandler) : State + data object Finishing : State + } + private class AnimationHandler( val animatable: PredictiveBackAnimatable?, ) { @@ -380,7 +394,7 @@ private data class AnimationItem( ) @ExperimentalDecomposeApi -private fun keyedItemsOf(vararg items: AnimationItem): Map> = +private fun keyedItemsOf(vararg items: AnimationItem): Map> = items.associateBy { it.child.key } /* 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 index 5a0fde088..cdf90b0ee 100644 --- 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 @@ -20,6 +20,7 @@ import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.essenty.backhandler.BackDispatcher import com.arkivanov.essenty.backhandler.BackEvent +import kotlinx.coroutines.suspendCancellableCoroutine import org.junit.Rule import kotlin.test.Test import kotlin.test.assertEquals @@ -216,7 +217,7 @@ class PredictiveBackGestureTest { @Test fun GIVEN_gesture_started_WHEN_stack_popped_THEN_gesture_cancelled() { var stack by mutableStateOf(stack("1", "2")) - val animation = DefaultStackAnimation(animator = fade(), onBack = { stack = stack.dropLast() },) + val animation = DefaultStackAnimation(animator = fade(), onBack = { stack = stack.dropLast() }) composeRule.setContent { animation(stack, Modifier) { @@ -492,6 +493,88 @@ class PredictiveBackGestureTest { assertEquals(0.7F, values["2"]) } + @Test + fun GIVEN_gesture_finishing_WHEN_new_gesture_started_THEN_new_animation_not_started() { + var stack by mutableStateOf(stack("1", "2")) + var animationCount = 0 + + val animation = + DefaultStackAnimation( + predictiveBackAnimatable = { + animationCount++ + + TestAnimatable( + initialBackEvent = it, + finish = { + suspendCancellableCoroutine { + // Simulate a long-running animation + } + }, + ) + }, + 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() + backDispatcher.startPredictiveBack(BackEvent(progress = 0F)) + composeRule.waitForIdle() + backDispatcher.progressPredictiveBack(BackEvent(progress = 0.5F)) + composeRule.waitForIdle() + + assertEquals(1, animationCount) + } + + @Test + fun GIVEN_gesture_finishing_WHEN_back_THEN_stack_not_popped() { + var stack by mutableStateOf(stack("1", "2")) + var animationCount = 0 + + val animation = + DefaultStackAnimation( + predictiveBackAnimatable = { + animationCount++ + + TestAnimatable( + initialBackEvent = it, + finish = { + suspendCancellableCoroutine { + // Simulate a long-running animation + } + }, + ) + }, + 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() + backDispatcher.back() + composeRule.waitForIdle() + + assertEquals(stack("1", "2"), stack) + } + private fun DefaultStackAnimation( predictiveBackAnimatable: (initialBackEvent: BackEvent) -> PredictiveBackAnimatable? = ::TestAnimatable, animator: StackAnimator? = null, @@ -538,6 +621,7 @@ class PredictiveBackGestureTest { private class TestAnimatable( initialBackEvent: BackEvent, + private val finish: suspend () -> Unit = {}, ) : PredictiveBackAnimatable { private var progress by mutableStateOf(initialBackEvent.progress) @@ -550,6 +634,7 @@ class PredictiveBackGestureTest { override suspend fun finish() { progress = 1F + finish.invoke() } override suspend fun cancel() {