Skip to content

Commit

Permalink
Fixed the predictive back gesture interrupted with the new animation API
Browse files Browse the repository at this point in the history
  • Loading branch information
arkivanov committed Aug 18, 2024
1 parent e2659fd commit 945cfa2
Show file tree
Hide file tree
Showing 5 changed files with 497 additions and 42 deletions.
Original file line number Diff line number Diff line change
@@ -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 <C : Any, T : Any> ChildStack<C, T>.dropLast(): ChildStack<C, T> =
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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -48,12 +49,21 @@ internal class DefaultStackAnimation<C : Any, T : Any>(
) {
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)
}
}
Expand Down Expand Up @@ -83,7 +93,7 @@ internal class DefaultStackAnimation<C : Any, T : Any>(
}

if ((predictiveBackParams != null) && currentStack.backStack.isNotEmpty()) {
key(items.keys) {
key(currentStackKeys) {
PredictiveBackController(
stack = currentStack,
predictiveBackParams = predictiveBackParams,
Expand Down Expand Up @@ -179,7 +189,11 @@ internal class DefaultStackAnimation<C : Any, T : Any>(

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)
}
}
}

Expand All @@ -204,81 +218,103 @@ internal class DefaultStackAnimation<C : Any, T : Any>(
private val setItems: (Map<Any, AnimationItem<C, T>>) -> 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<EnterExitState> = SeekableTransitionState(EnterExitState.Visible)
val enterTransitionState: SeekableTransitionState<EnterExitState> = 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
Expand All @@ -291,7 +327,7 @@ private fun Overlay(modifier: Modifier) {
event.changes.forEach { it.consume() }
}
}
}
},
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> =
collectSemanticsFromRoot()
.mapNotNull { it.getOrNull(SemanticsProperties.TestTag) }

private fun SemanticsNodeInteraction.collectSemanticsFromRoot(): List<SemanticsConfiguration> {
val list = ArrayList<SemanticsConfiguration>()
var node: SemanticsNode? = fetchSemanticsNode()
while (node != null) {
list += node.config
node = node.parent
}

return list
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -232,15 +235,39 @@ class ChildStackTest(
fun parameters(): List<Array<out Any?>> =
getParameters().map { arrayOf(it) }

private fun getParameters(): List<StackAnimation<Config, Config>?> =
listOf(
private fun getParameters(): List<StackAnimation<Config, Config>?> {
val predictiveBackParams1 =
PredictiveBackParams<Config, Config>(
backHandler = BackDispatcher(),
onBack = {},
)

val predictiveBackParams2 =
PredictiveBackParams<Config, Config>(
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
Expand Down
Loading

0 comments on commit 945cfa2

Please sign in to comment.