Make default compose fling animations uses constant MotionDurationScale
Update default fling behavior for scrollables in compose to always run on a 1x factor.
This will approximate the behavior expected by developers to what it used to be in views.
Test: Added new tests
Fixes: 241460890
Change-Id: I207e2eed0ef53e3a286fcd6de4042c09902350dd
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollableTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollableTest.kt
index 2a6ad37..2b284d1 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollableTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollableTest.kt
@@ -16,12 +16,16 @@
package androidx.compose.foundation
+import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.tween
+import androidx.compose.animation.rememberSplineBasedDecay
+import androidx.compose.foundation.gestures.DefaultFlingBehavior
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ModifierLocalScrollableContainer
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollScope
+import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.awaitFirstDown
@@ -48,6 +52,7 @@
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
+import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.focus.FocusRequester
@@ -105,6 +110,7 @@
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.instanceOf
import org.junit.After
@@ -2395,6 +2401,114 @@
}
}
+ @Test
+ fun disableSystemAnimations_defaultFlingBehaviorShouldContinueToWork() {
+
+ val controller = ScrollableState { 0f }
+ var defaultFlingBehavior: DefaultFlingBehavior? = null
+ setScrollableContent {
+ defaultFlingBehavior = ScrollableDefaults.flingBehavior() as? DefaultFlingBehavior
+ Modifier.scrollable(
+ state = controller,
+ orientation = Orientation.Horizontal,
+ flingBehavior = defaultFlingBehavior
+ )
+ }
+
+ scope.launch {
+ controller.scroll {
+ defaultFlingBehavior?.let {
+ with(it) { performFling(1000f) }
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(defaultFlingBehavior?.lastAnimationCycleCount).isGreaterThan(1)
+ }
+
+ // Simulate turning of animation
+ scope.launch {
+ controller.scroll {
+ withContext(TestScrollMotionDurationScale(0f)) {
+ defaultFlingBehavior?.let {
+ with(it) { performFling(1000f) }
+ }
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(defaultFlingBehavior?.lastAnimationCycleCount).isGreaterThan(1)
+ }
+ }
+
+ @Test
+ fun defaultFlingBehavior_useScrollMotionDurationScale() {
+
+ val controller = ScrollableState { 0f }
+ var defaultFlingBehavior: DefaultFlingBehavior? = null
+ var switchMotionDurationScale by mutableStateOf(true)
+
+ rule.setContentAndGetScope {
+ val flingSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay()
+ if (switchMotionDurationScale) {
+ defaultFlingBehavior =
+ DefaultFlingBehavior(flingSpec, TestScrollMotionDurationScale(1f))
+ Box(
+ modifier = Modifier
+ .testTag(scrollableBoxTag)
+ .size(100.dp)
+ .scrollable(
+ state = controller,
+ orientation = Orientation.Horizontal,
+ flingBehavior = defaultFlingBehavior
+ )
+ )
+ } else {
+ defaultFlingBehavior =
+ DefaultFlingBehavior(flingSpec, TestScrollMotionDurationScale(0f))
+ Box(
+ modifier = Modifier
+ .testTag(scrollableBoxTag)
+ .size(100.dp)
+ .scrollable(
+ state = controller,
+ orientation = Orientation.Horizontal,
+ flingBehavior = defaultFlingBehavior
+ )
+ )
+ }
+ }
+
+ scope.launch {
+ controller.scroll {
+ defaultFlingBehavior?.let {
+ with(it) { performFling(1000f) }
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(defaultFlingBehavior?.lastAnimationCycleCount).isGreaterThan(1)
+ }
+
+ switchMotionDurationScale = false
+ rule.waitForIdle()
+
+ scope.launch {
+ controller.scroll {
+ defaultFlingBehavior?.let {
+ with(it) { performFling(1000f) }
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(defaultFlingBehavior?.lastAnimationCycleCount).isEqualTo(1)
+ }
+ }
+
private fun setScrollableContent(scrollableModifierFactory: @Composable () -> Modifier) {
rule.setContentAndGetScope {
Box {
@@ -2520,4 +2634,6 @@
Swipe.FAST, start, end,
Press.FINGER
)
-}
\ No newline at end of file
+}
+
+private class TestScrollMotionDurationScale(override val scaleFactor: Float) : MotionDurationScale
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
index 5f2ab2d..aab5ed0 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
@@ -34,6 +34,7 @@
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
+import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
@@ -59,6 +60,7 @@
import androidx.compose.ui.util.fastForEach
import kotlin.math.abs
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
/**
* Configure touch scrolling and flinging for the UI element in a single [Orientation].
@@ -541,28 +543,37 @@
}
}
-private class DefaultFlingBehavior(
- private val flingDecay: DecayAnimationSpec<Float>
+internal class DefaultFlingBehavior(
+ private val flingDecay: DecayAnimationSpec<Float>,
+ private val motionDurationScale: MotionDurationScale = DefaultScrollMotionDurationScale
) : FlingBehavior {
+
+ // For Testing
+ var lastAnimationCycleCount = 0
+
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
+ lastAnimationCycleCount = 0
// come up with the better threshold, but we need it since spline curve gives us NaNs
- return if (abs(initialVelocity) > 1f) {
- var velocityLeft = initialVelocity
- var lastValue = 0f
- AnimationState(
- initialValue = 0f,
- initialVelocity = initialVelocity,
- ).animateDecay(flingDecay) {
- val delta = value - lastValue
- val consumed = scrollBy(delta)
- lastValue = value
- velocityLeft = this.velocity
- // avoid rounding errors and stop if anything is unconsumed
- if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
+ return withContext(motionDurationScale) {
+ if (abs(initialVelocity) > 1f) {
+ var velocityLeft = initialVelocity
+ var lastValue = 0f
+ AnimationState(
+ initialValue = 0f,
+ initialVelocity = initialVelocity,
+ ).animateDecay(flingDecay) {
+ val delta = value - lastValue
+ val consumed = scrollBy(delta)
+ lastValue = value
+ velocityLeft = this.velocity
+ // avoid rounding errors and stop if anything is unconsumed
+ if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
+ lastAnimationCycleCount++
+ }
+ velocityLeft
+ } else {
+ initialVelocity
}
- velocityLeft
- } else {
- initialVelocity
}
}
}
@@ -578,3 +589,10 @@
override val key = ModifierLocalScrollableContainer
override val value = true
}
+
+private const val DefaultScrollMotionDurationScaleFactor = 1f
+
+private val DefaultScrollMotionDurationScale = object : MotionDurationScale {
+ override val scaleFactor: Float
+ get() = DefaultScrollMotionDurationScaleFactor
+}
\ No newline at end of file