STL SwipeAnimation progress bounded between 0 and 1
Updates SwipeAnimation to explicitly bound the progress value between 0
and 1, ensuring that the animation behaves as expected even when the
user swipes beyond the target.
Test: atest SceneTransitionLayoutTest
Bug: 378470603
Flag: com.android.systemui.scene_container
Change-Id: I454e7e935a952de412e9bb1fe84736fe6f3af894
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeAnimation.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeAnimation.kt
index 607e4fa..ba92f9b 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeAnimation.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeAnimation.kt
@@ -315,16 +315,10 @@
val skipAnimation =
hasReachedTargetContent && !contentTransition.isWithinProgressRange(initialProgress)
- val targetOffset =
- if (targetContent == fromContent) {
- 0f
- } else {
- val distance = distance()
- check(distance != DistanceUnspecified) {
- "distance is equal to $DistanceUnspecified"
- }
- distance
- }
+ val distance = distance()
+ check(distance != DistanceUnspecified) { "distance is equal to $DistanceUnspecified" }
+
+ val targetOffset = if (targetContent == fromContent) 0f else distance
// If the effective current content changed, it should be reflected right now in the
// current state, even before the settle animation is ongoing. That way all the
@@ -343,7 +337,16 @@
}
val animatable =
- Animatable(initialOffset, OffsetVisibilityThreshold).also { offsetAnimation = it }
+ Animatable(initialOffset, OffsetVisibilityThreshold).also {
+ offsetAnimation = it
+
+ // We should animate when the progress value is between [0, 1].
+ if (distance > 0) {
+ it.updateBounds(0f, distance)
+ } else {
+ it.updateBounds(distance, 0f)
+ }
+ }
check(isAnimatingOffset())
@@ -370,42 +373,26 @@
val velocityConsumed = CompletableDeferred<Float>()
offsetAnimationRunnable.complete {
- try {
+ val result =
animatable.animateTo(
targetValue = targetOffset,
animationSpec = swipeSpec,
initialVelocity = initialVelocity,
- ) {
- // Immediately stop this transition if we are bouncing on a content that
- // does not bounce.
- if (!contentTransition.isWithinProgressRange(progress)) {
- // We are no longer able to consume the velocity, the rest can be
- // consumed by another component in the hierarchy.
- velocityConsumed.complete(initialVelocity - velocity)
- throw SnapException()
- }
- }
- } catch (_: SnapException) {
- /* Ignore. */
- } finally {
- if (!velocityConsumed.isCompleted) {
- // The animation consumed the whole available velocity
- velocityConsumed.complete(initialVelocity)
- }
+ )
- // Wait for overscroll to finish so that the transition is removed from the STLState
- // only after the overscroll is done, to avoid dropping frame right when the user
- // lifts their finger and overscroll is animated to 0.
- overscrollCompletable?.await()
- }
+ // We are no longer able to consume the velocity, the rest can be consumed by another
+ // component in the hierarchy.
+ velocityConsumed.complete(initialVelocity - result.endState.velocity)
+
+ // Wait for overscroll to finish so that the transition is removed from the STLState
+ // only after the overscroll is done, to avoid dropping frame right when the user
+ // lifts their finger and overscroll is animated to 0.
+ overscrollCompletable?.await()
}
return velocityConsumed.await()
}
- /** An exception thrown during the animation to stop it immediately. */
- private class SnapException : Exception()
-
private fun canChangeContent(targetContent: ContentKey): Boolean {
return when (val transition = contentTransition) {
is TransitionState.Transition.ChangeScene ->
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt
index fdbd0f6..2d57680 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt
@@ -21,6 +21,7 @@
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
@@ -33,6 +34,7 @@
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertHeightIsEqualTo
@@ -43,6 +45,9 @@
import androidx.compose.ui.test.onChild
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.swipeDown
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
@@ -469,4 +474,41 @@
assertThat(layoutImpl.overlaysOrNullForTest()).isNull()
}
+
+ @Test
+ fun transitionProgressBoundedBetween0And1() {
+ val layoutWidth = 200.dp
+ val layoutHeight = 400.dp
+
+ // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
+ // detected as a drag event.
+ var touchSlop = 0f
+ val state = rule.runOnUiThread { MutableSceneTransitionLayoutState(initialScene = SceneA) }
+ rule.setContent {
+ touchSlop = LocalViewConfiguration.current.touchSlop
+ SceneTransitionLayout(state, Modifier.size(layoutWidth, layoutHeight)) {
+ scene(SceneA, userActions = mapOf(Swipe.Down to SceneB)) {
+ Spacer(Modifier.fillMaxSize())
+ }
+ scene(SceneB) { Spacer(Modifier.fillMaxSize()) }
+ }
+ }
+ assertThat(state.transitionState).isIdle()
+
+ rule.mainClock.autoAdvance = false
+
+ // Swipe the verticalSwipeDistance.
+ rule.onRoot().performTouchInput {
+ swipeDown(endY = bottom + touchSlop, durationMillis = 50)
+ }
+
+ rule.mainClock.advanceTimeBy(16)
+ val transition = assertThat(state.transitionState).isSceneTransition()
+ assertThat(transition).isNotNull()
+ assertThat(transition).hasProgress(1f, tolerance = 0.01f)
+
+ rule.mainClock.advanceTimeBy(16)
+ // Fling animation, we are overscrolling now. Progress should always be between [0, 1].
+ assertThat(transition).hasProgress(1f)
+ }
}