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() {