From 5e4a5892b998353e8e9fbfe6f3d37afd917ec25c Mon Sep 17 00:00:00 2001 From: Mikael Sand Date: Tue, 26 Mar 2019 09:23:12 -0700 Subject: [PATCH] Support Interpolation of strings when using native driver in Animated, fix Expected node to be marked as "native", optimize AnimatedNode creation and connections (#18187) Summary: Allow interpolation of strings with useNativeDriver. This allows animating much more of react-native-svg. This PR adds support for native animation of lengths with units, path data, colors etc. Plus, fixing the redundantly created nodes and (and thus, previously incorrect) connection of native animated nodes, improving performance. Docs will need to change, specifying that string interpolation works with the native driver as well. [GENERAL] [Added] Add support for native driven string interpolation in Animated [GENERAL] Fix exception: Expected node to be marked as "native" [GENERAL] Fix connection of AnimatedNodes and creation of redundant AnimatedNodes Pull Request resolved: https://github.com/facebook/react-native/pull/18187 Differential Revision: D14597147 Pulled By: cpojer fbshipit-source-id: 82a948a95419236be7931a8cc4ff72f41e477e9c --- .../Animated/src/NativeAnimatedHelper.js | 3 +- .../Animated/src/animations/Animation.js | 1 + .../src/nodes/AnimatedInterpolation.js | 9 +- Libraries/Animated/src/nodes/AnimatedNode.js | 8 +- Libraries/Animated/src/nodes/AnimatedProps.js | 1 + Libraries/Animated/src/nodes/AnimatedStyle.js | 4 +- .../src/nodes/AnimatedWithChildren.js | 9 ++ .../Nodes/RCTInterpolationAnimatedNode.m | 108 +++++++++++++++++- .../Nodes/RCTPropsAnimatedNode.m | 9 +- .../Nodes/RCTValueAnimatedNode.h | 1 + .../animated/InterpolationAnimatedNode.java | 93 ++++++++++++++- .../react/animated/PropsAnimatedNode.java | 7 +- .../react/animated/ValueAnimatedNode.java | 5 + 13 files changed, 240 insertions(+), 18 deletions(-) diff --git a/Libraries/Animated/src/NativeAnimatedHelper.js b/Libraries/Animated/src/NativeAnimatedHelper.js index 2ac39f024ab..307a0de9abe 100644 --- a/Libraries/Animated/src/NativeAnimatedHelper.js +++ b/Libraries/Animated/src/NativeAnimatedHelper.js @@ -271,8 +271,7 @@ function transformDataType(value: any): number { const radians = (degrees * Math.PI) / 180.0; return radians; } else { - // Assume radians - return parseFloat(value) || 0; + return value; } } diff --git a/Libraries/Animated/src/animations/Animation.js b/Libraries/Animated/src/animations/Animation.js index b34ac9555a1..5c35a1a90fd 100644 --- a/Libraries/Animated/src/animations/Animation.js +++ b/Libraries/Animated/src/animations/Animation.js @@ -57,6 +57,7 @@ class Animation { } __startNativeAnimation(animatedValue: AnimatedValue): void { animatedValue.__makeNative(); + animatedValue.__connectAnimatedNodes(); this.__nativeId = NativeAnimatedHelper.generateNewAnimationId(); NativeAnimatedHelper.API.startAnimatingNode( this.__nativeId, diff --git a/Libraries/Animated/src/nodes/AnimatedInterpolation.js b/Libraries/Animated/src/nodes/AnimatedInterpolation.js index 1ba304d331a..d42a4b89392 100644 --- a/Libraries/Animated/src/nodes/AnimatedInterpolation.js +++ b/Libraries/Animated/src/nodes/AnimatedInterpolation.js @@ -242,10 +242,11 @@ function createInterpolationFromStringOutputRange( // -> // 'rgba(${interpolations[0](input)}, ${interpolations[1](input)}, ...' return outputRange[0].replace(stringShapeRegex, () => { - const val = +interpolations[i++](input); - const rounded = - shouldRound && i < 4 ? Math.round(val) : Math.round(val * 1000) / 1000; - return String(rounded); + let val = +interpolations[i++](input); + if (shouldRound) { + val = i < 4 ? Math.round(val) : Math.round(val * 1000) / 1000; + } + return String(val); }); }; } diff --git a/Libraries/Animated/src/nodes/AnimatedNode.js b/Libraries/Animated/src/nodes/AnimatedNode.js index 1d670f18a15..5b1664bb8d8 100644 --- a/Libraries/Animated/src/nodes/AnimatedNode.js +++ b/Libraries/Animated/src/nodes/AnimatedNode.js @@ -35,12 +35,18 @@ class AnimatedNode { /* Methods and props used by native Animated impl */ __isNative: boolean; + __isConnected: boolean; __nativeTag: ?number; __makeNative() { if (!this.__isNative) { throw new Error('This node cannot be made a "native" animated node'); } } + __connectAnimatedNodes() { + if (!this.__isNative) { + throw new Error('This node cannot be connected natively'); + } + } __getNativeTag(): ?number { NativeAnimatedHelper.assertNativeAnimatedModule(); invariant( @@ -49,11 +55,11 @@ class AnimatedNode { ); if (this.__nativeTag == null) { const nativeTag: ?number = NativeAnimatedHelper.generateNewNodeTag(); + this.__nativeTag = nativeTag; NativeAnimatedHelper.API.createAnimatedNode( nativeTag, this.__getNativeConfig(), ); - this.__nativeTag = nativeTag; } return this.__nativeTag; } diff --git a/Libraries/Animated/src/nodes/AnimatedProps.js b/Libraries/Animated/src/nodes/AnimatedProps.js index be3dfd86d5b..462949bec57 100644 --- a/Libraries/Animated/src/nodes/AnimatedProps.js +++ b/Libraries/Animated/src/nodes/AnimatedProps.js @@ -151,6 +151,7 @@ class AnimatedProps extends AnimatedNode { for (const propKey in this._props) { const value = this._props[propKey]; if (value instanceof AnimatedNode) { + value.__makeNative(); propsConfig[propKey] = value.__getNativeTag(); } } diff --git a/Libraries/Animated/src/nodes/AnimatedStyle.js b/Libraries/Animated/src/nodes/AnimatedStyle.js index e1f3d332a22..3ab591d2912 100644 --- a/Libraries/Animated/src/nodes/AnimatedStyle.js +++ b/Libraries/Animated/src/nodes/AnimatedStyle.js @@ -108,7 +108,9 @@ class AnimatedStyle extends AnimatedWithChildren { const styleConfig = {}; for (const styleKey in this._style) { if (this._style[styleKey] instanceof AnimatedNode) { - styleConfig[styleKey] = this._style[styleKey].__getNativeTag(); + const style = this._style[styleKey]; + style.__makeNative(); + styleConfig[styleKey] = style.__getNativeTag(); } // Non-animated styles are set using `setNativeProps`, no need // to pass those as a part of the node config diff --git a/Libraries/Animated/src/nodes/AnimatedWithChildren.js b/Libraries/Animated/src/nodes/AnimatedWithChildren.js index 3940c4ae206..5c3c575cabb 100644 --- a/Libraries/Animated/src/nodes/AnimatedWithChildren.js +++ b/Libraries/Animated/src/nodes/AnimatedWithChildren.js @@ -25,6 +25,15 @@ class AnimatedWithChildren extends AnimatedNode { this.__isNative = true; for (const child of this._children) { child.__makeNative(); + } + } + } + + __connectAnimatedNodes() { + if (!this.__isConnected) { + this.__isConnected = true; + for (const child of this._children) { + child.__connectAnimatedNodes(); NativeAnimatedHelper.API.connectAnimatedNodes( this.__getNativeTag(), child.__getNativeTag(), diff --git a/Libraries/NativeAnimation/Nodes/RCTInterpolationAnimatedNode.m b/Libraries/NativeAnimation/Nodes/RCTInterpolationAnimatedNode.m index 0fbaf57d4b4..5fbac0716f0 100644 --- a/Libraries/NativeAnimation/Nodes/RCTInterpolationAnimatedNode.m +++ b/Libraries/NativeAnimation/Nodes/RCTInterpolationAnimatedNode.m @@ -9,27 +9,89 @@ #import "RCTAnimationUtils.h" +static NSRegularExpression *regex; + @implementation RCTInterpolationAnimatedNode { __weak RCTValueAnimatedNode *_parentNode; NSArray *_inputRange; NSArray *_outputRange; + NSArray *> *_outputs; + NSArray *_soutputRange; NSString *_extrapolateLeft; NSString *_extrapolateRight; + NSUInteger _numVals; + bool _hasStringOutput; + bool _shouldRound; + NSArray *_matches; } - (instancetype)initWithTag:(NSNumber *)tag config:(NSDictionary *)config { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + regex = [NSRegularExpression regularExpressionWithPattern:@"[0-9.-]+" options:NSRegularExpressionCaseInsensitive error:nil]; + }); if ((self = [super initWithTag:tag config:config])) { _inputRange = [config[@"inputRange"] copy]; NSMutableArray *outputRange = [NSMutableArray array]; + NSMutableArray *soutputRange = [NSMutableArray array]; + NSMutableArray *> *_outputRanges = [NSMutableArray array]; + + _hasStringOutput = NO; for (id value in config[@"outputRange"]) { if ([value isKindOfClass:[NSNumber class]]) { [outputRange addObject:value]; + } else if ([value isKindOfClass:[NSString class]]) { + /** + * 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 + */ + NSMutableArray *output = [NSMutableArray array]; + [_outputRanges addObject:output]; + [soutputRange addObject:value]; + + _matches = [regex matchesInString:value options:0 range:NSMakeRange(0, [value length])]; + for (NSTextCheckingResult *match in _matches) { + NSString* strNumber = [value substringWithRange:match.range]; + [output addObject:[NSNumber numberWithDouble:strNumber.doubleValue]]; + } + + _hasStringOutput = YES; + [outputRange addObject:[output objectAtIndex:0]]; } } + if (_hasStringOutput) { + // ['rgba(0, 100, 200, 0)', 'rgba(50, 150, 250, 0.5)'] + // -> + // [ + // [0, 50], + // [100, 150], + // [200, 250], + // [0, 0.5], + // ] + _numVals = [_matches count]; + NSString *value = [soutputRange objectAtIndex:0]; + _shouldRound = [value containsString:@"rgb"]; + _matches = [regex matchesInString:value options:0 range:NSMakeRange(0, [value length])]; + NSMutableArray *> *outputs = [NSMutableArray arrayWithCapacity:_numVals]; + NSUInteger size = [soutputRange count]; + for (NSUInteger j = 0; j < _numVals; j++) { + NSMutableArray *output = [NSMutableArray arrayWithCapacity:size]; + [outputs addObject:output]; + for (int i = 0; i < size; i++) { + [output addObject:[[_outputRanges objectAtIndex:i] objectAtIndex:j]]; + } + } + _outputs = [outputs copy]; + } _outputRange = [outputRange copy]; + _soutputRange = [soutputRange copy]; _extrapolateLeft = config[@"extrapolateLeft"]; _extrapolateRight = config[@"extrapolateRight"]; } @@ -61,11 +123,47 @@ CGFloat inputValue = _parentNode.value; - self.value = RCTInterpolateValueInRange(inputValue, - _inputRange, - _outputRange, - _extrapolateLeft, - _extrapolateRight); + CGFloat interpolated = RCTInterpolateValueInRange(inputValue, + _inputRange, + _outputRange, + _extrapolateLeft, + _extrapolateRight); + self.value = interpolated; + if (_hasStringOutput) { + // 'rgba(0, 100, 200, 0)' + // -> + // 'rgba(${interpolations[0](input)}, ${interpolations[1](input)}, ...' + if (_numVals > 1) { + NSString *text = _soutputRange[0]; + NSMutableString *formattedText = [NSMutableString stringWithString:text]; + NSUInteger i = _numVals; + for (NSTextCheckingResult *match in [_matches reverseObjectEnumerator]) { + CGFloat val = RCTInterpolateValueInRange(inputValue, + _inputRange, + _outputs[--i], + _extrapolateLeft, + _extrapolateRight); + NSString *str; + if (_shouldRound) { + // 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). + bool isAlpha = i == 3; + CGFloat rounded = isAlpha ? round(val * 1000) / 1000 : round(val); + str = isAlpha ? [NSString stringWithFormat:@"%1.3f", rounded] : [NSString stringWithFormat:@"%1.0f", rounded]; + } else { + str = [NSString stringWithFormat:@"%1f", val]; + } + + [formattedText replaceCharactersInRange:[match range] withString:str]; + } + self.animatedObject = formattedText; + } else { + self.animatedObject = [regex stringByReplacingMatchesInString:_soutputRange[0] + options:0 + range:NSMakeRange(0, _soutputRange[0].length) + withTemplate:[NSString stringWithFormat:@"%1f", interpolated]]; + } + } } @end diff --git a/Libraries/NativeAnimation/Nodes/RCTPropsAnimatedNode.m b/Libraries/NativeAnimation/Nodes/RCTPropsAnimatedNode.m index 1b8e3e16f58..07a36cefe53 100644 --- a/Libraries/NativeAnimation/Nodes/RCTPropsAnimatedNode.m +++ b/Libraries/NativeAnimation/Nodes/RCTPropsAnimatedNode.m @@ -110,8 +110,13 @@ } else if ([parentNode isKindOfClass:[RCTValueAnimatedNode class]]) { NSString *property = [self propertyNameForParentTag:parentTag]; - CGFloat value = [(RCTValueAnimatedNode *)parentNode value]; - self->_propsDictionary[property] = @(value); + id animatedObject = [(RCTValueAnimatedNode *)parentNode animatedObject]; + if (animatedObject) { + self->_propsDictionary[property] = animatedObject; + } else { + CGFloat value = [(RCTValueAnimatedNode *)parentNode value]; + self->_propsDictionary[property] = @(value); + } } } diff --git a/Libraries/NativeAnimation/Nodes/RCTValueAnimatedNode.h b/Libraries/NativeAnimation/Nodes/RCTValueAnimatedNode.h index 53b5da3803e..c46d392caaa 100644 --- a/Libraries/NativeAnimation/Nodes/RCTValueAnimatedNode.h +++ b/Libraries/NativeAnimation/Nodes/RCTValueAnimatedNode.h @@ -24,6 +24,7 @@ - (void)extractOffset; @property (nonatomic, assign) CGFloat value; +@property (nonatomic, strong) id animatedObject; @property (nonatomic, weak) id valueObserver; @end 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 0b91c37814b..2f7f3616b45 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/InterpolationAnimatedNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/InterpolationAnimatedNode.java @@ -9,6 +9,12 @@ package com.facebook.react.animated; 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.regex.Matcher; +import java.util.regex.Pattern; + import javax.annotation.Nullable; /** @@ -21,6 +27,7 @@ import javax.annotation.Nullable; public static final String EXTRAPOLATE_TYPE_IDENTITY = "identity"; public static final String EXTRAPOLATE_TYPE_CLAMP = "clamp"; public static final String EXTRAPOLATE_TYPE_EXTEND = "extend"; + static final Pattern regex = Pattern.compile("[0-9.-]+"); private static double[] fromDoubleArray(ReadableArray ary) { double[] res = new double[ary.size()]; @@ -105,13 +112,68 @@ import javax.annotation.Nullable; private final double mInputRange[]; private final double mOutputRange[]; + private String mPattern; + private double mOutputs[][]; + private final boolean mHasStringOutput; + private final Matcher mSOutputMatcher; private final String mExtrapolateLeft; private final String mExtrapolateRight; private @Nullable ValueAnimatedNode mParent; + private boolean mShouldRound; + private int mNumVals; public InterpolationAnimatedNode(ReadableMap config) { mInputRange = fromDoubleArray(config.getArray("inputRange")); - mOutputRange = fromDoubleArray(config.getArray("outputRange")); + 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 = regex.matcher(mPattern); + ArrayList> mOutputRanges = new ArrayList<>(); + for (int i = 0; i < size; i++) { + String val = output.getString(i); + Matcher m = regex.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); + } + } + } else { + mOutputRange = fromDoubleArray(output); + mSOutputMatcher = null; + } mExtrapolateLeft = config.getString("extrapolateLeft"); mExtrapolateRight = config.getString("extrapolateRight"); } @@ -142,6 +204,33 @@ import javax.annotation.Nullable; // unattached node. return; } - mValue = interpolate(mParent.getValue(), mInputRange, mOutputRange, mExtrapolateLeft, mExtrapolateRight); + 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); + mSOutputMatcher.appendReplacement(sb, isAlpha ? String.valueOf((double)rounded / 1000) : String.valueOf(rounded)); + } else { + mSOutputMatcher.appendReplacement(sb, String.valueOf(val)); + } + } + mSOutputMatcher.appendTail(sb); + mAnimatedObject = sb.toString(); + } else { + mAnimatedObject = mSOutputMatcher.replaceFirst(String.valueOf(mValue)); + } + } } } 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 250eb21dbc0..7bb7eaaeba2 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/PropsAnimatedNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/PropsAnimatedNode.java @@ -84,7 +84,12 @@ import javax.annotation.Nullable; } else if (node instanceof StyleAnimatedNode) { ((StyleAnimatedNode) node).collectViewUpdates(mPropMap); } else if (node instanceof ValueAnimatedNode) { - mPropMap.putDouble(entry.getKey(), ((ValueAnimatedNode) node).getValue()); + Object animatedObject = ((ValueAnimatedNode) node).getAnimatedObject(); + if (animatedObject instanceof String) { + mPropMap.putString(entry.getKey(), (String)animatedObject); + } else { + mPropMap.putDouble(entry.getKey(), ((ValueAnimatedNode) node).getValue()); + } } else { throw new IllegalArgumentException("Unsupported type of node used in property node " + node.getClass()); 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 49426c77ebb..0c92f11f899 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/ValueAnimatedNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/ValueAnimatedNode.java @@ -16,6 +16,7 @@ import javax.annotation.Nullable; * library. */ /*package*/ class ValueAnimatedNode extends AnimatedNode { + /*package*/ Object mAnimatedObject = null; /*package*/ double mValue = Double.NaN; /*package*/ double mOffset = 0; private @Nullable AnimatedNodeValueListener mValueListener; @@ -33,6 +34,10 @@ import javax.annotation.Nullable; return mOffset + mValue; } + public Object getAnimatedObject() { + return mAnimatedObject; + } + public void flattenOffset() { mValue += mOffset; mOffset = 0;