From 8f75d7346f1b80a4e7139f138ff409c8244c538a Mon Sep 17 00:00:00 2001 From: Krzysztof Magiera Date: Fri, 5 Aug 2016 12:01:49 -0700 Subject: [PATCH] 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 --- .../Animated/src/AnimatedImplementation.js | 22 +- .../animated/NativeAnimatedNodesManager.java | 2 + .../react/animated/SpringAnimation.java | 218 ++++++++++++++++++ .../NativeAnimatedNodeTraversalTest.java | 61 +++++ 4 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/animated/SpringAnimation.java diff --git a/Libraries/Animated/src/AnimatedImplementation.js b/Libraries/Animated/src/AnimatedImplementation.js index 0c60e8353..02c984f83 100644 --- a/Libraries/Animated/src/AnimatedImplementation.js +++ b/Libraries/Animated/src/AnimatedImplementation.js @@ -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 { diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java index 8f5ad6ec8..deb236155 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java @@ -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); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/SpringAnimation.java b/ReactAndroid/src/main/java/com/facebook/react/animated/SpringAnimation.java new file mode 100644 index 000000000..7cb14d109 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/SpringAnimation.java @@ -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 + * http://facebook.github.io/rebound/) + */ +/*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; + } + } +} diff --git a/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java b/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java index e321a0b6e..a499aa26a 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java @@ -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 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);