From 8320587e5e8518ffcb7cef1df1d4967a894fe05f 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 --- .../Animated/src/nodes/AnimatedTracking.js | 37 +++ Libraries/Animated/src/nodes/AnimatedValue.js | 5 +- .../Drivers/RCTAnimationDriver.h | 1 + .../Drivers/RCTDecayAnimation.m | 23 +- .../Drivers/RCTFrameAnimation.m | 29 +- .../Drivers/RCTSpringAnimation.m | 44 +-- .../NativeAnimation/Nodes/RCTAnimatedNode.h | 3 + .../Nodes/RCTTrackingAnimatedNode.h | 15 + .../Nodes/RCTTrackingAnimatedNode.m | 54 ++++ .../RCTAnimation.xcodeproj/project.pbxproj | 12 + .../RCTNativeAnimatedNodesManager.m | 14 +- .../RCTNativeAnimatedNodesManagerTests.m | 223 ++++++++++++++ RNTester/js/NativeAnimationsExample.js | 75 +++++ .../react/animated/AnimationDriver.java | 13 + .../react/animated/DecayAnimation.java | 18 +- .../animated/FrameBasedAnimationDriver.java | 20 +- .../animated/NativeAnimatedNodesManager.java | 39 ++- .../react/animated/SpringAnimation.java | 16 +- .../react/animated/TrackingAnimatedNode.java | 37 +++ .../facebook/react/bridge/JavaOnlyArray.java | 32 +- .../facebook/react/bridge/JavaOnlyMap.java | 34 ++- .../NativeAnimatedNodeTraversalTest.java | 276 ++++++++++++++---- 22 files changed, 899 insertions(+), 121 deletions(-) create mode 100644 Libraries/NativeAnimation/Nodes/RCTTrackingAnimatedNode.h create mode 100644 Libraries/NativeAnimation/Nodes/RCTTrackingAnimatedNode.m create mode 100644 ReactAndroid/src/main/java/com/facebook/react/animated/TrackingAnimatedNode.java diff --git a/Libraries/Animated/src/nodes/AnimatedTracking.js b/Libraries/Animated/src/nodes/AnimatedTracking.js index 1a54f78abb5a23..0ec517d12bd560 100644 --- a/Libraries/Animated/src/nodes/AnimatedTracking.js +++ b/Libraries/Animated/src/nodes/AnimatedTracking.js @@ -14,6 +14,10 @@ const AnimatedValue = require('./AnimatedValue'); const AnimatedNode = require('./AnimatedNode'); +const { + generateNewAnimationId, + shouldUseNativeDriver, +} = require('../NativeAnimatedHelper'); import type {EndCallback} from '../animations/Animation'; @@ -23,6 +27,7 @@ class AnimatedTracking extends AnimatedNode { _callback: ?EndCallback; _animationConfig: Object; _animationClass: any; + _useNativeDriver: boolean; constructor( value: AnimatedValue, @@ -36,16 +41,32 @@ class AnimatedTracking extends AnimatedNode { this._parent = parent; this._animationClass = animationClass; this._animationConfig = animationConfig; + this._useNativeDriver = shouldUseNativeDriver(animationConfig); this._callback = callback; this.__attach(); } + __makeNative() { + this.__isNative = true; + this._parent.__makeNative(); + super.__makeNative(); + this._value.__makeNative(); + } + __getValue(): Object { return this._parent.__getValue(); } __attach(): void { this._parent.__addChild(this); + if (this._useNativeDriver) { + // when the tracking starts we need to convert this node to a "native node" + // so that the parent node will be made "native" too. This is necessary as + // if we don't do this `update` method will get called. At that point it + // may be too late as it would mean the JS driver has already started + // updating node values + this.__makeNative(); + } } __detach(): void { @@ -62,6 +83,22 @@ class AnimatedTracking extends AnimatedNode { this._callback, ); } + + __getNativeConfig(): any { + const animation = new this._animationClass({ + ...this._animationConfig, + // remove toValue from the config as it's a ref to Animated.Value + toValue: undefined, + }); + const animationConfig = animation.__getNativeAnimationConfig(); + return { + type: 'tracking', + animationId: generateNewAnimationId(), + animationConfig, + toValue: this._parent.__getNativeTag(), + value: this._value.__getNativeTag(), + }; + } } module.exports = AnimatedTracking; diff --git a/Libraries/Animated/src/nodes/AnimatedValue.js b/Libraries/Animated/src/nodes/AnimatedValue.js index 49f440471e1ba3..d5a5de9a9e48c7 100644 --- a/Libraries/Animated/src/nodes/AnimatedValue.js +++ b/Libraries/Animated/src/nodes/AnimatedValue.js @@ -20,6 +20,7 @@ const NativeAnimatedHelper = require('../NativeAnimatedHelper'); import type Animation, {EndCallback} from '../animations/Animation'; import type {InterpolationConfigType} from './AnimatedInterpolation'; +import type AnimatedTracking from './AnimatedTracking'; const NativeAnimatedAPI = NativeAnimatedHelper.API; @@ -76,7 +77,7 @@ class AnimatedValue extends AnimatedWithChildren { _startingValue: number; _offset: number; _animation: ?Animation; - _tracking: ?AnimatedNode; + _tracking: ?AnimatedTracking; _listeners: {[key: string]: ValueListenerCallback}; __nativeAnimatedValueListener: ?any; @@ -311,7 +312,7 @@ class AnimatedValue extends AnimatedWithChildren { /** * Typically only used internally. */ - track(tracking: AnimatedNode): void { + track(tracking: AnimatedTracking): void { this.stopTracking(); this._tracking = tracking; } diff --git a/Libraries/NativeAnimation/Drivers/RCTAnimationDriver.h b/Libraries/NativeAnimation/Drivers/RCTAnimationDriver.h index b87fd68c41381b..3364f174ae3734 100644 --- a/Libraries/NativeAnimation/Drivers/RCTAnimationDriver.h +++ b/Libraries/NativeAnimation/Drivers/RCTAnimationDriver.h @@ -33,6 +33,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)startAnimation; - (void)stepAnimationWithTime:(NSTimeInterval)currentTime; - (void)stopAnimation; +- (void)resetAnimationConfig:(NSDictionary *)config; NS_ASSUME_NONNULL_END diff --git a/Libraries/NativeAnimation/Drivers/RCTDecayAnimation.m b/Libraries/NativeAnimation/Drivers/RCTDecayAnimation.m index cb471f95ed8443..027ca81524f25f 100644 --- a/Libraries/NativeAnimation/Drivers/RCTDecayAnimation.m +++ b/Libraries/NativeAnimation/Drivers/RCTDecayAnimation.m @@ -41,22 +41,27 @@ - (instancetype)initWithId:(NSNumber *)animationId callBack:(nullable RCTResponseSenderBlock)callback; { if ((self = [super init])) { - NSNumber *iterations = [RCTConvert NSNumber:config[@"iterations"]] ?: @1; - + _callback = [callback copy]; _animationId = animationId; + _valueNode = valueNode; _fromValue = 0; _lastValue = 0; - _valueNode = valueNode; - _callback = [callback copy]; - _velocity = [RCTConvert CGFloat:config[@"velocity"]]; - _deceleration = [RCTConvert CGFloat:config[@"deceleration"]]; - _iterations = iterations.integerValue; - _currentLoop = 1; - _animationHasFinished = iterations.integerValue == 0; + _velocity = [RCTConvert CGFloat:config[@"velocity"]]; // initial velocity + [self resetAnimationConfig:config]; } return self; } +- (void)resetAnimationConfig:(NSDictionary *)config +{ + NSNumber *iterations = [RCTConvert NSNumber:config[@"iterations"]] ?: @1; + _fromValue = _lastValue; + _deceleration = [RCTConvert CGFloat:config[@"deceleration"]]; + _iterations = iterations.integerValue; + _currentLoop = 1; + _animationHasFinished = iterations.integerValue == 0; +} + RCT_NOT_IMPLEMENTED(- (instancetype)init) - (void)startAnimation diff --git a/Libraries/NativeAnimation/Drivers/RCTFrameAnimation.m b/Libraries/NativeAnimation/Drivers/RCTFrameAnimation.m index a46fea6bf26ec3..53846b2d0a2204 100644 --- a/Libraries/NativeAnimation/Drivers/RCTFrameAnimation.m +++ b/Libraries/NativeAnimation/Drivers/RCTFrameAnimation.m @@ -31,6 +31,7 @@ @implementation RCTFrameAnimation NSArray *_frames; CGFloat _toValue; CGFloat _fromValue; + CGFloat _lastPosition; NSTimeInterval _animationStartTime; NSTimeInterval _animationCurrentTime; RCTResponseSenderBlock _callback; @@ -44,23 +45,30 @@ - (instancetype)initWithId:(NSNumber *)animationId callBack:(nullable RCTResponseSenderBlock)callback; { if ((self = [super init])) { - NSNumber *toValue = [RCTConvert NSNumber:config[@"toValue"]] ?: @1; - NSArray *frames = [RCTConvert NSNumberArray:config[@"frames"]]; - NSNumber *iterations = [RCTConvert NSNumber:config[@"iterations"]] ?: @1; - _animationId = animationId; - _toValue = toValue.floatValue; - _fromValue = valueNode.value; + _lastPosition = _fromValue = valueNode.value; _valueNode = valueNode; - _frames = [frames copy]; _callback = [callback copy]; - _animationHasFinished = iterations.integerValue == 0; - _iterations = iterations.integerValue; - _currentLoop = 1; + [self resetAnimationConfig:config]; } return self; } +- (void)resetAnimationConfig:(NSDictionary *)config +{ + NSNumber *toValue = [RCTConvert NSNumber:config[@"toValue"]] ?: @1; + NSArray *frames = [RCTConvert NSNumberArray:config[@"frames"]]; + NSNumber *iterations = [RCTConvert NSNumber:config[@"iterations"]] ?: @1; + + _fromValue = _lastPosition; + _toValue = toValue.floatValue; + _frames = [frames copy]; + _animationStartTime = _animationCurrentTime = -1; + _animationHasFinished = iterations.integerValue == 0; + _iterations = iterations.integerValue; + _currentLoop = 1; +} + RCT_NOT_IMPLEMENTED(- (instancetype)init) - (void)startAnimation @@ -144,6 +152,7 @@ - (void)updateOutputWithFrameOutput:(CGFloat)frameOutput EXTRAPOLATE_TYPE_EXTEND, EXTRAPOLATE_TYPE_EXTEND); + _lastPosition = outputValue; _valueNode.value = outputValue; [_valueNode setNeedsUpdate]; } diff --git a/Libraries/NativeAnimation/Drivers/RCTSpringAnimation.m b/Libraries/NativeAnimation/Drivers/RCTSpringAnimation.m index e4811f601d2594..932433a16dae08 100644 --- a/Libraries/NativeAnimation/Drivers/RCTSpringAnimation.m +++ b/Libraries/NativeAnimation/Drivers/RCTSpringAnimation.m @@ -57,33 +57,37 @@ - (instancetype)initWithId:(NSNumber *)animationId callBack:(nullable RCTResponseSenderBlock)callback { if ((self = [super init])) { - NSNumber *iterations = [RCTConvert NSNumber:config[@"iterations"]] ?: @1; - _animationId = animationId; - _toValue = [RCTConvert CGFloat:config[@"toValue"]]; - _fromValue = valueNode.value; - _lastPosition = 0; + _lastPosition = valueNode.value; _valueNode = valueNode; - _overshootClamping = [RCTConvert BOOL:config[@"overshootClamping"]]; - _restDisplacementThreshold = [RCTConvert CGFloat:config[@"restDisplacementThreshold"]]; - _restSpeedThreshold = [RCTConvert CGFloat:config[@"restSpeedThreshold"]]; - _stiffness = [RCTConvert CGFloat:config[@"stiffness"]]; - _damping = [RCTConvert CGFloat:config[@"damping"]]; - _mass = [RCTConvert CGFloat:config[@"mass"]]; - _initialVelocity = [RCTConvert CGFloat:config[@"initialVelocity"]]; - + _lastVelocity = [RCTConvert CGFloat:config[@"initialVelocity"]]; _callback = [callback copy]; - - _lastPosition = _fromValue; - _lastVelocity = _initialVelocity; - - _animationHasFinished = iterations.integerValue == 0; - _iterations = iterations.integerValue; - _currentLoop = 1; + [self resetAnimationConfig:config]; } return self; } +- (void)resetAnimationConfig:(NSDictionary *)config +{ + NSNumber *iterations = [RCTConvert NSNumber:config[@"iterations"]] ?: @1; + _toValue = [RCTConvert CGFloat:config[@"toValue"]]; + _overshootClamping = [RCTConvert BOOL:config[@"overshootClamping"]]; + _restDisplacementThreshold = [RCTConvert CGFloat:config[@"restDisplacementThreshold"]]; + _restSpeedThreshold = [RCTConvert CGFloat:config[@"restSpeedThreshold"]]; + _stiffness = [RCTConvert CGFloat:config[@"stiffness"]]; + _damping = [RCTConvert CGFloat:config[@"damping"]]; + _mass = [RCTConvert CGFloat:config[@"mass"]]; + _initialVelocity = _lastVelocity; + _fromValue = _lastPosition; + _fromValue = _lastPosition; + _lastVelocity = _initialVelocity; + _animationHasFinished = iterations.integerValue == 0; + _iterations = iterations.integerValue; + _currentLoop = 1; + _animationStartTime = _animationCurrentTime = -1; + _animationHasBegun = YES; +} + RCT_NOT_IMPLEMENTED(- (instancetype)init) - (void)startAnimation diff --git a/Libraries/NativeAnimation/Nodes/RCTAnimatedNode.h b/Libraries/NativeAnimation/Nodes/RCTAnimatedNode.h index 198e02c9cfc90f..a8cad4e939c82e 100644 --- a/Libraries/NativeAnimation/Nodes/RCTAnimatedNode.h +++ b/Libraries/NativeAnimation/Nodes/RCTAnimatedNode.h @@ -9,12 +9,15 @@ #import +@class RCTNativeAnimatedNodesManager; + @interface RCTAnimatedNode : NSObject - (instancetype)initWithTag:(NSNumber *)tag config:(NSDictionary *)config NS_DESIGNATED_INITIALIZER; @property (nonatomic, readonly) NSNumber *nodeTag; +@property (nonatomic, weak) RCTNativeAnimatedNodesManager *manager; @property (nonatomic, copy, readonly) NSDictionary *config; @property (nonatomic, copy, readonly) NSMapTable *childNodes; diff --git a/Libraries/NativeAnimation/Nodes/RCTTrackingAnimatedNode.h b/Libraries/NativeAnimation/Nodes/RCTTrackingAnimatedNode.h new file mode 100644 index 00000000000000..8f3281789ddbb0 --- /dev/null +++ b/Libraries/NativeAnimation/Nodes/RCTTrackingAnimatedNode.h @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import "RCTAnimatedNode.h" + + +@interface RCTTrackingAnimatedNode : RCTAnimatedNode + +@end diff --git a/Libraries/NativeAnimation/Nodes/RCTTrackingAnimatedNode.m b/Libraries/NativeAnimation/Nodes/RCTTrackingAnimatedNode.m new file mode 100644 index 00000000000000..e77b773e040a12 --- /dev/null +++ b/Libraries/NativeAnimation/Nodes/RCTTrackingAnimatedNode.m @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import "RCTTrackingAnimatedNode.h" +#import "RCTValueAnimatedNode.h" +#import "RCTNativeAnimatedNodesManager.h" + +@implementation RCTTrackingAnimatedNode { + NSNumber *_animationId; + NSNumber *_toValueNodeTag; + NSNumber *_valueNodeTag; + NSMutableDictionary *_animationConfig; +} + +- (instancetype)initWithTag:(NSNumber *)tag + config:(NSDictionary *)config +{ + if ((self = [super initWithTag:tag config:config])) { + _animationId = config[@"animationId"]; + _toValueNodeTag = config[@"toValue"]; + _valueNodeTag = config[@"value"]; + _animationConfig = [NSMutableDictionary dictionaryWithDictionary:config[@"animationConfig"]]; + } + return self; +} + +- (void)onDetachedFromNode:(RCTAnimatedNode *)parent +{ + [self.manager stopAnimation:_animationId]; + [super onDetachedFromNode:parent]; +} + +- (void)performUpdate +{ + [super performUpdate]; + + // change animation config's "toValue" to reflect updated value of the parent node + RCTValueAnimatedNode *node = (RCTValueAnimatedNode *)[self.parentNodes objectForKey:_toValueNodeTag]; + _animationConfig[@"toValue"] = @(node.value); + + [self.manager startAnimatingNode:_animationId + nodeTag:_valueNodeTag + config:_animationConfig + endCallback:nil]; +} + +@end + diff --git a/Libraries/NativeAnimation/RCTAnimation.xcodeproj/project.pbxproj b/Libraries/NativeAnimation/RCTAnimation.xcodeproj/project.pbxproj index cddec7f5e67eeb..0ba2446271568a 100644 --- a/Libraries/NativeAnimation/RCTAnimation.xcodeproj/project.pbxproj +++ b/Libraries/NativeAnimation/RCTAnimation.xcodeproj/project.pbxproj @@ -111,6 +111,10 @@ 2D3B5EFE1D9B0B4800451313 /* RCTStyleAnimatedNode.m in Sources */ = {isa = PBXBuildFile; fileRef = 13E501E31D07A6C9005F35D8 /* RCTStyleAnimatedNode.m */; }; 2D3B5EFF1D9B0B4800451313 /* RCTTransformAnimatedNode.m in Sources */ = {isa = PBXBuildFile; fileRef = 13E501E51D07A6C9005F35D8 /* RCTTransformAnimatedNode.m */; }; 2D3B5F001D9B0B4800451313 /* RCTValueAnimatedNode.m in Sources */ = {isa = PBXBuildFile; fileRef = 13E501E71D07A6C9005F35D8 /* RCTValueAnimatedNode.m */; }; + 44DB7D942024F74200588FCD /* RCTTrackingAnimatedNode.h in Headers */ = {isa = PBXBuildFile; fileRef = 44DB7D932024F74200588FCD /* RCTTrackingAnimatedNode.h */; }; + 44DB7D952024F74200588FCD /* RCTTrackingAnimatedNode.h in Headers */ = {isa = PBXBuildFile; fileRef = 44DB7D932024F74200588FCD /* RCTTrackingAnimatedNode.h */; }; + 44DB7D972024F75100588FCD /* RCTTrackingAnimatedNode.m in Sources */ = {isa = PBXBuildFile; fileRef = 44DB7D962024F75100588FCD /* RCTTrackingAnimatedNode.m */; }; + 44DB7D982024F75100588FCD /* RCTTrackingAnimatedNode.m in Sources */ = {isa = PBXBuildFile; fileRef = 44DB7D962024F75100588FCD /* RCTTrackingAnimatedNode.m */; }; 5C9894951D999639008027DB /* RCTDivisionAnimatedNode.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C9894941D999639008027DB /* RCTDivisionAnimatedNode.m */; }; 944244D01DB962DA0032A02B /* RCTFrameAnimation.m in Sources */ = {isa = PBXBuildFile; fileRef = 94C1294D1D4069170025F25C /* RCTFrameAnimation.m */; }; 944244D11DB962DC0032A02B /* RCTSpringAnimation.m in Sources */ = {isa = PBXBuildFile; fileRef = 94C1294F1D4069170025F25C /* RCTSpringAnimation.m */; }; @@ -209,6 +213,8 @@ 19F00F201DC8847500113FEE /* RCTEventAnimation.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = RCTEventAnimation.h; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objcpp; }; 19F00F211DC8847500113FEE /* RCTEventAnimation.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTEventAnimation.m; sourceTree = ""; }; 2D2A28201D9B03D100D4039D /* libRCTAnimation.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRCTAnimation.a; sourceTree = BUILT_PRODUCTS_DIR; }; + 44DB7D932024F74200588FCD /* RCTTrackingAnimatedNode.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCTTrackingAnimatedNode.h; sourceTree = ""; }; + 44DB7D962024F75100588FCD /* RCTTrackingAnimatedNode.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTTrackingAnimatedNode.m; sourceTree = ""; }; 5C9894931D999639008027DB /* RCTDivisionAnimatedNode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = RCTDivisionAnimatedNode.h; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objcpp; }; 5C9894941D999639008027DB /* RCTDivisionAnimatedNode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTDivisionAnimatedNode.m; sourceTree = ""; }; 94C1294A1D4069170025F25C /* RCTAnimationDriver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = RCTAnimationDriver.h; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objcpp; }; @@ -256,6 +262,8 @@ 13E501E51D07A6C9005F35D8 /* RCTTransformAnimatedNode.m */, 13E501E61D07A6C9005F35D8 /* RCTValueAnimatedNode.h */, 13E501E71D07A6C9005F35D8 /* RCTValueAnimatedNode.m */, + 44DB7D932024F74200588FCD /* RCTTrackingAnimatedNode.h */, + 44DB7D962024F75100588FCD /* RCTTrackingAnimatedNode.m */, ); path = Nodes; sourceTree = ""; @@ -314,6 +322,7 @@ 192F69891E823F4A008692C7 /* RCTDiffClampAnimatedNode.h in Headers */, 192F698A1E823F4A008692C7 /* RCTAdditionAnimatedNode.h in Headers */, 192F698B1E823F4A008692C7 /* RCTAnimatedNode.h in Headers */, + 44DB7D952024F74200588FCD /* RCTTrackingAnimatedNode.h in Headers */, 192F698C1E823F4A008692C7 /* RCTInterpolationAnimatedNode.h in Headers */, 192F698D1E823F4A008692C7 /* RCTModuloAnimatedNode.h in Headers */, 192F698E1E823F4A008692C7 /* RCTMultiplicationAnimatedNode.h in Headers */, @@ -340,6 +349,7 @@ 1980B71D1E80D1C4004DC789 /* RCTDiffClampAnimatedNode.h in Headers */, 1980B71F1E80D1C4004DC789 /* RCTAdditionAnimatedNode.h in Headers */, 1980B7211E80D1C4004DC789 /* RCTAnimatedNode.h in Headers */, + 44DB7D942024F74200588FCD /* RCTTrackingAnimatedNode.h in Headers */, 1980B7231E80D1C4004DC789 /* RCTInterpolationAnimatedNode.h in Headers */, 1980B7251E80D1C4004DC789 /* RCTModuloAnimatedNode.h in Headers */, 1980B7271E80D1C4004DC789 /* RCTMultiplicationAnimatedNode.h in Headers */, @@ -441,6 +451,7 @@ 2D3B5EFA1D9B0B4800451313 /* RCTInterpolationAnimatedNode.m in Sources */, 2D3B5EFF1D9B0B4800451313 /* RCTTransformAnimatedNode.m in Sources */, 2D3B5EFC1D9B0B4800451313 /* RCTMultiplicationAnimatedNode.m in Sources */, + 44DB7D982024F75100588FCD /* RCTTrackingAnimatedNode.m in Sources */, 2D3B5EFD1D9B0B4800451313 /* RCTPropsAnimatedNode.m in Sources */, 944244D01DB962DA0032A02B /* RCTFrameAnimation.m in Sources */, 944244D11DB962DC0032A02B /* RCTSpringAnimation.m in Sources */, @@ -466,6 +477,7 @@ 13E501EC1D07A6C9005F35D8 /* RCTMultiplicationAnimatedNode.m in Sources */, 13E501ED1D07A6C9005F35D8 /* RCTPropsAnimatedNode.m in Sources */, 13E501E91D07A6C9005F35D8 /* RCTAnimatedNode.m in Sources */, + 44DB7D972024F75100588FCD /* RCTTrackingAnimatedNode.m in Sources */, 13E501EB1D07A6C9005F35D8 /* RCTInterpolationAnimatedNode.m in Sources */, 13E501E81D07A6C9005F35D8 /* RCTAdditionAnimatedNode.m in Sources */, 5C9894951D999639008027DB /* RCTDivisionAnimatedNode.m in Sources */, diff --git a/Libraries/NativeAnimation/RCTNativeAnimatedNodesManager.m b/Libraries/NativeAnimation/RCTNativeAnimatedNodesManager.m index f37046612cbe1d..ee82a8008c7cf6 100644 --- a/Libraries/NativeAnimation/RCTNativeAnimatedNodesManager.m +++ b/Libraries/NativeAnimation/RCTNativeAnimatedNodesManager.m @@ -27,6 +27,7 @@ #import "RCTStyleAnimatedNode.h" #import "RCTTransformAnimatedNode.h" #import "RCTValueAnimatedNode.h" +#import "RCTTrackingAnimatedNode.h" @implementation RCTNativeAnimatedNodesManager { @@ -67,7 +68,8 @@ - (void)createAnimatedNode:(nonnull NSNumber *)tag @"division" : [RCTDivisionAnimatedNode class], @"multiplication" : [RCTMultiplicationAnimatedNode class], @"modulus" : [RCTModuloAnimatedNode class], - @"transform" : [RCTTransformAnimatedNode class]}; + @"transform" : [RCTTransformAnimatedNode class], + @"tracking" : [RCTTrackingAnimatedNode class]}; }); NSString *nodeType = [RCTConvert NSString:config[@"type"]]; @@ -79,6 +81,7 @@ - (void)createAnimatedNode:(nonnull NSNumber *)tag } RCTAnimatedNode *node = [[nodeClass alloc] initWithTag:tag config:config]; + node.manager = self; _animationNodes[tag] = node; [node setNeedsUpdate]; } @@ -222,6 +225,15 @@ - (void)startAnimatingNode:(nonnull NSNumber *)animationId config:(NSDictionary *)config endCallback:(RCTResponseSenderBlock)callBack { + // check if the animation has already started + for (id driver in _activeAnimations) { + if ([driver.animationId isEqual:animationId]) { + // if the animation is running, we restart it with an updated configuration + [driver resetAnimationConfig:config]; + return; + } + } + RCTValueAnimatedNode *valueNode = (RCTValueAnimatedNode *)_animationNodes[nodeTag]; NSString *type = config[@"type"]; diff --git a/RNTester/RNTesterUnitTests/RCTNativeAnimatedNodesManagerTests.m b/RNTester/RNTesterUnitTests/RCTNativeAnimatedNodesManagerTests.m index 70678ac2854269..358264899fb528 100644 --- a/RNTester/RNTesterUnitTests/RCTNativeAnimatedNodesManagerTests.m +++ b/RNTester/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/RNTester/js/NativeAnimationsExample.js b/RNTester/js/NativeAnimationsExample.js index ed6f1acaf7045a..4aaf2063de57a0 100644 --- a/RNTester/js/NativeAnimationsExample.js +++ b/RNTester/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() { diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/AnimationDriver.java b/ReactAndroid/src/main/java/com/facebook/react/animated/AnimationDriver.java index ad715d45c9bac1..b2ae607513a13f 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/AnimationDriver.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/AnimationDriver.java @@ -10,6 +10,8 @@ package com.facebook.react.animated; import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.JSApplicationCausedNativeException; +import com.facebook.react.bridge.ReadableMap; /** * Base class for different types of animation drivers. Can be used to implement simple time-based @@ -27,4 +29,15 @@ * android choreographer callback. */ public abstract void runAnimationStep(long frameTimeNanos); + + /** + * This method will get called when some of the configuration gets updated while the animation is + * running. In that case animation should restart keeping its internal state to provide a smooth + * transision. E.g. in case of a spring animation we want to keep the current value and speed and + * start animating with the new properties (different destination or spring settings) + */ + public void resetConfig(ReadableMap config) { + throw new JSApplicationCausedNativeException( + "Animation config for " + getClass().getSimpleName() + " cannot be reset"); + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/DecayAnimation.java b/ReactAndroid/src/main/java/com/facebook/react/animated/DecayAnimation.java index 41b6d24ff31239..fb7c00d018f33d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/DecayAnimation.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/DecayAnimation.java @@ -18,20 +18,28 @@ public class DecayAnimation extends AnimationDriver { private final double mVelocity; - private final double mDeceleration; - private long mStartFrameTimeMillis = -1; - private double mFromValue = 0d; - private double mLastValue = 0d; + private double mDeceleration; + private long mStartFrameTimeMillis; + private double mFromValue; + private double mLastValue; private int mIterations; private int mCurrentLoop; public DecayAnimation(ReadableMap config) { - mVelocity = config.getDouble("velocity"); + mVelocity = config.getDouble("velocity"); // initial velocity + resetConfig(config); + } + + @Override + public void resetConfig(ReadableMap config) { mDeceleration = config.getDouble("deceleration"); mIterations = config.hasKey("iterations") ? config.getInt("iterations") : 1; mCurrentLoop = 1; mHasFinished = mIterations == 0; + mStartFrameTimeMillis = -1; + mFromValue = 0; + mLastValue = 0; } @Override diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/FrameBasedAnimationDriver.java b/ReactAndroid/src/main/java/com/facebook/react/animated/FrameBasedAnimationDriver.java index 94b12178979326..3d37846fb0c6f0 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/FrameBasedAnimationDriver.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/FrameBasedAnimationDriver.java @@ -22,17 +22,24 @@ class FrameBasedAnimationDriver extends AnimationDriver { // 60FPS private static final double FRAME_TIME_MILLIS = 1000d / 60d; - private long mStartFrameTimeNanos = -1; - private final double[] mFrames; - private final double mToValue; + private long mStartFrameTimeNanos; + private double[] mFrames; + private double mToValue; private double mFromValue; private int mIterations; private int mCurrentLoop; FrameBasedAnimationDriver(ReadableMap config) { + resetConfig(config); + } + + @Override + public void resetConfig(ReadableMap config) { ReadableArray frames = config.getArray("frames"); int numberOfFrames = frames.size(); - mFrames = new double[numberOfFrames]; + if (mFrames == null || mFrames.length != numberOfFrames) { + mFrames = new double[numberOfFrames]; + } for (int i = 0; i < numberOfFrames; i++) { mFrames[i] = frames.getDouble(i); } @@ -40,6 +47,7 @@ class FrameBasedAnimationDriver extends AnimationDriver { mIterations = config.hasKey("iterations") ? config.getInt("iterations") : 1; mCurrentLoop = 1; mHasFinished = mIterations == 0; + mStartFrameTimeNanos = -1; } @Override @@ -49,7 +57,7 @@ public void runAnimationStep(long frameTimeNanos) { mFromValue = mAnimatedValue.mValue; } long timeFromStartMillis = (frameTimeNanos - mStartFrameTimeNanos) / 1000000; - int frameIndex = (int) (timeFromStartMillis / FRAME_TIME_MILLIS); + int frameIndex = (int) Math.round(timeFromStartMillis / FRAME_TIME_MILLIS); if (frameIndex < 0) { throw new IllegalStateException("Calculated frame index should never be lower than 0"); } else if (mHasFinished) { @@ -60,7 +68,7 @@ public void runAnimationStep(long frameTimeNanos) { if (frameIndex >= mFrames.length - 1) { nextValue = mToValue; if (mIterations == -1 || mCurrentLoop < mIterations) { // looping animation, return to start - mStartFrameTimeNanos = frameTimeNanos; + mStartFrameTimeNanos = frameTimeNanos + ((long) FRAME_TIME_MILLIS) * 1000000L; mCurrentLoop++; } else { // animation has completed, no more frames left mHasFinished = true; diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java index d65b20fc503794..d54d24bd668ea9 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java @@ -105,6 +105,8 @@ public void createAnimatedNode(int tag, ReadableMap config) { node = new DiffClampAnimatedNode(config, this); } else if ("transform".equals(type)) { node = new TransformAnimatedNode(config, this); + } else if ("tracking".equals(type)) { + node = new TrackingAnimatedNode(config, this); } else { throw new JSApplicationIllegalArgumentException("Unsupported node type: " + type); } @@ -189,6 +191,15 @@ public void startAnimatingNode( throw new JSApplicationIllegalArgumentException("Animated node should be of type " + ValueAnimatedNode.class.getName()); } + + final AnimationDriver existingDriver = mActiveAnimations.get(animationId); + if (existingDriver != null) { + // animation with the given ID is already running, we need to update its configuration instead + // of spawning a new one + existingDriver.resetConfig(animationConfig); + return; + } + String type = animationConfig.getString("type"); final AnimationDriver animation; if ("frames".equals(type)) { @@ -214,10 +225,12 @@ private void stopAnimationsForNode(AnimatedNode animatedNode) { for (int i = 0; i < mActiveAnimations.size(); i++) { AnimationDriver animation = mActiveAnimations.valueAt(i); if (animatedNode.equals(animation.mAnimatedValue)) { - // Invoke animation end callback with {finished: false} - WritableMap endCallbackResponse = Arguments.createMap(); - endCallbackResponse.putBoolean("finished", false); - animation.mEndCallback.invoke(endCallbackResponse); + if (animation.mEndCallback != null) { + // Invoke animation end callback with {finished: false} + WritableMap endCallbackResponse = Arguments.createMap(); + endCallbackResponse.putBoolean("finished", false); + animation.mEndCallback.invoke(endCallbackResponse); + } mActiveAnimations.removeAt(i); i--; } @@ -232,10 +245,12 @@ public void stopAnimation(int animationId) { for (int i = 0; i < mActiveAnimations.size(); i++) { AnimationDriver animation = mActiveAnimations.valueAt(i); if (animation.mId == animationId) { - // Invoke animation end callback with {finished: false} - WritableMap endCallbackResponse = Arguments.createMap(); - endCallbackResponse.putBoolean("finished", false); - animation.mEndCallback.invoke(endCallbackResponse); + if (animation.mEndCallback != null) { + // Invoke animation end callback with {finished: false} + WritableMap endCallbackResponse = Arguments.createMap(); + endCallbackResponse.putBoolean("finished", false); + animation.mEndCallback.invoke(endCallbackResponse); + } mActiveAnimations.removeAt(i); return; } @@ -445,9 +460,11 @@ public void runUpdates(long frameTimeNanos) { for (int i = mActiveAnimations.size() - 1; i >= 0; i--) { AnimationDriver animation = mActiveAnimations.valueAt(i); if (animation.mHasFinished) { - WritableMap endCallbackResponse = Arguments.createMap(); - endCallbackResponse.putBoolean("finished", true); - animation.mEndCallback.invoke(endCallbackResponse); + if (animation.mEndCallback != null) { + WritableMap endCallbackResponse = Arguments.createMap(); + endCallbackResponse.putBoolean("finished", true); + animation.mEndCallback.invoke(endCallbackResponse); + } mActiveAnimations.removeAt(i); } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/SpringAnimation.java b/ReactAndroid/src/main/java/com/facebook/react/animated/SpringAnimation.java index 83ccc6f74a7184..3558756149b969 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/SpringAnimation.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/SpringAnimation.java @@ -37,24 +37,32 @@ private static class PhysicsState { // thresholds for determining when the spring is at rest private double mRestSpeedThreshold; private double mDisplacementFromRestThreshold; - private double mTimeAccumulator = 0; + private double mTimeAccumulator; // for controlling loop private int mIterations; - private int mCurrentLoop = 0; + private int mCurrentLoop; private double mOriginalValue; SpringAnimation(ReadableMap config) { + mCurrentState.velocity = config.getDouble("initialVelocity"); + resetConfig(config); + } + + @Override + public void resetConfig(ReadableMap config) { mSpringStiffness = config.getDouble("stiffness"); mSpringDamping = config.getDouble("damping"); mSpringMass = config.getDouble("mass"); - mInitialVelocity = config.getDouble("initialVelocity"); - mCurrentState.velocity = mInitialVelocity; + mInitialVelocity = mCurrentState.velocity; mEndValue = config.getDouble("toValue"); mRestSpeedThreshold = config.getDouble("restSpeedThreshold"); mDisplacementFromRestThreshold = config.getDouble("restDisplacementThreshold"); mOvershootClampingEnabled = config.getBoolean("overshootClamping"); mIterations = config.hasKey("iterations") ? config.getInt("iterations") : 1; mHasFinished = mIterations == 0; + mCurrentLoop = 0; + mTimeAccumulator = 0; + mSpringStarted = false; } @Override diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/TrackingAnimatedNode.java b/ReactAndroid/src/main/java/com/facebook/react/animated/TrackingAnimatedNode.java new file mode 100644 index 00000000000000..db312d23558078 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/TrackingAnimatedNode.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.animated; + +import com.facebook.react.bridge.JavaOnlyMap; +import com.facebook.react.bridge.ReadableMap; + +/* package */ class TrackingAnimatedNode extends AnimatedNode { + + private final NativeAnimatedNodesManager mNativeAnimatedNodesManager; + private final int mAnimationId; + private final int mToValueNode; + private final int mValueNode; + private final JavaOnlyMap mAnimationConfig; + + TrackingAnimatedNode(ReadableMap config, NativeAnimatedNodesManager nativeAnimatedNodesManager) { + mNativeAnimatedNodesManager = nativeAnimatedNodesManager; + mAnimationId = config.getInt("animationId"); + mToValueNode = config.getInt("toValue"); + mValueNode = config.getInt("value"); + mAnimationConfig = JavaOnlyMap.deepClone(config.getMap("animationConfig")); + } + + @Override + public void update() { + AnimatedNode toValue = mNativeAnimatedNodesManager.getNodeById(mToValueNode); + mAnimationConfig.putDouble("toValue", ((ValueAnimatedNode) toValue).getValue()); + mNativeAnimatedNodesManager.startAnimatingNode(mAnimationId, mValueNode, mAnimationConfig, null); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/bridge/JavaOnlyArray.java b/ReactAndroid/src/main/java/com/facebook/react/bridge/JavaOnlyArray.java index ab404d9b787f90..dd648f53d1c1b8 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/bridge/JavaOnlyArray.java +++ b/ReactAndroid/src/main/java/com/facebook/react/bridge/JavaOnlyArray.java @@ -36,6 +36,34 @@ public static JavaOnlyArray of(Object... values) { return new JavaOnlyArray(values); } + public static JavaOnlyArray deepClone(ReadableArray ary) { + JavaOnlyArray res = new JavaOnlyArray(); + for (int i = 0, size = ary.size(); i < size; i++) { + ReadableType type = ary.getType(i); + switch (type) { + case Null: + res.pushNull(); + break; + case Boolean: + res.pushBoolean(ary.getBoolean(i)); + break; + case Number: + res.pushDouble(ary.getDouble(i)); + break; + case String: + res.pushString(ary.getString(i)); + break; + case Map: + res.pushMap(JavaOnlyMap.deepClone(ary.getMap(i))); + break; + case Array: + res.pushArray(deepClone(ary.getArray(i))); + break; + } + } + return res; + } + private JavaOnlyArray(Object... values) { mBackingList = Arrays.asList(values); } @@ -60,12 +88,12 @@ public boolean isNull(int index) { @Override public double getDouble(int index) { - return (Double) mBackingList.get(index); + return ((Number) mBackingList.get(index)).doubleValue(); } @Override public int getInt(int index) { - return (Integer) mBackingList.get(index); + return ((Number) mBackingList.get(index)).intValue(); } @Override diff --git a/ReactAndroid/src/main/java/com/facebook/react/bridge/JavaOnlyMap.java b/ReactAndroid/src/main/java/com/facebook/react/bridge/JavaOnlyMap.java index 136786c7b75666..3b9ccf6fdde9de 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/bridge/JavaOnlyMap.java +++ b/ReactAndroid/src/main/java/com/facebook/react/bridge/JavaOnlyMap.java @@ -31,6 +31,36 @@ public static JavaOnlyMap of(Object... keysAndValues) { return new JavaOnlyMap(keysAndValues); } + public static JavaOnlyMap deepClone(ReadableMap map) { + JavaOnlyMap res = new JavaOnlyMap(); + ReadableMapKeySetIterator iter = map.keySetIterator(); + while (iter.hasNextKey()) { + String propKey = iter.nextKey(); + ReadableType type = map.getType(propKey); + switch (type) { + case Null: + res.putNull(propKey); + break; + case Boolean: + res.putBoolean(propKey, map.getBoolean(propKey)); + break; + case Number: + res.putDouble(propKey, map.getDouble(propKey)); + break; + case String: + res.putString(propKey, map.getString(propKey)); + break; + case Map: + res.putMap(propKey, deepClone(map.getMap(propKey))); + break; + case Array: + res.putArray(propKey, JavaOnlyArray.deepClone(map.getArray(propKey))); + break; + } + } + return res; + } + /** * @param keysAndValues keys and values, interleaved */ @@ -65,12 +95,12 @@ public boolean getBoolean(String name) { @Override public double getDouble(String name) { - return (Double) mBackingMap.get(name); + return ((Number) mBackingMap.get(name)).doubleValue(); } @Override public int getInt(String name) { - return (Integer) mBackingMap.get(name); + return ((Number) mBackingMap.get(name)).intValue(); } @Override diff --git a/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java b/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java index 6f244c70be4baf..e1d1958613e6ee 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java @@ -171,11 +171,6 @@ public void testFramesAnimation() { ArgumentCaptor stylesCaptor = ArgumentCaptor.forClass(ReactStylesDiffMap.class); - reset(mUIImplementationMock); - mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); - verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(1000), stylesCaptor.capture()); - assertThat(stylesCaptor.getValue().getDouble("opacity", Double.NaN)).isEqualTo(0); - for (int i = 0; i < frames.size(); i++) { reset(mUIImplementationMock); mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); @@ -205,11 +200,6 @@ public void testFramesAnimationLoopsFiveTimes() { ArgumentCaptor stylesCaptor = ArgumentCaptor.forClass(ReactStylesDiffMap.class); - reset(mUIImplementationMock); - mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); - verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(1000), stylesCaptor.capture()); - assertThat(stylesCaptor.getValue().getDouble("opacity", Double.NaN)).isEqualTo(0); - for (int iteration = 0; iteration < 5; iteration++) { for (int i = 0; i < frames.size(); i++) { reset(mUIImplementationMock); @@ -270,9 +260,6 @@ public void testNodeValueListenerIfListening() { JavaOnlyMap.of("type", "frames", "frames", frames, "toValue", 1d), animationCallback); - mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); - verify(valueListener).onValueUpdate(eq(0d)); - for (int i = 0; i < frames.size(); i++) { reset(valueListener); mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); @@ -600,7 +587,6 @@ public void testAnimationCallbackFinish() { reset(animationCallback); mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); - mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); verifyNoMoreInteractions(animationCallback); reset(animationCallback); @@ -629,10 +615,10 @@ private void createAnimatedGraphWithAdditionNode( double secondValue) { mNativeAnimatedNodesManager.createAnimatedNode( 1, - JavaOnlyMap.of("type", "value", "value", 100d, "offset", 0d)); + JavaOnlyMap.of("type", "value", "value", firstValue, "offset", 0d)); mNativeAnimatedNodesManager.createAnimatedNode( 2, - JavaOnlyMap.of("type", "value", "value", 1000d, "offset", 0d)); + JavaOnlyMap.of("type", "value", "value", secondValue, "offset", 0d)); mNativeAnimatedNodesManager.createAnimatedNode( 3, @@ -648,7 +634,7 @@ private void createAnimatedGraphWithAdditionNode( mNativeAnimatedNodesManager.connectAnimatedNodes(2, 3); mNativeAnimatedNodesManager.connectAnimatedNodes(3, 4); mNativeAnimatedNodesManager.connectAnimatedNodes(4, 5); - mNativeAnimatedNodesManager.connectAnimatedNodeToView(5, 50); + mNativeAnimatedNodesManager.connectAnimatedNodeToView(5, viewTag); } @Test @@ -677,12 +663,6 @@ public void testAdditionNode() { verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(50), stylesCaptor.capture()); assertThat(stylesCaptor.getValue().getDouble("translateX", Double.NaN)).isEqualTo(1100d); - reset(mUIImplementationMock); - mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); - verify(mUIImplementationMock) - .synchronouslyUpdateViewOnUIThread(eq(50), stylesCaptor.capture()); - assertThat(stylesCaptor.getValue().getDouble("translateX", Double.NaN)).isEqualTo(1100d); - reset(mUIImplementationMock); mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); verify(mUIImplementationMock) @@ -722,12 +702,6 @@ public void testViewReceiveUpdatesIfOneOfAnimationHasntStarted() { verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(50), stylesCaptor.capture()); assertThat(stylesCaptor.getValue().getDouble("translateX", Double.NaN)).isEqualTo(1100d); - reset(mUIImplementationMock); - mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); - verify(mUIImplementationMock) - .synchronouslyUpdateViewOnUIThread(eq(50), stylesCaptor.capture()); - assertThat(stylesCaptor.getValue().getDouble("translateX", Double.NaN)).isEqualTo(1100d); - reset(mUIImplementationMock); mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); verify(mUIImplementationMock) @@ -777,11 +751,6 @@ public void testViewReceiveUpdatesWhenOneOfAnimationHasFinished() { verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(50), stylesCaptor.capture()); assertThat(stylesCaptor.getValue().getDouble("translateX", Double.NaN)).isEqualTo(1100d); - reset(mUIImplementationMock); - mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); - verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(50), stylesCaptor.capture()); - assertThat(stylesCaptor.getValue().getDouble("translateX", Double.NaN)).isEqualTo(1100d); - for (int i = 1; i < secondFrames.size(); i++) { reset(mUIImplementationMock); mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); @@ -843,11 +812,6 @@ public void testMultiplicationNode() { verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(50), stylesCaptor.capture()); assertThat(stylesCaptor.getValue().getDouble("translateX", Double.NaN)).isEqualTo(5d); - reset(mUIImplementationMock); - mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); - verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(50), stylesCaptor.capture()); - assertThat(stylesCaptor.getValue().getDouble("translateX", Double.NaN)).isEqualTo(5d); - reset(mUIImplementationMock); mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(50), stylesCaptor.capture()); @@ -949,11 +913,6 @@ public void testInterpolationNode() { ArgumentCaptor stylesCaptor = ArgumentCaptor.forClass(ReactStylesDiffMap.class); - reset(mUIImplementationMock); - mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); - verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(50), stylesCaptor.capture()); - assertThat(stylesCaptor.getValue().getDouble("opacity", Double.NaN)).isEqualTo(0d); - for (int i = 0; i < frames.size(); i++) { reset(mUIImplementationMock); mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); @@ -1088,11 +1047,6 @@ public void testRestoreDefaultProps() { ArgumentCaptor stylesCaptor = ArgumentCaptor.forClass(ReactStylesDiffMap.class); - reset(mUIImplementationMock); - mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); - verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(viewTag), stylesCaptor.capture()); - assertThat(stylesCaptor.getValue().getDouble("opacity", Double.NaN)).isEqualTo(1); - for (int i = 0; i < frames.size(); i++) { reset(mUIImplementationMock); mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); @@ -1106,4 +1060,228 @@ public void testRestoreDefaultProps() { verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(viewTag), stylesCaptor.capture()); assertThat(stylesCaptor.getValue().isNull("opacity")); } + + + /** + * 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 + */ + private void createAnimatedGraphWithTrackingNode( + int viewTag, + double initialValue, + JavaOnlyMap animationConfig) { + mNativeAnimatedNodesManager.createAnimatedNode( + 1, + JavaOnlyMap.of("type", "value", "value", initialValue, "offset", 0d)); + mNativeAnimatedNodesManager.createAnimatedNode( + 3, + JavaOnlyMap.of("type", "value", "value", initialValue, "offset", 0d)); + + mNativeAnimatedNodesManager.createAnimatedNode( + 2, + JavaOnlyMap.of("type", "tracking", "animationId", 70, "value", 3, "toValue", 1, "animationConfig", animationConfig)); + + mNativeAnimatedNodesManager.createAnimatedNode( + 4, + JavaOnlyMap.of("type", "style", "style", JavaOnlyMap.of("translateX", 3))); + mNativeAnimatedNodesManager.createAnimatedNode( + 5, + JavaOnlyMap.of("type", "props", "props", JavaOnlyMap.of("style", 4))); + mNativeAnimatedNodesManager.connectAnimatedNodes(1, 2); + mNativeAnimatedNodesManager.connectAnimatedNodes(3, 4); + mNativeAnimatedNodesManager.connectAnimatedNodes(4, 5); + mNativeAnimatedNodesManager.connectAnimatedNodeToView(5, viewTag); + } + + /** + * 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. + */ + @Test + public void testTracking() { + JavaOnlyArray frames = JavaOnlyArray.of(0d, 0.25d, 0.5d, 0.75d, 1d); + JavaOnlyMap animationConfig = JavaOnlyMap.of("type", "frames", "frames", frames); + + createAnimatedGraphWithTrackingNode(1000, 0d, animationConfig); + + ArgumentCaptor stylesCaptor = + ArgumentCaptor.forClass(ReactStylesDiffMap.class); + + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(1000), stylesCaptor.capture()); + assertThat(stylesCaptor.getValue().getDouble("translateX", Double.NaN)).isEqualTo(0d); + + // update "toValue" to 100, we expect tracking animation to animate now from 0 to 100 in 5 steps + mNativeAnimatedNodesManager.setAnimatedNodeValue(1, 100d); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); // kick off the animation + + for (int i = 0; i < frames.size(); i++) { + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(mUIImplementationMock) + .synchronouslyUpdateViewOnUIThread(eq(1000), stylesCaptor.capture()); + assertThat(stylesCaptor.getValue().getDouble("translateX", Double.NaN)) + .isEqualTo(frames.getDouble(i) * 100d); + } + + // update "toValue" to 0 but run only two frames from the animation, + // we expect tracking animation to animate now from 100 to 75 + mNativeAnimatedNodesManager.setAnimatedNodeValue(1, 0d); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); // kick off the animation + + for (int i = 0; i < 2; i++) { + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(mUIImplementationMock) + .synchronouslyUpdateViewOnUIThread(eq(1000), stylesCaptor.capture()); + assertThat(stylesCaptor.getValue().getDouble("translateX", Double.NaN)) + .isEqualTo(100d * (1d - frames.getDouble(i))); + } + + // at this point we expect tracking value to be at 75 + assertThat(((ValueAnimatedNode) mNativeAnimatedNodesManager.getNodeById(3)).getValue()) + .isEqualTo(75d); + + // we update "toValue" again to 100 and expect the animation to restart from the current place + mNativeAnimatedNodesManager.setAnimatedNodeValue(1, 100d); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); // kick off the animation + + for (int i = 0; i < frames.size(); i++) { + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(mUIImplementationMock) + .synchronouslyUpdateViewOnUIThread(eq(1000), stylesCaptor.capture()); + assertThat(stylesCaptor.getValue().getDouble("translateX", Double.NaN)) + .isEqualTo(50d + 50d * frames.getDouble(i)); + } + } + + /** + * 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. + */ + @Test + public void testTrackingPausesWhenEndValueIsReached() { + JavaOnlyArray frames = JavaOnlyArray.of(0d, 0.5d, 1d); + JavaOnlyMap animationConfig = JavaOnlyMap.of("type", "frames", "frames", frames); + + createAnimatedGraphWithTrackingNode(1000, 0d, animationConfig); + mNativeAnimatedNodesManager.setAnimatedNodeValue(1, 100d); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); // make sure animation starts + + reset(mUIImplementationMock); + for (int i = 0; i < frames.size(); i++) { + assertThat(mNativeAnimatedNodesManager.hasActiveAnimations()).isTrue(); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + } + verify(mUIImplementationMock, times(frames.size())) + .synchronouslyUpdateViewOnUIThread(eq(1000), any(ReactStylesDiffMap.class)); + + // the animation has completed, we expect no updates to be done + reset(mUIImplementationMock); + assertThat(mNativeAnimatedNodesManager.hasActiveAnimations()).isFalse(); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verifyNoMoreInteractions(mUIImplementationMock); + + + // we update end value and expect the animation to restart + mNativeAnimatedNodesManager.setAnimatedNodeValue(1, 200d); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); // make sure animation starts + + reset(mUIImplementationMock); + for (int i = 0; i < frames.size(); i++) { + assertThat(mNativeAnimatedNodesManager.hasActiveAnimations()).isTrue(); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + } + verify(mUIImplementationMock, times(frames.size())) + .synchronouslyUpdateViewOnUIThread(eq(1000), any(ReactStylesDiffMap.class)); + + // the animation has completed, we expect no updates to be done + reset(mUIImplementationMock); + assertThat(mNativeAnimatedNodesManager.hasActiveAnimations()).isFalse(); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verifyNoMoreInteractions(mUIImplementationMock); + } + + /** + * 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. + */ + @Test + public void testSpringTrackingRetainsSpeed() { + // this spring config correspomds to tension 20 and friction 0.5 which makes the spring settle + // very slowly + JavaOnlyMap springConfig = JavaOnlyMap.of( + "type", + "spring", + "restSpeedThreshold", + 0.001, + "mass", + 1d, + "restDisplacementThreshold", + 0.001, + "initialVelocity", + 0.5d, + "damping", + 2.5, + "stiffness", + 157.8, + "overshootClamping", + false); + + createAnimatedGraphWithTrackingNode(1000, 0d, springConfig); + + // update "toValue" to 1, we expect tracking animation to animate now from 0 to 1 + mNativeAnimatedNodesManager.setAnimatedNodeValue(1, 1d); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + + // 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 + boolean isBoucingBack = false; + double previousValue = ((ValueAnimatedNode) mNativeAnimatedNodesManager.getNodeById(3)).getValue(); + for (int maxFrames = 500; maxFrames > 0; maxFrames--) { + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + double currentValue = ((ValueAnimatedNode) mNativeAnimatedNodesManager.getNodeById(3)).getValue(); + if (previousValue >= 1d && currentValue < 1d) { + isBoucingBack = true; + break; + } + previousValue = currentValue; + } + assertThat(isBoucingBack).isTrue(); + + // 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 + mNativeAnimatedNodesManager.setAnimatedNodeValue(1, 1.5d); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + int bounceBackInitialFrames = 0; + boolean hasTurnedForward = false; + + // we run 8 seconds of animation + for (int i = 0; i < 8 * 60; i++) { + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + double currentValue = ((ValueAnimatedNode) mNativeAnimatedNodesManager.getNodeById(3)).getValue(); + if (!hasTurnedForward) { + if (currentValue <= previousValue) { + bounceBackInitialFrames++; + } else { + hasTurnedForward = true; + } + } + previousValue = currentValue; + } + assertThat(hasTurnedForward).isEqualTo(true); + assertThat(bounceBackInitialFrames).isGreaterThan(3); + + // we verify that the value settled at 2 + assertThat(previousValue).isEqualTo(1.5d); + } }