From 48dae7fee01be39d1858c2f97228b866e6cf14ef Mon Sep 17 00:00:00 2001 From: Krzysztof Magiera Date: Fri, 16 Feb 2018 11:43:34 -0800 Subject: [PATCH] Support for animated tracking in native driver Summary: This PR adds support for Animated tracking to Animated Native Driver implementation on Android and iOS. Animated tracking allows for animation to be started with a "dynamic" end value. Instead of passing a fixed number as end value we can pass a reference to another Animated.Value. Then when that value changes, the animation will be reconfigured to drive the animation to the new destination point. What is important is that animation will keep its state in the process of updating "toValue". That is if it is a spring animation and the end value changes while the previous animation still hasn't settled the new animation will start from the current position and will inherit current velocity. This makes end value transitions very smooth. Animated tracking is available in JS implementation of Animated library but not in the native implementation. Therefore until now, it wasn't possible to utilize native driver when using animated tracking. Offloading animation from JS thread turns out to be crucial for gesture driven animations. This PR is a step forward towards feature parity between JS and native implementations of Animated. Here is a link to example video that shows how tracking can be used to implement chat heads effect: https://twitter.com/kzzzf/status/958362032650244101 In addition this PR fixes an issue with frames animation driver on Android that because of rounding issues was taking one extra frame to start. Because of that change I had to update a number of Android unit tests that were relying on that behavior and running that one additional animation step prior to performing checks. As a part of this PR I'm adding three unit tests for each of the platforms that verifies most important aspects of this implementation. Please refer to the code and look at the test cases top level comments to learn what they do. I'm also adding a section to "Native Animated Example" screen in RNTester app that provides a test case for tracking. In the example we have blue square that fallows the red line drawn on screen. Line uses Animated.Value for it's position while square is connected via tracking spring animation to that value. So it is ought to follow the line. When user taps in the area surrounding the button new position for the red line is selected at random and the value updates. Then we can watch blue screen animate to that position. You can also refer to this video that I use to demonstrate how tracking can be linked with native gesture events using react-native-gesture-handler lib: https://twitter.com/kzzzf/status/958362032650244101 [GENERAL][FEATURE][Native Animated] - Added support for animated tracking to native driver. Now you can use `useNativeDriver` flag with animations that track other Animated.Values Closes https://github.com/facebook/react-native/pull/17896 Differential Revision: D6974170 Pulled By: hramos fbshipit-source-id: 50e918b36ee10f80c1deb866c955661d4cc2619b --- .../RCTNativeAnimatedNodesManagerTests.m | 223 ++++++++++++++++++ js/NativeAnimationsExample.js | 75 ++++++ 2 files changed, 298 insertions(+) diff --git a/RNTesterUnitTests/RCTNativeAnimatedNodesManagerTests.m b/RNTesterUnitTests/RCTNativeAnimatedNodesManagerTests.m index 70678ac28..358264899 100644 --- a/RNTesterUnitTests/RCTNativeAnimatedNodesManagerTests.m +++ b/RNTesterUnitTests/RCTNativeAnimatedNodesManagerTests.m @@ -865,4 +865,227 @@ - (void)testNativeAnimatedEventDoNotUpdate [_uiManager verify]; } +/** + * Creates a following graph of nodes: + * Value(3, initialValue) ----> Style(4) ---> Props(5) ---> View(viewTag) + * + * Value(3) is set to track Value(1) via Tracking(2) node with the provided animation config + */ +- (void)createAnimatedGraphWithTrackingNode:(NSNumber *)viewTag + initialValue:(CGFloat)initialValue + animationConfig:(NSDictionary *)animationConfig +{ + [_nodesManager createAnimatedNode:@1 + config:@{@"type": @"value", @"value": @(initialValue), @"offset": @0}]; + [_nodesManager createAnimatedNode:@3 + config:@{@"type": @"value", @"value": @(initialValue), @"offset": @0}]; + + [_nodesManager createAnimatedNode:@2 + config:@{@"type": @"tracking", + @"animationId": @70, + @"value": @3, + @"toValue": @1, + @"animationConfig": animationConfig}]; + [_nodesManager createAnimatedNode:@4 + config:@{@"type": @"style", @"style": @{@"translateX": @3}}]; + [_nodesManager createAnimatedNode:@5 + config:@{@"type": @"props", @"props": @{@"style": @4}}]; + + [_nodesManager connectAnimatedNodes:@1 childTag:@2]; + [_nodesManager connectAnimatedNodes:@3 childTag:@4]; + [_nodesManager connectAnimatedNodes:@4 childTag:@5]; + [_nodesManager connectAnimatedNodeToView:@5 viewTag:viewTag viewName:@"UIView"]; +} + +/** + * In this test we verify that when value is being tracked we can update destination value in the + * middle of ongoing animation and the animation will update and animate to the new spot. This is + * tested using simple 5 frame backed timing animation. + */ +- (void)testTracking +{ + NSArray *frames = @[@0, @0.25, @0.5, @0.75, @1]; + NSDictionary *animationConfig = @{@"type": @"frames", @"frames": frames}; + [self createAnimatedGraphWithTrackingNode:@1000 initialValue:0 animationConfig:animationConfig]; + [_nodesManager stepAnimations:_displayLink]; // kick off the tracking + + [[_uiManager expect] synchronouslyUpdateViewOnUIThread:@1000 + viewName:@"UIView" + props:RCTPropChecker(@"translateX", 0)]; + [_nodesManager stepAnimations:_displayLink]; + [_uiManager verify]; + + // update "toValue" to 100, we expect tracking animation to animate now from 0 to 100 in 5 steps + [_nodesManager setAnimatedNodeValue:@1 value:@100]; + [_nodesManager stepAnimations:_displayLink]; // kick off the tracking + + for (NSNumber *frame in frames) { + NSNumber *expected = @([frame doubleValue] * 100); + [[_uiManager expect] synchronouslyUpdateViewOnUIThread:@1000 + viewName:@"UIView" + props:RCTPropChecker(@"translateX", expected)]; + [_nodesManager stepAnimations:_displayLink]; + [_uiManager verify]; + } + + // update "toValue" to 0 but run only two frames from the animation, + // we expect tracking animation to animate now from 100 to 75 + [_nodesManager setAnimatedNodeValue:@1 value:@0]; + [_nodesManager stepAnimations:_displayLink]; // kick off the tracking + + for (int i = 0; i < 2; i++) { + NSNumber *expected = @(100. * (1. - [frames[i] doubleValue])); + [[_uiManager expect] synchronouslyUpdateViewOnUIThread:@1000 + viewName:@"UIView" + props:RCTPropChecker(@"translateX", expected)]; + [_nodesManager stepAnimations:_displayLink]; + [_uiManager verify]; + } + + // at this point we expect tracking value to be at 75 + // we update "toValue" again to 100 and expect the animation to restart from the current place + [_nodesManager setAnimatedNodeValue:@1 value:@100]; + [_nodesManager stepAnimations:_displayLink]; // kick off the tracking + + for (NSNumber *frame in frames) { + NSNumber *expected = @(50. + 50. * [frame doubleValue]); + [[_uiManager expect] synchronouslyUpdateViewOnUIThread:@1000 + viewName:@"UIView" + props:RCTPropChecker(@"translateX", expected)]; + [_nodesManager stepAnimations:_displayLink]; + [_uiManager verify]; + } + + [_nodesManager stepAnimations:_displayLink]; + [[_uiManager reject] synchronouslyUpdateViewOnUIThread:OCMOCK_ANY viewName:OCMOCK_ANY props:OCMOCK_ANY]; + [_nodesManager stepAnimations:_displayLink]; + [_uiManager verify]; +} + +/** + * In this test we verify that when tracking is set up for a given animated node and when the + * animation settles it will not be registered as an active animation and therefore will not + * consume resources on running the animation that has already completed. Then we verify that when + * the value updates the animation will resume as expected and the complete again when reaches the + * end. + */ + + - (void)testTrackingPausesWhenEndValueIsReached +{ + NSArray *frames = @[@0, @0.5, @1]; + NSDictionary *animationConfig = @{@"type": @"frames", @"frames": frames}; + [self createAnimatedGraphWithTrackingNode:@1000 initialValue:0 animationConfig:animationConfig]; + + [_nodesManager setAnimatedNodeValue:@1 value:@100]; + [_nodesManager stepAnimations:_displayLink]; // kick off the tracking + + __block int callCount = 0; + [[[_uiManager stub] andDo:^(NSInvocation* __unused invocation) { + callCount++; + }] synchronouslyUpdateViewOnUIThread:OCMOCK_ANY viewName:OCMOCK_ANY props:OCMOCK_ANY]; + + for (NSUInteger i = 0; i < frames.count; i++) { + [_nodesManager stepAnimations:_displayLink]; + } + [_nodesManager stepAnimations:_displayLink]; + XCTAssertEqual(callCount, 4); + + // the animation has completed, we expect no updates to be done + [[[_uiManager stub] andDo:^(NSInvocation* __unused invocation) { + XCTFail("Expected not to be called"); + }] synchronouslyUpdateViewOnUIThread:OCMOCK_ANY viewName:OCMOCK_ANY props:OCMOCK_ANY]; + [_nodesManager stepAnimations:_displayLink]; + [_uiManager verify]; + + // restore rejected method, we will use it later on + callCount = 0; + [[[_uiManager stub] andDo:^(NSInvocation* __unused invocation) { + callCount++; + }] synchronouslyUpdateViewOnUIThread:OCMOCK_ANY viewName:OCMOCK_ANY props:OCMOCK_ANY]; + + // we update end value and expect the animation to restart + [_nodesManager setAnimatedNodeValue:@1 value:@200]; + [_nodesManager stepAnimations:_displayLink]; // kick off the tracking + + for (NSUInteger i = 0; i < frames.count; i++) { + [_nodesManager stepAnimations:_displayLink]; + } + [_nodesManager stepAnimations:_displayLink]; + XCTAssertEqual(callCount, 4); + + // the animation has completed, we expect no updates to be done + [[_uiManager reject] synchronouslyUpdateViewOnUIThread:OCMOCK_ANY viewName:OCMOCK_ANY props:OCMOCK_ANY]; + [_nodesManager stepAnimations:_displayLink]; + [_uiManager verify]; +} + +/** + * In this test we verify that when tracking is configured to use spring animation and when the + * destination value updates the current speed of the animated value will be taken into account + * while updating the spring animation and it will smoothly transition to the new end value. + */ +- (void) testSpringTrackingRetainsSpeed +{ + // this spring config correspomds to tension 20 and friction 0.5 which makes the spring settle + // very slowly + NSDictionary *springConfig = @{@"type": @"spring", + @"restSpeedThreshold": @0.001, + @"mass": @1, + @"restDisplacementThreshold": @0.001, + @"initialVelocity": @0.5, + @"damping": @2.5, + @"stiffness": @157.8, + @"overshootClamping": @NO}; + [self createAnimatedGraphWithTrackingNode:@1000 initialValue:0 animationConfig:springConfig]; + + __block CGFloat lastTranslateX = 0; + [[[_uiManager stub] andDo:^(NSInvocation *invocation) { + __unsafe_unretained NSDictionary *props = nil; + [invocation getArgument:&props atIndex:4]; + lastTranslateX = [props[@"translateX"] doubleValue]; + }] synchronouslyUpdateViewOnUIThread:OCMOCK_ANY viewName:OCMOCK_ANY props:OCMOCK_ANY]; + + // update "toValue" to 1, we expect tracking animation to animate now from 0 to 1 + [_nodesManager setAnimatedNodeValue:@1 value:@1]; + [_nodesManager stepAnimations:_displayLink]; // kick off the tracking + + // we run several steps of animation until the value starts bouncing, has negative speed and + // passes the final point (that is 1) while going backwards + BOOL isBoucingBack = NO; + CGFloat previousValue = 0; + for (int maxFrames = 500; maxFrames > 0; maxFrames--) { + [_nodesManager stepAnimations:_displayLink]; // kick off the tracking + if (previousValue >= 1. && lastTranslateX < 1.) { + isBoucingBack = YES; + break; + } + previousValue = lastTranslateX; + } + XCTAssert(isBoucingBack); + + // we now update "toValue" to 1.5 but since the value have negative speed and has also pretty + // low friction we expect it to keep going in the opposite direction for a few more frames + [_nodesManager setAnimatedNodeValue:@1 value:@1.5]; + [_nodesManager stepAnimations:_displayLink]; // kick off the tracking + + int bounceBackInitialFrames = 0; + BOOL hasTurnedForward = NO; + + // we run 8 seconds of animation + for (int i = 0; i < 8 * 60; i++) { + [_nodesManager stepAnimations:_displayLink]; + if (!hasTurnedForward) { + if (lastTranslateX <= previousValue) { + bounceBackInitialFrames++; + } else { + hasTurnedForward = true; + } + } + previousValue = lastTranslateX; + } + XCTAssert(hasTurnedForward); + XCTAssertGreaterThan(bounceBackInitialFrames, 3); + XCTAssertEqual(lastTranslateX, 1.5); +} + @end diff --git a/js/NativeAnimationsExample.js b/js/NativeAnimationsExample.js index ed6f1acaf..4aaf2063d 100644 --- a/js/NativeAnimationsExample.js +++ b/js/NativeAnimationsExample.js @@ -255,6 +255,67 @@ class EventExample extends React.Component<{}, $FlowFixMeState> { } } +class TrackingExample extends React.Component<$FlowFixMeProps, $FlowFixMeState> { + state = { + native: new Animated.Value(0), + toNative: new Animated.Value(0), + js: new Animated.Value(0), + toJS: new Animated.Value(0), + }; + + componentDidMount() { + // we configure spring to take a bit of time to settle so that the user + // have time to click many times and see "toValue" getting updated and + const longSettlingSpring = { + tension: 20, + friction: 0.5, + }; + Animated.spring(this.state.native, { + ...longSettlingSpring, + toValue: this.state.toNative, + useNativeDriver: true, + }).start(); + Animated.spring(this.state.js, { + ...longSettlingSpring, + toValue: this.state.toJS, + useNativeDriver: false, + }).start(); + } + + onPress = () => { + // select next value to be tracked by random + const nextValue = Math.random() * 200; + this.state.toNative.setValue(nextValue); + this.state.toJS.setValue(nextValue); + }; + + renderBlock = (anim, dest) => [ + , + , + ] + + render() { + return ( + + + + Native: + + + {this.renderBlock(this.state.native, this.state.toNative)} + + + JavaScript: + + + {this.renderBlock(this.state.js, this.state.toJS)} + + + + ); + } +} + const styles = StyleSheet.create({ row: { padding: 10, @@ -265,6 +326,14 @@ const styles = StyleSheet.create({ height: 50, backgroundColor: 'blue', }, + line: { + position: 'absolute', + left: 35, + top: 0, + bottom: 0, + width: 1, + backgroundColor: 'red', + }, }); exports.framework = 'React'; @@ -540,6 +609,12 @@ exports.examples = [ return ; }, }, + { + title: 'Animated Tracking - tap me many times', + render: function() { + return ; + }, + }, { title: 'Internal Settings', render: function() {