Skip to content

Commit 80a0b1c

Browse files
Adam Miskiewiczfacebook-github-bot
authored andcommitted
Add closed-form damped harmonic oscillator algorithm to Animated.spring
Summary: As I was working on mimicking iOS animations for my ongoing work with `react-navigation`, one task I had was to match the "push from right" animation that is common in UINavigationController. I was able to grab the exact animation values for this animation with some LLDB magic, and found that the screen is animated using a `CASpringAnimation` with the parameters: - stiffness: 1000 - damping: 500 - mass: 3 After spending a considerable amount of time attempting to replicate the spring created with these values by CASpringAnimation by specifying values for tension and friction in the current `Animated.spring` implementation, I was unable to come up with mathematically equivalent values that could replicate the spring _exactly_. After doing some research, I ended up disassembling the QuartzCore framework, reading the assembly, and determined that Apple's implementation of `CASpringAnimation` does not use an integrated, numerical animation model as we do in Animated.spring, but instead solved for the closed form of the equations that govern damped harmonic oscillation (the differential equations themselves are [here](https://en.wikipedia.org/wiki/Harmonic_oscillator#Damped_harmonic_oscillator), and a paper describing the math to arrive at the closed-form solution to the second-order ODE that describes the DHO is [here](http://planetmath.org/sites/default/files/texpdf/39745.pdf)). Though we can get the currently implemented RK4 integration close by tweaking some values, it is, the current model is at it's core, an approximation. It seemed that if I wanted to implement the `CASpringAnimation` behavior _exactly_, I needed to implement the analytical model (as is implemented in `CASpringAnimation`) in `Animated`. We add three new optional parameters to `Animated.spring` (to both the JS and native implementations): - `stiffness`, a value describing the spring's stiffness coefficient - `damping`, a value defining how the spring's motion should be damped due to the forces of friction (technically called the _viscous damping coefficient_). - `mass`, a value describing the mass of the object attached to the end of the simulated spring Just like if a developer were to specify `bounciness`/`speed` and `tension`/`friction` in the same config, specifying any of these new parameters while also specifying the aforementioned config values will cause an error to be thrown. ~Defaults for `Animated.spring` across all three implementations (JS/iOS/Android) stay the same, so this is intended to be *a non-breaking change*.~ ~If `stiffness`, `damping`, or `mass` are provided in the config, we switch to animating the spring with the new damped harmonic oscillator model (`DHO` as described in the code).~ We replace the old RK4 integration implementation with our new analytic implementation. Tension/friction nicely correspond directly to stiffness/damping with the mass of the spring locked at 1. This is intended to be *a non-breaking change*, but there may be very slight differences in people's springs (maybe not even noticeable to the naked eye), given the fact that this implementation is more accurate. The DHO animation algorithm will calculate the _position_ of the spring at time _t_ explicitly and in an analytical fashion, and use this calculation to update the animation's value. It will also analytically calculate the velocity at time _t_, so as to allow animated value tracking to continue to work as expected. Also, docs have been updated to cover the new configuration options (and also I added docs for Animated configuration options that were missing, such as `restDisplacementThreshold`, etc). Run tests. Run "Animated Gratuitous App" and "NativeAnimation" example in RNTester. Closes facebook/react-native#15322 Differential Revision: D5794791 Pulled By: hramos fbshipit-source-id: 58ed9e134a097e321c85c417a142576f6a8952f8
1 parent e947fb3 commit 80a0b1c

File tree

3 files changed

+81
-29
lines changed

3 files changed

+81
-29
lines changed

RNTesterUnitTests/RCTNativeAnimatedNodesManagerTests.m

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -266,19 +266,12 @@ - (void)testNodeValueListenerIfListening
266266
XCTAssertEqual(observer.calls.count, 7UL);
267267
}
268268

