Support for spring animations

Summary:
This change adds support for spring animations to be run off the JS thread on android. The implementation is based on the android spring implementation from Rebound (http://facebook.github.io/rebound/) but since only a small subset of the library is used the relevant parts are copied instead of making RN to import the whole library.

**Test Plan**
Run java tests: `buck test ReactAndroid/src/test/java/com/facebook/react/animated`
Add `useNativeDriver: true` to spring animation in animated example app, run it on android
Closes https://github.com/facebook/react-native/pull/8860

Differential Revision: D3676436

fbshipit-source-id: 3a4b1b006725a938562712989b93dd4090577c48
This commit is contained in:
Krzysztof Magiera 2016-08-05 12:01:49 -07:00 committed by Facebook Github Bot 2
parent 0222107170
commit 8f75d7346f
4 changed files with 302 additions and 1 deletions

View File

@ -449,6 +449,7 @@ class SpringAnimation extends Animation {
_lastTime: number;
_onUpdate: (value: number) => void;
_animationFrame: any;
_useNativeDriver: bool;
constructor(
config: SpringAnimationConfigSingle,
@ -461,6 +462,7 @@ class SpringAnimation extends Animation {
this._initialVelocity = config.velocity;
this._lastVelocity = withDefault(config.velocity, 0);
this._toValue = config.toValue;
this._useNativeDriver = config.useNativeDriver !== undefined ? config.useNativeDriver : false;
this.__isInteraction = config.isInteraction !== undefined ? config.isInteraction : true;
var springConfig;
@ -483,11 +485,25 @@ class SpringAnimation extends Animation {
this._friction = springConfig.friction;
}
_getNativeAnimationConfig() {
return {
type: 'spring',
overshootClamping: this._overshootClamping,
restDisplacementThreshold: this._restDisplacementThreshold,
restSpeedThreshold: this._restSpeedThreshold,
tension: this._tension,
friction: this._friction,
initialVelocity: withDefault(this._initialVelocity, this._lastVelocity),
toValue: this._toValue,
};
}
start(
fromValue: number,
onUpdate: (value: number) => void,
onEnd: ?EndCallback,
previousAnimation: ?Animation,
animatedValue: AnimatedValue
): void {
this.__active = true;
this._startPosition = fromValue;
@ -507,7 +523,11 @@ class SpringAnimation extends Animation {
this._initialVelocity !== null) {
this._lastVelocity = this._initialVelocity;
}
this.onUpdate();
if (this._useNativeDriver) {
this.__startNativeAnimation(animatedValue);
} else {
this.onUpdate();
}
}
getInternalState(): Object {

View File

@ -135,6 +135,8 @@ import javax.annotation.Nullable;
final AnimationDriver animation;
if ("frames".equals(type)) {
animation = new FrameBasedAnimationDriver(animationConfig);
} else if ("spring".equals(type)) {
animation = new SpringAnimation(animationConfig);
} else {
throw new JSApplicationIllegalArgumentException("Unsupported animation type: " + type);
}

View File

@ -0,0 +1,218 @@
package com.facebook.react.animated;
import com.facebook.react.bridge.ReadableMap;
/**
* Implementation of {@link AnimationDriver} providing support for spring animations. The
* implementation has been copied from android implementation of Rebound library (see
* <a href="http://facebook.github.io/rebound/">http://facebook.github.io/rebound/</a>)
*/
/*package*/ class SpringAnimation extends AnimationDriver {
// maximum amount of time to simulate per physics iteration in seconds (4 frames at 60 FPS)
private static final double MAX_DELTA_TIME_SEC = 0.064;
// fixed timestep to use in the physics solver in seconds
private static final double SOLVER_TIMESTEP_SEC = 0.001;
// storage for the current and prior physics state while integration is occurring
private static class PhysicsState {
double position;
double velocity;
}
private long mLastTime;
private boolean mSpringStarted;
// configuration
private double mSpringFriction;
private double mSpringTension;
private boolean mOvershootClampingEnabled;
// all physics simulation objects are final and reused in each processing pass
private final PhysicsState mCurrentState = new PhysicsState();
private final PhysicsState mPreviousState = new PhysicsState();
private final PhysicsState mTempState = new PhysicsState();
private double mStartValue;
private double mEndValue;
// thresholds for determining when the spring is at rest
private double mRestSpeedThreshold;
private double mDisplacementFromRestThreshold;
private double mTimeAccumulator = 0;
SpringAnimation(ReadableMap config) {
mSpringFriction = config.getDouble("friction");
mSpringTension = config.getDouble("tension");
mCurrentState.velocity = config.getDouble("initialVelocity");
mEndValue = config.getDouble("toValue");
mRestSpeedThreshold = config.getDouble("restSpeedThreshold");
mDisplacementFromRestThreshold = config.getDouble("restDisplacementThreshold");
mOvershootClampingEnabled = config.getBoolean("overshootClamping");
}
@Override
public void runAnimationStep(long frameTimeNanos) {
long frameTimeMillis = frameTimeNanos / 1000000;
if (!mSpringStarted) {
mStartValue = mCurrentState.position = mAnimatedValue.mValue;
mLastTime = frameTimeMillis;
mSpringStarted = true;
}
advance((frameTimeMillis - mLastTime) / 1000.0);
mLastTime = frameTimeMillis;
mAnimatedValue.mValue = mCurrentState.position;
mHasFinished = isAtRest();
}
/**
* get the displacement from rest for a given physics state
* @param state the state to measure from
* @return the distance displaced by
*/
private double getDisplacementDistanceForState(PhysicsState state) {
return Math.abs(mEndValue - state.position);
}
/**
* check if the current state is at rest
* @return is the spring at rest
*/
private boolean isAtRest() {
return Math.abs(mCurrentState.velocity) <= mRestSpeedThreshold &&
(getDisplacementDistanceForState(mCurrentState) <= mDisplacementFromRestThreshold ||
mSpringTension == 0);
}
/**
* Check if the spring is overshooting beyond its target.
* @return true if the spring is overshooting its target
*/
private boolean isOvershooting() {
return mSpringTension > 0 &&
((mStartValue < mEndValue && mCurrentState.position > mEndValue) ||
(mStartValue > mEndValue && mCurrentState.position < mEndValue));
}
/**
* linear interpolation between the previous and current physics state based on the amount of
* timestep remaining after processing the rendering delta time in timestep sized chunks.
* @param alpha from 0 to 1, where 0 is the previous state, 1 is the current state
*/
private void interpolate(double alpha) {
mCurrentState.position = mCurrentState.position * alpha + mPreviousState.position *(1-alpha);
mCurrentState.velocity = mCurrentState.velocity * alpha + mPreviousState.velocity *(1-alpha);
}
/**
* advance the physics simulation in SOLVER_TIMESTEP_SEC sized chunks to fulfill the required
* realTimeDelta.
* The math is inlined inside the loop since it made a huge performance impact when there are
* several springs being advanced.
* @param time clock time
* @param realDeltaTime clock drift
*/
private void advance(double realDeltaTime) {
if (isAtRest()) {
return;
}
// clamp the amount of realTime to simulate to avoid stuttering in the UI. We should be able
// to catch up in a subsequent advance if necessary.
double adjustedDeltaTime = realDeltaTime;
if (realDeltaTime > MAX_DELTA_TIME_SEC) {
adjustedDeltaTime = MAX_DELTA_TIME_SEC;
}
mTimeAccumulator += adjustedDeltaTime;
double tension = mSpringTension;
double friction = mSpringFriction;
double position = mCurrentState.position;
double velocity = mCurrentState.velocity;
double tempPosition = mTempState.position;
double tempVelocity = mTempState.velocity;
double aVelocity, aAcceleration;
double bVelocity, bAcceleration;
double cVelocity, cAcceleration;
double dVelocity, dAcceleration;
double dxdt, dvdt;
// iterate over the true time
while (mTimeAccumulator >= SOLVER_TIMESTEP_SEC) {
/* begin debug
iterations++;
end debug */
mTimeAccumulator -= SOLVER_TIMESTEP_SEC;
if (mTimeAccumulator < SOLVER_TIMESTEP_SEC) {
// This will be the last iteration. Remember the previous state in case we need to
// interpolate
mPreviousState.position = position;
mPreviousState.velocity = velocity;
}
// Perform an RK4 integration to provide better detection of the acceleration curve via
// sampling of Euler integrations at 4 intervals feeding each derivative into the calculation
// of the next and taking a weighted sum of the 4 derivatives as the final output.
// This math was inlined since it made for big performance improvements when advancing several
// springs in one pass of the BaseSpringSystem.
// The initial derivative is based on the current velocity and the calculated acceleration
aVelocity = velocity;
aAcceleration = (tension * (mEndValue - tempPosition)) - friction * velocity;
// Calculate the next derivatives starting with the last derivative and integrating over the
// timestep
tempPosition = position + aVelocity * SOLVER_TIMESTEP_SEC * 0.5;
tempVelocity = velocity + aAcceleration * SOLVER_TIMESTEP_SEC * 0.5;
bVelocity = tempVelocity;
bAcceleration = (tension * (mEndValue - tempPosition)) - friction * tempVelocity;
tempPosition = position + bVelocity * SOLVER_TIMESTEP_SEC * 0.5;
tempVelocity = velocity + bAcceleration * SOLVER_TIMESTEP_SEC * 0.5;
cVelocity = tempVelocity;
cAcceleration = (tension * (mEndValue - tempPosition)) - friction * tempVelocity;
tempPosition = position + cVelocity * SOLVER_TIMESTEP_SEC;
tempVelocity = velocity + cAcceleration * SOLVER_TIMESTEP_SEC;
dVelocity = tempVelocity;
dAcceleration = (tension * (mEndValue - tempPosition)) - friction * tempVelocity;
// Take the weighted sum of the 4 derivatives as the final output.
dxdt = 1.0/6.0 * (aVelocity + 2.0 * (bVelocity + cVelocity) + dVelocity);
dvdt = 1.0/6.0 * (aAcceleration + 2.0 * (bAcceleration + cAcceleration) + dAcceleration);
position += dxdt * SOLVER_TIMESTEP_SEC;
velocity += dvdt * SOLVER_TIMESTEP_SEC;
}
mTempState.position = tempPosition;
mTempState.velocity = tempVelocity;
mCurrentState.position = position;
mCurrentState.velocity = velocity;
if (mTimeAccumulator > 0) {
interpolate(mTimeAccumulator / SOLVER_TIMESTEP_SEC);
}
// End the spring immediately if it is overshooting and overshoot clamping is enabled.
// Also make sure that if the spring was considered within a resting threshold that it's now
// snapped to its end value.
if (isAtRest() || (mOvershootClampingEnabled && isOvershooting())) {
// Don't call setCurrentValue because that forces a call to onSpringUpdate
if (tension > 0) {
mStartValue = mEndValue;
mCurrentState.position = mEndValue;
} else {
mEndValue = mCurrentState.position;
mStartValue = mEndValue;
}
mCurrentState.velocity = 0;
}
}
}

View File

@ -35,6 +35,7 @@ import static org.fest.assertions.api.Assertions.assertThat;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyInt;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.atMost;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
@ -198,6 +199,66 @@ public class NativeAnimatedNodeTraversalTest {
verifyNoMoreInteractions(valueListener);
}
@Test
public void testSpringAnimation() {
createSimpleAnimatedViewWithOpacity(1000, 0d);
Callback animationCallback = mock(Callback.class);
mNativeAnimatedNodesManager.startAnimatingNode(
1,
1,
JavaOnlyMap.of(
"type",
"spring",
"friction",
7d,
"tension",
40.0d,
"initialVelocity",
0d,
"toValue",
1d,
"restSpeedThreshold",
0.001d,
"restDisplacementThreshold",
0.001d,
"overshootClamping",
false),
animationCallback);
ArgumentCaptor<ReactStylesDiffMap> stylesCaptor =
ArgumentCaptor.forClass(ReactStylesDiffMap.class);
reset(mUIImplementationMock);
mNativeAnimatedNodesManager.runUpdates(nextFrameTime());
verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(1000), stylesCaptor.capture());
assertThat(stylesCaptor.getValue().getDouble("opacity", Double.NaN)).isEqualTo(0);
double previousValue = 0d;
boolean wasGreaterThanOne = false;
/* run 3 secs of animation */
for (int i = 0; i < 3 * 60; i++) {
reset(mUIImplementationMock);
mNativeAnimatedNodesManager.runUpdates(nextFrameTime());
verify(mUIImplementationMock, atMost(1))
.synchronouslyUpdateViewOnUIThread(eq(1000), stylesCaptor.capture());
double currentValue = stylesCaptor.getValue().getDouble("opacity", Double.NaN);
if (currentValue > 1d) {
wasGreaterThanOne = true;
}
// verify that animation step is relatively small
assertThat(Math.abs(currentValue - previousValue)).isLessThan(0.1d);
previousValue = currentValue;
}
// verify that we've reach the final value at the end of animation
assertThat(previousValue).isEqualTo(1d);
// verify that value has reached some maximum value that is greater than the final value (bounce)
assertThat(wasGreaterThanOne);
reset(mUIImplementationMock);
mNativeAnimatedNodesManager.runUpdates(nextFrameTime());
verifyNoMoreInteractions(mUIImplementationMock);
}
@Test
public void testAnimationCallbackFinish() {
createSimpleAnimatedViewWithOpacity(1000, 0d);