diff --git a/Libraries/Animated/src/AnimatedImplementation.js b/Libraries/Animated/src/AnimatedImplementation.js index e1b6040441d..91b7bb3b5b7 100644 --- a/Libraries/Animated/src/AnimatedImplementation.js +++ b/Libraries/Animated/src/AnimatedImplementation.js @@ -712,7 +712,7 @@ class AnimatedValue extends AnimatedWithChildren { * 0-10. */ interpolate(config: InterpolationConfigType): AnimatedInterpolation { - return new AnimatedInterpolation(this, Interpolation.create(config)); + return new AnimatedInterpolation(this, config); } /** @@ -919,12 +919,14 @@ class AnimatedValueXY extends AnimatedWithChildren { class AnimatedInterpolation extends AnimatedWithChildren { _parent: Animated; + _config: InterpolationConfigType; _interpolation: (input: number) => number | string; - constructor(parent: Animated, interpolation: (input: number) => number | string) { + constructor(parent: Animated, config: InterpolationConfigType) { super(); this._parent = parent; - this._interpolation = interpolation; + this._config = config; + this._interpolation = Interpolation.create(config); } __getValue(): number | string { @@ -937,7 +939,7 @@ class AnimatedInterpolation extends AnimatedWithChildren { } interpolate(config: InterpolationConfigType): AnimatedInterpolation { - return new AnimatedInterpolation(this, Interpolation.create(config)); + return new AnimatedInterpolation(this, config); } __attach(): void { @@ -946,6 +948,15 @@ class AnimatedInterpolation extends AnimatedWithChildren { __detach(): void { this._parent.__removeChild(this); + super.__detach(); + } + + __getNativeConfig(): any { + NativeAnimatedHelper.validateInterpolation(this._config); + return { + ...this._config, + type: 'interpolation', + }; } } @@ -960,9 +971,9 @@ class AnimatedAddition extends AnimatedWithChildren { } __makeNative() { - super.__makeNative(); this._a.__makeNative(); this._b.__makeNative(); + super.__makeNative(); } __getValue(): number { @@ -970,7 +981,7 @@ class AnimatedAddition extends AnimatedWithChildren { } interpolate(config: InterpolationConfigType): AnimatedInterpolation { - return new AnimatedInterpolation(this, Interpolation.create(config)); + return new AnimatedInterpolation(this, config); } __attach(): void { @@ -1013,7 +1024,7 @@ class AnimatedMultiplication extends AnimatedWithChildren { } interpolate(config: InterpolationConfigType): AnimatedInterpolation { - return new AnimatedInterpolation(this, Interpolation.create(config)); + return new AnimatedInterpolation(this, config); } __attach(): void { @@ -1050,7 +1061,7 @@ class AnimatedModulo extends AnimatedWithChildren { } interpolate(config: InterpolationConfigType): AnimatedInterpolation { - return new AnimatedInterpolation(this, Interpolation.create(config)); + return new AnimatedInterpolation(this, config); } __attach(): void { diff --git a/Libraries/Animated/src/NativeAnimatedHelper.js b/Libraries/Animated/src/NativeAnimatedHelper.js index e70749802c2..2e5cc68b173 100644 --- a/Libraries/Animated/src/NativeAnimatedHelper.js +++ b/Libraries/Animated/src/NativeAnimatedHelper.js @@ -101,6 +101,18 @@ function validateStyles(styles: Object): void { } } +function validateInterpolation(config: Object): void { + var SUPPORTED_INTERPOLATION_PARAMS = { + inputRange: true, + outputRange: true, + }; + for (var key in config) { + if (!SUPPORTED_INTERPOLATION_PARAMS.hasOwnProperty(key)) { + throw new Error(`Interpolation property '${key}' is not supported by native animated module`); + } + } +} + function generateNewNodeTag(): number { return __nativeAnimatedNodeTagCount++; } @@ -117,6 +129,7 @@ module.exports = { API, validateProps, validateStyles, + validateInterpolation, generateNewNodeTag, generateNewAnimationId, assertNativeAnimatedModule, diff --git a/Libraries/Animated/src/__tests__/AnimatedNative-test.js b/Libraries/Animated/src/__tests__/AnimatedNative-test.js index d6b254e4a9f..bd29f92a119 100644 --- a/Libraries/Animated/src/__tests__/AnimatedNative-test.js +++ b/Libraries/Animated/src/__tests__/AnimatedNative-test.js @@ -160,6 +160,39 @@ describe('Animated', () => { .toBeCalledWith(multiplicationCall[1].input[1], { type: 'value', value: 1 }); }); + it('sends a valid graph description for interpolate() nodes', () => { + var node = new Animated.Value(10); + + var c = new Animated.View(); + c.props = { + style: { + opacity: node.interpolate({ + inputRange: [10, 20], + outputRange: [0, 1], + }), + }, + }; + c.componentWillMount(); + + Animated.timing(node, {toValue: 20, duration: 1000, useNativeDriver: true}).start(); + + var nativeAnimatedModule = require('NativeModules').NativeAnimatedModule; + expect(nativeAnimatedModule.createAnimatedNode).toBeCalledWith(jasmine.any(Number), { type: 'value', value: 10 }); + expect(nativeAnimatedModule.createAnimatedNode) + .toBeCalledWith(jasmine.any(Number), { + type: 'interpolation', + inputRange: [10, 20], + outputRange: [0, 1], + }); + var interpolationNodeTag = nativeAnimatedModule.createAnimatedNode.mock.calls.find( + (call) => call[1].type === 'interpolation' + )[0]; + var valueNodeTag = nativeAnimatedModule.createAnimatedNode.mock.calls.find( + (call) => call[1].type === 'value' + )[0]; + expect(nativeAnimatedModule.connectAnimatedNodes).toBeCalledWith(valueNodeTag, interpolationNodeTag); + }); + it('sends a valid timing animation description', () => { var anim = new Animated.Value(0); Animated.timing(anim, {toValue: 10, duration: 1000, useNativeDriver: true}).start(); @@ -250,6 +283,25 @@ describe('Animated', () => { expect(animation.start).toThrowError(/left/); }); + it('fails for unsupported interpolation parameters', () => { + var anim = new Animated.Value(0); + + var c = new Animated.View(); + c.props = { + style: { + opacity: anim.interpolate({ + inputRange: [0, 100], + outputRange: [0, 1], + extrapolate: 'clamp', + }), + }, + }; + c.componentWillMount(); + + var animation = Animated.timing(anim, {toValue: 100, duration: 50, useNativeDriver: true}); + expect(animation.start).toThrowError(/extrapolate/); + }); + it('works for any `static` props and styles', () => { // Passing "unsupported" props should work just fine as long as they are not animated var anim = new Animated.Value(0); diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/InterpolationAnimatedNode.java b/ReactAndroid/src/main/java/com/facebook/react/animated/InterpolationAnimatedNode.java new file mode 100644 index 00000000000..84ac32a3cfb --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/InterpolationAnimatedNode.java @@ -0,0 +1,89 @@ +package com.facebook.react.animated; + +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; + +import javax.annotation.Nullable; + +/** + * Animated node that corresponds to {@code AnimatedInterpolation} from AnimatedImplementation.js. + * + * Currently only a linear interpolation is supported on an input range of an arbitrary size. + */ +/*package*/ class InterpolationAnimatedNode extends ValueAnimatedNode { + + private static double[] fromDoubleArray(ReadableArray ary) { + double[] res = new double[ary.size()]; + for (int i = 0; i < res.length; i++) { + res[i] = ary.getDouble(i); + } + return res; + } + + private static double interpolate( + double value, + double inputMin, + double inputMax, + double outputMin, + double outputMax) { + return outputMin + (outputMax - outputMin) * + (value - inputMin) / (inputMax - inputMin); + } + + /*package*/ static double interpolate(double value, double[] inputRange, double[] outputRange) { + int rangeIndex = findRangeIndex(value, inputRange); + return interpolate( + value, + inputRange[rangeIndex], + inputRange[rangeIndex + 1], + outputRange[rangeIndex], + outputRange[rangeIndex + 1]); + } + + private static int findRangeIndex(double value, double[] ranges) { + int index; + for (index = 1; index < ranges.length - 1; index++) { + if (ranges[index] >= value) { + break; + } + } + return index - 1; + } + + private final double mInputRange[]; + private final double mOutputRange[]; + private @Nullable ValueAnimatedNode mParent; + + public InterpolationAnimatedNode(ReadableMap config) { + mInputRange = fromDoubleArray(config.getArray("inputRange")); + mOutputRange = fromDoubleArray(config.getArray("outputRange")); + } + + @Override + public void onAttachedToNode(AnimatedNode parent) { + if (mParent != null) { + throw new IllegalStateException("Parent already attached"); + } + if (!(parent instanceof ValueAnimatedNode)) { + throw new IllegalArgumentException("Parent is of an invalid type"); + } + mParent = (ValueAnimatedNode) parent; + } + + @Override + public void onDetachedFromNode(AnimatedNode parent) { + if (parent != mParent) { + throw new IllegalArgumentException("Invalid parent node provided"); + } + mParent = null; + } + + @Override + public void update() { + if (mParent == null) { + throw new IllegalStateException("Trying to update interpolation node that has not been " + + "attached to the parent"); + } + mValue = interpolate(mParent.mValue, mInputRange, mOutputRange); + } +} 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 057546802fa..1aa5e863127 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java @@ -72,6 +72,8 @@ import javax.annotation.Nullable; mUpdatedNodes.add(node); } else if ("props".equals(type)) { node = new PropsAnimatedNode(config, this); + } else if ("interpolation".equals(type)) { + node = new InterpolationAnimatedNode(config); } else if ("addition".equals(type)) { node = new AdditionAnimatedNode(config, this); } else if ("multiplication".equals(type)) { diff --git a/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedInterpolationTest.java b/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedInterpolationTest.java new file mode 100644 index 00000000000..492fa44be23 --- /dev/null +++ b/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedInterpolationTest.java @@ -0,0 +1,64 @@ +package com.facebook.react.animated; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import static org.fest.assertions.api.Assertions.assertThat; + +/** + * Tests method used by {@link InterpolationAnimatedNode} to interpolate value of the input nodes. + */ +@RunWith(RobolectricTestRunner.class) +public class NativeAnimatedInterpolationTest { + + @Test + public void testSimpleOneToOneMapping() { + double[] input = new double[] {0d, 1d}; + double[] output = new double[] {0d, 1d}; + assertThat(InterpolationAnimatedNode.interpolate(0, input, output)).isEqualTo(0); + assertThat(InterpolationAnimatedNode.interpolate(0.5, input, output)).isEqualTo(0.5); + assertThat(InterpolationAnimatedNode.interpolate(0.8, input, output)).isEqualTo(0.8); + assertThat(InterpolationAnimatedNode.interpolate(1, input, output)).isEqualTo(1); + } + + @Test + public void testWiderOutputRange() { + double[] input = new double[] {0d, 1d}; + double[] output = new double[] {100d, 200d}; + assertThat(InterpolationAnimatedNode.interpolate(0, input, output)).isEqualTo(100); + assertThat(InterpolationAnimatedNode.interpolate(0.5, input, output)).isEqualTo(150); + assertThat(InterpolationAnimatedNode.interpolate(0.8, input, output)).isEqualTo(180); + assertThat(InterpolationAnimatedNode.interpolate(1, input, output)).isEqualTo(200); + } + + @Test + public void testWiderInputRange() { + double[] input = new double[] {2000d, 3000d}; + double[] output = new double[] {1d, 2d}; + assertThat(InterpolationAnimatedNode.interpolate(2000, input, output)).isEqualTo(1); + assertThat(InterpolationAnimatedNode.interpolate(2250, input, output)).isEqualTo(1.25); + assertThat(InterpolationAnimatedNode.interpolate(2800, input, output)).isEqualTo(1.8); + assertThat(InterpolationAnimatedNode.interpolate(3000, input, output)).isEqualTo(2); + } + + @Test + public void testManySegments() { + double[] input = new double[] {-1d, 1d, 5d}; + double[] output = new double[] {0, 10d, 20d}; + assertThat(InterpolationAnimatedNode.interpolate(-1, input, output)).isEqualTo(0); + assertThat(InterpolationAnimatedNode.interpolate(0, input, output)).isEqualTo(5); + assertThat(InterpolationAnimatedNode.interpolate(1, input, output)).isEqualTo(10); + assertThat(InterpolationAnimatedNode.interpolate(2, input, output)).isEqualTo(12.5); + assertThat(InterpolationAnimatedNode.interpolate(5, input, output)).isEqualTo(20); + } + + @Test + public void testExtrapolate() { + double[] input = new double[] {10d, 20d}; + double[] output = new double[] {0d, 1d}; + assertThat(InterpolationAnimatedNode.interpolate(30d, input, output)).isEqualTo(2); + assertThat(InterpolationAnimatedNode.interpolate(5d, input, output)).isEqualTo(-0.5); + } + +} 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 5465f0746a1..5f1a63f2730 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java @@ -462,4 +462,61 @@ public class NativeAnimatedNodeTraversalTest { verifyNoMoreInteractions(mUIImplementationMock); verifyNoMoreInteractions(animationCallback); } + + @Test + public void testInterpolationNode() { + mNativeAnimatedNodesManager.createAnimatedNode( + 1, + JavaOnlyMap.of("type", "value", "value", 10d)); + + mNativeAnimatedNodesManager.createAnimatedNode( + 2, + JavaOnlyMap.of( + "type", + "interpolation", + "inputRange", + JavaOnlyArray.of(10d, 20d), + "outputRange", + JavaOnlyArray.of(0d, 1d))); + + mNativeAnimatedNodesManager.createAnimatedNode( + 3, + JavaOnlyMap.of("type", "style", "style", JavaOnlyMap.of("opacity", 2))); + mNativeAnimatedNodesManager.createAnimatedNode( + 4, + JavaOnlyMap.of("type", "props", "props", JavaOnlyMap.of("style", 3))); + mNativeAnimatedNodesManager.connectAnimatedNodes(1, 2); + mNativeAnimatedNodesManager.connectAnimatedNodes(2, 3); + mNativeAnimatedNodesManager.connectAnimatedNodes(3, 4); + mNativeAnimatedNodesManager.connectAnimatedNodeToView(4, 50); + + Callback animationCallback = mock(Callback.class); + JavaOnlyArray frames = JavaOnlyArray.of(0d, 0.2d, 0.4d, 0.6d, 0.8d, 1d); + mNativeAnimatedNodesManager.startAnimatingNode( + 1, + 1, + JavaOnlyMap.of("type", "frames", "frames", frames, "toValue", 20d), + animationCallback); + + ArgumentCaptor stylesCaptor = + ArgumentCaptor.forClass(ReactStylesDiffMap.class); + + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(50), stylesCaptor.capture()); + assertThat(stylesCaptor.getValue().getDouble("opacity", Double.NaN)).isEqualTo(0d); + + for (int i = 0; i < frames.size(); i++) { + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(mUIImplementationMock) + .synchronouslyUpdateViewOnUIThread(eq(50), stylesCaptor.capture()); + assertThat(stylesCaptor.getValue().getDouble("opacity", Double.NaN)) + .isEqualTo(frames.getDouble(i)); + } + + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verifyNoMoreInteractions(mUIImplementationMock); + } }