269-
- (void)testSpringAnimation
269+
- (void)performSpringAnimationTestWithConfig:(NSDictionary*)config isCriticallyDamped:(BOOL)testForCriticallyDamped
270270
{
271271
[self createSimpleAnimatedView:@1000 withOpacity:0];
272272
[_nodesManager startAnimatingNode:@1
273273
nodeTag:@1
274-
config:@{@"type": @"spring",
275-
@"friction": @7,
276-
@"tension": @40,
277-
@"initialVelocity": @0,
278-
@"toValue": @1,
279-
@"restSpeedThreshold": @0.001,
280-
@"restDisplacementThreshold": @0.001,
281-
@"overshootClamping": @NO}
274+
config:config
282275
endCallback:nil];
283276

284277
BOOL wasGreaterThanOne = NO;
@@ -299,7 +292,7 @@ - (void)testSpringAnimation
299292
}
300293

301294
// Verify that animation step is relatively small.
302-
XCTAssertLessThan(fabs(currentValue - previousValue), 0.1);
295+
XCTAssertLessThan(fabs(currentValue - previousValue), 0.12);
303296

304297
previousValue = currentValue;
305298
}
@@ -308,13 +301,45 @@ - (void)testSpringAnimation
308301
XCTAssertEqual(previousValue, 1.0);
309302

310303
// Verify that value has reached some maximum value that is greater than the final value (bounce).
311-
XCTAssertTrue(wasGreaterThanOne);
304+
if (testForCriticallyDamped) {
305+
XCTAssertFalse(wasGreaterThanOne);
306+
} else {
307+
XCTAssertTrue(wasGreaterThanOne);
308+
}
312309

313310
[[_uiManager reject] synchronouslyUpdateViewOnUIThread:OCMOCK_ANY viewName:OCMOCK_ANY props:OCMOCK_ANY];
314311
[_nodesManager stepAnimations:_displayLink];
315312
[_uiManager verify];
316313
}
317314

315+
- (void)testUnderdampedSpringAnimation
316+
{
317+
[self performSpringAnimationTestWithConfig:@{@"type": @"spring",
318+
@"stiffness": @230.3,
319+
@"damping": @22,
320+
@"mass": @1,
321+
@"initialVelocity": @0,
322+
@"toValue": @1,
323+
@"restSpeedThreshold": @0.001,
324+
@"restDisplacementThreshold": @0.001,
325+
@"overshootClamping": @NO}
326+
isCriticallyDamped:NO];
327+
}
328+
329+
- (void)testCritcallyDampedSpringAnimation
330+
{
331+
[self performSpringAnimationTestWithConfig:@{@"type": @"spring",
332+
@"stiffness": @1000,
333+
@"damping": @500,
334+
@"mass": @3,
335+
@"initialVelocity": @0,
336+
@"toValue": @1,
337+
@"restSpeedThreshold": @0.001,
338+
@"restDisplacementThreshold": @0.001,
339+
@"overshootClamping": @NO}
340+
isCriticallyDamped:YES];
341+
}
342+
318343
- (void)testDecayAnimation
319344
{
320345
[self createSimpleAnimatedView:@1000 withOpacity:0];
@@ -415,15 +440,16 @@ - (void)testSpringAnimationLoop
415440
nodeTag:@1
416441
config:@{@"type": @"spring",
417442
@"iterations": @5,
418-
@"friction": @7,
419-
@"tension": @40,
443+
@"stiffness": @230.2,
444+
@"damping": @22,
445+
@"mass": @1,
420446
@"initialVelocity": @0,
421447
@"toValue": @1,
422448
@"restSpeedThreshold": @0.001,
423449
@"restDisplacementThreshold": @0.001,
424450
@"overshootClamping": @NO}
425451
endCallback:nil];
426-
452+
427453
BOOL didComeToRest = NO;
428454
CGFloat previousValue = 0;
429455
NSUInteger numberOfResets = 0;
@@ -433,32 +459,32 @@ - (void)testSpringAnimationLoop
433459
[invocation getArgument:&props atIndex:4];
434460
currentValue = props[@"opacity"].doubleValue;
435461
}] synchronouslyUpdateViewOnUIThread:OCMOCK_ANY viewName:OCMOCK_ANY props:OCMOCK_ANY];
436-
462+
437463
// Run for 3 seconds five times.
438464
for (NSUInteger i = 0; i < 3 * 60 * 5; i++) {
439465
[_nodesManager stepAnimations:_displayLink];
440-
466+
441467
if (!didComeToRest) {
442468
// Verify that animation step is relatively small.
443-
XCTAssertLessThan(fabs(currentValue - previousValue), 0.1);
469+
XCTAssertLessThan(fabs(currentValue - previousValue), 0.12);
444470
}
445-
471+
446472
// Test to see if it reset after coming to rest
447473
if (didComeToRest && currentValue == 0) {
448474
didComeToRest = NO;
449475
numberOfResets++;
450476
}
451-
477+
452478
// Record that the animation did come to rest when it rests on toValue.
453479
didComeToRest = fabs(currentValue - 1) < 0.001 && fabs(currentValue - previousValue) < 0.001;
454-
480+
455481
previousValue = currentValue;
456482
}
457-
483+
458484
// Verify that value reset 4 times after finishing a full animation and is currently resting.
459485
XCTAssertEqual(numberOfResets, 4u);
460486
XCTAssertTrue(didComeToRest);
461-
487+
462488
[[_uiManager reject] synchronouslyUpdateViewOnUIThread:OCMOCK_ANY viewName:OCMOCK_ANY props:OCMOCK_ANY];
463489
[_nodesManager stepAnimations:_displayLink];
464490
[_uiManager verify];

