From 408b9595e3668625fc715ea661d8a1e690be6e43 Mon Sep 17 00:00:00 2001 From: Joshua Tsuji Date: Thu, 7 Nov 2019 18:32:58 -0500 Subject: [PATCH] Adds PhysicsAnimator and accompanying test utilities! This is the first CL, which adds the basic spring/fling and listener capabilities. Change-Id: I059b82a79bb2f2d93c57998669a6aac7a608cb42 Bug: 138115889 Test: atest SystemUITests --- .../util/animation/PhysicsAnimator.kt | 717 ++++++++++++++++++ .../animation/PhysicsAnimatorTestUtils.kt | 457 +++++++++++ .../util/animation/PhysicsAnimatorTest.kt | 436 +++++++++++ 3 files changed, 1610 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimator.kt create mode 100644 packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimatorTestUtils.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/util/animation/PhysicsAnimatorTest.kt diff --git a/packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimator.kt b/packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimator.kt new file mode 100644 index 0000000000000..62ae7b9d620f8 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimator.kt @@ -0,0 +1,717 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.util.animation + +import android.os.Looper +import android.util.ArrayMap +import android.util.Log +import android.view.View +import androidx.dynamicanimation.animation.DynamicAnimation +import androidx.dynamicanimation.animation.FlingAnimation +import androidx.dynamicanimation.animation.FloatPropertyCompat +import androidx.dynamicanimation.animation.SpringAnimation +import androidx.dynamicanimation.animation.SpringForce +import com.android.systemui.util.animation.PhysicsAnimator.Companion.getInstance +import java.util.WeakHashMap + +/** + * Extension function for all objects which will return a PhysicsAnimator instance for that object. + */ +val T.physicsAnimator: PhysicsAnimator get() { return getInstance(this) } + +private const val TAG = "PhysicsAnimator" + +typealias EndAction = () -> Unit + +/** A map of Property -> AnimationUpdate, which is provided to update listeners on each frame. */ +typealias UpdateMap = + ArrayMap, PhysicsAnimator.AnimationUpdate> + +/** + * Map of the animators associated with a given object. This ensures that only one animator + * per object exists. + */ +internal val animators = WeakHashMap>() + +/** + * Default spring configuration to use for animations where stiffness and/or damping ratio + * were not provided. + */ +private val defaultSpring = PhysicsAnimator.SpringConfig( + SpringForce.STIFFNESS_MEDIUM, + SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY) + +/** Default fling configuration to use for animations where friction was not provided. */ +private val defaultFling = PhysicsAnimator.FlingConfig( + friction = 1f, min = -Float.MAX_VALUE, max = Float.MAX_VALUE) + +/** Whether to log helpful debug information about animations. */ +private var verboseLogging = false + +/** + * Animator that uses physics-based animations to animate properties on views and objects. Physics + * animations use real-world physical concepts, such as momentum and mass, to realistically simulate + * motion. PhysicsAnimator is heavily inspired by [android.view.ViewPropertyAnimator], and + * also uses the builder pattern to configure and start animations. + * + * The physics animations are backed by [DynamicAnimation]. + * + * @param T The type of the object being animated. + */ +class PhysicsAnimator private constructor (val target: T) { + + /** Data class for representing animation frame updates. */ + data class AnimationUpdate(val value: Float, val velocity: Float) + + /** [DynamicAnimation] instances for the given properties. */ + private val springAnimations = ArrayMap, SpringAnimation>() + private val flingAnimations = ArrayMap, FlingAnimation>() + + /** + * Spring and fling configurations for the properties to be animated on the target. We'll + * configure and start the DynamicAnimations for these properties according to the provided + * configurations. + */ + private val springConfigs = ArrayMap, SpringConfig>() + private val flingConfigs = ArrayMap, FlingConfig>() + + /** + * Animation listeners for the animation. These will be notified when each property animation + * updates or ends. + */ + private val updateListeners = ArrayList>() + private val endListeners = ArrayList>() + + /** End actions to run when all animations have completed. */ + private val endActions = ArrayList() + + /** + * Internal listeners that respond to DynamicAnimations updating and ending, and dispatch to + * the listeners provided via [addUpdateListener] and [addEndListener]. This allows us to add + * just one permanent update and end listener to the DynamicAnimations. + */ + internal var internalListeners = ArrayList() + + /** + * Action to run when [start] is called. This can be changed by + * [PhysicsAnimatorTestUtils.prepareForTest] to enable animators to run under test and provide + * helpful test utilities. + */ + internal var startAction: () -> Unit = ::startInternal + + /** + * Springs a property to the given value, using the provided configuration settings. + * + * Springs are used when you know the exact value to which you want to animate. They can be + * configured with a start velocity (typically used when the spring is initiated by a touch + * event), but this velocity will be realistically attenuated as forces are applied to move the + * property towards the end value. + * + * If you find yourself repeating the same stiffness and damping ratios many times, consider + * storing a single [SpringConfig] instance and passing that in instead of individual values. + * + * @param property The property to spring to the given value. The property must be an instance + * of FloatPropertyCompat<? super T>. For example, if this is a + * PhysicsAnimator<FrameLayout>, you can use a FloatPropertyCompat<FrameLayout>, as + * well as a FloatPropertyCompat<ViewGroup>, and so on. + * @param toPosition The value to spring the given property to. + * @param startVelocity The initial velocity to use for the animation. + * @param stiffness The stiffness to use for the spring. Higher stiffness values result in + * faster animations, while lower stiffness means a slower animation. Reasonable values for + * low, medium, and high stiffness can be found as constants in [SpringForce]. + * @param dampingRatio The damping ratio (bounciness) to use for the spring. Higher values + * result in a less 'springy' animation, while lower values allow the animation to bounce + * back and forth for a longer time after reaching the final position. Reasonable values for + * low, medium, and high damping can be found in [SpringForce]. + */ + fun spring( + property: FloatPropertyCompat, + toPosition: Float, + startVelocity: Float = 0f, + stiffness: Float = defaultSpring.stiffness, + dampingRatio: Float = defaultSpring.dampingRatio + ): PhysicsAnimator { + if (verboseLogging) { + Log.d(TAG, "Springing ${getReadablePropertyName(property)} to $toPosition.") + } + + springConfigs[property] = + SpringConfig(stiffness, dampingRatio, startVelocity, toPosition) + return this + } + + /** + * Springs a property to a given value using the provided start velocity and configuration + * options. + * + * @see spring + */ + fun spring( + property: FloatPropertyCompat, + toPosition: Float, + startVelocity: Float, + config: SpringConfig = defaultSpring + ): PhysicsAnimator { + return spring( + property, toPosition, startVelocity, config.stiffness, config.dampingRatio) + } + + /** + * Springs a property to a given value using the provided configuration options, and a start + * velocity of 0f. + * + * @see spring + */ + fun spring( + property: FloatPropertyCompat, + toPosition: Float, + config: SpringConfig = defaultSpring + ): PhysicsAnimator { + return spring(property, toPosition, 0f, config) + } + + /** + * Flings a property using the given start velocity, using a [FlingAnimation] configured using + * the provided configuration settings. + * + * Flings are used when you have a start velocity, and want the property value to realistically + * decrease as friction is applied until the velocity reaches zero. Flings do not have a + * deterministic end value. If you are attempting to animate to a specific end value, use + * [spring]. + * + * If you find yourself repeating the same friction/min/max values, consider storing a single + * [FlingConfig] and passing that in instead. + * + * @param property The property to fling using the given start velocity. + * @param startVelocity The start velocity (in pixels per second) with which to start the fling. + * @param friction Friction value applied to slow down the animation over time. Higher values + * will more quickly slow the animation. Typical friction values range from 1f to 10f. + * @param min The minimum value allowed for the animation. If this value is reached, the + * animation will end abruptly. + * @param max The maximum value allowed for the animation. If this value is reached, the + * animation will end abruptly. + */ + fun fling( + property: FloatPropertyCompat, + startVelocity: Float, + friction: Float = defaultFling.friction, + min: Float = defaultFling.min, + max: Float = defaultFling.max + ): PhysicsAnimator { + if (verboseLogging) { + Log.d(TAG, "Flinging ${getReadablePropertyName(property)} " + + "with velocity $startVelocity.") + } + + flingConfigs[property] = FlingConfig(friction, min, max, startVelocity) + return this + } + + /** + * Flings a property using the given start velocity, using a [FlingAnimation] configured using + * the provided configuration settings. + * + * @see fling + */ + fun fling( + property: FloatPropertyCompat, + startVelocity: Float, + config: FlingConfig = defaultFling + ): PhysicsAnimator { + return fling(property, startVelocity, config.friction, config.min, config.max) + } + + /** + * 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. + */ + fun addUpdateListener(listener: UpdateListener): PhysicsAnimator { + updateListeners.add(listener) + return this + } + + /** + * Adds a listener that will be called whenever a property's animation ends. 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]. + */ + fun addEndListener(listener: EndListener): PhysicsAnimator { + endListeners.add(listener) + return this + } + + /** + * Adds end actions that will be run sequentially when animations for every property involved in + * this specific animation have ended (unless they were explicitly canceled). For example, if + * you call: + * + * animator + * .spring(TRANSLATION_X, ...) + * .spring(TRANSLATION_Y, ...) + * .withEndAction(action) + * .start() + * + * 'action' will be run when both TRANSLATION_X and TRANSLATION_Y end. + * + * Other properties may still be animating, if those animations were not started in the same + * call. For example: + * + * animator + * .spring(ALPHA, ...) + * .start() + * + * animator + * .spring(TRANSLATION_X, ...) + * .spring(TRANSLATION_Y, ...) + * .withEndAction(action) + * .start() + * + * 'action' will still be run as soon as TRANSLATION_X and TRANSLATION_Y end, even if ALPHA is + * still animating. + * + * If you want to run actions as soon as a subset of property animations have ended, you want + * access to the animation's end value/velocity, or you want to run these actions even if the + * animation is explicitly canceled, use [addEndListener]. End listeners have an allEnded param, + * which indicates that all relevant animations have ended. + */ + fun withEndActions(vararg endActions: EndAction): PhysicsAnimator { + this.endActions.addAll(endActions) + return this + } + + /** Starts the animations! */ + fun start() { + startAction() + } + + /** + * Starts the animations for real! This is typically called immediately by [start] unless this + * animator is under test. + */ + internal fun startInternal() { + if (!Looper.getMainLooper().isCurrentThread) { + Log.e(TAG, "Animations can only be started on the main thread. If you are seeing " + + "this message in a test, call PhysicsAnimatorTestUtils#prepareForTest in " + + "your test setup.") + } + + // Add an internal listener that will dispatch animation events to the provided listeners. + internalListeners.add(InternalListener( + getAnimatedProperties(), + ArrayList(updateListeners), + 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() + } + + clearAnimator() + } + + /** Clear the animator's builder variables. */ + private fun clearAnimator() { + springConfigs.clear() + flingConfigs.clear() + + updateListeners.clear() + endListeners.clear() + endActions.clear() + } + + /** Retrieves a spring animation for the given property, building one if needed. */ + private fun getSpringAnimation(property: FloatPropertyCompat): SpringAnimation { + return springAnimations.getOrPut( + property, + { configureDynamicAnimation(SpringAnimation(target, property), property) + as SpringAnimation }) + } + + /** Retrieves a fling animation for the given property, building one if needed. */ + private fun getFlingAnimation(property: FloatPropertyCompat): FlingAnimation { + return flingAnimations.getOrPut( + property, + { configureDynamicAnimation(FlingAnimation(target, property), property) + as FlingAnimation }) + } + + /** + * Adds update and end listeners to the DynamicAnimation which will dispatch to the internal + * listeners. + */ + private fun configureDynamicAnimation( + anim: DynamicAnimation<*>, + property: FloatPropertyCompat + ): DynamicAnimation<*> { + anim.addUpdateListener { _, value, velocity -> + for (i in 0 until internalListeners.size) { + internalListeners[i].onInternalAnimationUpdate(property, value, velocity) + } + } + anim.addEndListener { _, canceled, value, velocity -> + internalListeners.removeAll { + it.onInternalAnimationEnd(property, canceled, value, velocity) } } + return anim + } + + /** + * Internal listener class that receives updates from DynamicAnimation listeners, and dispatches + * them to the appropriate update/end listeners. This class is also aware of which properties + * were being animated when the end listeners were passed in, so that we can provide the + * appropriate value for allEnded to [EndListener.onAnimationEnd]. + */ + internal inner class InternalListener constructor( + private var properties: Set>, + private var updateListeners: List>, + private var endListeners: List>, + private var endActions: List + ) { + + /** The number of properties whose animations haven't ended. */ + private var numPropertiesAnimating = properties.size + + /** + * Update values that haven't yet been dispatched because not all property animations have + * updated yet. + */ + private val undispatchedUpdates = + ArrayMap, AnimationUpdate>() + + /** Called when a DynamicAnimation updates. */ + internal fun onInternalAnimationUpdate( + property: FloatPropertyCompat, + value: Float, + velocity: Float + ) { + + // If this property animation isn't relevant to this listener, ignore it. + if (!properties.contains(property)) { + return + } + + undispatchedUpdates[property] = AnimationUpdate(value, velocity) + maybeDispatchUpdates() + } + + /** + * Called when a DynamicAnimation ends. + * + * @return True if this listener should be removed from the list of internal listeners, so + * it no longer receives updates from DynamicAnimations. + */ + internal fun onInternalAnimationEnd( + property: FloatPropertyCompat, + canceled: Boolean, + finalValue: Float, + finalVelocity: Float + ): Boolean { + + // If this property animation isn't relevant to this listener, ignore it. + if (!properties.contains(property)) { + return false + } + + // Dispatch updates if we have one for each property. + numPropertiesAnimating-- + maybeDispatchUpdates() + + // If we didn't have an update for each property, dispatch the update for the ending + // property. This guarantees that an update isn't sent for this property *after* we call + // onAnimationEnd for that property. + if (undispatchedUpdates.contains(property)) { + updateListeners.forEach { updateListener -> + updateListener.onAnimationUpdateForProperty( + target, + UpdateMap().also { it[property] = undispatchedUpdates[property] }) + } + + undispatchedUpdates.remove(property) + } + + val allEnded = !arePropertiesAnimating(properties) + endListeners.forEach { + it.onAnimationEnd(target, property, canceled, finalValue, finalVelocity, allEnded) } + + // If all of the animations that this listener cares about have ended, run the end + // actions unless the animation was canceled. + if (allEnded && !canceled) { + endActions.forEach { it() } + } + + return allEnded + } + + /** + * Dispatch undispatched values if we've received an update from each of the animating + * properties. + */ + private fun maybeDispatchUpdates() { + if (undispatchedUpdates.size >= numPropertiesAnimating && + undispatchedUpdates.size > 0) { + updateListeners.forEach { + it.onAnimationUpdateForProperty(target, ArrayMap(undispatchedUpdates)) + } + + undispatchedUpdates.clear() + } + } + } + + /** Return true if any animations are running on the object. */ + fun isRunning(): Boolean { + return arePropertiesAnimating(springAnimations.keys.union(flingAnimations.keys)) + } + + /** Returns whether the given property is animating. */ + fun isPropertyAnimating(property: FloatPropertyCompat): Boolean { + return springAnimations[property]?.isRunning ?: false + } + + /** Returns whether any of the given properties are animating. */ + fun arePropertiesAnimating(properties: Set>): Boolean { + return properties.any { isPropertyAnimating(it) } + } + + /** Return the set of properties that will begin animating upon calling [start]. */ + internal fun getAnimatedProperties(): Set> { + return springConfigs.keys.union(flingConfigs.keys) + } + + /** Cancels all in progress animations on all properties. */ + fun cancel() { + for (dynamicAnim in flingAnimations.values.union(springAnimations.values)) { + dynamicAnim.cancel() + } + } + + /** + * Container object for spring animation configuration settings. This allows you to store + * default stiffness and damping ratio values in a single configuration object, which you can + * pass to [spring]. + */ + 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 + ) { + + constructor() : + this(defaultSpring.stiffness, defaultSpring.dampingRatio) + + constructor(stiffness: Float, dampingRatio: Float) : + this(stiffness = stiffness, dampingRatio = dampingRatio, startVel = 0f) + + /** Apply these configuration settings to the given SpringAnimation. */ + internal fun applyToAnimation(anim: SpringAnimation) { + val springForce = anim.spring ?: SpringForce() + anim.spring = springForce.apply { + stiffness = this@SpringConfig.stiffness + dampingRatio = this@SpringConfig.dampingRatio + finalPosition = this@SpringConfig.finalPosition + } + + if (startVel != 0f) anim.setStartVelocity(startVel) + } + } + + /** + * Container object for fling animation configuration settings. This allows you to store default + * friction values (as well as optional min/max values) in a single configuration object, which + * you can pass to [fling] and related methods. + */ + data class FlingConfig internal constructor( + internal var friction: Float, + internal var min: Float, + internal var max: Float, + internal var startVel: Float + ) { + + constructor() : this(defaultFling.friction) + + constructor(friction: Float) : + this(friction, defaultFling.min, defaultFling.max) + + constructor(friction: Float, min: Float, max: Float) : + this(friction, min, max, startVel = 0f) + + /** Apply these configuration settings to the given FlingAnimation. */ + internal fun applyToAnimation(anim: FlingAnimation) { + anim.apply { + friction = this@FlingConfig.friction + setMinValue(min) + setMaxValue(max) + setStartVelocity(startVel) + } + } + } + + /** + * Listener for receiving values from in progress animations. Used with + * [PhysicsAnimator.addUpdateListener]. + * + * @param The type of the object being animated. + */ + interface UpdateListener { + + /** + * Called on each animation frame with the target object, and a map of FloatPropertyCompat + * -> AnimationUpdate, containing the latest value and velocity for that property. When + * multiple properties are animating together, the map will typically contain one entry for + * each property. However, you should never assume that this is the case - when a property + * animation ends earlier than the others, you'll receive an UpdateMap containing only that + * property's final update. Subsequently, you'll only receive updates for the properties + * that are still animating. + * + * Always check that the map contains an update for the property you're interested in before + * accessing it. + * + * @param target The animated object itself. + * @param values Map of property to AnimationUpdate, which contains that property + * animation's latest value and velocity. You should never assume that a particular property + * is present in this map. + */ + fun onAnimationUpdateForProperty( + target: T, + values: UpdateMap + ) + } + + /** + * Listener for receiving callbacks when animations end. + * + * @param The type of the object being animated. + */ + interface EndListener { + + /** + * Called with the final animation values as each property animation ends. This can be used + * to respond to specific property animations concluding (such as hiding a view when ALPHA + * ends, even if the corresponding TRANSLATION animations have not ended). + * + * If you just want to run an action when all of the property animations have ended, you can + * use [PhysicsAnimator.withEndActions]. + * + * @param target The animated object itself. + * @param property The property whose animation has just ended. + * @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. + * This is typically zero, unless this was a fling animation which ended abruptly due to + * reaching its configured min/max values. + * @param allRelevantPropertyAnimsEnded Whether all properties relevant to this end listener + * have ended. Relevant properties are those which were animated alongside the + * [addEndListener] call where this animator was passed in. For example: + * + * animator + * .spring(TRANSLATION_X, 100f) + * .spring(TRANSLATION_Y, 200f) + * .withEndListener(firstEndListener) + * .start() + * + * firstEndListener will be called first for TRANSLATION_X, with allEnded = false, + * because TRANSLATION_Y is still running. When TRANSLATION_Y ends, it'll be called with + * allEnded = true. + * + * If a subsequent call to start() is made with other properties, those properties are not + * considered relevant and allEnded will still equal true when only TRANSLATION_X and + * TRANSLATION_Y end. For example, if immediately after the prior example, while + * TRANSLATION_X and TRANSLATION_Y are still animating, we called: + * + * animator. + * .spring(SCALE_X, 2f, stiffness = 10f) // That will take awhile... + * .withEndListener(secondEndListener) + * .start() + * + * firstEndListener will still be called with allEnded = true when TRANSLATION_X/Y end, even + * though SCALE_X is still animating. Similarly, secondEndListener will be called with + * allEnded = true as soon as SCALE_X ends, even if the translation animations are still + * running. + */ + fun onAnimationEnd( + target: T, + property: FloatPropertyCompat, + canceled: Boolean, + finalValue: Float, + finalVelocity: Float, + allRelevantPropertyAnimsEnded: Boolean + ) + } + + companion object { + + /** + * Constructor to use to for new physics animator instances in [getInstance]. This is + * typically the default constructor, but [PhysicsAnimatorTestUtils] can change it so that + * all code using the physics animator is given testable instances instead. + */ + internal var instanceConstructor: (Any) -> PhysicsAnimator<*> = ::PhysicsAnimator + + @JvmStatic + fun getInstance(target: T): PhysicsAnimator { + if (!animators.containsKey(target)) { + animators[target] = instanceConstructor(target) + } + + return animators[target] as PhysicsAnimator + } + + /** + * Set whether all physics animators should log a lot of information about animations. + * Useful for debugging! + */ + @JvmStatic + fun setVerboseLogging(debug: Boolean) { + verboseLogging = debug + } + + @JvmStatic + fun getReadablePropertyName(property: FloatPropertyCompat<*>): String { + return when (property) { + DynamicAnimation.TRANSLATION_X -> "translationX" + DynamicAnimation.TRANSLATION_Y -> "translationY" + DynamicAnimation.TRANSLATION_Z -> "translationZ" + DynamicAnimation.SCALE_X -> "scaleX" + DynamicAnimation.SCALE_Y -> "scaleY" + DynamicAnimation.ROTATION -> "rotation" + DynamicAnimation.ROTATION_X -> "rotationX" + DynamicAnimation.ROTATION_Y -> "rotationY" + DynamicAnimation.SCROLL_X -> "scrollX" + DynamicAnimation.SCROLL_Y -> "scrollY" + DynamicAnimation.ALPHA -> "alpha" + else -> "Custom FloatPropertyCompat instance" + } + } + } +} \ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimatorTestUtils.kt b/packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimatorTestUtils.kt new file mode 100644 index 0000000000000..a1f74eb40cad9 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/util/animation/PhysicsAnimatorTestUtils.kt @@ -0,0 +1,457 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.systemui.util.animation + +import android.os.Handler +import android.os.Looper +import android.util.ArrayMap +import androidx.dynamicanimation.animation.FloatPropertyCompat +import java.util.ArrayDeque +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +typealias UpdateMatcher = (PhysicsAnimator.AnimationUpdate) -> Boolean +typealias UpdateFramesPerProperty = + ArrayMap, ArrayList> + +/** + * Utilities for testing code that uses [PhysicsAnimator]. + * + * Start by calling [prepareForTest] at the beginning of each test - this will modify the behavior + * of all PhysicsAnimator instances so that they post animations to the main thread (so they don't + * crash). It'll also enable the use of the other static helper methods in this class, which you can + * use to do things like block the test until animations complete (so you can test end states), or + * verify keyframes. + */ +object PhysicsAnimatorTestUtils { + var timeoutMs: Long = 2000 + private var startBlocksUntilAnimationsEnd = false + private val animationThreadHandler = Handler(Looper.getMainLooper()) + private val allAnimatedObjects = HashSet() + private val animatorTestHelpers = HashMap, AnimatorTestHelper<*>>() + + /** + * Modifies the behavior of all [PhysicsAnimator] instances so that they post animations to the + * main thread, and report all of their + */ + @JvmStatic + fun prepareForTest() { + val defaultConstructor = PhysicsAnimator.instanceConstructor + PhysicsAnimator.instanceConstructor = fun(target: Any): PhysicsAnimator<*> { + val animator = defaultConstructor(target) + allAnimatedObjects.add(target) + animatorTestHelpers[animator] = AnimatorTestHelper(animator) + return animator + } + + timeoutMs = 2000 + startBlocksUntilAnimationsEnd = false + allAnimatedObjects.clear() + } + + @JvmStatic + fun tearDown() { + val latch = CountDownLatch(1) + animationThreadHandler.post { + animatorTestHelpers.keys.forEach { it.cancel() } + latch.countDown() + } + + latch.await() + + animatorTestHelpers.clear() + animators.clear() + allAnimatedObjects.clear() + } + + /** + * Sets the maximum time (in milliseconds) to block the test thread while waiting for animations + * before throwing an exception. + */ + @JvmStatic + fun setBlockTimeout(timeoutMs: Long) { + this.timeoutMs = timeoutMs + } + + /** + * Sets whether all animations should block the test thread until they end. This is typically + * the desired behavior, since you can invoke code that runs an animation and then assert things + * about its end state. + */ + @JvmStatic + fun setAllAnimationsBlock(block: Boolean) { + startBlocksUntilAnimationsEnd = block + } + + /** + * Blocks the calling thread until animations of the given property on the target object end. + */ + @JvmStatic + @Throws(InterruptedException::class) + fun blockUntilAnimationsEnd( + animator: PhysicsAnimator, + vararg properties: FloatPropertyCompat + ) { + val animatingProperties = HashSet>() + for (property in properties) { + if (animator.isPropertyAnimating(property)) { + animatingProperties.add(property) + } + } + + if (animatingProperties.size > 0) { + val latch = CountDownLatch(animatingProperties.size) + getAnimationTestHelper(animator).addTestEndListener( + object : PhysicsAnimator.EndListener { + override fun onAnimationEnd( + target: T, + property: FloatPropertyCompat, + canceled: Boolean, + finalValue: Float, + finalVelocity: Float, + allRelevantPropertyAnimsEnded: Boolean + ) { + if (animatingProperties.contains(property)) { + latch.countDown() + } + } + }) + + latch.await(timeoutMs, TimeUnit.MILLISECONDS) + } + } + + /** + * Blocks the calling thread until all animations of the given property (on all target objects) + * have ended. Useful when you don't have access to the objects being animated, but still need + * to wait for them to end so that other testable side effects occur (such as update/end + * listeners). + */ + @JvmStatic + @Throws(InterruptedException::class) + fun blockUntilAnimationsEnd( + properties: FloatPropertyCompat + ) { + for (target in allAnimatedObjects) { + try { + blockUntilAnimationsEnd( + PhysicsAnimator.getInstance(target) as PhysicsAnimator, properties) + } catch (e: ClassCastException) { + // Keep checking the other objects for ones whose types match the provided + // properties. + } + } + } + + /** + * Blocks the calling thread until the first animation frame in which predicate returns true. If + * the given object isn't animating, returns without blocking. + */ + @JvmStatic + @Throws(InterruptedException::class) + fun blockUntilFirstAnimationFrameWhereTrue( + animator: PhysicsAnimator, + predicate: (T) -> Boolean + ) { + if (animator.isRunning()) { + val latch = CountDownLatch(1) + getAnimationTestHelper(animator).addTestUpdateListener(object : PhysicsAnimator + .UpdateListener { + override fun onAnimationUpdateForProperty( + target: T, + values: UpdateMap + ) { + if (predicate(target)) { + latch.countDown() + } + } + }) + + latch.await(timeoutMs, TimeUnit.MILLISECONDS) + } + } + + /** + * Verifies that the animator reported animation frame values to update listeners that satisfy + * the given matchers, in order. Not all frames need to satisfy a matcher - we'll run through + * all animation frames, and check them against the current predicate. If it returns false, we + * continue through the frames until it returns true, and then move on to the next matcher. + * Verification fails if we run out of frames while unsatisfied matchers remain. + * + * If verification is successful, all frames to this point are considered 'verified' and will be + * cleared. Subsequent calls to this method will start verification at the next animation frame. + * + * Example: Verify that an animation surpassed x = 50f before going negative. + * verifyAnimationUpdateFrames( + * animator, TRANSLATION_X, + * { u -> u.value > 50f }, + * { u -> u.value < 0f }) + * + * Example: verify that an animation went backwards at some point while still being on-screen. + * verifyAnimationUpdateFrames( + * animator, TRANSLATION_X, + * { u -> u.velocity < 0f && u.value >= 0f }) + * + * This method is intended to help you test longer, more complicated animations where it's + * critical that certain values were reached. Using this method to test short animations can + * fail due to the animation having fewer frames than provided matchers. For example, an + * animation from x = 1f to x = 5f might only have two frames, at x = 3f and x = 5f. The + * following would then fail despite it seeming logically sound: + * + * verifyAnimationUpdateFrames( + * animator, TRANSLATION_X, + * { u -> u.value > 1f }, + * { u -> u.value > 2f }, + * { u -> u.value > 3f }) + * + * Tests might also fail if your matchers are too granular, such as this example test after an + * animation from x = 0f to x = 100f. It's unlikely there was a frame specifically between 2f + * and 3f. + * + * verifyAnimationUpdateFrames( + * animator, TRANSLATION_X, + * { u -> u.value > 2f && u.value < 3f }, + * { u -> u.value >= 50f }) + * + * Failures will print a helpful log of all animation frames so you can see what caused the test + * to fail. + */ + fun verifyAnimationUpdateFrames( + animator: PhysicsAnimator, + property: FloatPropertyCompat, + firstUpdateMatcher: UpdateMatcher, + vararg additionalUpdateMatchers: UpdateMatcher + ) { + val updateFrames: UpdateFramesPerProperty = getAnimationUpdateFrames(animator) + val matchers = ArrayDeque( + additionalUpdateMatchers.toList()) + val frameTraceMessage = StringBuilder() + + var curMatcher = firstUpdateMatcher + + // Loop through the updates from the testable animator. + for (update in updateFrames[property] + ?: error("No frames for given target object and property.")) { + + // Check whether this frame satisfies the current matcher. + if (curMatcher(update)) { + + // If that was the last unsatisfied matcher, we're good here. 'Verify' all remaining + // frames and return without failing. + if (matchers.size == 0) { + getAnimationUpdateFrames(animator).remove(property) + return + } + + frameTraceMessage.append("$update\t(satisfied matcher)\n") + curMatcher = matchers.pop() // Get the next matcher and keep going. + } else { + frameTraceMessage.append("${update}\n") + } + } + + val readablePropertyName = PhysicsAnimator.getReadablePropertyName(property) + getAnimationUpdateFrames(animator).remove(property) + + throw RuntimeException( + "Failed to verify animation frames for property $readablePropertyName: " + + "Provided ${additionalUpdateMatchers.size + 1} matchers, " + + "however ${matchers.size + 1} remained unsatisfied.\n\n" + + "All frames:\n$frameTraceMessage") + } + + /** + * Overload of [verifyAnimationUpdateFrames] that builds matchers for you, from given float + * values. For example, to verify that an animations passed from 0f to 50f to 100f back to 50f: + * + * verifyAnimationUpdateFrames(animator, TRANSLATION_X, 0f, 50f, 100f, 50f) + * + * This verifies that update frames were received with values of >= 0f, >= 50f, >= 100f, and + * <= 50f. + * + * The same caveats apply: short animations might not have enough frames to satisfy all of the + * matchers, and overly specific calls (such as 0f, 1f, 2f, 3f, etc. for an animation from + * x = 0f to x = 100f) might fail as the animation only had frames at 0f, 25f, 50f, 75f, and + * 100f. As with [verifyAnimationUpdateFrames], failures will print a helpful log of all frames + * so you can see what caused the test to fail. + */ + fun verifyAnimationUpdateFrames( + animator: PhysicsAnimator, + property: FloatPropertyCompat, + startValue: Float, + firstTargetValue: Float, + vararg additionalTargetValues: Float + ) { + val matchers = ArrayList() + + val values = ArrayList().also { + it.add(firstTargetValue) + it.addAll(additionalTargetValues.toList()) + } + + var prevVal = startValue + for (value in values) { + if (value > prevVal) { + matchers.add { update -> update.value >= value } + } else { + matchers.add { update -> update.value <= value } + } + + prevVal = value + } + + verifyAnimationUpdateFrames( + animator, property, matchers[0], *matchers.drop(0).toTypedArray()) + } + + /** + * Returns all of the values that have ever been reported to update listeners, per property. + */ + fun getAnimationUpdateFrames(animator: PhysicsAnimator): + UpdateFramesPerProperty { + return animatorTestHelpers[animator]?.getUpdates() as UpdateFramesPerProperty + } + + /** + * Clears animation frame updates from the given animator so they aren't used the next time its + * passed to [verifyAnimationUpdateFrames]. + */ + fun clearAnimationUpdateFrames(animator: PhysicsAnimator) { + animatorTestHelpers[animator]?.clearUpdates() + } + + private fun getAnimationTestHelper(animator: PhysicsAnimator): AnimatorTestHelper { + return animatorTestHelpers[animator] as AnimatorTestHelper + } + + /** + * Helper class for testing an animator. This replaces the animator's start action with + * [startForTest] and adds test listeners to enable other test utility behaviors. We build one + * these for each Animator and keep them around so we can access the updates. + */ + class AnimatorTestHelper (private val animator: PhysicsAnimator) { + + /** All updates received for each property animation. */ + private val allUpdates = + ArrayMap, ArrayList>() + + private val testEndListeners = ArrayList>() + private val testUpdateListeners = ArrayList>() + + init { + animator.startAction = ::startForTest + } + + internal fun addTestEndListener(listener: PhysicsAnimator.EndListener) { + testEndListeners.add(listener) + } + + internal fun addTestUpdateListener(listener: PhysicsAnimator.UpdateListener) { + testUpdateListeners.add(listener) + } + + internal fun getUpdates(): UpdateFramesPerProperty { + return allUpdates + } + + internal fun clearUpdates() { + allUpdates.clear() + } + + private fun startForTest() { + // The testable animator needs to block the main thread until super.start() has been + // called, since callers expect .start() to be synchronous but we're posting it to a + // handler here. We may also continue blocking until all animations end, if + // startBlocksUntilAnimationsEnd = true. + 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 { + override fun onAnimationUpdateForProperty( + target: T, + values: ArrayMap, PhysicsAnimator.AnimationUpdate> + ) { + for (listener in testUpdateListeners) { + listener.onAnimationUpdateForProperty(target, values) + } + } + }) + + // Add an end listener that dispatches to any test end listeners added by tests, and + // unblocks the main thread if required. + animator.addEndListener(object : PhysicsAnimator.EndListener { + override fun onAnimationEnd( + target: T, + property: FloatPropertyCompat, + canceled: Boolean, + finalValue: Float, + finalVelocity: Float, + allRelevantPropertyAnimsEnded: Boolean + ) { + for (listener in testEndListeners) { + listener.onAnimationEnd( + target, property, canceled, finalValue, finalVelocity, + allRelevantPropertyAnimsEnded) + } + + if (allRelevantPropertyAnimsEnded) { + testEndListeners.clear() + testUpdateListeners.clear() + + if (startBlocksUntilAnimationsEnd) { + unblockLatch.countDown() + } + } + } + }) + + val updateListeners = ArrayList>().also { + it.add(object : PhysicsAnimator.UpdateListener { + override fun onAnimationUpdateForProperty( + target: T, + values: ArrayMap, + 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() + } + + unblockLatch.await(timeoutMs, TimeUnit.MILLISECONDS) + } + } +} 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 new file mode 100644 index 0000000000000..a39fbc4c232e5 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/util/animation/PhysicsAnimatorTest.kt @@ -0,0 +1,436 @@ +package com.android.systemui.util.animation + +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import android.util.ArrayMap +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.dynamicanimation.animation.DynamicAnimation +import androidx.dynamicanimation.animation.FloatPropertyCompat +import androidx.dynamicanimation.animation.SpringForce +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.util.animation.PhysicsAnimator.EndListener +import com.android.systemui.util.animation.PhysicsAnimator.UpdateListener +import com.android.systemui.util.animation.PhysicsAnimatorTestUtils.clearAnimationUpdateFrames +import com.android.systemui.util.animation.PhysicsAnimatorTestUtils.getAnimationUpdateFrames +import com.android.systemui.util.animation.PhysicsAnimatorTestUtils.verifyAnimationUpdateFrames +import org.junit.After +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyFloat +import org.mockito.Mockito +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions +import org.mockito.MockitoAnnotations + +@TestableLooper.RunWithLooper +@RunWith(AndroidTestingRunner::class) +@SmallTest +class PhysicsAnimatorTest : SysuiTestCase() { + private lateinit var viewGroup: ViewGroup + private lateinit var testView: View + private lateinit var testView2: View + + private lateinit var animator: PhysicsAnimator + + private val springConfig = PhysicsAnimator.SpringConfig( + SpringForce.STIFFNESS_LOW, SpringForce.DAMPING_RATIO_NO_BOUNCY) + private val flingConfig = PhysicsAnimator.FlingConfig(2f) + + private lateinit var mockUpdateListener: UpdateListener + private lateinit var mockEndListener: EndListener + private lateinit var mockEndAction: Runnable + + private fun eq(value: T): T = Mockito.eq(value) ?: value + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + + mockUpdateListener = mock(UpdateListener::class.java) as UpdateListener + mockEndListener = mock(EndListener::class.java) as EndListener + mockEndAction = mock(Runnable::class.java) + + viewGroup = FrameLayout(context) + testView = View(context) + testView2 = View(context) + viewGroup.addView(testView) + viewGroup.addView(testView2) + + PhysicsAnimatorTestUtils.prepareForTest() + + // Most of our tests involve checking the end state of animations, so we want calls that + // start animations to block the test thread until the animations have ended. + PhysicsAnimatorTestUtils.setAllAnimationsBlock(true) + + animator = PhysicsAnimator.getInstance(testView) + } + + @After + fun tearDown() { + PhysicsAnimatorTestUtils.tearDown() + } + + @Test + fun testOneAnimatorPerView() { + assertEquals(animator, PhysicsAnimator.getInstance(testView)) + assertEquals(PhysicsAnimator.getInstance(testView), PhysicsAnimator.getInstance(testView)) + assertNotEquals(animator, PhysicsAnimator.getInstance(testView2)) + } + + @Test + fun testSpringOneProperty() { + animator + .spring(DynamicAnimation.TRANSLATION_X, 50f, springConfig) + .start() + + assertEquals(testView.translationX, 50f, 1f) + } + + @Test + fun testSpringMultipleProperties() { + animator + .spring(DynamicAnimation.TRANSLATION_X, 10f, springConfig) + .spring(DynamicAnimation.TRANSLATION_Y, 50f, springConfig) + .spring(DynamicAnimation.SCALE_Y, 1.1f, springConfig) + .start() + + assertEquals(10f, testView.translationX, 1f) + assertEquals(50f, testView.translationY, 1f) + assertEquals(1.1f, testView.scaleY, 0.01f) + } + + @Test + fun testFling() { + val startTime = System.currentTimeMillis() + + animator + .fling(DynamicAnimation.TRANSLATION_X, 1000f /* startVelocity */, flingConfig) + .fling(DynamicAnimation.TRANSLATION_Y, 500f, flingConfig) + .start() + + val elapsedTimeSeconds = (System.currentTimeMillis() - startTime) / 1000f + + // If the fling worked, the view should be somewhere between its starting position and the + // and the theoretical no-friction maximum of startVelocity (in pixels per second) + // multiplied by elapsedTimeSeconds. We can't calculate an exact expected location for a + // fling, so this is close enough. + assertTrue(testView.translationX > 0f) + assertTrue(testView.translationX < 1000f * elapsedTimeSeconds) + assertTrue(testView.translationY > 0f) + assertTrue(testView.translationY < 500f * elapsedTimeSeconds) + } + + @Test + @Throws(InterruptedException::class) + fun testEndListenersAndActions() { + PhysicsAnimatorTestUtils.setAllAnimationsBlock(false) + animator + .spring(DynamicAnimation.TRANSLATION_X, 10f, springConfig) + .spring(DynamicAnimation.TRANSLATION_Y, 500f, springConfig) + .addEndListener(mockEndListener) + .withEndActions(mockEndAction::run) + .start() + + PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(animator, DynamicAnimation.TRANSLATION_X) + + // Once TRANSLATION_X is done, the view should be at x = 10... + assertEquals(10f, testView.translationX, 1f) + + // / ...TRANSLATION_Y should still be running... + assertTrue(animator.isPropertyAnimating(DynamicAnimation.TRANSLATION_Y)) + + // ...and our end listener should have been called with x = 10, velocity = 0, and allEnded = + // false since TRANSLATION_Y is still running. + verify(mockEndListener).onAnimationEnd( + testView, + DynamicAnimation.TRANSLATION_X, + canceled = false, + finalValue = 10f, + finalVelocity = 0f, + allRelevantPropertyAnimsEnded = false) + verifyNoMoreInteractions(mockEndListener) + + // The end action should not have been run yet. + verify(mockEndAction, times(0)).run() + + // Block until TRANSLATION_Y finishes. + PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(animator, DynamicAnimation.TRANSLATION_Y) + + // The view should have been moved. + assertEquals(10f, testView.translationX, 1f) + assertEquals(500f, testView.translationY, 1f) + + // The end listener should have been called, this time with TRANSLATION_Y, y = 50, and + // allEnded = true. + verify(mockEndListener).onAnimationEnd( + testView, + DynamicAnimation.TRANSLATION_Y, + canceled = false, + finalValue = 500f, + finalVelocity = 0f, + allRelevantPropertyAnimsEnded = true) + verifyNoMoreInteractions(mockEndListener) + + // Now that all properties are done animating, the end action should have been called. + verify(mockEndAction, times(1)).run() + } + + @Test + fun testUpdateListeners() { + animator + .spring(DynamicAnimation.TRANSLATION_X, 100f, springConfig) + .spring(DynamicAnimation.TRANSLATION_Y, 50f, springConfig) + .addUpdateListener(object : UpdateListener { + override fun onAnimationUpdateForProperty( + target: View, + values: UpdateMap + ) { + mockUpdateListener.onAnimationUpdateForProperty(target, values) + } + }) + .start() + + verifyUpdateListenerCalls(animator, mockUpdateListener) + } + + @Test + fun testListenersNotCalledOnSubsequentAnimations() { + animator + .spring(DynamicAnimation.TRANSLATION_X, 10f, springConfig) + .addUpdateListener(mockUpdateListener) + .addEndListener(mockEndListener) + .withEndActions(mockEndAction::run) + .start() + + verifyUpdateListenerCalls(animator, mockUpdateListener) + verify(mockEndListener, times(1)).onAnimationEnd( + eq(testView), eq(DynamicAnimation.TRANSLATION_X), eq(false), anyFloat(), anyFloat(), + eq(true)) + verify(mockEndAction, times(1)).run() + + animator + .spring(DynamicAnimation.TRANSLATION_X, 0f, springConfig) + .start() + + // We didn't pass any of the listeners/actions to the subsequent animation, so they should + // never have been called. + verifyNoMoreInteractions(mockUpdateListener) + verifyNoMoreInteractions(mockEndListener) + verifyNoMoreInteractions(mockEndAction) + } + + @Test + @Throws(InterruptedException::class) + fun testAnimationsUpdatedWhileInMotion() { + PhysicsAnimatorTestUtils.setAllAnimationsBlock(false) + + // Spring towards x = 100f. + animator + .spring( + DynamicAnimation.TRANSLATION_X, + 100f, + springConfig) + .start() + + // Block until it reaches x = 50f. + PhysicsAnimatorTestUtils.blockUntilFirstAnimationFrameWhereTrue( + animator) { view -> view.translationX > 50f } + + // Translation X value at the time of reversing the animation to spring to x = 0f. + val reversalTranslationX = testView.translationX + + // Spring back towards 0f. + animator + .spring( + DynamicAnimation.TRANSLATION_X, + 0f, + // Lower the stiffness to ensure the update listener receives at least one + // update frame where the view has continued to move to the right. + springConfig.apply { stiffness = SpringForce.STIFFNESS_LOW }) + .start() + + // Wait for TRANSLATION_X. + PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(animator, DynamicAnimation.TRANSLATION_X) + + // Verify that the animation continued past the X value at the time of reversal, before + // springing back. This ensures the change in direction was not abrupt. + verifyAnimationUpdateFrames( + animator, DynamicAnimation.TRANSLATION_X, + { u -> u.value > reversalTranslationX }, + { u -> u.value < reversalTranslationX }) + + // Verify that the view is where it should be. + assertEquals(0f, testView.translationX, 1f) + } + + @Test + @Throws(InterruptedException::class) + fun testAnimationsUpdatedWhileInMotion_originalListenersStillCalled() { + PhysicsAnimatorTestUtils.setAllAnimationsBlock(false) + + // Spring TRANSLATION_X to 100f, with an update and end listener provided. + animator + .spring(DynamicAnimation.TRANSLATION_X, 100f, springConfig) + .addUpdateListener(mockUpdateListener) + .addEndListener(mockEndListener) + .start() + + // Wait until the animation is halfway there. + PhysicsAnimatorTestUtils.blockUntilFirstAnimationFrameWhereTrue( + animator) { view -> view.translationX > 50f } + + // The end listener shouldn't have been called since the animation hasn't ended. + verifyNoMoreInteractions(mockEndListener) + + // Make sure we called the update listener with appropriate values. + verifyAnimationUpdateFrames(animator, DynamicAnimation.TRANSLATION_X, + { u -> u.value > 0f }, + { u -> u.value >= 50f }) + + // Mock a second end listener. + val secondEndListener = mock(EndListener::class.java) as EndListener + val secondUpdateListener = mock(UpdateListener::class.java) as UpdateListener + + // Start a new animation that springs both TRANSLATION_X and TRANSLATION_Y, and provide it + // the second end listener. This new end listener should be called for the end of + // TRANSLATION_X and TRANSLATION_Y, with allEnded = true when both have ended. + animator + .spring(DynamicAnimation.TRANSLATION_X, 200f, springConfig) + .spring(DynamicAnimation.TRANSLATION_Y, 4000f, springConfig) + .addUpdateListener(secondUpdateListener) + .addEndListener(secondEndListener) + .start() + + // Wait for TRANSLATION_X to end. + PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(animator, DynamicAnimation.TRANSLATION_X) + + // The update listener provided to the initial animation call (the one that only animated + // TRANSLATION_X) should have been called with values on the way to x = 200f. This is + // because the second animation call updated the original TRANSLATION_X animation. + verifyAnimationUpdateFrames( + animator, DynamicAnimation.TRANSLATION_X, + { u -> u.value > 100f }, { u -> u.value >= 200f }) + + // 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) + 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) + verifyNoMoreInteractions(secondEndListener) + + PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(animator, DynamicAnimation.TRANSLATION_Y) + + // The original end listener shouldn't receive any callbacks because it was not provided to + // an animator that animated TRANSLATION_Y. + verifyNoMoreInteractions(mockEndListener) + + verify(secondEndListener, times(1)) + .onAnimationEnd(testView, DynamicAnimation.TRANSLATION_Y, false, 4000f, 0f, true) + verifyNoMoreInteractions(secondEndListener) + } + + @Test + fun testFlingRespectsMinMax() { + animator + .fling(DynamicAnimation.TRANSLATION_X, + startVelocity = 1000f, + friction = 1.1f, + max = 10f) + .addEndListener(mockEndListener) + .start() + + // Ensure that the view stopped at x = 10f, and the end listener was called once with that + // value. + assertEquals(10f, testView.translationX, 1f) + verify(mockEndListener, times(1)) + .onAnimationEnd( + eq(testView), eq(DynamicAnimation.TRANSLATION_X), eq(false), eq(10f), + anyFloat(), eq(true)) + + animator + .fling( + DynamicAnimation.TRANSLATION_X, + startVelocity = -1000f, + friction = 1.1f, + min = -5f) + .addEndListener(mockEndListener) + .start() + + // Ensure that the view stopped at x = -5f, and the end listener was called once with that + // value. + assertEquals(-5f, testView.translationX, 1f) + verify(mockEndListener, times(1)) + .onAnimationEnd( + eq(testView), eq(DynamicAnimation.TRANSLATION_X), eq(false), eq(-5f), + anyFloat(), eq(true)) + } + + @Test + fun testExtensionProperty() { + testView + .physicsAnimator + .spring(DynamicAnimation.TRANSLATION_X, 200f) + .start() + + assertEquals(200f, 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. + */ + private fun verifyUpdateListenerCalls( + animator: PhysicsAnimator, + mockUpdateListener: UpdateListener + ) { + val updates = getAnimationUpdateFrames(animator) + + for (invocation in Mockito.mockingDetails(mockUpdateListener).invocations) { + + // Grab the update map of Property -> AnimationUpdate that was passed to the mock update + // listener. + val updateMap = invocation.arguments[1] + as ArrayMap, PhysicsAnimator.AnimationUpdate> + + // + for ((property, update) in updateMap) { + val updatesForProperty = updates[property]!! + + // This update should be the next one in the list for this property. + if (update != updatesForProperty[0]) { + Assert.fail("The update listener was called with an unexpected value: $update.") + } + + updatesForProperty.remove(update) + } + + // Mark this invocation verified. + verify(mockUpdateListener).onAnimationUpdateForProperty(animator.target, updateMap) + } + + verifyNoMoreInteractions(mockUpdateListener) + + // Since we were removing values as matching invocations were found, there should no longer + // be any values remaining. If there are, it means the update listener wasn't notified when + // it should have been. + assertEquals(0, + updates.values.fold(0, { count, propertyUpdates -> count + propertyUpdates.size })) + + clearAnimationUpdateFrames(animator) + } +} \ No newline at end of file