From dd244f553072d538629b865f74b82357e6e55030 Mon Sep 17 00:00:00 2001 From: Krzysztof Magiera Date: Sat, 23 Apr 2016 02:37:01 -0700 Subject: [PATCH] Support for Animated.interpolate Summary:This change adds native animated support for Animated.interpolate Animated.interpolate allows for defining nodes that outputs an interpolated value of their input node based on the interpolation node configuration. For now native animated implementation only supports a linear interpolation for a given input and output ranges (ranges can consists of multiple segments). Native interpolation node is compatible with the JS implementation with the exception that not all attributes that can be used in JS are supported. Before we migrate interpolation node from JS->native we verify that only supported props are used. **Test Plan** Run JS tests: `npm test Libraries/Animated/src/__tests__/AnimatedNative-test.js` Run java tests: `buck test ReactAndroid/src/test/java/com/facebook/react/animated` Closes https://github.com/facebook/react-native/pull/7141 Differential Revision: D3216546 fb-gh-sync-id: 29876e33956615c6370ca4d332abe048f8dba5b8 fbshipit-source-id: 29876e33956615c6370ca4d332abe048f8dba5b8 --- .../Animated/src/AnimatedImplementation.js | 27 ++++-- .../Animated/src/NativeAnimatedHelper.js | 13 +++ .../src/__tests__/AnimatedNative-test.js | 52 +++++++++++ .../animated/InterpolationAnimatedNode.java | 89 +++++++++++++++++++ .../animated/NativeAnimatedNodesManager.java | 2 + .../NativeAnimatedInterpolationTest.java | 64 +++++++++++++ .../NativeAnimatedNodeTraversalTest.java | 57 ++++++++++++ 7 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/animated/InterpolationAnimatedNode.java create mode 100644 ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedInterpolationTest.java 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); + } }