js/AnimatedGratuitousApp/AnExChained.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ class AnExChained extends React.Component<Object, any> {
7373
<Animated.Image
7474
{...handlers}
7575
key={i}
76-
source={{uri: CHAIN_IMGS[j]}}
76+
source={CHAIN_IMGS[j]}
7777
style={[styles.sticker, {
7878
transform: this.state.stickers[j].getTranslateTransform(), // simple conversion
7979
}]}
@@ -100,11 +100,11 @@ var styles = StyleSheet.create({
100100
});
101101

102102
var CHAIN_IMGS = [
103-
'https://scontent-sea1-1.xx.fbcdn.net/hphotos-xpf1/t39.1997-6/p160x160/10574705_1529175770666007_724328156_n.png',
104-
'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-xfa1/t39.1997-6/p160x160/851575_392309884199657_1917957497_n.png',
105-
'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-xfa1/t39.1997-6/p160x160/851567_555288911225630_1628791128_n.png',
106-
'https://scontent-sea1-1.xx.fbcdn.net/hphotos-xfa1/t39.1997-6/p160x160/851583_531111513625557_903469595_n.png',
107-
'https://scontent-sea1-1.xx.fbcdn.net/hphotos-xpa1/t39.1997-6/p160x160/851577_510515972354399_2147096990_n.png',
103+
require('../hawk.png'),
104+
require('../bunny.png'),
105+
require('../relay.png'),
106+
require('../hawk.png'),
107+
require('../bunny.png')
108108
];
109109

110110
module.exports = AnExChained;

js/NativeAnimationsExample.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ exports.examples = [
429429
},
430430
},
431431
{
432-
title: 'translateX => Animated.spring',
432+
title: 'translateX => Animated.spring (bounciness/speed)',
433433
render: function() {
434434
return (
435435
<Tester type="spring" config={{bounciness: 0}}>
@@ -454,6 +454,32 @@ exports.examples = [
454454
);
455455
},
456456
},
457+
{
458+
title: 'translateX => Animated.spring (stiffness/damping/mass)',
459+
render: function() {
460+
return (
461+
<Tester type="spring" config={{stiffness: 1000, damping: 500, mass: 3 }}>
462+
{anim => (
463+
<Animated.View
464+
style={[
465+
styles.block,
466+
{
467+
transform: [
468+
{
469+
translateX: anim.interpolate({
470+
inputRange: [0, 1],
471+
outputRange: [0, 100],
472+
}),
473+
},
474+
],
475+
},
476+
]}
477+
/>
478+
)}
479+
</Tester>
480+
);
481+
},
482+
},
457483
{
458484
title: 'translateX => Animated.decay',
459485
render: function() {

0 commit comments

Comments
 (0)