diff --git a/Libraries/Animated/src/NativeAnimatedHelper.js b/Libraries/Animated/src/NativeAnimatedHelper.js index 2ac39f024ab63a..ff25208814bbd3 100644 --- a/Libraries/Animated/src/NativeAnimatedHelper.js +++ b/Libraries/Animated/src/NativeAnimatedHelper.js @@ -29,11 +29,25 @@ type EventMapping = { let nativeEventEmitter; +let queueConnections = false; +let queue = []; + /** * Simple wrappers around NativeAnimatedModule to provide flow and autocmplete support for * the native module methods */ const API = { + enableQueue: function(): void { + queueConnections = true; + }, + disableQueue: function(): void { + queueConnections = false; + for (let q = 0, l = queue.length; q < l; q++) { + const args = queue[q]; + NativeAnimatedModule.connectAnimatedNodes(args[0], args[1]); + } + queue.length = 0; + }, createAnimatedNode: function(tag: ?number, config: Object): void { assertNativeAnimatedModule(); NativeAnimatedModule.createAnimatedNode(tag, config); @@ -48,6 +62,10 @@ const API = { }, connectAnimatedNodes: function(parentTag: ?number, childTag: ?number): void { assertNativeAnimatedModule(); + if (queueConnections) { + queue.push([parentTag, childTag]); + return; + } NativeAnimatedModule.connectAnimatedNodes(parentTag, childTag); }, disconnectAnimatedNodes: function( @@ -271,8 +289,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 b34ac9555a1eca..826cf61b84738e 100644 --- a/Libraries/Animated/src/animations/Animation.js +++ b/Libraries/Animated/src/animations/Animation.js @@ -56,7 +56,9 @@ class Animation { onEnd && onEnd(result); } __startNativeAnimation(animatedValue: AnimatedValue): void { + NativeAnimatedHelper.API.enableQueue(); animatedValue.__makeNative(); + NativeAnimatedHelper.API.disableQueue(); 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 1ba304d331ad5f..8a5b6922c10ea3 100644 --- a/Libraries/Animated/src/nodes/AnimatedInterpolation.js +++ b/Libraries/Animated/src/nodes/AnimatedInterpolation.js @@ -181,7 +181,7 @@ function colorToRgba(input: string): string { return `rgba(${r}, ${g}, ${b}, ${a})`; } -const stringShapeRegex = /[0-9\.-]+/g; +const stringShapeRegex = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g; /** * Supports string shapes by extracting numbers so new values can be computed, @@ -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 1d670f18a159c9..3a655f6ea69070 100644 --- a/Libraries/Animated/src/nodes/AnimatedNode.js +++ b/Libraries/Animated/src/nodes/AnimatedNode.js @@ -49,11 +49,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 be3dfd86d5b4f2..462949bec57fbc 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 e1f3d332a2280f..3ab591d2912b99 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/NativeAnimation/Nodes/RCTInterpolationAnimatedNode.m b/Libraries/NativeAnimation/Nodes/RCTInterpolationAnimatedNode.m index 0fbaf57d4b4ac4..1fdadc263d538e 100644 --- a/Libraries/NativeAnimation/Nodes/RCTInterpolationAnimatedNode.m +++ b/Libraries/NativeAnimation/Nodes/RCTInterpolationAnimatedNode.m @@ -9,27 +9,90 @@ #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, ^{ + NSString *fpRegex = @"[+-]?(\\d+\\.?\\d*|\\.\\d+)([eE][+-]?\\d+)?"; + regex = [NSRegularExpression regularExpressionWithPattern:fpRegex 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 +124,48 @@ - (void)performUpdate 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 { + NSNumber *numberValue = [NSNumber numberWithDouble:val]; + str = [numberValue stringValue]; + } + + [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 1b8e3e16f588a0..07a36cefe53d6d 100644 --- a/Libraries/NativeAnimation/Nodes/RCTPropsAnimatedNode.m +++ b/Libraries/NativeAnimation/Nodes/RCTPropsAnimatedNode.m @@ -110,8 +110,13 @@ - (void)performUpdate } 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 53b5da3803e51f..c46d392caaac79 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/RNTester/js/RNTesterList.android.js b/RNTester/js/RNTesterList.android.js index 9946ad0df62428..ee258eb9bb953b 100644 --- a/RNTester/js/RNTesterList.android.js +++ b/RNTester/js/RNTesterList.android.js @@ -59,6 +59,10 @@ const ComponentExamples: Array = [ key: 'ScrollViewSimpleExample', module: require('./ScrollViewSimpleExample'), }, + { + key: 'ScrollViewAnimatedExample', + module: require('./ScrollViewAnimatedExample'), + }, { key: 'SectionListExample', module: require('./SectionListExample'), diff --git a/RNTester/js/RNTesterList.ios.js b/RNTester/js/RNTesterList.ios.js index 0a5557727c5c47..0b3096b2e769df 100644 --- a/RNTester/js/RNTesterList.ios.js +++ b/RNTester/js/RNTesterList.ios.js @@ -98,6 +98,11 @@ const ComponentExamples: Array = [ module: require('./ScrollViewSimpleExample'), supportsTVOS: true, }, + { + key: 'ScrollViewAnimatedExample', + module: require('./ScrollViewAnimatedExample'), + supportsTVOS: true, + }, { key: 'SafeAreaViewExample', module: require('./SafeAreaViewExample'), diff --git a/RNTester/js/ScrollViewAnimatedExample.js b/RNTester/js/ScrollViewAnimatedExample.js new file mode 100644 index 00000000000000..d69e91ded20659 --- /dev/null +++ b/RNTester/js/ScrollViewAnimatedExample.js @@ -0,0 +1,103 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow + */ + +'use strict'; + +const React = require('react'); +const ReactNative = require('react-native'); +const {Component} = React; +const { + StyleSheet, + Text, + View, + Animated, + Easing, + TouchableOpacity, + Dimensions, +} = ReactNative; + +class ScrollViewAnimatedExample extends Component<{}> { + _scrollViewPos = new Animated.Value(0); + + startAnimation = () => { + this._scrollViewPos.setValue(0); + Animated.timing(this._scrollViewPos, { + toValue: 100, + duration: 10000, + easing: Easing.linear, + useNativeDriver: true, + }).start(); + }; + + render() { + const interpolated = this._scrollViewPos.interpolate({ + inputRange: [0, 1], + outputRange: [0, 0.1], + }); + const interpolated2 = this._scrollViewPos.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '1deg'], + }); + return ( + + + + + + Scroll me horizontally + + + + + ); + } +} + +const {width, height} = Dimensions.get('window'); + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#F5FCFF', + }, + button: { + margin: 50, + width: width, + marginRight: width, + height: height / 2, + }, +}); + +exports.title = ''; +exports.description = 'Component that is animated when ScrollView is offset.'; + +exports.examples = [ + { + title: 'Animated by scroll view', + render: function(): React.Element { + return ; + }, + }, +]; 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 0b91c37814b56b..0a6733dd93adad 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 @@ 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; /** @@ -22,6 +28,9 @@ 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 double[] fromDoubleArray(ReadableArray ary) { double[] res = new double[ary.size()]; for (int i = 0; i < res.length; i++) { @@ -105,13 +114,68 @@ private static int findRangeIndex(double value, double[] ranges) { 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 = 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); + } + } + } else { + mOutputRange = fromDoubleArray(output); + mSOutputMatcher = null; + } mExtrapolateLeft = config.getString("extrapolateLeft"); mExtrapolateRight = config.getString("extrapolateRight"); } @@ -142,6 +206,36 @@ public void update() { // 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); + 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)); + } + } } } 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 250eb21dbc0d9a..7bb7eaaeba2cd9 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 @@ public final void updateView() { } 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 49426c77ebb237..0c92f11f899362 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 @@ * 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 @@ public double getValue() { return mOffset + mValue; } + public Object getAnimatedObject() { + return mAnimatedObject; + } + public void flattenOffset() { mValue += mOffset; mOffset = 0;