diff options
6 files changed, 1545 insertions, 1 deletions
diff --git a/core/java/android/animation/AnimationHandler.java b/core/java/android/animation/AnimationHandler.java index 7e814af3451d..1403ba2744b3 100644 --- a/core/java/android/animation/AnimationHandler.java +++ b/core/java/android/animation/AnimationHandler.java @@ -432,8 +432,9 @@ public class AnimationHandler { /** * Callbacks that receives notifications for animation timing and frame commit timing. + * @hide */ - interface AnimationFrameCallback { + public interface AnimationFrameCallback { /** * Run animation based on the frame time. * @param frameTime The frame start time, in the {@link SystemClock#uptimeMillis()} time diff --git a/core/java/com/android/internal/dynamicanimation/animation/DynamicAnimation.java b/core/java/com/android/internal/dynamicanimation/animation/DynamicAnimation.java new file mode 100644 index 000000000000..d4fe7c8d7f36 --- /dev/null +++ b/core/java/com/android/internal/dynamicanimation/animation/DynamicAnimation.java @@ -0,0 +1,815 @@ +/* + * Copyright (C) 2022 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.internal.dynamicanimation.animation; + +import android.animation.AnimationHandler; +import android.animation.ValueAnimator; +import android.annotation.FloatRange; +import android.annotation.MainThread; +import android.annotation.NonNull; +import android.annotation.SuppressLint; +import android.os.Looper; +import android.util.AndroidRuntimeException; +import android.util.FloatProperty; +import android.view.View; + +import java.util.ArrayList; + +/** + * This class is the base class of physics-based animations. It manages the animation's + * lifecycle such as {@link #start()} and {@link #cancel()}. This base class also handles the common + * setup for all the subclass animations. For example, DynamicAnimation supports adding + * {@link OnAnimationEndListener} and {@link OnAnimationUpdateListener} so that the important + * animation events can be observed through the callbacks. The start conditions for any subclass of + * DynamicAnimation can be set using {@link #setStartValue(float)} and + * {@link #setStartVelocity(float)}. + * + * @param <T> subclass of DynamicAnimation + */ +public abstract class DynamicAnimation<T extends DynamicAnimation<T>> + implements AnimationHandler.AnimationFrameCallback { + + /** + * ViewProperty holds the access of a property of a {@link View}. When an animation is + * created with a {@link ViewProperty} instance, the corresponding property value of the view + * will be updated through this ViewProperty instance. + */ + public abstract static class ViewProperty extends FloatProperty<View> { + private ViewProperty(String name) { + super(name); + } + } + + /** + * View's translationX property. + */ + public static final ViewProperty TRANSLATION_X = new ViewProperty("translationX") { + @Override + public void setValue(View view, float value) { + view.setTranslationX(value); + } + + @Override + public Float get(View view) { + return view.getTranslationX(); + } + }; + + /** + * View's translationY property. + */ + public static final ViewProperty TRANSLATION_Y = new ViewProperty("translationY") { + @Override + public void setValue(View view, float value) { + view.setTranslationY(value); + } + + @Override + public Float get(View view) { + return view.getTranslationY(); + } + }; + + /** + * View's translationZ property. + */ + public static final ViewProperty TRANSLATION_Z = new ViewProperty("translationZ") { + @Override + public void setValue(View view, float value) { + view.setTranslationZ(value); + } + + @Override + public Float get(View view) { + return view.getTranslationZ(); + } + }; + + /** + * View's scaleX property. + */ + public static final ViewProperty SCALE_X = new ViewProperty("scaleX") { + @Override + public void setValue(View view, float value) { + view.setScaleX(value); + } + + @Override + public Float get(View view) { + return view.getScaleX(); + } + }; + + /** + * View's scaleY property. + */ + public static final ViewProperty SCALE_Y = new ViewProperty("scaleY") { + @Override + public void setValue(View view, float value) { + view.setScaleY(value); + } + + @Override + public Float get(View view) { + return view.getScaleY(); + } + }; + + /** + * View's rotation property. + */ + public static final ViewProperty ROTATION = new ViewProperty("rotation") { + @Override + public void setValue(View view, float value) { + view.setRotation(value); + } + + @Override + public Float get(View view) { + return view.getRotation(); + } + }; + + /** + * View's rotationX property. + */ + public static final ViewProperty ROTATION_X = new ViewProperty("rotationX") { + @Override + public void setValue(View view, float value) { + view.setRotationX(value); + } + + @Override + public Float get(View view) { + return view.getRotationX(); + } + }; + + /** + * View's rotationY property. + */ + public static final ViewProperty ROTATION_Y = new ViewProperty("rotationY") { + @Override + public void setValue(View view, float value) { + view.setRotationY(value); + } + + @Override + public Float get(View view) { + return view.getRotationY(); + } + }; + + /** + * View's x property. + */ + public static final ViewProperty X = new ViewProperty("x") { + @Override + public void setValue(View view, float value) { + view.setX(value); + } + + @Override + public Float get(View view) { + return view.getX(); + } + }; + + /** + * View's y property. + */ + public static final ViewProperty Y = new ViewProperty("y") { + @Override + public void setValue(View view, float value) { + view.setY(value); + } + + @Override + public Float get(View view) { + return view.getY(); + } + }; + + /** + * View's z property. + */ + public static final ViewProperty Z = new ViewProperty("z") { + @Override + public void setValue(View view, float value) { + view.setZ(value); + } + + @Override + public Float get(View view) { + return view.getZ(); + } + }; + + /** + * View's alpha property. + */ + public static final ViewProperty ALPHA = new ViewProperty("alpha") { + @Override + public void setValue(View view, float value) { + view.setAlpha(value); + } + + @Override + public Float get(View view) { + return view.getAlpha(); + } + }; + + // Properties below are not RenderThread compatible + /** + * View's scrollX property. + */ + public static final ViewProperty SCROLL_X = new ViewProperty("scrollX") { + @Override + public void setValue(View view, float value) { + view.setScrollX((int) value); + } + + @Override + public Float get(View view) { + return (float) view.getScrollX(); + } + }; + + /** + * View's scrollY property. + */ + public static final ViewProperty SCROLL_Y = new ViewProperty("scrollY") { + @Override + public void setValue(View view, float value) { + view.setScrollY((int) value); + } + + @Override + public Float get(View view) { + return (float) view.getScrollY(); + } + }; + + /** + * The minimum visible change in pixels that can be visible to users. + */ + @SuppressLint("MinMaxConstant") + public static final float MIN_VISIBLE_CHANGE_PIXELS = 1f; + /** + * The minimum visible change in degrees that can be visible to users. + */ + @SuppressLint("MinMaxConstant") + public static final float MIN_VISIBLE_CHANGE_ROTATION_DEGREES = 1f / 10f; + /** + * The minimum visible change in alpha that can be visible to users. + */ + @SuppressLint("MinMaxConstant") + public static final float MIN_VISIBLE_CHANGE_ALPHA = 1f / 256f; + /** + * The minimum visible change in scale that can be visible to users. + */ + @SuppressLint("MinMaxConstant") + public static final float MIN_VISIBLE_CHANGE_SCALE = 1f / 500f; + + // Use the max value of float to indicate an unset state. + private static final float UNSET = Float.MAX_VALUE; + + // Multiplier to the min visible change value for value threshold + private static final float THRESHOLD_MULTIPLIER = 0.75f; + + // Internal tracking for velocity. + float mVelocity = 0; + + // Internal tracking for value. + float mValue = UNSET; + + // Tracks whether start value is set. If not, the animation will obtain the value at the time + // of starting through the getter and use that as the starting value of the animation. + boolean mStartValueIsSet = false; + + // Target to be animated. + final Object mTarget; + + // View property id. + final FloatProperty mProperty; + + // Package private tracking of animation lifecycle state. Visible to subclass animations. + boolean mRunning = false; + + // Min and max values that defines the range of the animation values. + float mMaxValue = Float.MAX_VALUE; + float mMinValue = -mMaxValue; + + // Last frame time. Always gets reset to -1 at the end of the animation. + private long mLastFrameTime = 0; + + private float mMinVisibleChange; + + // List of end listeners + private final ArrayList<OnAnimationEndListener> mEndListeners = new ArrayList<>(); + + // List of update listeners + private final ArrayList<OnAnimationUpdateListener> mUpdateListeners = new ArrayList<>(); + + // Animation handler used to schedule updates for this animation. + private AnimationHandler mAnimationHandler; + + // Internal state for value/velocity pair. + static class MassState { + float mValue; + float mVelocity; + } + + /** + * Creates a dynamic animation with the given FloatValueHolder instance. + * + * @param floatValueHolder the FloatValueHolder instance to be animated. + */ + DynamicAnimation(final FloatValueHolder floatValueHolder) { + mTarget = null; + mProperty = new FloatProperty("FloatValueHolder") { + @Override + public Float get(Object object) { + return floatValueHolder.getValue(); + } + + @Override + public void setValue(Object object, float value) { + floatValueHolder.setValue(value); + } + }; + mMinVisibleChange = MIN_VISIBLE_CHANGE_PIXELS; + } + + /** + * Creates a dynamic animation to animate the given property for the given {@link View} + * + * @param object the Object whose property is to be animated + * @param property the property to be animated + */ + + <K> DynamicAnimation(K object, FloatProperty<K> property) { + mTarget = object; + mProperty = property; + if (mProperty == ROTATION || mProperty == ROTATION_X + || mProperty == ROTATION_Y) { + mMinVisibleChange = MIN_VISIBLE_CHANGE_ROTATION_DEGREES; + } else if (mProperty == ALPHA) { + mMinVisibleChange = MIN_VISIBLE_CHANGE_ALPHA; + } else if (mProperty == SCALE_X || mProperty == SCALE_Y) { + mMinVisibleChange = MIN_VISIBLE_CHANGE_SCALE; + } else { + mMinVisibleChange = MIN_VISIBLE_CHANGE_PIXELS; + } + } + + /** + * Sets the start value of the animation. If start value is not set, the animation will get + * the current value for the view's property, and use that as the start value. + * + * @param startValue start value for the animation + * @return the Animation whose start value is being set + */ + @SuppressWarnings("unchecked") + public T setStartValue(float startValue) { + mValue = startValue; + mStartValueIsSet = true; + return (T) this; + } + + /** + * Start velocity of the animation. Default velocity is 0. Unit: change in property per + * second (e.g. pixels per second, scale/alpha value change per second). + * + * <p>Note when using a fixed value as the start velocity (as opposed to getting the velocity + * through touch events), it is recommended to define such a value in dp/second and convert it + * to pixel/second based on the density of the screen to achieve a consistent look across + * different screens. + * + * <p>To convert from dp/second to pixel/second: + * <pre class="prettyprint"> + * float pixelPerSecond = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dpPerSecond, + * getResources().getDisplayMetrics()); + * </pre> + * + * @param startVelocity start velocity of the animation + * @return the Animation whose start velocity is being set + */ + @SuppressWarnings("unchecked") + public T setStartVelocity(float startVelocity) { + mVelocity = startVelocity; + return (T) this; + } + + /** + * Sets the max value of the animation. Animations will not animate beyond their max value. + * Whether or not animation will come to an end when max value is reached is dependent on the + * child animation's implementation. + * + * @param max maximum value of the property to be animated + * @return the Animation whose max value is being set + */ + @SuppressWarnings("unchecked") + public T setMaxValue(float max) { + // This max value should be checked and handled in the subclass animations, instead of + // assuming the end of the animations when the max/min value is hit in the base class. + // The reason is that hitting max/min value may just be a transient state, such as during + // the spring oscillation. + mMaxValue = max; + return (T) this; + } + + /** + * Sets the min value of the animation. Animations will not animate beyond their min value. + * Whether or not animation will come to an end when min value is reached is dependent on the + * child animation's implementation. + * + * @param min minimum value of the property to be animated + * @return the Animation whose min value is being set + */ + @SuppressWarnings("unchecked") + public T setMinValue(float min) { + mMinValue = min; + return (T) this; + } + + /** + * Adds an end listener to the animation for receiving onAnimationEnd callbacks. If the listener + * is {@code null} or has already been added to the list of listeners for the animation, no op. + * + * @param listener the listener to be added + * @return the animation to which the listener is added + */ + @SuppressWarnings("unchecked") + public T addEndListener(OnAnimationEndListener listener) { + if (!mEndListeners.contains(listener)) { + mEndListeners.add(listener); + } + return (T) this; + } + + /** + * Removes the end listener from the animation, so as to stop receiving animation end callbacks. + * + * @param listener the listener to be removed + */ + public void removeEndListener(OnAnimationEndListener listener) { + removeEntry(mEndListeners, listener); + } + + /** + * Adds an update listener to the animation for receiving per-frame animation update callbacks. + * If the listener is {@code null} or has already been added to the list of listeners for the + * animation, no op. + * + * <p>Note that update listener should only be added before the start of the animation. + * + * @param listener the listener to be added + * @return the animation to which the listener is added + * @throws UnsupportedOperationException if the update listener is added after the animation has + * started + */ + @SuppressWarnings("unchecked") + public T addUpdateListener(OnAnimationUpdateListener listener) { + if (isRunning()) { + // Require update listener to be added before the animation, such as when we start + // the animation, we know whether the animation is RenderThread compatible. + throw new UnsupportedOperationException("Error: Update listeners must be added before" + + "the animation."); + } + if (!mUpdateListeners.contains(listener)) { + mUpdateListeners.add(listener); + } + return (T) this; + } + + /** + * Removes the update listener from the animation, so as to stop receiving animation update + * callbacks. + * + * @param listener the listener to be removed + */ + public void removeUpdateListener(OnAnimationUpdateListener listener) { + removeEntry(mUpdateListeners, listener); + } + + + /** + * This method sets the minimal change of animation value that is visible to users, which helps + * determine a reasonable threshold for the animation's termination condition. It is critical + * to set the minimal visible change for custom properties (i.e. non-<code>ViewProperty</code>s) + * unless the custom property is in pixels. + * + * <p>For custom properties, this minimum visible change defaults to change in pixel + * (i.e. {@link #MIN_VISIBLE_CHANGE_PIXELS}. It is recommended to adjust this value that is + * reasonable for the property to be animated. A general rule of thumb to calculate such a value + * is: minimum visible change = range of custom property value / corresponding pixel range. For + * example, if the property to be animated is a progress (from 0 to 100) that corresponds to a + * 200-pixel change. Then the min visible change should be 100 / 200. (i.e. 0.5). + * + * <p>It's not necessary to call this method when animating {@link ViewProperty}s, as the + * minimum visible change will be derived from the property. For example, if the property to be + * animated is in pixels (i.e. {@link #TRANSLATION_X}, {@link #TRANSLATION_Y}, + * {@link #TRANSLATION_Z}, @{@link #SCROLL_X} or {@link #SCROLL_Y}), the default minimum visible + * change is 1 (pixel). For {@link #ROTATION}, {@link #ROTATION_X} or {@link #ROTATION_Y}, the + * animation will use {@link #MIN_VISIBLE_CHANGE_ROTATION_DEGREES} as the min visible change, + * which is 1/10. Similarly, the minimum visible change for alpha ( + * i.e. {@link #MIN_VISIBLE_CHANGE_ALPHA} is defined as 1 / 256. + * + * @param minimumVisibleChange minimum change in property value that is visible to users + * @return the animation whose min visible change is being set + * @throws IllegalArgumentException if the given threshold is not positive + */ + @SuppressWarnings("unchecked") + public T setMinimumVisibleChange(@FloatRange(from = 0.0, fromInclusive = false) + float minimumVisibleChange) { + if (minimumVisibleChange <= 0) { + throw new IllegalArgumentException("Minimum visible change must be positive."); + } + mMinVisibleChange = minimumVisibleChange; + setValueThreshold(minimumVisibleChange * THRESHOLD_MULTIPLIER); + return (T) this; + } + + /** + * Returns the minimum change in the animation property that could be visibly different to + * users. + * + * @return minimum change in property value that is visible to users + */ + public float getMinimumVisibleChange() { + return mMinVisibleChange; + } + + /** + * Remove {@code null} entries from the list. + */ + private static <T> void removeNullEntries(ArrayList<T> list) { + // Clean up null entries + for (int i = list.size() - 1; i >= 0; i--) { + if (list.get(i) == null) { + list.remove(i); + } + } + } + + /** + * Remove an entry from the list by marking it {@code null} and clean up later. + */ + private static <T> void removeEntry(ArrayList<T> list, T entry) { + int id = list.indexOf(entry); + if (id >= 0) { + list.set(id, null); + } + } + + /****************Animation Lifecycle Management***************/ + + /** + * Starts an animation. If the animation has already been started, no op. Note that calling + * {@link #start()} will not immediately set the property value to start value of the animation. + * The property values will be changed at each animation pulse, which happens before the draw + * pass. As a result, the changes will be reflected in the next frame, the same as if the values + * were set immediately. This method should only be called on main thread. + * + * Unless a AnimationHandler is provided via setAnimationHandler, a default AnimationHandler + * is created on the same thread as the first call to start/cancel an animation. All the + * subsequent animation lifecycle manipulations need to be on that same thread, until the + * AnimationHandler is reset (using [setAnimationHandler]). + * + * @throws AndroidRuntimeException if this method is not called on the same thread as the + * animation handler + */ + @MainThread + public void start() { + if (!isCurrentThread()) { + throw new AndroidRuntimeException("Animations may only be started on the same thread " + + "as the animation handler"); + } + if (!mRunning) { + startAnimationInternal(); + } + } + + boolean isCurrentThread() { + return Thread.currentThread() == Looper.myLooper().getThread(); + } + + /** + * Cancels the on-going animation. If the animation hasn't started, no op. + * + * Unless a AnimationHandler is provided via setAnimationHandler, a default AnimationHandler + * is created on the same thread as the first call to start/cancel an animation. All the + * subsequent animation lifecycle manipulations need to be on that same thread, until the + * AnimationHandler is reset (using [setAnimationHandler]). + * + * @throws AndroidRuntimeException if this method is not called on the same thread as the + * animation handler + */ + @MainThread + public void cancel() { + if (!isCurrentThread()) { + throw new AndroidRuntimeException("Animations may only be canceled from the same " + + "thread as the animation handler"); + } + if (mRunning) { + endAnimationInternal(true); + } + } + + /** + * Returns whether the animation is currently running. + * + * @return {@code true} if the animation is currently running, {@code false} otherwise + */ + public boolean isRunning() { + return mRunning; + } + + /************************** Private APIs below ********************************/ + + // This gets called when the animation is started, to finish the setup of the animation + // before the animation pulsing starts. + private void startAnimationInternal() { + if (!mRunning) { + mRunning = true; + if (!mStartValueIsSet) { + mValue = getPropertyValue(); + } + // Sanity check: + if (mValue > mMaxValue || mValue < mMinValue) { + throw new IllegalArgumentException("Starting value need to be in between min" + + " value and max value"); + } + getAnimationHandler().addAnimationFrameCallback(this, 0); + } + } + + /** + * This gets call on each frame of the animation. Animation value and velocity are updated + * in this method based on the new frame time. The property value of the view being animated + * is then updated. The animation's ending conditions are also checked in this method. Once + * the animation reaches equilibrium, the animation will come to its end, and end listeners + * will be notified, if any. + */ + @Override + public boolean doAnimationFrame(long frameTime) { + if (mLastFrameTime == 0) { + // First frame. + mLastFrameTime = frameTime; + setPropertyValue(mValue); + return false; + } + long deltaT = frameTime - mLastFrameTime; + mLastFrameTime = frameTime; + float durationScale = ValueAnimator.getDurationScale(); + deltaT = durationScale == 0.0f ? Integer.MAX_VALUE : (long) (deltaT / durationScale); + boolean finished = updateValueAndVelocity(deltaT); + // Clamp value & velocity. + mValue = Math.min(mValue, mMaxValue); + mValue = Math.max(mValue, mMinValue); + + setPropertyValue(mValue); + + if (finished) { + endAnimationInternal(false); + } + return finished; + } + + @Override + public void commitAnimationFrame(long frameTime) { + doAnimationFrame(frameTime); + } + + /** + * Updates the animation state (i.e. value and velocity). This method is package private, so + * subclasses can override this method to calculate the new value and velocity in their custom + * way. + * + * @param deltaT time elapsed in millisecond since last frame + * @return whether the animation has finished + */ + abstract boolean updateValueAndVelocity(long deltaT); + + /** + * Internal method to reset the animation states when animation is finished/canceled. + */ + private void endAnimationInternal(boolean canceled) { + mRunning = false; + getAnimationHandler().removeCallback(this); + mLastFrameTime = 0; + mStartValueIsSet = false; + for (int i = 0; i < mEndListeners.size(); i++) { + if (mEndListeners.get(i) != null) { + mEndListeners.get(i).onAnimationEnd(this, canceled, mValue, mVelocity); + } + } + removeNullEntries(mEndListeners); + } + + /** + * Updates the property value through the corresponding setter. + */ + @SuppressWarnings("unchecked") + void setPropertyValue(float value) { + mProperty.setValue(mTarget, value); + for (int i = 0; i < mUpdateListeners.size(); i++) { + if (mUpdateListeners.get(i) != null) { + mUpdateListeners.get(i).onAnimationUpdate(this, mValue, mVelocity); + } + } + removeNullEntries(mUpdateListeners); + } + + /** + * Returns the default threshold. + */ + float getValueThreshold() { + return mMinVisibleChange * THRESHOLD_MULTIPLIER; + } + + /** + * Obtain the property value through the corresponding getter. + */ + @SuppressWarnings("unchecked") + private float getPropertyValue() { + return (Float) mProperty.get(mTarget); + } + + /** + * Returns the {@link AnimationHandler} used to schedule updates for this animator. + * + * @return the {@link AnimationHandler} for this animator. + */ + @NonNull + public AnimationHandler getAnimationHandler() { + return mAnimationHandler != null ? mAnimationHandler : AnimationHandler.getInstance(); + } + + /****************Sub class animations**************/ + /** + * Returns the acceleration at the given value with the given velocity. + **/ + abstract float getAcceleration(float value, float velocity); + + /** + * Returns whether the animation has reached equilibrium. + */ + abstract boolean isAtEquilibrium(float value, float velocity); + + /** + * Updates the default value threshold for the animation based on the property to be animated. + */ + abstract void setValueThreshold(float threshold); + + /** + * An animation listener that receives end notifications from an animation. + */ + public interface OnAnimationEndListener { + /** + * Notifies the end of an animation. Note that this callback will be invoked not only when + * an animation reach equilibrium, but also when the animation is canceled. + * + * @param animation animation that has ended or was canceled + * @param canceled whether the animation has been canceled + * @param value the final value when the animation stopped + * @param velocity the final velocity when the animation stopped + */ + void onAnimationEnd(DynamicAnimation animation, boolean canceled, float value, + float velocity); + } + + /** + * Implementors of this interface can add themselves as update listeners + * to an <code>DynamicAnimation</code> instance to receive callbacks on every animation + * frame, after the current frame's values have been calculated for that + * <code>DynamicAnimation</code>. + */ + public interface OnAnimationUpdateListener { + + /** + * Notifies the occurrence of another frame of the animation. + * + * @param animation animation that the update listener is added to + * @param value the current value of the animation + * @param velocity the current velocity of the animation + */ + void onAnimationUpdate(DynamicAnimation animation, float value, float velocity); + } +} diff --git a/core/java/com/android/internal/dynamicanimation/animation/FloatValueHolder.java b/core/java/com/android/internal/dynamicanimation/animation/FloatValueHolder.java new file mode 100644 index 000000000000..c3a2cacd16ec --- /dev/null +++ b/core/java/com/android/internal/dynamicanimation/animation/FloatValueHolder.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2022 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.internal.dynamicanimation.animation; + +/** + * <p>FloatValueHolder holds a float value. FloatValueHolder provides a setter and a getter ( + * i.e. {@link #setValue(float)} and {@link #getValue()}) to access this float value. Animations can + * be performed on a FloatValueHolder instance. During each frame of the animation, the + * FloatValueHolder will have its value updated via {@link #setValue(float)}. The caller can + * obtain the up-to-date animation value via {@link FloatValueHolder#getValue()}. + * + * @see SpringAnimation#SpringAnimation(FloatValueHolder) + */ + +public class FloatValueHolder { + private float mValue = 0.0f; + + /** + * Constructs a holder for a float value that is initialized to 0. + */ + public FloatValueHolder() { + } + + /** + * Constructs a holder for a float value that is initialized to the input value. + * + * @param value the value to initialize the value held in the FloatValueHolder + */ + public FloatValueHolder(float value) { + setValue(value); + } + + /** + * Sets the value held in the FloatValueHolder instance. + * + * @param value float value held in the FloatValueHolder instance + */ + public void setValue(float value) { + mValue = value; + } + + /** + * Returns the float value held in the FloatValueHolder instance. + * + * @return float value held in the FloatValueHolder instance + */ + public float getValue() { + return mValue; + } +} diff --git a/core/java/com/android/internal/dynamicanimation/animation/Force.java b/core/java/com/android/internal/dynamicanimation/animation/Force.java new file mode 100644 index 000000000000..fcb9c459fff3 --- /dev/null +++ b/core/java/com/android/internal/dynamicanimation/animation/Force.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2022 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.internal.dynamicanimation.animation; + +/** + * Hide this for now, in case we want to change the API. + */ +interface Force { + // Acceleration based on position. + float getAcceleration(float position, float velocity); + + boolean isAtEquilibrium(float value, float velocity); +} diff --git a/core/java/com/android/internal/dynamicanimation/animation/SpringAnimation.java b/core/java/com/android/internal/dynamicanimation/animation/SpringAnimation.java new file mode 100644 index 000000000000..2f3b72c4f97d --- /dev/null +++ b/core/java/com/android/internal/dynamicanimation/animation/SpringAnimation.java @@ -0,0 +1,314 @@ +/* + * Copyright (C) 2022 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.internal.dynamicanimation.animation; + +import android.util.AndroidRuntimeException; +import android.util.FloatProperty; + +/** + * SpringAnimation is an animation that is driven by a {@link SpringForce}. The spring force defines + * the spring's stiffness, damping ratio, as well as the rest position. Once the SpringAnimation is + * started, on each frame the spring force will update the animation's value and velocity. + * The animation will continue to run until the spring force reaches equilibrium. If the spring used + * in the animation is undamped, the animation will never reach equilibrium. Instead, it will + * oscillate forever. + * + * <div class="special reference"> + * <h3>Developer Guides</h3> + * </div> + * + * <p>To create a simple {@link SpringAnimation} that uses the default {@link SpringForce}:</p> + * <pre class="prettyprint"> + * // Create an animation to animate view's X property, set the rest position of the + * // default spring to 0, and start the animation with a starting velocity of 5000 (pixel/s). + * final SpringAnimation anim = new SpringAnimation(view, DynamicAnimation.X, 0) + * .setStartVelocity(5000); + * anim.start(); + * </pre> + * + * <p>Alternatively, a {@link SpringAnimation} can take a pre-configured {@link SpringForce}, and + * use that to drive the animation. </p> + * <pre class="prettyprint"> + * // Create a low stiffness, low bounce spring at position 0. + * SpringForce spring = new SpringForce(0) + * .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY) + * .setStiffness(SpringForce.STIFFNESS_LOW); + * // Create an animation to animate view's scaleY property, and start the animation using + * // the spring above and a starting value of 0.5. Additionally, constrain the range of value for + * // the animation to be non-negative, effectively preventing any spring overshoot. + * final SpringAnimation anim = new SpringAnimation(view, DynamicAnimation.SCALE_Y) + * .setMinValue(0).setSpring(spring).setStartValue(1); + * anim.start(); + * </pre> + */ +public final class SpringAnimation extends DynamicAnimation<SpringAnimation> { + + private SpringForce mSpring = null; + private float mPendingPosition = UNSET; + private static final float UNSET = Float.MAX_VALUE; + private boolean mEndRequested = false; + + /** + * <p>This creates a SpringAnimation that animates a {@link FloatValueHolder} instance. During + * the animation, the {@link FloatValueHolder} instance will be updated via + * {@link FloatValueHolder#setValue(float)} each frame. The caller can obtain the up-to-date + * animation value via {@link FloatValueHolder#getValue()}. + * + * <p><strong>Note:</strong> changing the value in the {@link FloatValueHolder} via + * {@link FloatValueHolder#setValue(float)} outside of the animation during an + * animation run will not have any effect on the on-going animation. + * + * @param floatValueHolder the property to be animated + */ + public SpringAnimation(FloatValueHolder floatValueHolder) { + super(floatValueHolder); + } + + /** + * <p>This creates a SpringAnimation that animates a {@link FloatValueHolder} instance. During + * the animation, the {@link FloatValueHolder} instance will be updated via + * {@link FloatValueHolder#setValue(float)} each frame. The caller can obtain the up-to-date + * animation value via {@link FloatValueHolder#getValue()}. + * + * A Spring will be created with the given final position and default stiffness and damping + * ratio. This spring can be accessed and reconfigured through {@link #setSpring(SpringForce)}. + * + * <p><strong>Note:</strong> changing the value in the {@link FloatValueHolder} via + * {@link FloatValueHolder#setValue(float)} outside of the animation during an + * animation run will not have any effect on the on-going animation. + * + * @param floatValueHolder the property to be animated + * @param finalPosition the final position of the spring to be created. + */ + public SpringAnimation(FloatValueHolder floatValueHolder, float finalPosition) { + super(floatValueHolder); + mSpring = new SpringForce(finalPosition); + } + + /** + * This creates a SpringAnimation that animates the property of the given object. + * Note, a spring will need to setup through {@link #setSpring(SpringForce)} before + * the animation starts. + * + * @param object the Object whose property will be animated + * @param property the property to be animated + * @param <K> the class on which the Property is declared + */ + public <K> SpringAnimation(K object, FloatProperty<K> property) { + super(object, property); + } + + /** + * This creates a SpringAnimation that animates the property of the given object. A Spring will + * be created with the given final position and default stiffness and damping ratio. + * This spring can be accessed and reconfigured through {@link #setSpring(SpringForce)}. + * + * @param object the Object whose property will be animated + * @param property the property to be animated + * @param finalPosition the final position of the spring to be created. + * @param <K> the class on which the Property is declared + */ + public <K> SpringAnimation(K object, FloatProperty<K> property, + float finalPosition) { + super(object, property); + mSpring = new SpringForce(finalPosition); + } + + /** + * Returns the spring that the animation uses for animations. + * + * @return the spring that the animation uses for animations + */ + public SpringForce getSpring() { + return mSpring; + } + + /** + * Uses the given spring as the force that drives this animation. If this spring force has its + * parameters re-configured during the animation, the new configuration will be reflected in the + * animation immediately. + * + * @param force a pre-defined spring force that drives the animation + * @return the animation that the spring force is set on + */ + public SpringAnimation setSpring(SpringForce force) { + mSpring = force; + return this; + } + + @Override + public void start() { + sanityCheck(); + mSpring.setValueThreshold(getValueThreshold()); + super.start(); + } + + /** + * Updates the final position of the spring. + * <p/> + * When the animation is running, calling this method would assume the position change of the + * spring as a continuous movement since last frame, which yields more accurate results than + * changing the spring position directly through {@link SpringForce#setFinalPosition(float)}. + * <p/> + * If the animation hasn't started, calling this method will change the spring position, and + * immediately start the animation. + * + * @param finalPosition rest position of the spring + */ + public void animateToFinalPosition(float finalPosition) { + if (isRunning()) { + mPendingPosition = finalPosition; + } else { + if (mSpring == null) { + mSpring = new SpringForce(finalPosition); + } + mSpring.setFinalPosition(finalPosition); + start(); + } + } + + /** + * Cancels the on-going animation. If the animation hasn't started, no op. Note that this method + * should only be called on main thread. + * + * @throws AndroidRuntimeException if this method is not called on the main thread + */ + @Override + public void cancel() { + super.cancel(); + if (mPendingPosition != UNSET) { + if (mSpring == null) { + mSpring = new SpringForce(mPendingPosition); + } else { + mSpring.setFinalPosition(mPendingPosition); + } + mPendingPosition = UNSET; + } + } + + /** + * Skips to the end of the animation. If the spring is undamped, an + * {@link IllegalStateException} will be thrown, as the animation would never reach to an end. + * It is recommended to check {@link #canSkipToEnd()} before calling this method. If animation + * is not running, no-op. + * + * Unless a AnimationHandler is provided via setAnimationHandler, a default AnimationHandler + * is created on the same thread as the first call to start/cancel an animation. All the + * subsequent animation lifecycle manipulations need to be on that same thread, until the + * AnimationHandler is reset (using [setAnimationHandler]). + * + * @throws IllegalStateException if the spring is undamped (i.e. damping ratio = 0) + * @throws AndroidRuntimeException if this method is not called on the same thread as the + * animation handler + */ + public void skipToEnd() { + if (!canSkipToEnd()) { + throw new UnsupportedOperationException("Spring animations can only come to an end" + + " when there is damping"); + } + if (!isCurrentThread()) { + throw new AndroidRuntimeException("Animations may only be started on the same thread " + + "as the animation handler"); + } + if (mRunning) { + mEndRequested = true; + } + } + + /** + * Queries whether the spring can eventually come to the rest position. + * + * @return {@code true} if the spring is damped, otherwise {@code false} + */ + public boolean canSkipToEnd() { + return mSpring.mDampingRatio > 0; + } + + /************************ Below are private APIs *************************/ + + private void sanityCheck() { + if (mSpring == null) { + throw new UnsupportedOperationException("Incomplete SpringAnimation: Either final" + + " position or a spring force needs to be set."); + } + double finalPosition = mSpring.getFinalPosition(); + if (finalPosition > mMaxValue) { + throw new UnsupportedOperationException("Final position of the spring cannot be greater" + + " than the max value."); + } else if (finalPosition < mMinValue) { + throw new UnsupportedOperationException("Final position of the spring cannot be less" + + " than the min value."); + } + } + + @Override + boolean updateValueAndVelocity(long deltaT) { + // If user had requested end, then update the value and velocity to end state and consider + // animation done. + if (mEndRequested) { + if (mPendingPosition != UNSET) { + mSpring.setFinalPosition(mPendingPosition); + mPendingPosition = UNSET; + } + mValue = mSpring.getFinalPosition(); + mVelocity = 0; + mEndRequested = false; + return true; + } + + if (mPendingPosition != UNSET) { + // Approximate by considering half of the time spring position stayed at the old + // position, half of the time it's at the new position. + MassState massState = mSpring.updateValues(mValue, mVelocity, deltaT / 2); + mSpring.setFinalPosition(mPendingPosition); + mPendingPosition = UNSET; + + massState = mSpring.updateValues(massState.mValue, massState.mVelocity, deltaT / 2); + mValue = massState.mValue; + mVelocity = massState.mVelocity; + + } else { + MassState massState = mSpring.updateValues(mValue, mVelocity, deltaT); + mValue = massState.mValue; + mVelocity = massState.mVelocity; + } + + mValue = Math.max(mValue, mMinValue); + mValue = Math.min(mValue, mMaxValue); + + if (isAtEquilibrium(mValue, mVelocity)) { + mValue = mSpring.getFinalPosition(); + mVelocity = 0f; + return true; + } + return false; + } + + @Override + float getAcceleration(float value, float velocity) { + return mSpring.getAcceleration(value, velocity); + } + + @Override + boolean isAtEquilibrium(float value, float velocity) { + return mSpring.isAtEquilibrium(value, velocity); + } + + @Override + void setValueThreshold(float threshold) { + } +} diff --git a/core/java/com/android/internal/dynamicanimation/animation/SpringForce.java b/core/java/com/android/internal/dynamicanimation/animation/SpringForce.java new file mode 100644 index 000000000000..36242ae2cf3d --- /dev/null +++ b/core/java/com/android/internal/dynamicanimation/animation/SpringForce.java @@ -0,0 +1,323 @@ +/* + * Copyright (C) 2022 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.internal.dynamicanimation.animation; + +import android.annotation.FloatRange; + +/** + * Spring Force defines the characteristics of the spring being used in the animation. + * <p> + * By configuring the stiffness and damping ratio, callers can create a spring with the look and + * feel suits their use case. Stiffness corresponds to the spring constant. The stiffer the spring + * is, the harder it is to stretch it, the faster it undergoes dampening. + * <p> + * Spring damping ratio describes how oscillations in a system decay after a disturbance. + * When damping ratio > 1* (i.e. over-damped), the object will quickly return to the rest position + * without overshooting. If damping ratio equals to 1 (i.e. critically damped), the object will + * return to equilibrium within the shortest amount of time. When damping ratio is less than 1 + * (i.e. under-damped), the mass tends to overshoot, and return, and overshoot again. Without any + * damping (i.e. damping ratio = 0), the mass will oscillate forever. + */ +public final class SpringForce implements Force { + /** + * Stiffness constant for extremely stiff spring. + */ + public static final float STIFFNESS_HIGH = 10_000f; + /** + * Stiffness constant for medium stiff spring. This is the default stiffness for spring force. + */ + public static final float STIFFNESS_MEDIUM = 1500f; + /** + * Stiffness constant for a spring with low stiffness. + */ + public static final float STIFFNESS_LOW = 200f; + /** + * Stiffness constant for a spring with very low stiffness. + */ + public static final float STIFFNESS_VERY_LOW = 50f; + + /** + * Damping ratio for a very bouncy spring. Note for under-damped springs + * (i.e. damping ratio < 1), the lower the damping ratio, the more bouncy the spring. + */ + public static final float DAMPING_RATIO_HIGH_BOUNCY = 0.2f; + /** + * Damping ratio for a medium bouncy spring. This is also the default damping ratio for spring + * force. Note for under-damped springs (i.e. damping ratio < 1), the lower the damping ratio, + * the more bouncy the spring. + */ + public static final float DAMPING_RATIO_MEDIUM_BOUNCY = 0.5f; + /** + * Damping ratio for a spring with low bounciness. Note for under-damped springs + * (i.e. damping ratio < 1), the lower the damping ratio, the higher the bounciness. + */ + public static final float DAMPING_RATIO_LOW_BOUNCY = 0.75f; + /** + * Damping ratio for a spring with no bounciness. This damping ratio will create a critically + * damped spring that returns to equilibrium within the shortest amount of time without + * oscillating. + */ + public static final float DAMPING_RATIO_NO_BOUNCY = 1f; + + // This multiplier is used to calculate the velocity threshold given a certain value threshold. + // The idea is that if it takes >= 1 frame to move the value threshold amount, then the velocity + // is a reasonable threshold. + private static final double VELOCITY_THRESHOLD_MULTIPLIER = 1000.0 / 16.0; + + // Natural frequency + double mNaturalFreq = Math.sqrt(STIFFNESS_MEDIUM); + // Damping ratio. + double mDampingRatio = DAMPING_RATIO_MEDIUM_BOUNCY; + + // Value to indicate an unset state. + private static final double UNSET = Double.MAX_VALUE; + + // Indicates whether the spring has been initialized + private boolean mInitialized = false; + + // Threshold for velocity and value to determine when it's reasonable to assume that the spring + // is approximately at rest. + private double mValueThreshold; + private double mVelocityThreshold; + + // Intermediate values to simplify the spring function calculation per frame. + private double mGammaPlus; + private double mGammaMinus; + private double mDampedFreq; + + // Final position of the spring. This must be set before the start of the animation. + private double mFinalPosition = UNSET; + + // Internal state to hold a value/velocity pair. + private final DynamicAnimation.MassState mMassState = new DynamicAnimation.MassState(); + + /** + * Creates a spring force. Note that final position of the spring must be set through + * {@link #setFinalPosition(float)} before the spring animation starts. + */ + public SpringForce() { + // No op. + } + + /** + * Creates a spring with a given final rest position. + * + * @param finalPosition final position of the spring when it reaches equilibrium + */ + public SpringForce(float finalPosition) { + mFinalPosition = finalPosition; + } + + /** + * Sets the stiffness of a spring. The more stiff a spring is, the more force it applies to + * the object attached when the spring is not at the final position. Default stiffness is + * {@link #STIFFNESS_MEDIUM}. + * + * @param stiffness non-negative stiffness constant of a spring + * @return the spring force that the given stiffness is set on + * @throws IllegalArgumentException if the given spring stiffness is not positive + */ + public SpringForce setStiffness( + @FloatRange(from = 0.0, fromInclusive = false) float stiffness) { + if (stiffness <= 0) { + throw new IllegalArgumentException("Spring stiffness constant must be positive."); + } + mNaturalFreq = Math.sqrt(stiffness); + // All the intermediate values need to be recalculated. + mInitialized = false; + return this; + } + + /** + * Gets the stiffness of the spring. + * + * @return the stiffness of the spring + */ + public float getStiffness() { + return (float) (mNaturalFreq * mNaturalFreq); + } + + /** + * Spring damping ratio describes how oscillations in a system decay after a disturbance. + * <p> + * When damping ratio > 1 (over-damped), the object will quickly return to the rest position + * without overshooting. If damping ratio equals to 1 (i.e. critically damped), the object will + * return to equilibrium within the shortest amount of time. When damping ratio is less than 1 + * (i.e. under-damped), the mass tends to overshoot, and return, and overshoot again. Without + * any damping (i.e. damping ratio = 0), the mass will oscillate forever. + * <p> + * Default damping ratio is {@link #DAMPING_RATIO_MEDIUM_BOUNCY}. + * + * @param dampingRatio damping ratio of the spring, it should be non-negative + * @return the spring force that the given damping ratio is set on + * @throws IllegalArgumentException if the {@param dampingRatio} is negative. + */ + public SpringForce setDampingRatio(@FloatRange(from = 0.0) float dampingRatio) { + if (dampingRatio < 0) { + throw new IllegalArgumentException("Damping ratio must be non-negative"); + } + mDampingRatio = dampingRatio; + // All the intermediate values need to be recalculated. + mInitialized = false; + return this; + } + + /** + * Returns the damping ratio of the spring. + * + * @return damping ratio of the spring + */ + public float getDampingRatio() { + return (float) mDampingRatio; + } + + /** + * Sets the rest position of the spring. + * + * @param finalPosition rest position of the spring + * @return the spring force that the given final position is set on + */ + public SpringForce setFinalPosition(float finalPosition) { + mFinalPosition = finalPosition; + return this; + } + + /** + * Returns the rest position of the spring. + * + * @return rest position of the spring + */ + public float getFinalPosition() { + return (float) mFinalPosition; + } + + /*********************** Below are private APIs *********************/ + + @Override + public float getAcceleration(float lastDisplacement, float lastVelocity) { + + lastDisplacement -= getFinalPosition(); + + double k = mNaturalFreq * mNaturalFreq; + double c = 2 * mNaturalFreq * mDampingRatio; + + return (float) (-k * lastDisplacement - c * lastVelocity); + } + + @Override + public boolean isAtEquilibrium(float value, float velocity) { + if (Math.abs(velocity) < mVelocityThreshold + && Math.abs(value - getFinalPosition()) < mValueThreshold) { + return true; + } + return false; + } + + /** + * Initialize the string by doing the necessary pre-calculation as well as some sanity check + * on the setup. + * + * @throws IllegalStateException if the final position is not yet set by the time the spring + * animation has started + */ + private void init() { + if (mInitialized) { + return; + } + + if (mFinalPosition == UNSET) { + throw new IllegalStateException("Error: Final position of the spring must be" + + " set before the animation starts"); + } + + if (mDampingRatio > 1) { + // Over damping + mGammaPlus = -mDampingRatio * mNaturalFreq + + mNaturalFreq * Math.sqrt(mDampingRatio * mDampingRatio - 1); + mGammaMinus = -mDampingRatio * mNaturalFreq + - mNaturalFreq * Math.sqrt(mDampingRatio * mDampingRatio - 1); + } else if (mDampingRatio >= 0 && mDampingRatio < 1) { + // Under damping + mDampedFreq = mNaturalFreq * Math.sqrt(1 - mDampingRatio * mDampingRatio); + } + + mInitialized = true; + } + + /** + * Internal only call for Spring to calculate the spring position/velocity using + * an analytical approach. + */ + DynamicAnimation.MassState updateValues(double lastDisplacement, double lastVelocity, + long timeElapsed) { + init(); + + double deltaT = timeElapsed / 1000d; // unit: seconds + lastDisplacement -= mFinalPosition; + double displacement; + double currentVelocity; + if (mDampingRatio > 1) { + // Overdamped + double coeffA = lastDisplacement - (mGammaMinus * lastDisplacement - lastVelocity) + / (mGammaMinus - mGammaPlus); + double coeffB = (mGammaMinus * lastDisplacement - lastVelocity) + / (mGammaMinus - mGammaPlus); + displacement = coeffA * Math.pow(Math.E, mGammaMinus * deltaT) + + coeffB * Math.pow(Math.E, mGammaPlus * deltaT); + currentVelocity = coeffA * mGammaMinus * Math.pow(Math.E, mGammaMinus * deltaT) + + coeffB * mGammaPlus * Math.pow(Math.E, mGammaPlus * deltaT); + } else if (mDampingRatio == 1) { + // Critically damped + double coeffA = lastDisplacement; + double coeffB = lastVelocity + mNaturalFreq * lastDisplacement; + displacement = (coeffA + coeffB * deltaT) * Math.pow(Math.E, -mNaturalFreq * deltaT); + currentVelocity = (coeffA + coeffB * deltaT) * Math.pow(Math.E, -mNaturalFreq * deltaT) + * -mNaturalFreq + coeffB * Math.pow(Math.E, -mNaturalFreq * deltaT); + } else { + // Underdamped + double cosCoeff = lastDisplacement; + double sinCoeff = (1 / mDampedFreq) * (mDampingRatio * mNaturalFreq + * lastDisplacement + lastVelocity); + displacement = Math.pow(Math.E, -mDampingRatio * mNaturalFreq * deltaT) + * (cosCoeff * Math.cos(mDampedFreq * deltaT) + + sinCoeff * Math.sin(mDampedFreq * deltaT)); + currentVelocity = displacement * -mNaturalFreq * mDampingRatio + + Math.pow(Math.E, -mDampingRatio * mNaturalFreq * deltaT) + * (-mDampedFreq * cosCoeff * Math.sin(mDampedFreq * deltaT) + + mDampedFreq * sinCoeff * Math.cos(mDampedFreq * deltaT)); + } + + mMassState.mValue = (float) (displacement + mFinalPosition); + mMassState.mVelocity = (float) currentVelocity; + return mMassState; + } + + /** + * This threshold defines how close the animation value needs to be before the animation can + * finish. This default value is based on the property being animated, e.g. animations on alpha, + * scale, translation or rotation would have different thresholds. This value should be small + * enough to avoid visual glitch of "jumping to the end". But it shouldn't be so small that + * animations take seconds to finish. + * + * @param threshold the difference between the animation value and final spring position that + * is allowed to end the animation when velocity is very low + */ + void setValueThreshold(double threshold) { + mValueThreshold = Math.abs(threshold); + mVelocityThreshold = mValueThreshold * VELOCITY_THRESHOLD_MULTIPLIER; + } +} |