diff options
3 files changed, 364 insertions, 66 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimator.kt b/packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimator.kt index 05f815c91971..8a1759d4d0cd 100644 --- a/packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimator.kt +++ b/packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimator.kt @@ -27,6 +27,9 @@ import androidx.dynamicanimation.animation.SpringAnimation import androidx.dynamicanimation.animation.SpringForce import com.android.systemui.util.animation.PhysicsAnimator.Companion.getInstance import java.util.WeakHashMap +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min /** * Extension function for all objects which will return a PhysicsAnimator instance for that object. @@ -35,6 +38,15 @@ val <T : View> T.physicsAnimator: PhysicsAnimator<T> get() { return getInstance( private const val TAG = "PhysicsAnimator" +private val UNSET = -Float.MAX_VALUE + +/** + * [FlingAnimation] multiplies the friction set via [FlingAnimation.setFriction] by 4.2f, which is + * where this number comes from. We use it in [PhysicsAnimator.flingThenSpring] to calculate the + * minimum velocity for a fling to reach a certain value, given the fling's friction. + */ +private const val FLING_FRICTION_SCALAR_MULTIPLIER = 4.2f + typealias EndAction = () -> Unit /** A map of Property -> AnimationUpdate, which is provided to update listeners on each frame. */ @@ -236,6 +248,71 @@ class PhysicsAnimator<T> private constructor (val target: T) { } /** + * Flings a property using the given start velocity. If the fling animation reaches the min/max + * bounds (from the [flingConfig]) with velocity remaining, it'll overshoot it and spring back. + * + * If the object is already out of the fling bounds, it will immediately spring back within + * bounds. + * + * This is useful for animating objects that are bounded by constraints such as screen edges, + * since otherwise the fling animation would end abruptly upon reaching the min/max bounds. + * + * @param property The property to animate. + * @param startVelocity The velocity, in pixels/second, with which to start the fling. If the + * object is already outside the fling bounds, this velocity will be used as the start velocity + * of the spring that will spring it back within bounds. + * @param flingMustReachMinOrMax If true, the fling animation is guaranteed to reach either its + * minimum bound (if [startVelocity] is negative) or maximum bound (if it's positive). The + * animator will use startVelocity if it's sufficient, or add more velocity if necessary. This + * is useful when fling's deceleration-based physics are preferable to the acceleration-based + * forces used by springs - typically, when you're allowing the user to move an object somewhere + * on the screen, but it needs to be along an edge. + * @param flingConfig The configuration to use for the fling portion of the animation. + * @param springConfig The configuration to use for the spring portion of the animation. + */ + @JvmOverloads + fun flingThenSpring( + property: FloatPropertyCompat<in T>, + startVelocity: Float, + flingConfig: FlingConfig, + springConfig: SpringConfig, + flingMustReachMinOrMax: Boolean = false + ): PhysicsAnimator<T> { + val flingConfigCopy = flingConfig.copy() + val springConfigCopy = springConfig.copy() + val toAtLeast = if (startVelocity < 0) flingConfig.min else flingConfig.max + + // If the fling needs to reach min/max, calculate the velocity required to do so and use + // that if the provided start velocity is not sufficient. + if (flingMustReachMinOrMax && + toAtLeast != -Float.MAX_VALUE && toAtLeast != Float.MAX_VALUE) { + val distanceToDestination = toAtLeast - property.getValue(target) + + // The minimum velocity required for the fling to end up at the given destination, + // taking the provided fling friction value. + val velocityToReachDestination = distanceToDestination * + (flingConfig.friction * FLING_FRICTION_SCALAR_MULTIPLIER) + + // Try to use the provided start velocity, but use the required velocity to reach the + // destination if the provided velocity is insufficient. + val sufficientVelocity = + if (distanceToDestination < 0) + min(velocityToReachDestination, startVelocity) + else + max(velocityToReachDestination, startVelocity) + + flingConfigCopy.startVelocity = sufficientVelocity + springConfigCopy.finalPosition = toAtLeast + } else { + flingConfigCopy.startVelocity = startVelocity + } + + flingConfigs[property] = flingConfigCopy + springConfigs[property] = springConfigCopy + return this + } + + /** * Adds a listener that will be called whenever any property on the animated object is updated. * This will be called on every animation frame, with the current value of the animated object * and the new property values. @@ -246,7 +323,7 @@ class PhysicsAnimator<T> private constructor (val target: T) { } /** - * Adds a listener that will be called whenever a property's animation ends. This is useful if + * Adds a listener that will be called when a property stops animating. This is useful if * you care about a specific property ending, or want to use the end value/end velocity from a * particular property's animation. If you just want to run an action when all property * animations have ended, use [withEndActions]. @@ -311,6 +388,114 @@ class PhysicsAnimator<T> private constructor (val target: T) { "your test setup.") } + // Functions that will actually start the animations. These are run after we build and add + // the InternalListener, since some animations might update/end immediately and we don't + // want to miss those updates. + val animationStartActions = ArrayList<() -> Unit>() + + for (animatedProperty in getAnimatedProperties()) { + val flingConfig = flingConfigs[animatedProperty] + val springConfig = springConfigs[animatedProperty] + + // The property's current value on the object. + val currentValue = animatedProperty.getValue(target) + + // Start by checking for a fling configuration. If one is present, we're either flinging + // or flinging-then-springing. Either way, we'll want to start the fling first. + if (flingConfig != null) { + animationStartActions.add { + // When the animation is starting, adjust the min/max bounds to include the + // current value of the property, if necessary. This is required to allow a + // fling to bring an out-of-bounds object back into bounds. For example, if an + // object was dragged halfway off the left side of the screen, but then flung to + // the right, we don't want the animation to end instantly just because the + // object started out of bounds. If the fling is in the direction that would + // take it farther out of bounds, it will end instantly as expected. + flingConfig.apply { + min = min(currentValue, this.min) + max = max(currentValue, this.max) + } + + // Apply the configuration and start the animation. + getFlingAnimation(animatedProperty) + .also { flingConfig.applyToAnimation(it) } + .start() + } + } + + // Check for a spring configuration. If one is present, we're either springing, or + // flinging-then-springing. + if (springConfig != null) { + + // If there is no corresponding fling config, we're only springing. + if (flingConfig == null) { + // Apply the configuration and start the animation. + val springAnim = getSpringAnimation(animatedProperty) + springConfig.applyToAnimation(springAnim) + animationStartActions.add(springAnim::start) + } else { + // If there's a corresponding fling config, we're flinging-then-springing. Save + // the fling's original bounds so we can spring to them when the fling ends. + val flingMin = flingConfig.min + val flingMax = flingConfig.max + + // Add an end listener that will start the spring when the fling ends. + endListeners.add(0, object : EndListener<T> { + override fun onAnimationEnd( + target: T, + property: FloatPropertyCompat<in T>, + wasFling: Boolean, + canceled: Boolean, + finalValue: Float, + finalVelocity: Float, + allRelevantPropertyAnimsEnded: Boolean + ) { + // If this isn't the relevant property, it wasn't a fling, or the fling + // was explicitly cancelled, don't spring. + if (property != animatedProperty || !wasFling || canceled) { + return + } + + val endedWithVelocity = abs(finalVelocity) > 0 + + // If the object was out of bounds when the fling animation started, it + // will immediately end. In that case, we'll spring it back in bounds. + val endedOutOfBounds = finalValue !in flingMin..flingMax + + // If the fling ended either out of bounds or with remaining velocity, + // it's time to spring. + if (endedWithVelocity || endedOutOfBounds) { + springConfig.startVelocity = finalVelocity + + // If the spring's final position isn't set, this is a + // flingThenSpring where flingMustReachMinOrMax was false. We'll + // need to set the spring's final position here. + if (springConfig.finalPosition == UNSET) { + if (endedWithVelocity) { + // If the fling ended with negative velocity, that means it + // hit the min bound, so spring to that bound (and vice + // versa). + springConfig.finalPosition = + if (finalVelocity < 0) flingMin else flingMax + } else if (endedOutOfBounds) { + // If the fling ended out of bounds, spring it to the + // nearest bound. + springConfig.finalPosition = + if (finalValue < flingMin) flingMin else flingMax + } + } + + // Apply the configuration and start the spring animation. + getSpringAnimation(animatedProperty) + .also { springConfig.applyToAnimation(it) } + .start() + } + } + }) + } + } + } + // Add an internal listener that will dispatch animation events to the provided listeners. internalListeners.add(InternalListener( getAnimatedProperties(), @@ -318,24 +503,10 @@ class PhysicsAnimator<T> private constructor (val target: T) { ArrayList(endListeners), ArrayList(endActions))) - for ((property, config) in flingConfigs) { - val currentValue = property.getValue(target) - - // If the fling is already out of bounds, don't start it. - if (currentValue <= config.min || currentValue >= config.max) { - continue - } - - val flingAnim = getFlingAnimation(property) - config.applyToAnimation(flingAnim) - flingAnim.start() - } - - for ((property, config) in springConfigs) { - val springAnim = getSpringAnimation(property) - config.applyToAnimation(springAnim) - springAnim.start() - } + // Actually start the DynamicAnimations. This is delayed until after the InternalListener is + // constructed and added so that we don't miss the end listener firing for any animations + // that immediately end. + animationStartActions.forEach { it.invoke() } clearAnimator() } @@ -381,7 +552,10 @@ class PhysicsAnimator<T> private constructor (val target: T) { } anim.addEndListener { _, canceled, value, velocity -> internalListeners.removeAll { - it.onInternalAnimationEnd(property, canceled, value, velocity) } } + it.onInternalAnimationEnd( + property, canceled, value, velocity, anim is FlingAnimation) + } + } return anim } @@ -434,7 +608,8 @@ class PhysicsAnimator<T> private constructor (val target: T) { property: FloatPropertyCompat<in T>, canceled: Boolean, finalValue: Float, - finalVelocity: Float + finalVelocity: Float, + isFling: Boolean ): Boolean { // If this property animation isn't relevant to this listener, ignore it. @@ -461,7 +636,15 @@ class PhysicsAnimator<T> private constructor (val target: T) { val allEnded = !arePropertiesAnimating(properties) endListeners.forEach { - it.onAnimationEnd(target, property, canceled, finalValue, finalVelocity, allEnded) } + it.onAnimationEnd( + target, property, isFling, canceled, finalValue, finalVelocity, + allEnded) + + // Check that the end listener didn't restart this property's animation. + if (isPropertyAnimating(property)) { + return false + } + } // If all of the animations that this listener cares about have ended, run the end // actions unless the animation was canceled. @@ -524,15 +707,15 @@ class PhysicsAnimator<T> private constructor (val target: T) { data class SpringConfig internal constructor( internal var stiffness: Float, internal var dampingRatio: Float, - internal var startVel: Float = 0f, - internal var finalPosition: Float = -Float.MAX_VALUE + internal var startVelocity: Float = 0f, + internal var finalPosition: Float = UNSET ) { constructor() : this(defaultSpring.stiffness, defaultSpring.dampingRatio) constructor(stiffness: Float, dampingRatio: Float) : - this(stiffness = stiffness, dampingRatio = dampingRatio, startVel = 0f) + this(stiffness = stiffness, dampingRatio = dampingRatio, startVelocity = 0f) /** Apply these configuration settings to the given SpringAnimation. */ internal fun applyToAnimation(anim: SpringAnimation) { @@ -543,7 +726,7 @@ class PhysicsAnimator<T> private constructor (val target: T) { finalPosition = this@SpringConfig.finalPosition } - if (startVel != 0f) anim.setStartVelocity(startVel) + if (startVelocity != 0f) anim.setStartVelocity(startVelocity) } } @@ -556,7 +739,7 @@ class PhysicsAnimator<T> private constructor (val target: T) { internal var friction: Float, internal var min: Float, internal var max: Float, - internal var startVel: Float + internal var startVelocity: Float ) { constructor() : this(defaultFling.friction) @@ -565,7 +748,7 @@ class PhysicsAnimator<T> private constructor (val target: T) { this(friction, defaultFling.min, defaultFling.max) constructor(friction: Float, min: Float, max: Float) : - this(friction, min, max, startVel = 0f) + this(friction, min, max, startVelocity = 0f) /** Apply these configuration settings to the given FlingAnimation. */ internal fun applyToAnimation(anim: FlingAnimation) { @@ -573,7 +756,7 @@ class PhysicsAnimator<T> private constructor (val target: T) { friction = this@FlingConfig.friction setMinValue(min) setMaxValue(max) - setStartVelocity(startVel) + setStartVelocity(startVelocity) } } } @@ -626,6 +809,10 @@ class PhysicsAnimator<T> private constructor (val target: T) { * * @param target The animated object itself. * @param property The property whose animation has just ended. + * @param wasFling Whether this property ended after a fling animation (as opposed to a + * spring animation). If this property was animated via [flingThenSpring], this will be true + * if the fling animation did not reach the min/max bounds, decelerating to a stop + * naturally. It will be false if it hit the bounds and was sprung back. * @param canceled Whether the animation was explicitly canceled before it naturally ended. * @param finalValue The final value of the animated property. * @param finalVelocity The final velocity (in pixels per second) of the ended animation. @@ -663,6 +850,7 @@ class PhysicsAnimator<T> private constructor (val target: T) { fun onAnimationEnd( target: T, property: FloatPropertyCompat<in T>, + wasFling: Boolean, canceled: Boolean, finalValue: Float, finalVelocity: Float, diff --git a/packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimatorTestUtils.kt b/packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimatorTestUtils.kt index e86970c117cc..965decd255a0 100644 --- a/packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimatorTestUtils.kt +++ b/packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimatorTestUtils.kt @@ -19,6 +19,7 @@ import android.os.Handler import android.os.Looper import android.util.ArrayMap import androidx.dynamicanimation.animation.FloatPropertyCompat +import com.android.systemui.util.animation.PhysicsAnimatorTestUtils.prepareForTest import java.util.ArrayDeque import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -119,6 +120,7 @@ object PhysicsAnimatorTestUtils { override fun onAnimationEnd( target: T, property: FloatPropertyCompat<in T>, + wasFling: Boolean, canceled: Boolean, finalValue: Float, finalVelocity: Float, @@ -389,8 +391,6 @@ object PhysicsAnimatorTestUtils { val unblockLatch = CountDownLatch(if (startBlocksUntilAnimationsEnd) 2 else 1) animationThreadHandler.post { - val animatedProperties = animator.getAnimatedProperties() - // Add an update listener that dispatches to any test update listeners added by // tests. animator.addUpdateListener(object : PhysicsAnimator.UpdateListener<T> { @@ -398,6 +398,10 @@ object PhysicsAnimatorTestUtils { target: T, values: ArrayMap<FloatPropertyCompat<in T>, PhysicsAnimator.AnimationUpdate> ) { + values.forEach { (property, value) -> + allUpdates.getOrPut(property, { ArrayList() }).add(value) + } + for (listener in testUpdateListeners) { listener.onAnimationUpdateForProperty(target, values) } @@ -410,6 +414,7 @@ object PhysicsAnimatorTestUtils { override fun onAnimationEnd( target: T, property: FloatPropertyCompat<in T>, + wasFling: Boolean, canceled: Boolean, finalValue: Float, finalVelocity: Float, @@ -417,7 +422,7 @@ object PhysicsAnimatorTestUtils { ) { for (listener in testEndListeners) { listener.onAnimationEnd( - target, property, canceled, finalValue, finalVelocity, + target, property, wasFling, canceled, finalValue, finalVelocity, allRelevantPropertyAnimsEnded) } @@ -432,31 +437,6 @@ object PhysicsAnimatorTestUtils { } }) - val updateListeners = ArrayList<PhysicsAnimator.UpdateListener<T>>().also { - it.add(object : PhysicsAnimator.UpdateListener<T> { - override fun onAnimationUpdateForProperty( - target: T, - values: ArrayMap<FloatPropertyCompat<in T>, - PhysicsAnimator.AnimationUpdate> - ) { - values.forEach { (property, value) -> - allUpdates.getOrPut(property, { ArrayList() }).add(value) - } - } - }) - } - - /** - * Add an internal listener at the head of the list that captures update values - * directly from DynamicAnimation. We use this to build a list of all updates so we - * can verify that InternalListener dispatches to the real listeners properly. - */ - animator.internalListeners.add(0, animator.InternalListener( - animatedProperties, - updateListeners, - ArrayList(), - ArrayList())) - animator.startInternal() unblockLatch.countDown() } diff --git a/packages/SystemUI/tests/src/com/android/systemui/util/animation/PhysicsAnimatorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/util/animation/PhysicsAnimatorTest.kt index 01b0621938c9..a785d4ba2cb8 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/util/animation/PhysicsAnimatorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/util/animation/PhysicsAnimatorTest.kt @@ -155,6 +155,7 @@ class PhysicsAnimatorTest : SysuiTestCase() { verify(mockEndListener).onAnimationEnd( testView, DynamicAnimation.TRANSLATION_X, + wasFling = false, canceled = false, finalValue = 10f, finalVelocity = 0f, @@ -176,6 +177,7 @@ class PhysicsAnimatorTest : SysuiTestCase() { verify(mockEndListener).onAnimationEnd( testView, DynamicAnimation.TRANSLATION_Y, + wasFling = false, canceled = false, finalValue = 500f, finalVelocity = 0f, @@ -215,8 +217,8 @@ class PhysicsAnimatorTest : SysuiTestCase() { verifyUpdateListenerCalls(animator, mockUpdateListener) verify(mockEndListener, times(1)).onAnimationEnd( - eq(testView), eq(DynamicAnimation.TRANSLATION_X), eq(false), anyFloat(), anyFloat(), - eq(true)) + eq(testView), eq(DynamicAnimation.TRANSLATION_X), eq(false), eq(false), anyFloat(), + anyFloat(), eq(true)) verify(mockEndAction, times(1)).run() animator @@ -330,13 +332,24 @@ class PhysicsAnimatorTest : SysuiTestCase() { // The original end listener should also have been called, with allEnded = true since it was // provided to an animator that animated only TRANSLATION_X. verify(mockEndListener, times(1)) - .onAnimationEnd(testView, DynamicAnimation.TRANSLATION_X, false, 200f, 0f, true) + .onAnimationEnd( + testView, DynamicAnimation.TRANSLATION_X, + wasFling = false, + canceled = false, + finalValue = 200f, + finalVelocity = 0f, + allRelevantPropertyAnimsEnded = true) verifyNoMoreInteractions(mockEndListener) // The second end listener should have been called, but with allEnded = false since it was // provided to an animator that animated both TRANSLATION_X and TRANSLATION_Y. verify(secondEndListener, times(1)) - .onAnimationEnd(testView, DynamicAnimation.TRANSLATION_X, false, 200f, 0f, false) + .onAnimationEnd(testView, DynamicAnimation.TRANSLATION_X, + wasFling = false, + canceled = false, + finalValue = 200f, + finalVelocity = 0f, + allRelevantPropertyAnimsEnded = false) verifyNoMoreInteractions(secondEndListener) PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(animator, DynamicAnimation.TRANSLATION_Y) @@ -346,7 +359,12 @@ class PhysicsAnimatorTest : SysuiTestCase() { verifyNoMoreInteractions(mockEndListener) verify(secondEndListener, times(1)) - .onAnimationEnd(testView, DynamicAnimation.TRANSLATION_Y, false, 4000f, 0f, true) + .onAnimationEnd(testView, DynamicAnimation.TRANSLATION_Y, + wasFling = false, + canceled = false, + finalValue = 4000f, + finalVelocity = 0f, + allRelevantPropertyAnimsEnded = true) verifyNoMoreInteractions(secondEndListener) } @@ -365,8 +383,8 @@ class PhysicsAnimatorTest : SysuiTestCase() { assertEquals(10f, testView.translationX, 1f) verify(mockEndListener, times(1)) .onAnimationEnd( - eq(testView), eq(DynamicAnimation.TRANSLATION_X), eq(false), eq(10f), - anyFloat(), eq(true)) + eq(testView), eq(DynamicAnimation.TRANSLATION_X), eq(true), eq(false), + eq(10f), anyFloat(), eq(true)) animator .fling( @@ -382,8 +400,8 @@ class PhysicsAnimatorTest : SysuiTestCase() { assertEquals(-5f, testView.translationX, 1f) verify(mockEndListener, times(1)) .onAnimationEnd( - eq(testView), eq(DynamicAnimation.TRANSLATION_X), eq(false), eq(-5f), - anyFloat(), eq(true)) + eq(testView), eq(DynamicAnimation.TRANSLATION_X), eq(true), eq(false), + eq(-5f), anyFloat(), eq(true)) } @Test @@ -427,6 +445,118 @@ class PhysicsAnimatorTest : SysuiTestCase() { assertEquals(200f, testView.translationX, 1f) } + @Test + fun testFlingThenSpring() { + PhysicsAnimatorTestUtils.setAllAnimationsBlock(false) + + // Start at 500f and fling hard to the left. We should quickly reach the 250f minimum, fly + // past it since there's so much velocity remaining, then spring back to 250f. + testView.translationX = 500f + animator + .flingThenSpring( + DynamicAnimation.TRANSLATION_X, + -5000f, + flingConfig.apply { min = 250f }, + springConfig) + .addUpdateListener(mockUpdateListener) + .addEndListener(mockEndListener) + .withEndActions(mockEndAction::run) + .start() + + // Block until we pass the minimum. + PhysicsAnimatorTestUtils.blockUntilFirstAnimationFrameWhereTrue( + animator) { v -> v.translationX <= 250f } + + // Double check that the view is there. + assertTrue(testView.translationX <= 250f) + + // The update listener should have been called with a value < 500f, and then a value less + // than or equal to the 250f minimum. + verifyAnimationUpdateFrames(animator, DynamicAnimation.TRANSLATION_X, + { u -> u.value < 500f }, + { u -> u.value <= 250f }) + + // Despite the fact that the fling has ended, the end listener shouldn't have been called + // since we're about to begin springing the same property. + verifyNoMoreInteractions(mockEndListener) + verifyNoMoreInteractions(mockEndAction) + + // Wait for the spring to finish. + PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_X) + + // Make sure we continued past 250f since the spring should have been started with some + // remaining negative velocity from the fling. + verifyAnimationUpdateFrames(animator, DynamicAnimation.TRANSLATION_X, + { u -> u.value < 250f }) + + // At this point, the animation end listener should have been called once, and only once, + // when the spring ended at 250f. + verify(mockEndListener).onAnimationEnd(testView, DynamicAnimation.TRANSLATION_X, + wasFling = false, + canceled = false, + finalValue = 250f, + finalVelocity = 0f, + allRelevantPropertyAnimsEnded = true) + verifyNoMoreInteractions(mockEndListener) + + // The end action should also have been called once. + verify(mockEndAction, times(1)).run() + verifyNoMoreInteractions(mockEndAction) + + assertEquals(250f, testView.translationX) + } + + @Test + fun testFlingThenSpring_objectOutsideFlingBounds() { + // Start the view at x = -500, well outside the fling bounds of min = 0f, with strong + // negative velocity. + testView.translationX = -500f + animator + .flingThenSpring( + DynamicAnimation.TRANSLATION_X, + -5000f, + flingConfig.apply { min = 0f }, + springConfig) + .addUpdateListener(mockUpdateListener) + .addEndListener(mockEndListener) + .withEndActions(mockEndAction::run) + .start() + + // The initial -5000f velocity should result in frames to the left of -500f before the view + // springs back towards 0f. + verifyAnimationUpdateFrames( + animator, DynamicAnimation.TRANSLATION_X, + { u -> u.value < -500f }, + { u -> u.value > -500f }) + + // We should end up at the fling min. + assertEquals(0f, testView.translationX, 1f) + } + + @Test + fun testFlingToMinMaxThenSpring() { + // Start at x = 500f. + testView.translationX = 500f + + // Fling to the left at the very sad rate of -1 pixels per second. That won't get us much of + // anywhere, and certainly not to the 0f min. + animator + // Good thing we have flingToMinMaxThenSpring! + .flingThenSpring( + DynamicAnimation.TRANSLATION_X, + -10000f, + flingConfig.apply { min = 0f }, + springConfig, + flingMustReachMinOrMax = true) + .addUpdateListener(mockUpdateListener) + .addEndListener(mockEndListener) + .withEndActions(mockEndAction::run) + .start() + + // Thanks, flingToMinMaxThenSpring, for adding enough velocity to get us here. + assertEquals(0f, testView.translationX, 1f) + } + /** * Verifies that the calls to the mock update listener match the animation update frames * reported by the test internal listener, in order. |