From 8a7eb170dd4ce42023eb8c20f65fb60145880524 Mon Sep 17 00:00:00 2001 From: Edwin Date: Mon, 13 Mar 2017 23:49:47 -0700 Subject: [PATCH] Adds Animated.loop to Animated API Summary: * Any animation can be looped on the javascript thread * Only basic animations supported natively at this stage, loops run using the native driver cannot contain animations of type sequence, parallel, stagger, or loop Motivation: We need a spinner in our app that is displayed and animated while the javascript thread is tied up with other tasks. This means it needs to be offloaded from the javascript thread, so that it will continue to run while those tasks are churning away. I originally submitted PR #9513, which has served our needs, but brentvatne pointed out a better way to do it. Had hoped his suggestion would be implemented by janicduplessis or another fb employee, but after 5 months I thought I'd give it another push. I've put together an implementation that basically matches the suggested API. Let me know what you think, and whether others can pick it up from here and get it in to core. Personal Motivation: I am leaving my current organisation on Feb 10th, so am trying to clean thing Closes https://github.com/facebook/react-native/pull/11973 Differential Revision: D4704381 fbshipit-source-id: 42a2cdf5d53a7c0d08f86a58485f7f38739e6cd9 --- .../Animated/src/AnimatedImplementation.js | 222 ++++++++++++++++-- .../Animated/src/__tests__/Animated-test.js | 177 ++++++++++++++ .../react/animated/DecayAnimation.java | 26 +- .../animated/FrameBasedAnimationDriver.java | 13 +- .../react/animated/SpringAnimation.java | 20 +- .../NativeAnimatedNodeTraversalTest.java | 175 ++++++++++++++ 6 files changed, 603 insertions(+), 30 deletions(-) diff --git a/Libraries/Animated/src/AnimatedImplementation.js b/Libraries/Animated/src/AnimatedImplementation.js index e0860eced..1c06b9a6e 100644 --- a/Libraries/Animated/src/AnimatedImplementation.js +++ b/Libraries/Animated/src/AnimatedImplementation.js @@ -97,6 +97,7 @@ type AnimationConfig = { isInteraction?: bool, useNativeDriver?: bool, onComplete?: ?EndCallback, + iterations?: number, }; // Important note: start() and stop() will only be called at most once. @@ -107,6 +108,7 @@ class Animation { __isInteraction: bool; __nativeId: number; __onEnd: ?EndCallback; + __iterations: number; start( fromValue: number, onUpdate: (value: number) => void, @@ -228,7 +230,7 @@ function _flush(rootNode: AnimatedValue): void { animatedStyles.forEach(animatedStyle => animatedStyle.update()); } -type TimingAnimationConfig = AnimationConfig & { +type TimingAnimationConfig = AnimationConfig & { toValue: number | AnimatedValue | {x: number, y: number} | AnimatedValueXY, easing?: (value: number) => number, duration?: number, @@ -271,6 +273,7 @@ class TimingAnimation extends Animation { this._easing = config.easing !== undefined ? config.easing : easeInOut(); this._duration = config.duration !== undefined ? config.duration : 500; this._delay = config.delay !== undefined ? config.delay : 0; + this.__iterations = config.iterations !== undefined ? config.iterations : 1; this.__isInteraction = config.isInteraction !== undefined ? config.isInteraction : true; this._useNativeDriver = shouldUseNativeDriver(config); } @@ -286,7 +289,8 @@ class TimingAnimation extends Animation { type: 'frames', frames, toValue: this._toValue, - delay: this._delay + delay: this._delay, + iterations: this.__iterations, }; } @@ -386,6 +390,7 @@ class DecayAnimation extends Animation { this._velocity = config.velocity; this._useNativeDriver = shouldUseNativeDriver(config); this.__isInteraction = config.isInteraction !== undefined ? config.isInteraction : true; + this.__iterations = config.iterations !== undefined ? config.iterations : 1; } __getNativeAnimationConfig() { @@ -393,6 +398,7 @@ class DecayAnimation extends Animation { type: 'decay', deceleration: this._deceleration, velocity: this._velocity, + iterations: this.__iterations, }; } @@ -505,6 +511,7 @@ class SpringAnimation extends Animation { this._toValue = config.toValue; this._useNativeDriver = shouldUseNativeDriver(config); this.__isInteraction = config.isInteraction !== undefined ? config.isInteraction : true; + this.__iterations = config.iterations !== undefined ? config.iterations : 1; var springConfig; if (config.bounciness !== undefined || config.speed !== undefined) { @@ -536,6 +543,7 @@ class SpringAnimation extends Animation { friction: this._friction, initialVelocity: withDefault(this._initialVelocity, this._lastVelocity), toValue: this._toValue, + iterations: this.__iterations, }; } @@ -695,6 +703,7 @@ var _uniqueId = 1; */ class AnimatedValue extends AnimatedWithChildren { _value: number; + _startingValue: number; _offset: number; _animation: ?Animation; _tracking: ?Animated; @@ -703,7 +712,7 @@ class AnimatedValue extends AnimatedWithChildren { constructor(value: number) { super(); - this._value = value; + this._startingValue = this._value = value; this._offset = 0; this._animation = null; this._listeners = {}; @@ -846,6 +855,14 @@ class AnimatedValue extends AnimatedWithChildren { callback && callback(this.__getValue()); } + /** + * Stops any animation and resets the value to its original + */ + resetAnimation(callback?: ?(value: number) => void): void { + this.stopAnimation(callback); + this._value = this._startingValue; + } + /** * Interpolates the value before updating the property, e.g. mapping 0-1 to * 0-10. @@ -1013,6 +1030,12 @@ class AnimatedValueXY extends AnimatedWithChildren { }; } + resetAnimation(callback?: (value: {x: number, y: number}) => void): void { + this.x.resetAnimation(); + this.y.resetAnimation(); + callback && callback(this.__getValue()); + } + stopAnimation(callback?: (value: {x: number, y: number}) => void): void { this.x.stopAnimation(); this.y.stopAnimation(); @@ -1888,6 +1911,9 @@ class AnimatedTracking extends Animated { type CompositeAnimation = { start: (callback?: ?EndCallback) => void, stop: () => void, + reset: () => void, + _startNativeLoop: (iterations?: number) => void, + _isUsingNativeDriver: () => boolean, }; var add = function( @@ -1965,16 +1991,18 @@ var spring = function( value: AnimatedValue | AnimatedValueXY, config: SpringAnimationConfig, ): CompositeAnimation { - return maybeVectorAnim(value, config, spring) || { - start: function(callback?: ?EndCallback): void { - callback = _combineCallbacks(callback, config); - var singleValue: any = value; - var singleConfig: any = config; + var start = function( + animatedValue: AnimatedValue | AnimatedValueXY, + configuration: SpringAnimationConfig, + callback?: ?EndCallback): void { + callback = _combineCallbacks(callback, configuration); + var singleValue: any = animatedValue; + var singleConfig: any = configuration; singleValue.stopTracking(); - if (config.toValue instanceof Animated) { + if (configuration.toValue instanceof Animated) { singleValue.track(new AnimatedTracking( singleValue, - config.toValue, + configuration.toValue, SpringAnimation, singleConfig, callback @@ -1982,11 +2010,28 @@ var spring = function( } else { singleValue.animate(new SpringAnimation(singleConfig), callback); } + }; + return maybeVectorAnim(value, config, spring) || { + start: function(callback?: ?EndCallback): void { + start(value, config, callback); }, stop: function(): void { value.stopAnimation(); }, + + reset: function(): void { + value.resetAnimation(); + }, + + _startNativeLoop: function(iterations?: number): void { + var singleConfig = { ...config, iterations }; + start(value, singleConfig); + }, + + _isUsingNativeDriver: function(): boolean { + return config.useNativeDriver || false; + } }; }; @@ -1994,16 +2039,18 @@ var timing = function( value: AnimatedValue | AnimatedValueXY, config: TimingAnimationConfig, ): CompositeAnimation { - return maybeVectorAnim(value, config, timing) || { - start: function(callback?: ?EndCallback): void { - callback = _combineCallbacks(callback, config); - var singleValue: any = value; - var singleConfig: any = config; + var start = function( + animatedValue: AnimatedValue | AnimatedValueXY, + configuration: TimingAnimationConfig, + callback?: ?EndCallback): void { + callback = _combineCallbacks(callback, configuration); + var singleValue: any = animatedValue; + var singleConfig: any = configuration; singleValue.stopTracking(); - if (config.toValue instanceof Animated) { + if (configuration.toValue instanceof Animated) { singleValue.track(new AnimatedTracking( singleValue, - config.toValue, + configuration.toValue, TimingAnimation, singleConfig, callback @@ -2011,11 +2058,29 @@ var timing = function( } else { singleValue.animate(new TimingAnimation(singleConfig), callback); } + }; + + return maybeVectorAnim(value, config, timing) || { + start: function(callback?: ?EndCallback): void { + start(value, config, callback); }, stop: function(): void { value.stopAnimation(); }, + + reset: function(): void { + value.resetAnimation(); + }, + + _startNativeLoop: function(iterations?: number): void { + var singleConfig = { ...config, iterations }; + start(value, singleConfig); + }, + + _isUsingNativeDriver: function(): boolean { + return config.useNativeDriver || false; + } }; }; @@ -2023,18 +2088,38 @@ var decay = function( value: AnimatedValue | AnimatedValueXY, config: DecayAnimationConfig, ): CompositeAnimation { - return maybeVectorAnim(value, config, decay) || { - start: function(callback?: ?EndCallback): void { - callback = _combineCallbacks(callback, config); - var singleValue: any = value; - var singleConfig: any = config; + var start = function( + animatedValue: AnimatedValue | AnimatedValueXY, + configuration: DecayAnimationConfig, + callback?: ?EndCallback): void { + callback = _combineCallbacks(callback, configuration); + var singleValue: any = animatedValue; + var singleConfig: any = configuration; singleValue.stopTracking(); singleValue.animate(new DecayAnimation(singleConfig), callback); + }; + + return maybeVectorAnim(value, config, decay) || { + start: function(callback?: ?EndCallback): void { + start(value, config, callback); }, stop: function(): void { value.stopAnimation(); }, + + reset: function(): void { + value.resetAnimation(); + }, + + _startNativeLoop: function(iterations?: number): void { + var singleConfig = { ...config, iterations }; + start(value, singleConfig); + }, + + _isUsingNativeDriver: function(): boolean { + return config.useNativeDriver || false; + } }; }; @@ -2071,6 +2156,23 @@ var sequence = function( if (current < animations.length) { animations[current].stop(); } + }, + + reset: function() { + animations.forEach((animation, idx) => { + if (idx <= current) { + animation.reset(); + } + }); + current = 0; + }, + + _startNativeLoop: function() { + throw new Error('Loops run using the native driver cannot contain Animated.sequence animations'); + }, + + _isUsingNativeDriver: function(): boolean { + return false; } }; }; @@ -2122,6 +2224,22 @@ var parallel = function( !hasEnded[idx] && animation.stop(); hasEnded[idx] = true; }); + }, + + reset: function(): void { + animations.forEach((animation, idx) => { + animation.reset(); + hasEnded[idx] = false; + doneCount = 0; + }); + }, + + _startNativeLoop: function() { + throw new Error('Loops run using the native driver cannot contain Animated.parallel animations'); + }, + + _isUsingNativeDriver: function(): boolean { + return false; } }; @@ -2145,6 +2263,59 @@ var stagger = function( })); }; +type LoopAnimationConfig = { iterations: number }; + +var loop = function( + animation: CompositeAnimation, + { iterations = -1 }: LoopAnimationConfig = {}, +): CompositeAnimation { + var isFinished = false; + var iterationsSoFar = 0; + return { + start: function(callback?: ?EndCallback) { + var restart = function(result: EndResult = {finished: true}): void { + if (isFinished || + (iterationsSoFar === iterations) || + (result.finished === false)) { + callback && callback(result); + } else { + iterationsSoFar++; + animation.reset(); + animation.start(restart); + } + }; + if (!animation || iterations === 0) { + callback && callback({finished: true}); + } else { + if (animation._isUsingNativeDriver()) { + animation._startNativeLoop(iterations); + } else { + restart(); // Start looping recursively on the js thread + } + } + }, + + stop: function(): void { + isFinished = true; + animation.stop(); + }, + + reset: function(): void { + iterationsSoFar = 0; + isFinished = false; + animation.reset(); + }, + + _startNativeLoop: function() { + throw new Error('Loops run using the native driver cannot contain Animated.loop animations'); + }, + + _isUsingNativeDriver: function(): boolean { + return animation._isUsingNativeDriver(); + } + }; +}; + type Mapping = {[key: string]: Mapping} | AnimatedValue; type EventConfig = { listener?: ?Function, @@ -2606,6 +2777,13 @@ module.exports = { * sequence with successive delays. Nice for doing trailing effects. */ stagger, + /** + * Loops a given animation continuously, so that each time it reaches the + * end, it resets and begins again from the start. Can specify number of + * times to loop using the key 'iterations' in the config. Will loop without + * blocking the UI thread if the child animation is set to 'useNativeDriver'. + */ + loop, /** * Takes an array of mappings and extracts values from each arg accordingly, diff --git a/Libraries/Animated/src/__tests__/Animated-test.js b/Libraries/Animated/src/__tests__/Animated-test.js index fc313a8af..c89d29799 100644 --- a/Libraries/Animated/src/__tests__/Animated-test.js +++ b/Libraries/Animated/src/__tests__/Animated-test.js @@ -214,6 +214,183 @@ describe('Animated tests', () => { }); }); + describe('Animated Loop', () => { + + it('loops indefinitely if config not specified', () => { + var animation = {start: jest.fn(), reset: jest.fn(), _isUsingNativeDriver: () => false}; + var cb = jest.fn(); + + var loop = Animated.loop(animation); + + expect(animation.start).not.toBeCalled(); + + loop.start(cb); + + expect(animation.start).toBeCalled(); + expect(animation.reset).toHaveBeenCalledTimes(1); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: true}); // End of loop 1 + expect(animation.reset).toHaveBeenCalledTimes(2); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: true}); // End of loop 2 + expect(animation.reset).toHaveBeenCalledTimes(3); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: true}); // End of loop 3 + expect(animation.reset).toHaveBeenCalledTimes(4); + expect(cb).not.toBeCalled(); + }); + + it('loops indefinitely if iterations is -1', () => { + var animation = {start: jest.fn(), reset: jest.fn(), _isUsingNativeDriver: () => false}; + var cb = jest.fn(); + + var loop = Animated.loop(animation, { iterations: -1 }); + + expect(animation.start).not.toBeCalled(); + + loop.start(cb); + + expect(animation.start).toBeCalled(); + expect(animation.reset).toHaveBeenCalledTimes(1); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: true}); // End of loop 1 + expect(animation.reset).toHaveBeenCalledTimes(2); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: true}); // End of loop 2 + expect(animation.reset).toHaveBeenCalledTimes(3); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: true}); // End of loop 3 + expect(animation.reset).toHaveBeenCalledTimes(4); + expect(cb).not.toBeCalled(); + }); + + it('loops indefinitely if iterations not specified', () => { + var animation = {start: jest.fn(), reset: jest.fn(), _isUsingNativeDriver: () => false}; + var cb = jest.fn(); + + var loop = Animated.loop(animation, { anotherKey: 'value' }); + + expect(animation.start).not.toBeCalled(); + + loop.start(cb); + + expect(animation.start).toBeCalled(); + expect(animation.reset).toHaveBeenCalledTimes(1); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: true}); // End of loop 1 + expect(animation.reset).toHaveBeenCalledTimes(2); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: true}); // End of loop 2 + expect(animation.reset).toHaveBeenCalledTimes(3); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: true}); // End of loop 3 + expect(animation.reset).toHaveBeenCalledTimes(4); + expect(cb).not.toBeCalled(); + }); + + it('loops three times if iterations is 3', () => { + var animation = {start: jest.fn(), reset: jest.fn(), _isUsingNativeDriver: () => false}; + var cb = jest.fn(); + + var loop = Animated.loop(animation, { iterations: 3 }); + + expect(animation.start).not.toBeCalled(); + + loop.start(cb); + + expect(animation.start).toBeCalled(); + expect(animation.reset).toHaveBeenCalledTimes(1); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: true}); // End of loop 1 + expect(animation.reset).toHaveBeenCalledTimes(2); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: true}); // End of loop 2 + expect(animation.reset).toHaveBeenCalledTimes(3); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: true}); // End of loop 3 + expect(animation.reset).toHaveBeenCalledTimes(3); + expect(cb).toBeCalledWith({finished: true}); + }); + + it('does not loop if iterations is 1', () => { + var animation = {start: jest.fn(), reset: jest.fn(), _isUsingNativeDriver: () => false}; + var cb = jest.fn(); + + var loop = Animated.loop(animation, { iterations: 1 }); + + expect(animation.start).not.toBeCalled(); + + loop.start(cb); + + expect(animation.start).toBeCalled(); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: true}); // End of loop 1 + expect(cb).toBeCalledWith({finished: true}); + }); + + it('does not animate if iterations is 0', () => { + var animation = {start: jest.fn(), reset: jest.fn(), _isUsingNativeDriver: () => false}; + var cb = jest.fn(); + + var loop = Animated.loop(animation, { iterations: 0 }); + + expect(animation.start).not.toBeCalled(); + + loop.start(cb); + + expect(animation.start).not.toBeCalled(); + expect(cb).toBeCalledWith({ finished: true }); + }); + + it('supports interrupting an indefinite loop', () => { + var animation = {start: jest.fn(), reset: jest.fn(), _isUsingNativeDriver: () => false}; + var cb = jest.fn(); + + Animated.loop(animation).start(cb); + expect(animation.start).toBeCalled(); + expect(animation.reset).toHaveBeenCalledTimes(1); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: true}); // End of loop 1 + expect(animation.reset).toHaveBeenCalledTimes(2); + expect(cb).not.toBeCalled(); + + animation.start.mock.calls[0][0]({finished: false}); // Interrupt loop + expect(animation.reset).toHaveBeenCalledTimes(2); + expect(cb).toBeCalledWith({finished: false}); + }); + + it('supports stopping loop', () => { + var animation = {start: jest.fn(), stop: jest.fn(), reset: jest.fn(), _isUsingNativeDriver: () => false}; + var cb = jest.fn(); + + var loop = Animated.loop(animation); + loop.start(cb); + loop.stop(); + + expect(animation.start).toBeCalled(); + expect(animation.reset).toHaveBeenCalledTimes(1); + expect(animation.stop).toBeCalled(); + + animation.start.mock.calls[0][0]({finished: false}); // Interrupt loop + expect(animation.reset).toHaveBeenCalledTimes(1); + expect(cb).toBeCalledWith({finished: false}); + }); + }); + describe('Animated Parallel', () => { it('works with an empty parallel', () => { diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/DecayAnimation.java b/ReactAndroid/src/main/java/com/facebook/react/animated/DecayAnimation.java index 84dac0623..41b6d24ff 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/DecayAnimation.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/DecayAnimation.java @@ -21,12 +21,17 @@ public class DecayAnimation extends AnimationDriver { private final double mDeceleration; private long mStartFrameTimeMillis = -1; - private double mFromValue; - private double mLastValue; + private double mFromValue = 0d; + private double mLastValue = 0d; + private int mIterations; + private int mCurrentLoop; public DecayAnimation(ReadableMap config) { mVelocity = config.getDouble("velocity"); mDeceleration = config.getDouble("deceleration"); + mIterations = config.hasKey("iterations") ? config.getInt("iterations") : 1; + mCurrentLoop = 1; + mHasFinished = mIterations == 0; } @Override @@ -35,7 +40,11 @@ public class DecayAnimation extends AnimationDriver { if (mStartFrameTimeMillis == -1) { // since this is the first animation step, consider the start to be on the previous frame mStartFrameTimeMillis = frameTimeMillis - 16; - mFromValue = mAnimatedValue.mValue; + if (mFromValue == mLastValue) { // first iteration, assign mFromValue based on mAnimatedValue + mFromValue = mAnimatedValue.mValue; + } else { // not the first iteration, reset mAnimatedValue based on mFromValue + mAnimatedValue.mValue = mFromValue; + } mLastValue = mAnimatedValue.mValue; } @@ -44,8 +53,15 @@ public class DecayAnimation extends AnimationDriver { (1 - Math.exp(-(1 - mDeceleration) * (frameTimeMillis - mStartFrameTimeMillis))); if (Math.abs(mLastValue - value) < 0.1) { - mHasFinished = true; - return; + + if (mIterations == -1 || mCurrentLoop < mIterations) { // looping animation, return to start + // set mStartFrameTimeMillis to -1 to reset instance variables on the next runAnimationStep + mStartFrameTimeMillis = -1; + mCurrentLoop++; + } else { // animation has completed + mHasFinished = true; + return; + } } mLastValue = value; diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/FrameBasedAnimationDriver.java b/ReactAndroid/src/main/java/com/facebook/react/animated/FrameBasedAnimationDriver.java index 86eac8452..94b121789 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/FrameBasedAnimationDriver.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/FrameBasedAnimationDriver.java @@ -26,6 +26,8 @@ class FrameBasedAnimationDriver extends AnimationDriver { private final double[] mFrames; private final double mToValue; private double mFromValue; + private int mIterations; + private int mCurrentLoop; FrameBasedAnimationDriver(ReadableMap config) { ReadableArray frames = config.getArray("frames"); @@ -35,6 +37,9 @@ class FrameBasedAnimationDriver extends AnimationDriver { mFrames[i] = frames.getDouble(i); } mToValue = config.getDouble("toValue"); + mIterations = config.hasKey("iterations") ? config.getInt("iterations") : 1; + mCurrentLoop = 1; + mHasFinished = mIterations == 0; } @Override @@ -53,9 +58,13 @@ class FrameBasedAnimationDriver extends AnimationDriver { } double nextValue; if (frameIndex >= mFrames.length - 1) { - // animation has completed, no more frames left - mHasFinished = true; nextValue = mToValue; + if (mIterations == -1 || mCurrentLoop < mIterations) { // looping animation, return to start + mStartFrameTimeNanos = frameTimeNanos; + mCurrentLoop++; + } else { // animation has completed, no more frames left + mHasFinished = true; + } } else { nextValue = mFromValue + mFrames[frameIndex] * (mToValue - mFromValue); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/SpringAnimation.java b/ReactAndroid/src/main/java/com/facebook/react/animated/SpringAnimation.java index 7cb14d109..a57a9152d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/SpringAnimation.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/SpringAnimation.java @@ -38,6 +38,10 @@ import com.facebook.react.bridge.ReadableMap; private double mRestSpeedThreshold; private double mDisplacementFromRestThreshold; private double mTimeAccumulator = 0; + // for controlling loop + private int mIterations; + private int mCurrentLoop = 0; + private double mOriginalValue; SpringAnimation(ReadableMap config) { mSpringFriction = config.getDouble("friction"); @@ -47,12 +51,18 @@ import com.facebook.react.bridge.ReadableMap; mRestSpeedThreshold = config.getDouble("restSpeedThreshold"); mDisplacementFromRestThreshold = config.getDouble("restDisplacementThreshold"); mOvershootClampingEnabled = config.getBoolean("overshootClamping"); + mIterations = config.hasKey("iterations") ? config.getInt("iterations") : 1; + mHasFinished = mIterations == 0; } @Override public void runAnimationStep(long frameTimeNanos) { long frameTimeMillis = frameTimeNanos / 1000000; if (!mSpringStarted) { + if (mCurrentLoop == 0) { + mOriginalValue = mAnimatedValue.mValue; + mCurrentLoop = 1; + } mStartValue = mCurrentState.position = mAnimatedValue.mValue; mLastTime = frameTimeMillis; mSpringStarted = true; @@ -60,7 +70,15 @@ import com.facebook.react.bridge.ReadableMap; advance((frameTimeMillis - mLastTime) / 1000.0); mLastTime = frameTimeMillis; mAnimatedValue.mValue = mCurrentState.position; - mHasFinished = isAtRest(); + if (isAtRest()) { + if (mIterations == -1 || mCurrentLoop < mIterations) { // looping animation, return to start + mSpringStarted = false; + mAnimatedValue.mValue = mOriginalValue; + mCurrentLoop++; + } else { // animation has completed + mHasFinished = true; + } + } } /** 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 5476ab5ea..ced79811c 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java @@ -167,6 +167,42 @@ public class NativeAnimatedNodeTraversalTest { verifyNoMoreInteractions(mUIImplementationMock); } + @Test + public void testFramesAnimationLoopsFiveTimes() { + createSimpleAnimatedViewWithOpacity(1000, 0d); + + JavaOnlyArray frames = JavaOnlyArray.of(0d, 0.2d, 0.4d, 0.6d, 0.8d, 1d); + Callback animationCallback = mock(Callback.class); + mNativeAnimatedNodesManager.startAnimatingNode( + 1, + 1, + JavaOnlyMap.of("type", "frames", "frames", frames, "toValue", 1d, "iterations", 5), + 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); + + for (int iteration = 0; iteration < 5; iteration++) { + for (int i = 0; i < frames.size(); i++) { + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(mUIImplementationMock) + .synchronouslyUpdateViewOnUIThread(eq(1000), stylesCaptor.capture()); + assertThat(stylesCaptor.getValue().getDouble("opacity", Double.NaN)) + .isEqualTo(frames.getDouble(i)); + } + } + + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verifyNoMoreInteractions(mUIImplementationMock); + } + @Test public void testNodeValueListenerIfNotListening() { int nodeId = 1; @@ -285,6 +321,84 @@ public class NativeAnimatedNodeTraversalTest { verifyNoMoreInteractions(mUIImplementationMock); } + @Test + public void testSpringAnimationLoopsFiveTimes() { + 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, + "iterations", + 5), + 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; + boolean didComeToRest = false; + int numberOfResets = 0; + /* run 3 secs of animation, five times */ + for (int i = 0; i < 3 * 60 * 5; 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; + } + // Test to see if it reset after coming to rest + if (didComeToRest && + currentValue == 0d && + Math.abs(Math.abs(currentValue - previousValue) - 1d) < 0.001d) { + numberOfResets++; + } + + // verify that an animation step is relatively small, unless it has come to rest and reset + if (!didComeToRest) assertThat(Math.abs(currentValue - previousValue)).isLessThan(0.1d); + + + // record that the animation did come to rest when it rests on toValue + didComeToRest = Math.abs(currentValue - 1d) < 0.001d && + Math.abs(currentValue - previousValue) < 0.001d; + 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); + // verify that value reset 4 times after finishing a full animation + assertThat(numberOfResets).isEqualTo(4); + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verifyNoMoreInteractions(mUIImplementationMock); + } + @Test public void testDecayAnimation() { createSimpleAnimatedViewWithOpacity(1000, 0d); @@ -342,6 +456,67 @@ public class NativeAnimatedNodeTraversalTest { verifyNoMoreInteractions(mUIImplementationMock); } + @Test + public void testDecayAnimationLoopsFiveTimes() { + createSimpleAnimatedViewWithOpacity(1000, 0d); + + Callback animationCallback = mock(Callback.class); + mNativeAnimatedNodesManager.startAnimatingNode( + 1, + 1, + JavaOnlyMap.of( + "type", + "decay", + "velocity", + 0.5d, + "deceleration", + 0.998d, + "iterations", + 5), + animationCallback); + + ArgumentCaptor stylesCaptor = + ArgumentCaptor.forClass(ReactStylesDiffMap.class); + + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(mUIImplementationMock, atMost(1)) + .synchronouslyUpdateViewOnUIThread(eq(1000), stylesCaptor.capture()); + double previousValue = stylesCaptor.getValue().getDouble("opacity", Double.NaN); + double previousDiff = Double.POSITIVE_INFINITY; + double initialValue = stylesCaptor.getValue().getDouble("opacity", Double.NaN); + boolean didComeToRest = false; + int numberOfResets = 0; + /* run 3 secs of animation, five times */ + for (int i = 0; i < 3 * 60 * 5; i++) { + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(mUIImplementationMock, atMost(1)) + .synchronouslyUpdateViewOnUIThread(eq(1000), stylesCaptor.capture()); + double currentValue = stylesCaptor.getValue().getDouble("opacity", Double.NaN); + double currentDiff = currentValue - previousValue; + // Test to see if it reset after coming to rest (i.e. dropped back to ) + if (didComeToRest && currentValue == initialValue) { + numberOfResets++; + } + + // verify monotonicity, unless it has come to rest and reset + // greater *or equal* because the animation stops during these 3 seconds + if (!didComeToRest) assertThat(currentValue).as("on frame " + i).isGreaterThanOrEqualTo(previousValue); + + // Test if animation has come to rest using the 0.1 threshold from DecayAnimation.java + didComeToRest = Math.abs(currentDiff) < 0.1d; + previousValue = currentValue; + previousDiff = currentDiff; + } + + // verify that value reset (looped) 4 times after finishing a full animation + assertThat(numberOfResets).isEqualTo(4); + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verifyNoMoreInteractions(mUIImplementationMock); + } + @Test public void testAnimationCallbackFinish() { createSimpleAnimatedViewWithOpacity(1000, 0d);