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
This commit is contained in:
@@ -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 : View> T.physicsAnimator: PhysicsAnimator<T> 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<T> =
|
||||
ArrayMap<FloatPropertyCompat<in T>, PhysicsAnimator.AnimationUpdate>
|
||||
|
||||
/**
|
||||
* Map of the animators associated with a given object. This ensures that only one animator
|
||||
* per object exists.
|
||||
*/
|
||||
internal val animators = WeakHashMap<Any, PhysicsAnimator<*>>()
|
||||
|
||||
/**
|
||||
* 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<T> 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<FloatPropertyCompat<in T>, SpringAnimation>()
|
||||
private val flingAnimations = ArrayMap<FloatPropertyCompat<in T>, 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<FloatPropertyCompat<in T>, SpringConfig>()
|
||||
private val flingConfigs = ArrayMap<FloatPropertyCompat<in T>, FlingConfig>()
|
||||
|
||||
/**
|
||||
* Animation listeners for the animation. These will be notified when each property animation
|
||||
* updates or ends.
|
||||
*/
|
||||
private val updateListeners = ArrayList<UpdateListener<T>>()
|
||||
private val endListeners = ArrayList<EndListener<T>>()
|
||||
|
||||
/** End actions to run when all animations have completed. */
|
||||
private val endActions = ArrayList<EndAction>()
|
||||
|
||||
/**
|
||||
* 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<InternalListener>()
|
||||
|
||||
/**
|
||||
* 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<in T>,
|
||||
toPosition: Float,
|
||||
startVelocity: Float = 0f,
|
||||
stiffness: Float = defaultSpring.stiffness,
|
||||
dampingRatio: Float = defaultSpring.dampingRatio
|
||||
): PhysicsAnimator<T> {
|
||||
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<in T>,
|
||||
toPosition: Float,
|
||||
startVelocity: Float,
|
||||
config: SpringConfig = defaultSpring
|
||||
): PhysicsAnimator<T> {
|
||||
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<in T>,
|
||||
toPosition: Float,
|
||||
config: SpringConfig = defaultSpring
|
||||
): PhysicsAnimator<T> {
|
||||
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<in T>,
|
||||
startVelocity: Float,
|
||||
friction: Float = defaultFling.friction,
|
||||
min: Float = defaultFling.min,
|
||||
max: Float = defaultFling.max
|
||||
): PhysicsAnimator<T> {
|
||||
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<in T>,
|
||||
startVelocity: Float,
|
||||
config: FlingConfig = defaultFling
|
||||
): PhysicsAnimator<T> {
|
||||
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<T>): PhysicsAnimator<T> {
|
||||
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<T>): PhysicsAnimator<T> {
|
||||
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<T> {
|
||||
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<in T>): 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<in T>): 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<in T>
|
||||
): 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<FloatPropertyCompat<in T>>,
|
||||
private var updateListeners: List<UpdateListener<T>>,
|
||||
private var endListeners: List<EndListener<T>>,
|
||||
private var endActions: List<EndAction>
|
||||
) {
|
||||
|
||||
/** 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<FloatPropertyCompat<in T>, AnimationUpdate>()
|
||||
|
||||
/** Called when a DynamicAnimation updates. */
|
||||
internal fun onInternalAnimationUpdate(
|
||||
property: FloatPropertyCompat<in T>,
|
||||
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<in T>,
|
||||
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<T>().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<in T>): Boolean {
|
||||
return springAnimations[property]?.isRunning ?: false
|
||||
}
|
||||
|
||||
/** Returns whether any of the given properties are animating. */
|
||||
fun arePropertiesAnimating(properties: Set<FloatPropertyCompat<in T>>): Boolean {
|
||||
return properties.any { isPropertyAnimating(it) }
|
||||
}
|
||||
|
||||
/** Return the set of properties that will begin animating upon calling [start]. */
|
||||
internal fun getAnimatedProperties(): Set<FloatPropertyCompat<in T>> {
|
||||
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 <T> The type of the object being animated.
|
||||
</T> */
|
||||
interface UpdateListener<T> {
|
||||
|
||||
/**
|
||||
* 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<T>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener for receiving callbacks when animations end.
|
||||
*
|
||||
* @param <T> The type of the object being animated.
|
||||
</T> */
|
||||
interface EndListener<T> {
|
||||
|
||||
/**
|
||||
* 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<in T>,
|
||||
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 <T : Any> getInstance(target: T): PhysicsAnimator<T> {
|
||||
if (!animators.containsKey(target)) {
|
||||
animators[target] = instanceConstructor(target)
|
||||
}
|
||||
|
||||
return animators[target] as PhysicsAnimator<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T> =
|
||||
ArrayMap<FloatPropertyCompat<in T>, ArrayList<PhysicsAnimator.AnimationUpdate>>
|
||||
|
||||
/**
|
||||
* 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<Any>()
|
||||
private val animatorTestHelpers = HashMap<PhysicsAnimator<*>, 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 <T : Any> blockUntilAnimationsEnd(
|
||||
animator: PhysicsAnimator<T>,
|
||||
vararg properties: FloatPropertyCompat<in T>
|
||||
) {
|
||||
val animatingProperties = HashSet<FloatPropertyCompat<in T>>()
|
||||
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<T> {
|
||||
override fun onAnimationEnd(
|
||||
target: T,
|
||||
property: FloatPropertyCompat<in T>,
|
||||
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 <T : Any> blockUntilAnimationsEnd(
|
||||
properties: FloatPropertyCompat<in T>
|
||||
) {
|
||||
for (target in allAnimatedObjects) {
|
||||
try {
|
||||
blockUntilAnimationsEnd(
|
||||
PhysicsAnimator.getInstance(target) as PhysicsAnimator<T>, 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 <T : Any> blockUntilFirstAnimationFrameWhereTrue(
|
||||
animator: PhysicsAnimator<T>,
|
||||
predicate: (T) -> Boolean
|
||||
) {
|
||||
if (animator.isRunning()) {
|
||||
val latch = CountDownLatch(1)
|
||||
getAnimationTestHelper(animator).addTestUpdateListener(object : PhysicsAnimator
|
||||
.UpdateListener<T> {
|
||||
override fun onAnimationUpdateForProperty(
|
||||
target: T,
|
||||
values: UpdateMap<T>
|
||||
) {
|
||||
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 <T : Any> verifyAnimationUpdateFrames(
|
||||
animator: PhysicsAnimator<T>,
|
||||
property: FloatPropertyCompat<in T>,
|
||||
firstUpdateMatcher: UpdateMatcher,
|
||||
vararg additionalUpdateMatchers: UpdateMatcher
|
||||
) {
|
||||
val updateFrames: UpdateFramesPerProperty<T> = getAnimationUpdateFrames(animator)
|
||||
val matchers = ArrayDeque<UpdateMatcher>(
|
||||
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 <T : Any> verifyAnimationUpdateFrames(
|
||||
animator: PhysicsAnimator<T>,
|
||||
property: FloatPropertyCompat<in T>,
|
||||
startValue: Float,
|
||||
firstTargetValue: Float,
|
||||
vararg additionalTargetValues: Float
|
||||
) {
|
||||
val matchers = ArrayList<UpdateMatcher>()
|
||||
|
||||
val values = ArrayList<Float>().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 <T : Any> getAnimationUpdateFrames(animator: PhysicsAnimator<T>):
|
||||
UpdateFramesPerProperty<T> {
|
||||
return animatorTestHelpers[animator]?.getUpdates() as UpdateFramesPerProperty<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears animation frame updates from the given animator so they aren't used the next time its
|
||||
* passed to [verifyAnimationUpdateFrames].
|
||||
*/
|
||||
fun <T : Any> clearAnimationUpdateFrames(animator: PhysicsAnimator<T>) {
|
||||
animatorTestHelpers[animator]?.clearUpdates()
|
||||
}
|
||||
|
||||
private fun <T> getAnimationTestHelper(animator: PhysicsAnimator<T>): AnimatorTestHelper<T> {
|
||||
return animatorTestHelpers[animator] as AnimatorTestHelper<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T> (private val animator: PhysicsAnimator<T>) {
|
||||
|
||||
/** All updates received for each property animation. */
|
||||
private val allUpdates =
|
||||
ArrayMap<FloatPropertyCompat<in T>, ArrayList<PhysicsAnimator.AnimationUpdate>>()
|
||||
|
||||
private val testEndListeners = ArrayList<PhysicsAnimator.EndListener<T>>()
|
||||
private val testUpdateListeners = ArrayList<PhysicsAnimator.UpdateListener<T>>()
|
||||
|
||||
init {
|
||||
animator.startAction = ::startForTest
|
||||
}
|
||||
|
||||
internal fun addTestEndListener(listener: PhysicsAnimator.EndListener<T>) {
|
||||
testEndListeners.add(listener)
|
||||
}
|
||||
|
||||
internal fun addTestUpdateListener(listener: PhysicsAnimator.UpdateListener<T>) {
|
||||
testUpdateListeners.add(listener)
|
||||
}
|
||||
|
||||
internal fun getUpdates(): UpdateFramesPerProperty<T> {
|
||||
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<T> {
|
||||
override fun onAnimationUpdateForProperty(
|
||||
target: T,
|
||||
values: ArrayMap<FloatPropertyCompat<in T>, 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<T> {
|
||||
override fun onAnimationEnd(
|
||||
target: T,
|
||||
property: FloatPropertyCompat<in T>,
|
||||
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<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()
|
||||
}
|
||||
|
||||
unblockLatch.await(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<View>
|
||||
|
||||
private val springConfig = PhysicsAnimator.SpringConfig(
|
||||
SpringForce.STIFFNESS_LOW, SpringForce.DAMPING_RATIO_NO_BOUNCY)
|
||||
private val flingConfig = PhysicsAnimator.FlingConfig(2f)
|
||||
|
||||
private lateinit var mockUpdateListener: UpdateListener<View>
|
||||
private lateinit var mockEndListener: EndListener<View>
|
||||
private lateinit var mockEndAction: Runnable
|
||||
|
||||
private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
MockitoAnnotations.initMocks(this)
|
||||
|
||||
mockUpdateListener = mock(UpdateListener::class.java) as UpdateListener<View>
|
||||
mockEndListener = mock(EndListener::class.java) as EndListener<View>
|
||||
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<View> {
|
||||
override fun onAnimationUpdateForProperty(
|
||||
target: View,
|
||||
values: UpdateMap<View>
|
||||
) {
|
||||
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<View>
|
||||
val secondUpdateListener = mock(UpdateListener::class.java) as UpdateListener<View>
|
||||
|
||||
// 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 <T : Any> verifyUpdateListenerCalls(
|
||||
animator: PhysicsAnimator<T>,
|
||||
mockUpdateListener: UpdateListener<T>
|
||||
) {
|
||||
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<FloatPropertyCompat<in T>, 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user