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);