From e7dbfb2dbd98059ddd6e982e2e43c1e7df91a4cc Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Wed, 30 Nov 2022 10:19:12 -0800 Subject: [PATCH] Support colors in AnimatedInterpolation on Android Summary: Color support for AnimatedInterpolation was incomplete with native drivers, as only rgba type strings were supported. There was also an issue where color props instead a StyleAnimatedNode would never get applied. We were also potentially duplicating color parsing support, which is already centralized in `normalizeColor` / `processColor`. Changelog: [Android][Added] Enable AnimatedInterpolation to interpolate arbitrary color types. Reviewed By: mdvacca Differential Revision: D40571873 fbshipit-source-id: 41857ab0391279c5307bc31b855ea8fbcb4cccd8 --- .../Animated/__tests__/AnimatedNative-test.js | 1 + .../Animated/nodes/AnimatedInterpolation.js | 25 +- .../animated/InterpolationAnimatedNode.java | 231 +++++++++++------- .../react/animated/PropsAnimatedNode.java | 4 +- .../react/animated/StyleAnimatedNode.java | 9 +- .../react/animated/ValueAnimatedNode.java | 3 +- .../NativeAnimatedInterpolationTest.java | 38 +++ .../examples/Animated/ColorStylesExample.js | 30 ++- 8 files changed, 242 insertions(+), 99 deletions(-) diff --git a/Libraries/Animated/__tests__/AnimatedNative-test.js b/Libraries/Animated/__tests__/AnimatedNative-test.js index 2f7b10af052629..c6d52e095f0dcf 100644 --- a/Libraries/Animated/__tests__/AnimatedNative-test.js +++ b/Libraries/Animated/__tests__/AnimatedNative-test.js @@ -625,6 +625,7 @@ describe('Native Animated', () => { type: 'interpolation', inputRange: [10, 20], outputRange: [0, 1], + outputType: null, extrapolateLeft: 'extend', extrapolateRight: 'extend', }, diff --git a/Libraries/Animated/nodes/AnimatedInterpolation.js b/Libraries/Animated/nodes/AnimatedInterpolation.js index dbc3433cbc4904..62909423180c9a 100644 --- a/Libraries/Animated/nodes/AnimatedInterpolation.js +++ b/Libraries/Animated/nodes/AnimatedInterpolation.js @@ -16,6 +16,7 @@ import type {PlatformConfig} from '../AnimatedPlatformConfig'; import type AnimatedNode from './AnimatedNode'; import normalizeColor from '../../StyleSheet/normalizeColor'; +import processColor from '../../StyleSheet/processColor'; import Easing from '../Easing'; import NativeAnimatedHelper from '../NativeAnimatedHelper'; import AnimatedWithChildren from './AnimatedWithChildren'; @@ -377,19 +378,31 @@ export default class AnimatedInterpolation< super.__detach(); } - __transformDataType(range: $ReadOnlyArray): Array { - return range.map(NativeAnimatedHelper.transformDataType); - } - __getNativeConfig(): any { if (__DEV__) { NativeAnimatedHelper.validateInterpolation(this._config); } + // Only the `outputRange` can contain strings so we don't need to transform `inputRange` here + let outputRange = this._config.outputRange; + let outputType = null; + if (typeof outputRange[0] === 'string') { + // $FlowIgnoreMe[incompatible-cast] + outputRange = ((outputRange: $ReadOnlyArray).map(value => { + const processedColor = processColor(value); + if (typeof processedColor === 'number') { + outputType = 'color'; + return processedColor; + } else { + return NativeAnimatedHelper.transformDataType(value); + } + }): any); + } + return { inputRange: this._config.inputRange, - // Only the `outputRange` can contain strings so we don't need to transform `inputRange` here - outputRange: this.__transformDataType(this._config.outputRange), + outputRange, + outputType, extrapolateLeft: this._config.extrapolateLeft || this._config.extrapolate || 'extend', extrapolateRight: diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/InterpolationAnimatedNode.java b/ReactAndroid/src/main/java/com/facebook/react/animated/InterpolationAnimatedNode.java index 5a738ae7fbe034..1f5c26de20de94 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/InterpolationAnimatedNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/InterpolationAnimatedNode.java @@ -8,11 +8,13 @@ package com.facebook.react.animated; import androidx.annotation.Nullable; +import androidx.core.graphics.ColorUtils; import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableType; import java.util.ArrayList; +import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -27,8 +29,10 @@ public static final String EXTRAPOLATE_TYPE_CLAMP = "clamp"; public static final String EXTRAPOLATE_TYPE_EXTEND = "extend"; - private static final String fpRegex = "[+-]?(\\d+\\.?\\d*|\\.\\d+)([eE][+-]?\\d+)?"; - private static final Pattern fpPattern = Pattern.compile(fpRegex); + private static final Pattern sNumericPattern = + Pattern.compile("[+-]?(\\d+\\.?\\d*|\\.\\d+)([eE][+-]?\\d+)?"); + + private static final String COLOR_OUTPUT_TYPE = "color"; private static double[] fromDoubleArray(ReadableArray ary) { double[] res = new double[ary.size()]; @@ -38,6 +42,44 @@ private static double[] fromDoubleArray(ReadableArray ary) { return res; } + private static int[] fromIntArray(ReadableArray ary) { + int[] res = new int[ary.size()]; + for (int i = 0; i < res.length; i++) { + res[i] = ary.getInt(i); + } + return res; + } + + private static double[][] fromStringPattern(ReadableArray array) { + int size = array.size(); + double[][] outputRange = new double[size][]; + + // Match the first pattern into a List, since we don't know its length yet + Matcher m = sNumericPattern.matcher(array.getString(0)); + List firstOutputRange = new ArrayList<>(); + while (m.find()) { + firstOutputRange.add(Double.parseDouble(m.group())); + } + double[] firstOutputRangeArr = new double[firstOutputRange.size()]; + for (int i = 0; i < firstOutputRange.size(); i++) { + firstOutputRangeArr[i] = firstOutputRange.get(i).doubleValue(); + } + outputRange[0] = firstOutputRangeArr; + + for (int i = 1; i < size; i++) { + double[] outputArr = new double[firstOutputRangeArr.length]; + + int j = 0; + m = sNumericPattern.matcher(array.getString(i)); + while (m.find() && j < firstOutputRangeArr.length) { + outputArr[j++] = Double.parseDouble(m.group()); + } + outputRange[i] = outputArr; + } + + return outputRange; + } + private static double interpolate( double value, double inputMin, @@ -110,6 +152,57 @@ private static double interpolate( extrapolateRight); } + /*package*/ static int interpolateColor(double value, double[] inputRange, int[] outputRange) { + int rangeIndex = findRangeIndex(value, inputRange); + int outputMin = outputRange[rangeIndex]; + int outputMax = outputRange[rangeIndex + 1]; + if (outputMin == outputMax) { + return outputMin; + } + + double inputMin = inputRange[rangeIndex]; + double inputMax = inputRange[rangeIndex + 1]; + if (inputMin == inputMax) { + if (value <= inputMin) { + return outputMin; + } + return outputMax; + } + + double ratio = (value - inputMin) / (inputMax - inputMin); + return ColorUtils.blendARGB(outputMin, outputMax, (float) ratio); + } + + /*package*/ static String interpolateString( + String pattern, + double value, + double[] inputRange, + double[][] outputRange, + String extrapolateLeft, + String extrapolateRight) { + int rangeIndex = findRangeIndex(value, inputRange); + StringBuffer sb = new StringBuffer(pattern.length()); + + Matcher m = sNumericPattern.matcher(pattern); + int i = 0; + while (m.find() && i < outputRange[rangeIndex].length) { + double val = + interpolate( + value, + inputRange[rangeIndex], + inputRange[rangeIndex + 1], + outputRange[rangeIndex][i], + outputRange[rangeIndex + 1][i], + extrapolateLeft, + extrapolateRight); + int intVal = (int) val; + m.appendReplacement(sb, intVal != val ? Double.toString(val) : Integer.toString(intVal)); + i++; + } + m.appendTail(sb); + return sb.toString(); + } + private static int findRangeIndex(double value, double[] ranges) { int index; for (index = 1; index < ranges.length - 1; index++) { @@ -120,70 +213,39 @@ private static int findRangeIndex(double value, double[] ranges) { return index - 1; } + private enum OutputType { + Number, + Color, + String, + } + private final double mInputRange[]; - private final double mOutputRange[]; - private String mPattern; - private double mOutputs[][]; - private final boolean mHasStringOutput; - private final Matcher mSOutputMatcher; + private final Object mOutputRange; + private final OutputType mOutputType; + private final @Nullable String mPattern; private final String mExtrapolateLeft; private final String mExtrapolateRight; private @Nullable ValueAnimatedNode mParent; - private boolean mShouldRound; - private int mNumVals; + private Object mObjectValue; public InterpolationAnimatedNode(ReadableMap config) { mInputRange = fromDoubleArray(config.getArray("inputRange")); ReadableArray output = config.getArray("outputRange"); - mHasStringOutput = output.getType(0) == ReadableType.String; - if (mHasStringOutput) { - /* - * Supports string shapes by extracting numbers so new values can be computed, - * and recombines those values into new strings of the same shape. Supports - * things like: - * - * rgba(123, 42, 99, 0.36) // colors - * -45deg // values with units - */ - int size = output.size(); - mOutputRange = new double[size]; - mPattern = output.getString(0); - mShouldRound = mPattern.startsWith("rgb"); - mSOutputMatcher = fpPattern.matcher(mPattern); - ArrayList> mOutputRanges = new ArrayList<>(); - for (int i = 0; i < size; i++) { - String val = output.getString(i); - Matcher m = fpPattern.matcher(val); - ArrayList outputRange = new ArrayList<>(); - mOutputRanges.add(outputRange); - while (m.find()) { - Double parsed = Double.parseDouble(m.group()); - outputRange.add(parsed); - } - mOutputRange[i] = outputRange.get(0); - } - // ['rgba(0, 100, 200, 0)', 'rgba(50, 150, 250, 0.5)'] - // -> - // [ - // [0, 50], - // [100, 150], - // [200, 250], - // [0, 0.5], - // ] - mNumVals = mOutputRanges.get(0).size(); - mOutputs = new double[mNumVals][]; - for (int j = 0; j < mNumVals; j++) { - double[] arr = new double[size]; - mOutputs[j] = arr; - for (int i = 0; i < size; i++) { - arr[i] = mOutputRanges.get(i).get(j); - } - } + if (COLOR_OUTPUT_TYPE.equals(config.getString("outputType"))) { + mOutputType = OutputType.Color; + mOutputRange = fromIntArray(output); + mPattern = null; + } else if (output.getType(0) == ReadableType.String) { + mOutputType = OutputType.String; + mOutputRange = fromStringPattern(output); + mPattern = output.getString(0); } else { + mOutputType = OutputType.Number; mOutputRange = fromDoubleArray(output); - mSOutputMatcher = null; + mPattern = null; } + mExtrapolateLeft = config.getString("extrapolateLeft"); mExtrapolateRight = config.getString("extrapolateRight"); } @@ -210,46 +272,39 @@ public void onDetachedFromNode(AnimatedNode parent) { @Override public void update() { if (mParent == null) { - // The graph is in the middle of being created, just skip this - // unattached node. + // The graph is in the middle of being created, just skip this unattached node. return; } + double value = mParent.getValue(); - mValue = interpolate(value, mInputRange, mOutputRange, mExtrapolateLeft, mExtrapolateRight); - if (mHasStringOutput) { - // 'rgba(0, 100, 200, 0)' - // -> - // 'rgba(${interpolations[0](input)}, ${interpolations[1](input)}, ...' - if (mNumVals > 1) { - StringBuffer sb = new StringBuffer(mPattern.length()); - int i = 0; - mSOutputMatcher.reset(); - while (mSOutputMatcher.find()) { - double val = - interpolate(value, mInputRange, mOutputs[i++], mExtrapolateLeft, mExtrapolateRight); - if (mShouldRound) { - // rgba requires that the r,g,b are integers.... so we want to round them, but we *dont* - // want to - // round the opacity (4th column). - boolean isAlpha = i == 4; - int rounded = (int) Math.round(isAlpha ? val * 1000 : val); - String num = - isAlpha ? Double.toString((double) rounded / 1000) : Integer.toString(rounded); - mSOutputMatcher.appendReplacement(sb, num); - } else { - int intVal = (int) val; - String num = intVal != val ? Double.toString(val) : Integer.toString(intVal); - mSOutputMatcher.appendReplacement(sb, num); - } - } - mSOutputMatcher.appendTail(sb); - mAnimatedObject = sb.toString(); - } else { - mAnimatedObject = mSOutputMatcher.replaceFirst(String.valueOf(mValue)); - } + switch (mOutputType) { + case Number: + mValue = + interpolate( + value, mInputRange, (double[]) mOutputRange, mExtrapolateLeft, mExtrapolateRight); + break; + case Color: + mObjectValue = Integer.valueOf(interpolateColor(value, mInputRange, (int[]) mOutputRange)); + break; + case String: + mObjectValue = + interpolateString( + mPattern, + value, + mInputRange, + (double[][]) mOutputRange, + mExtrapolateLeft, + mExtrapolateRight); + break; } } + @Override + public Object getAnimatedObject() { + return mObjectValue; + } + + @Override public String prettyPrint() { return "InterpolationAnimatedNode[" + mTag + "] super: " + super.prettyPrint(); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/PropsAnimatedNode.java b/ReactAndroid/src/main/java/com/facebook/react/animated/PropsAnimatedNode.java index aacf6bf1c3f76d..b70f62c3c508a2 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/PropsAnimatedNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/PropsAnimatedNode.java @@ -102,7 +102,9 @@ public final void updateView() { ((StyleAnimatedNode) node).collectViewUpdates(mPropMap); } else if (node instanceof ValueAnimatedNode) { Object animatedObject = ((ValueAnimatedNode) node).getAnimatedObject(); - if (animatedObject instanceof String) { + if (animatedObject instanceof Integer) { + mPropMap.putInt(entry.getKey(), (Integer) animatedObject); + } else if (animatedObject instanceof String) { mPropMap.putString(entry.getKey(), (String) animatedObject); } else { mPropMap.putDouble(entry.getKey(), ((ValueAnimatedNode) node).getValue()); diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/StyleAnimatedNode.java b/ReactAndroid/src/main/java/com/facebook/react/animated/StyleAnimatedNode.java index 68ef6a4186e967..dbb377a14bde8c 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/StyleAnimatedNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/StyleAnimatedNode.java @@ -42,7 +42,14 @@ public void collectViewUpdates(JavaOnlyMap propsMap) { } else if (node instanceof TransformAnimatedNode) { ((TransformAnimatedNode) node).collectViewUpdates(propsMap); } else if (node instanceof ValueAnimatedNode) { - propsMap.putDouble(entry.getKey(), ((ValueAnimatedNode) node).getValue()); + Object animatedObject = ((ValueAnimatedNode) node).getAnimatedObject(); + if (animatedObject instanceof Integer) { + propsMap.putInt(entry.getKey(), (Integer) animatedObject); + } else if (animatedObject instanceof String) { + propsMap.putString(entry.getKey(), (String) animatedObject); + } else { + propsMap.putDouble(entry.getKey(), ((ValueAnimatedNode) node).getValue()); + } } else if (node instanceof ColorAnimatedNode) { propsMap.putInt(entry.getKey(), ((ColorAnimatedNode) node).getColor()); } else { diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/ValueAnimatedNode.java b/ReactAndroid/src/main/java/com/facebook/react/animated/ValueAnimatedNode.java index ddc3df607509da..35b7b99d30befd 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/ValueAnimatedNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/ValueAnimatedNode.java @@ -15,7 +15,6 @@ * library. */ /*package*/ class ValueAnimatedNode extends AnimatedNode { - /*package*/ Object mAnimatedObject = null; /*package*/ double mValue = Double.NaN; /*package*/ double mOffset = 0; private @Nullable AnimatedNodeValueListener mValueListener; @@ -37,7 +36,7 @@ public double getValue() { } public Object getAnimatedObject() { - return mAnimatedObject; + return null; } public void flattenOffset() { diff --git a/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedInterpolationTest.java b/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedInterpolationTest.java index 4bf4cb3d332d4d..5fdb2e9bc72edf 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedInterpolationTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedInterpolationTest.java @@ -120,4 +120,42 @@ public void testIdentityExtrapolate() { InterpolationAnimatedNode.EXTRAPOLATE_TYPE_IDENTITY)) .isEqualTo(5); } + + @Test + public void testInterpolateColor() { + double[] input = new double[] {0, 1}; + int[] output = new int[] {0xFF000000, 0xFFFF0000}; + assertThat(InterpolationAnimatedNode.interpolateColor(0, input, output)).isEqualTo(0xFF000000); + assertThat(InterpolationAnimatedNode.interpolateColor(0.5, input, output)) + .isEqualTo(0xFF7F0000); + } + + @Test + public void testInterpolateString() { + double[] input = new double[] {0, 1}; + double[][] output = + new double[][] { + new double[] {20, 20, 20, 80, 80, 80, 80, 20}, + new double[] {40, 40, 33, 60, 60, 60, 65, 40}, + }; + String pattern = "M20,20L20,80L80,80L80,20Z"; + assertThat( + InterpolationAnimatedNode.interpolateString( + pattern, + 0, + input, + output, + InterpolationAnimatedNode.EXTRAPOLATE_TYPE_IDENTITY, + InterpolationAnimatedNode.EXTRAPOLATE_TYPE_IDENTITY)) + .isEqualTo("M20,20L20,80L80,80L80,20Z"); + assertThat( + InterpolationAnimatedNode.interpolateString( + pattern, + 0.5, + input, + output, + InterpolationAnimatedNode.EXTRAPOLATE_TYPE_IDENTITY, + InterpolationAnimatedNode.EXTRAPOLATE_TYPE_IDENTITY)) + .isEqualTo("M30,30L26.5,70L70,70L72.5,30Z"); + } } diff --git a/packages/rn-tester/js/examples/Animated/ColorStylesExample.js b/packages/rn-tester/js/examples/Animated/ColorStylesExample.js index 3da510586e46cc..e13fcd24be6cf9 100644 --- a/packages/rn-tester/js/examples/Animated/ColorStylesExample.js +++ b/packages/rn-tester/js/examples/Animated/ColorStylesExample.js @@ -37,6 +37,25 @@ function AnimatedView({useNativeDriver}: {useNativeDriver: boolean}) { }), ); + const animatedBaseValue = new Animated.Value(0); + const interpolationAnimatedStyle = { + backgroundColor: animatedBaseValue.interpolate({ + inputRange: [0, 1], + outputRange: ['blue', 'red'], + }), + borderColor: animatedBaseValue.interpolate({ + inputRange: [0, 1], + outputRange: ['orange', 'purple'], + }), + }; + animations.push( + Animated.timing(animatedBaseValue, { + toValue: 1, + duration: 1000, + useNativeDriver, + }), + ); + const animatedFirstSpanTextStyle = { color: new Animated.Color('blue'), }; @@ -81,7 +100,12 @@ function AnimatedView({useNativeDriver}: {useNativeDriver: boolean}) { }}> Press to animate - + + + + The quick @@ -121,6 +145,7 @@ const styles = StyleSheet.create({ height: 100, width: 100, borderWidth: 10, + marginRight: 10, }, animatedText: { fontSize: 20, @@ -130,6 +155,9 @@ const styles = StyleSheet.create({ height: 100, width: 100, }, + boxes: { + flexDirection: 'row', + }, }); export default ({