diff --git a/Libraries/Animated/src/AnimatedImplementation.js b/Libraries/Animated/src/AnimatedImplementation.js index 1c516dfd7..ab493f091 100644 --- a/Libraries/Animated/src/AnimatedImplementation.js +++ b/Libraries/Animated/src/AnimatedImplementation.js @@ -698,6 +698,8 @@ module.exports = { * * - `velocity`: Initial velocity. Required. * - `deceleration`: Rate of decay. Default 0.997. + * - `isInteraction`: Whether or not this animation creates an "interaction handle" on the + * `InteractionManager`. Default true. * - `useNativeDriver`: Uses the native driver when true. Default false. */ decay, @@ -712,21 +714,56 @@ module.exports = { * - `easing`: Easing function to define curve. * Default is `Easing.inOut(Easing.ease)`. * - `delay`: Start the animation after delay (milliseconds). Default 0. + * - `isInteraction`: Whether or not this animation creates an "interaction handle" on the + * `InteractionManager`. Default true. * - `useNativeDriver`: Uses the native driver when true. Default false. */ timing, /** - * Spring animation based on Rebound and - * [Origami](https://facebook.github.io/origami/). Tracks velocity state to - * create fluid motions as the `toValue` updates, and can be chained together. + * Animates a value according to an analytical spring model based on + * [damped harmonic oscillation](https://en.wikipedia.org/wiki/Harmonic_oscillator#Damped_harmonic_oscillator). + * Tracks velocity state to create fluid motions as the `toValue` updates, and + * can be chained together. * - * Config is an object that may have the following options. Note that you can - * only define bounciness/speed or tension/friction but not both: + * Config is an object that may have the following options. + * + * Note that you can only define one of bounciness/speed, tension/friction, or + * stiffness/damping/mass, but not more than one: + * + * The friction/tension or bounciness/speed options match the spring model in + * [Facebook Pop](https://github.com/facebook/pop), [Rebound](http://facebook.github.io/rebound/), + * and [Origami](http://origami.design/). * * - `friction`: Controls "bounciness"/overshoot. Default 7. * - `tension`: Controls speed. Default 40. * - `speed`: Controls speed of the animation. Default 12. * - `bounciness`: Controls bounciness. Default 8. + * + * Specifying stiffness/damping/mass as parameters makes `Animated.spring` use an + * analytical spring model based on the motion equations of a [damped harmonic + * oscillator](https://en.wikipedia.org/wiki/Harmonic_oscillator#Damped_harmonic_oscillator). + * This behavior is slightly more precise and faithful to the physics behind + * spring dynamics, and closely mimics the implementation in iOS's + * CASpringAnimation primitive. + * + * - `stiffness`: The spring stiffness coefficient. Default 100. + * - `damping`: Defines how the spring’s motion should be damped due to the forces of friction. + * Default 10. + * - `mass`: The mass of the object attached to the end of the spring. Default 1. + * + * Other configuration options are as follows: + * + * - `velocity`: The initial velocity of the object attached to the spring. Default 0 (object + * is at rest). + * - `overshootClamping`: Boolean indiciating whether the spring should be clamped and not + * bounce. Default false. + * - `restDisplacementThreshold`: The threshold of displacement from rest below which the + * spring should be considered at rest. Default 0.001. + * - `restSpeedThreshold`: The speed at which the spring should be considered at rest in pixels + * per second. Default 0.001. + * - `delay`: Start the animation after delay (milliseconds). Default 0. + * - `isInteraction`: Whether or not this animation creates an "interaction handle" on the + * `InteractionManager`. Default true. * - `useNativeDriver`: Uses the native driver when true. Default false. */ spring, diff --git a/Libraries/Animated/src/SpringConfig.js b/Libraries/Animated/src/SpringConfig.js index d10abced2..e74d167ef 100644 --- a/Libraries/Animated/src/SpringConfig.js +++ b/Libraries/Animated/src/SpringConfig.js @@ -13,15 +13,15 @@ 'use strict'; type SpringConfigType = { - tension: number, - friction: number, + stiffness: number, + damping: number, }; -function tensionFromOrigamiValue(oValue) { +function stiffnessFromOrigamiValue(oValue) { return (oValue - 30) * 3.62 + 194; } -function frictionFromOrigamiValue(oValue) { +function dampingFromOrigamiValue(oValue) { return (oValue - 8) * 3 + 25; } @@ -30,8 +30,8 @@ function fromOrigamiTensionAndFriction( friction: number, ): SpringConfigType { return { - tension: tensionFromOrigamiValue(tension), - friction: frictionFromOrigamiValue(friction) + stiffness: stiffnessFromOrigamiValue(tension), + damping: dampingFromOrigamiValue(friction), }; } @@ -91,8 +91,8 @@ function fromBouncinessAndSpeed( ); return { - tension: tensionFromOrigamiValue(bouncyTension), - friction: frictionFromOrigamiValue(bouncyFriction) + stiffness: stiffnessFromOrigamiValue(bouncyTension), + damping: dampingFromOrigamiValue(bouncyFriction), }; } diff --git a/Libraries/Animated/src/__tests__/Animated-test.js b/Libraries/Animated/src/__tests__/Animated-test.js index d839bc3e7..6476e18ad 100644 --- a/Libraries/Animated/src/__tests__/Animated-test.js +++ b/Libraries/Animated/src/__tests__/Animated-test.js @@ -135,7 +135,7 @@ describe('Animated tests', () => { expect(callback).toBeCalled(); }); - it('send toValue when a spring stops', () => { + it('send toValue when an underdamped spring stops', () => { var anim = new Animated.Value(0); var listener = jest.fn(); anim.addListener(listener); @@ -147,6 +147,18 @@ describe('Animated tests', () => { expect(anim.__getValue()).toBe(15); }); + it('send toValue when a critically damped spring stops', () => { + var anim = new Animated.Value(0); + var listener = jest.fn(); + anim.addListener(listener); + Animated.spring(anim, {stiffness: 8000, damping: 2000, toValue: 15}).start(); + jest.runAllTimers(); + var lastValue = listener.mock.calls[listener.mock.calls.length - 2][0].value; + expect(lastValue).not.toBe(15); + expect(lastValue).toBeCloseTo(15); + expect(anim.__getValue()).toBe(15); + }); + it('convert to JSON', () => { expect(JSON.stringify(new Animated.Value(10))).toBe('10'); }); diff --git a/Libraries/Animated/src/__tests__/AnimatedNative-test.js b/Libraries/Animated/src/__tests__/AnimatedNative-test.js index d9874efc4..324c02f75 100644 --- a/Libraries/Animated/src/__tests__/AnimatedNative-test.js +++ b/Libraries/Animated/src/__tests__/AnimatedNative-test.js @@ -595,12 +595,38 @@ describe('Native Animated', () => { jasmine.any(Number), { type: 'spring', - friction: 16, + stiffness: 679.08, + damping: 16, + mass: 1, + initialVelocity: 0, + overshootClamping: false, + restDisplacementThreshold: 0.001, + restSpeedThreshold: 0.001, + toValue: 10, + iterations: 1, + }, + jasmine.any(Function) + ); + + Animated.spring(anim, { + toValue: 10, + stiffness: 1000, + damping: 500, + mass: 3, + useNativeDriver: true + }).start(); + expect(nativeAnimatedModule.startAnimatingNode).toBeCalledWith( + jasmine.any(Number), + jasmine.any(Number), + { + type: 'spring', + stiffness: 1000, + damping: 500, + mass: 3, initialVelocity: 0, overshootClamping: false, restDisplacementThreshold: 0.001, restSpeedThreshold: 0.001, - tension: 679.08, toValue: 10, iterations: 1, }, @@ -613,12 +639,13 @@ describe('Native Animated', () => { jasmine.any(Number), { type: 'spring', - friction: 23.05223140901191, + damping: 23.05223140901191, initialVelocity: 0, overshootClamping: false, restDisplacementThreshold: 0.001, restSpeedThreshold: 0.001, - tension: 299.61882352941177, + stiffness: 299.61882352941177, + mass: 1, toValue: 10, iterations: 1, }, diff --git a/Libraries/Animated/src/animations/SpringAnimation.js b/Libraries/Animated/src/animations/SpringAnimation.js index c9ee998f7..67dee081b 100644 --- a/Libraries/Animated/src/animations/SpringAnimation.js +++ b/Libraries/Animated/src/animations/SpringAnimation.js @@ -32,6 +32,9 @@ export type SpringAnimationConfig = AnimationConfig & { speed?: number, tension?: number, friction?: number, + stiffness?: number, + damping?: number, + mass?: number, delay?: number, }; @@ -45,6 +48,9 @@ export type SpringAnimationConfigSingle = AnimationConfig & { speed?: number, tension?: number, friction?: number, + stiffness?: number, + damping?: number, + mass?: number, delay?: number, }; @@ -59,17 +65,20 @@ class SpringAnimation extends Animation { _overshootClamping: boolean; _restDisplacementThreshold: number; _restSpeedThreshold: number; - _initialVelocity: ?number; _lastVelocity: number; _startPosition: number; _lastPosition: number; _fromValue: number; _toValue: any; - _tension: number; - _friction: number; + _stiffness: number; + _damping: number; + _mass: number; + _initialVelocity: number; _delay: number; _timeout: any; + _startTime: number; _lastTime: number; + _frameTime: number; _onUpdate: (value: number) => void; _animationFrame: any; _useNativeDriver: boolean; @@ -83,7 +92,7 @@ class SpringAnimation extends Animation { 0.001, ); this._restSpeedThreshold = withDefault(config.restSpeedThreshold, 0.001); - this._initialVelocity = config.velocity; + this._initialVelocity = withDefault(config.velocity, 0); this._lastVelocity = withDefault(config.velocity, 0); this._toValue = config.toValue; this._delay = withDefault(config.delay, 0); @@ -92,24 +101,54 @@ class SpringAnimation extends Animation { config.isInteraction !== undefined ? config.isInteraction : true; this.__iterations = config.iterations !== undefined ? config.iterations : 1; - let springConfig; - if (config.bounciness !== undefined || config.speed !== undefined) { + if ( + config.stiffness !== undefined || + config.damping !== undefined || + config.mass !== undefined + ) { invariant( - config.tension === undefined && config.friction === undefined, - 'You can only define bounciness/speed or tension/friction but not both', + config.bounciness === undefined && + config.speed === undefined && + config.tension === undefined && + config.friction === undefined, + 'You can define one of bounciness/speed, tension/friction, or stiffness/damping/mass, but not more than one', ); - springConfig = SpringConfig.fromBouncinessAndSpeed( + this._stiffness = withDefault(config.stiffness, 100); + this._damping = withDefault(config.damping, 10); + this._mass = withDefault(config.mass, 1); + } else if (config.bounciness !== undefined || config.speed !== undefined) { + // Convert the origami bounciness/speed values to stiffness/damping + // We assume mass is 1. + invariant( + config.tension === undefined && + config.friction === undefined && + config.stiffness === undefined && + config.damping === undefined && + config.mass === undefined, + 'You can define one of bounciness/speed, tension/friction, or stiffness/damping/mass, but not more than one', + ); + const springConfig = SpringConfig.fromBouncinessAndSpeed( withDefault(config.bounciness, 8), withDefault(config.speed, 12), ); + this._stiffness = springConfig.stiffness; + this._damping = springConfig.damping; + this._mass = 1; } else { - springConfig = SpringConfig.fromOrigamiTensionAndFriction( + // Convert the origami tension/friction values to stiffness/damping + // We assume mass is 1. + const springConfig = SpringConfig.fromOrigamiTensionAndFriction( withDefault(config.tension, 40), withDefault(config.friction, 7), ); + this._stiffness = springConfig.stiffness; + this._damping = springConfig.damping; + this._mass = 1; } - this._tension = springConfig.tension; - this._friction = springConfig.friction; + + invariant(this._stiffness > 0, 'Stiffness value must be greater than 0'); + invariant(this._damping > 0, 'Damping value must be greater than 0'); + invariant(this._mass > 0, 'Mass value must be greater than 0'); } __getNativeAnimationConfig() { @@ -118,8 +157,9 @@ class SpringAnimation extends Animation { overshootClamping: this._overshootClamping, restDisplacementThreshold: this._restDisplacementThreshold, restSpeedThreshold: this._restSpeedThreshold, - tension: this._tension, - friction: this._friction, + stiffness: this._stiffness, + damping: this._damping, + mass: this._mass, initialVelocity: withDefault(this._initialVelocity, this._lastVelocity), toValue: this._toValue, iterations: this.__iterations, @@ -140,16 +180,16 @@ class SpringAnimation extends Animation { this._onUpdate = onUpdate; this.__onEnd = onEnd; this._lastTime = Date.now(); + this._frameTime = 0.0; if (previousAnimation instanceof SpringAnimation) { const internalState = previousAnimation.getInternalState(); this._lastPosition = internalState.lastPosition; this._lastVelocity = internalState.lastVelocity; + // Set the initial velocity to the last velocity + this._initialVelocity = this._lastVelocity; this._lastTime = internalState.lastTime; } - if (this._initialVelocity !== undefined && this._initialVelocity !== null) { - this._lastVelocity = this._initialVelocity; - } const start = () => { if (this._useNativeDriver) { @@ -175,13 +215,28 @@ class SpringAnimation extends Animation { }; } + /** + * This spring model is based off of a damped harmonic oscillator + * (https://en.wikipedia.org/wiki/Harmonic_oscillator#Damped_harmonic_oscillator). + * + * We use the closed form of the second order differential equation: + * + * x'' + (2ζ⍵_0)x' + ⍵^2x = 0 + * + * where + * ⍵_0 = √(k / m) (undamped angular frequency of the oscillator), + * ζ = c / 2√mk (damping ratio), + * c = damping constant + * k = stiffness + * m = mass + * + * The derivation of the closed form is described in detail here: + * http://planetmath.org/sites/default/files/texpdf/39745.pdf + * + * This algorithm happens to match the algorithm used by CASpringAnimation, + * a QuartzCore (iOS) API that creates spring animations. + */ onUpdate(): void { - let position = this._lastPosition; - let velocity = this._lastVelocity; - - let tempPosition = this._lastPosition; - let tempVelocity = this._lastVelocity; - // If for some reason we lost a lot of frames (e.g. process large payload or // stopped in the debugger), we only advance by 4 frames worth of // computation and will continue on the next frame. It's better to have it @@ -192,56 +247,47 @@ class SpringAnimation extends Animation { now = this._lastTime + MAX_STEPS; } - // We are using a fixed time step and a maximum number of iterations. - // The following post provides a lot of thoughts into how to build this - // loop: http://gafferongames.com/game-physics/fix-your-timestep/ - const TIMESTEP_MSEC = 1; - const numSteps = Math.max( - 1, // Always take at least one step to make progress. - Math.floor((now - this._lastTime) / TIMESTEP_MSEC), - ); + const deltaTime = (now - this._lastTime) / 1000; + this._frameTime += deltaTime; - for (let i = 0; i < numSteps; ++i) { - // Velocity is based on seconds instead of milliseconds - const step = TIMESTEP_MSEC / 1000; + const c: number = this._damping; + const m: number = this._mass; + const k: number = this._stiffness; + const v0: number = -this._initialVelocity; - // This is using RK4. A good blog post to understand how it works: - // http://gafferongames.com/game-physics/integration-basics/ - const aVelocity = velocity; - const aAcceleration = - this._tension * (this._toValue - tempPosition) - - this._friction * tempVelocity; - tempPosition = position + aVelocity * step / 2; - tempVelocity = velocity + aAcceleration * step / 2; + const zeta = c / (2 * Math.sqrt(k * m)); // damping ratio + const omega0 = Math.sqrt(k / m); // undamped angular frequency of the oscillator (rad/ms) + const omega1 = omega0 * Math.sqrt(1.0 - zeta * zeta); // exponential decay + const x0 = this._toValue - this._startPosition; // calculate the oscillation from x0 = 1 to x = 0 - const bVelocity = tempVelocity; - const bAcceleration = - this._tension * (this._toValue - tempPosition) - - this._friction * tempVelocity; - tempPosition = position + bVelocity * step / 2; - tempVelocity = velocity + bAcceleration * step / 2; - - const cVelocity = tempVelocity; - const cAcceleration = - this._tension * (this._toValue - tempPosition) - - this._friction * tempVelocity; - tempPosition = position + cVelocity * step / 2; - tempVelocity = velocity + cAcceleration * step / 2; - - const dVelocity = tempVelocity; - const dAcceleration = - this._tension * (this._toValue - tempPosition) - - this._friction * tempVelocity; - tempPosition = position + cVelocity * step / 2; - tempVelocity = velocity + cAcceleration * step / 2; - - const dxdt = (aVelocity + 2 * (bVelocity + cVelocity) + dVelocity) / 6; - const dvdt = - (aAcceleration + 2 * (bAcceleration + cAcceleration) + dAcceleration) / - 6; - - position += dxdt * step; - velocity += dvdt * step; + let position = 0.0; + let velocity = 0.0; + const t = this._frameTime; + if (zeta < 1) { + // Under damped + const envelope = Math.exp(-zeta * omega0 * t); + position = + this._toValue - + envelope * + ((v0 + zeta * omega0 * x0) / omega1 * Math.sin(omega1 * t) + + x0 * Math.cos(omega1 * t)); + // This looks crazy -- it's actually just the derivative of the + // oscillation function + velocity = + zeta * + omega0 * + envelope * + (Math.sin(omega1 * t) * (v0 + zeta * omega0 * x0) / omega1 + + x0 * Math.cos(omega1 * t)) - + envelope * + (Math.cos(omega1 * t) * (v0 + zeta * omega0 * x0) - + omega1 * x0 * Math.sin(omega1 * t)); + } else { + // Critically damped + const envelope = Math.exp(-omega0 * t); + position = this._toValue - envelope * (x0 + (v0 + omega0 * x0) * t); + velocity = + envelope * (v0 * (t * omega0 - 1) + t * x0 * (omega0 * omega0)); } this._lastTime = now; @@ -256,7 +302,7 @@ class SpringAnimation extends Animation { // Conditions for stopping the spring animation let isOvershooting = false; - if (this._overshootClamping && this._tension !== 0) { + if (this._overshootClamping && this._stiffness !== 0) { if (this._startPosition < this._toValue) { isOvershooting = position > this._toValue; } else { @@ -265,14 +311,16 @@ class SpringAnimation extends Animation { } const isVelocity = Math.abs(velocity) <= this._restSpeedThreshold; let isDisplacement = true; - if (this._tension !== 0) { + if (this._stiffness !== 0) { isDisplacement = Math.abs(this._toValue - position) <= this._restDisplacementThreshold; } if (isOvershooting || (isVelocity && isDisplacement)) { - if (this._tension !== 0) { + if (this._stiffness !== 0) { // Ensure that we end up with a round value + this._lastPosition = this._toValue; + this._lastVelocity = 0; this._onUpdate(this._toValue); } diff --git a/Libraries/NativeAnimation/Drivers/RCTSpringAnimation.m b/Libraries/NativeAnimation/Drivers/RCTSpringAnimation.m index 5ef299c12..e4811f601 100644 --- a/Libraries/NativeAnimation/Drivers/RCTSpringAnimation.m +++ b/Libraries/NativeAnimation/Drivers/RCTSpringAnimation.m @@ -25,6 +25,8 @@ @end +const NSTimeInterval MAX_DELTA_TIME = 0.064; + @implementation RCTSpringAnimation { CGFloat _toValue; @@ -32,8 +34,9 @@ BOOL _overshootClamping; CGFloat _restDisplacementThreshold; CGFloat _restSpeedThreshold; - CGFloat _tension; - CGFloat _friction; + CGFloat _stiffness; + CGFloat _damping; + CGFloat _mass; CGFloat _initialVelocity; NSTimeInterval _animationStartTime; NSTimeInterval _animationCurrentTime; @@ -44,6 +47,8 @@ NSInteger _iterations; NSInteger _currentLoop; + + NSTimeInterval _t; // Current time (startTime + dt) } - (instancetype)initWithId:(NSNumber *)animationId @@ -57,13 +62,16 @@ _animationId = animationId; _toValue = [RCTConvert CGFloat:config[@"toValue"]]; _fromValue = valueNode.value; + _lastPosition = 0; _valueNode = valueNode; _overshootClamping = [RCTConvert BOOL:config[@"overshootClamping"]]; _restDisplacementThreshold = [RCTConvert CGFloat:config[@"restDisplacementThreshold"]]; _restSpeedThreshold = [RCTConvert CGFloat:config[@"restSpeedThreshold"]]; - _tension = [RCTConvert CGFloat:config[@"tension"]]; - _friction = [RCTConvert CGFloat:config[@"friction"]]; + _stiffness = [RCTConvert CGFloat:config[@"stiffness"]]; + _damping = [RCTConvert CGFloat:config[@"damping"]]; + _mass = [RCTConvert CGFloat:config[@"mass"]]; _initialVelocity = [RCTConvert CGFloat:config[@"initialVelocity"]]; + _callback = [callback copy]; _lastPosition = _fromValue; @@ -100,72 +108,68 @@ RCT_NOT_IMPLEMENTED(- (instancetype)init) // Animation has not begun or animation has already finished. return; } - - if (_animationStartTime == -1) { - _animationStartTime = _animationCurrentTime = currentTime; + + // calculate delta time + NSTimeInterval deltaTime; + if(_animationStartTime == -1) { + _t = 0.0; + _animationStartTime = currentTime; + deltaTime = 0.0; + } else { + // Handle frame drops, and only advance dt by a max of MAX_DELTA_TIME + deltaTime = MIN(MAX_DELTA_TIME, currentTime - _animationCurrentTime); + _t = _t + deltaTime; } - - // We are using a fixed time step and a maximum number of iterations. - // The following post provides a lot of thoughts into how to build this - // loop: http://gafferongames.com/game-physics/fix-your-timestep/ - CGFloat TIMESTEP_MSEC = 1; - // Velocity is based on seconds instead of milliseconds - CGFloat step = TIMESTEP_MSEC / 1000; - - NSInteger numSteps = floorf((currentTime - _animationCurrentTime) / step); + + // store the timestamp _animationCurrentTime = currentTime; - if (numSteps == 0) { - return; + + CGFloat c = _damping; + CGFloat m = _mass; + CGFloat k = _stiffness; + CGFloat v0 = -_initialVelocity; + + CGFloat zeta = c / (2 * sqrtf(k * m)); + CGFloat omega0 = sqrtf(k / m); + CGFloat omega1 = omega0 * sqrtf(1.0 - (zeta * zeta)); + CGFloat x0 = _toValue - _fromValue; + + CGFloat position; + CGFloat velocity; + if (zeta < 1) { + // Under damped + CGFloat envelope = expf(-zeta * omega0 * _t); + position = + _toValue - + envelope * + ((v0 + zeta * omega0 * x0) / omega1 * sinf(omega1 * _t) + + x0 * cosf(omega1 * _t)); + // This looks crazy -- it's actually just the derivative of the + // oscillation function + velocity = + zeta * + omega0 * + envelope * + (sinf(omega1 * _t) * (v0 + zeta * omega0 * x0) / omega1 + + x0 * cosf(omega1 * _t)) - + envelope * + (cosf(omega1 * _t) * (v0 + zeta * omega0 * x0) - + omega1 * x0 * sinf(omega1 * _t)); + } else { + CGFloat envelope = expf(-omega0 * _t); + position = _toValue - envelope * (x0 + (v0 + omega0 * x0) * _t); + velocity = + envelope * (v0 * (_t * omega0 - 1) + _t * x0 * (omega0 * omega0)); } - - CGFloat position = _lastPosition; - CGFloat velocity = _lastVelocity; - - CGFloat tempPosition = _lastPosition; - CGFloat tempVelocity = _lastVelocity; - - for (NSInteger i = 0; i < numSteps; ++i) { - // This is using RK4. A good blog post to understand how it works: - // http://gafferongames.com/game-physics/integration-basics/ - CGFloat aVelocity = velocity; - CGFloat aAcceleration = _tension * (_toValue - tempPosition) - _friction * tempVelocity; - tempPosition = position + aVelocity * step / 2; - tempVelocity = velocity + aAcceleration * step / 2; - - CGFloat bVelocity = tempVelocity; - CGFloat bAcceleration = _tension * (_toValue - tempPosition) - _friction * tempVelocity; - tempPosition = position + bVelocity * step / 2; - tempVelocity = velocity + bAcceleration * step / 2; - - CGFloat cVelocity = tempVelocity; - CGFloat cAcceleration = _tension * (_toValue - tempPosition) - _friction * tempVelocity; - tempPosition = position + cVelocity * step / 2; - tempVelocity = velocity + cAcceleration * step / 2; - - CGFloat dVelocity = tempVelocity; - CGFloat dAcceleration = _tension * (_toValue - tempPosition) - _friction * tempVelocity; - tempPosition = position + cVelocity * step / 2; - tempVelocity = velocity + cAcceleration * step / 2; - - CGFloat dxdt = (aVelocity + 2 * (bVelocity + cVelocity) + dVelocity) / 6; - CGFloat dvdt = (aAcceleration + 2 * (bAcceleration + cAcceleration) + dAcceleration) / 6; - - position += dxdt * step; - velocity += dvdt * step; - } - + _lastPosition = position; _lastVelocity = velocity; - + [self onUpdate:position]; - - if (_animationHasFinished) { - return; - } - + // Conditions for stopping the spring animation BOOL isOvershooting = NO; - if (_overshootClamping && _tension != 0) { + if (_overshootClamping && _stiffness != 0) { if (_fromValue < _toValue) { isOvershooting = position > _toValue; } else { @@ -174,22 +178,24 @@ RCT_NOT_IMPLEMENTED(- (instancetype)init) } BOOL isVelocity = ABS(velocity) <= _restSpeedThreshold; BOOL isDisplacement = YES; - if (_tension != 0) { + if (_stiffness != 0) { isDisplacement = ABS(_toValue - position) <= _restDisplacementThreshold; } - + if (isOvershooting || (isVelocity && isDisplacement)) { - if (_tension != 0) { + if (_stiffness != 0) { // Ensure that we end up with a round value if (_animationHasFinished) { return; } [self onUpdate:_toValue]; } - + if (_iterations == -1 || _currentLoop < _iterations) { _lastPosition = _fromValue; _lastVelocity = _initialVelocity; + // Set _animationStartTime to -1 to reset instance variables on the next animation step. + _animationStartTime = -1; _currentLoop++; [self onUpdate:_fromValue]; } else { diff --git a/RNTester/RNTesterUnitTests/RCTNativeAnimatedNodesManagerTests.m b/RNTester/RNTesterUnitTests/RCTNativeAnimatedNodesManagerTests.m index 3e23129d4..70678ac28 100644 --- a/RNTester/RNTesterUnitTests/RCTNativeAnimatedNodesManagerTests.m +++ b/RNTester/RNTesterUnitTests/RCTNativeAnimatedNodesManagerTests.m @@ -266,19 +266,12 @@ static id RCTPropChecker(NSString *prop, NSNumber *value) XCTAssertEqual(observer.calls.count, 7UL); } -- (void)testSpringAnimation +- (void)performSpringAnimationTestWithConfig:(NSDictionary*)config isCriticallyDamped:(BOOL)testForCriticallyDamped { [self createSimpleAnimatedView:@1000 withOpacity:0]; [_nodesManager startAnimatingNode:@1 nodeTag:@1 - config:@{@"type": @"spring", - @"friction": @7, - @"tension": @40, - @"initialVelocity": @0, - @"toValue": @1, - @"restSpeedThreshold": @0.001, - @"restDisplacementThreshold": @0.001, - @"overshootClamping": @NO} + config:config endCallback:nil]; BOOL wasGreaterThanOne = NO; @@ -299,7 +292,7 @@ static id RCTPropChecker(NSString *prop, NSNumber *value) } // Verify that animation step is relatively small. - XCTAssertLessThan(fabs(currentValue - previousValue), 0.1); + XCTAssertLessThan(fabs(currentValue - previousValue), 0.12); previousValue = currentValue; } @@ -308,13 +301,45 @@ static id RCTPropChecker(NSString *prop, NSNumber *value) XCTAssertEqual(previousValue, 1.0); // Verify that value has reached some maximum value that is greater than the final value (bounce). - XCTAssertTrue(wasGreaterThanOne); + if (testForCriticallyDamped) { + XCTAssertFalse(wasGreaterThanOne); + } else { + XCTAssertTrue(wasGreaterThanOne); + } [[_uiManager reject] synchronouslyUpdateViewOnUIThread:OCMOCK_ANY viewName:OCMOCK_ANY props:OCMOCK_ANY]; [_nodesManager stepAnimations:_displayLink]; [_uiManager verify]; } +- (void)testUnderdampedSpringAnimation +{ + [self performSpringAnimationTestWithConfig:@{@"type": @"spring", + @"stiffness": @230.3, + @"damping": @22, + @"mass": @1, + @"initialVelocity": @0, + @"toValue": @1, + @"restSpeedThreshold": @0.001, + @"restDisplacementThreshold": @0.001, + @"overshootClamping": @NO} + isCriticallyDamped:NO]; +} + +- (void)testCritcallyDampedSpringAnimation +{ + [self performSpringAnimationTestWithConfig:@{@"type": @"spring", + @"stiffness": @1000, + @"damping": @500, + @"mass": @3, + @"initialVelocity": @0, + @"toValue": @1, + @"restSpeedThreshold": @0.001, + @"restDisplacementThreshold": @0.001, + @"overshootClamping": @NO} + isCriticallyDamped:YES]; +} + - (void)testDecayAnimation { [self createSimpleAnimatedView:@1000 withOpacity:0]; @@ -415,15 +440,16 @@ static id RCTPropChecker(NSString *prop, NSNumber *value) nodeTag:@1 config:@{@"type": @"spring", @"iterations": @5, - @"friction": @7, - @"tension": @40, + @"stiffness": @230.2, + @"damping": @22, + @"mass": @1, @"initialVelocity": @0, @"toValue": @1, @"restSpeedThreshold": @0.001, @"restDisplacementThreshold": @0.001, @"overshootClamping": @NO} endCallback:nil]; - + BOOL didComeToRest = NO; CGFloat previousValue = 0; NSUInteger numberOfResets = 0; @@ -433,32 +459,32 @@ static id RCTPropChecker(NSString *prop, NSNumber *value) [invocation getArgument:&props atIndex:4]; currentValue = props[@"opacity"].doubleValue; }] synchronouslyUpdateViewOnUIThread:OCMOCK_ANY viewName:OCMOCK_ANY props:OCMOCK_ANY]; - + // Run for 3 seconds five times. for (NSUInteger i = 0; i < 3 * 60 * 5; i++) { [_nodesManager stepAnimations:_displayLink]; - + if (!didComeToRest) { // Verify that animation step is relatively small. - XCTAssertLessThan(fabs(currentValue - previousValue), 0.1); + XCTAssertLessThan(fabs(currentValue - previousValue), 0.12); } - + // Test to see if it reset after coming to rest if (didComeToRest && currentValue == 0) { didComeToRest = NO; numberOfResets++; } - + // Record that the animation did come to rest when it rests on toValue. didComeToRest = fabs(currentValue - 1) < 0.001 && fabs(currentValue - previousValue) < 0.001; - + previousValue = currentValue; } - + // Verify that value reset 4 times after finishing a full animation and is currently resting. XCTAssertEqual(numberOfResets, 4u); XCTAssertTrue(didComeToRest); - + [[_uiManager reject] synchronouslyUpdateViewOnUIThread:OCMOCK_ANY viewName:OCMOCK_ANY props:OCMOCK_ANY]; [_nodesManager stepAnimations:_displayLink]; [_uiManager verify]; diff --git a/RNTester/js/AnimatedGratuitousApp/AnExChained.js b/RNTester/js/AnimatedGratuitousApp/AnExChained.js index d59ae58c9..124ed34e2 100644 --- a/RNTester/js/AnimatedGratuitousApp/AnExChained.js +++ b/RNTester/js/AnimatedGratuitousApp/AnExChained.js @@ -73,7 +73,7 @@ class AnExChained extends React.Component { Animated.spring', + title: 'translateX => Animated.spring (bounciness/speed)', render: function() { return ( @@ -454,6 +454,32 @@ exports.examples = [ ); }, }, + { + title: 'translateX => Animated.spring (stiffness/damping/mass)', + render: function() { + return ( + + {anim => ( + + )} + + ); + }, + }, { title: 'translateX => Animated.decay', render: function() { 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 a57a9152d..83ccc6f74 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/SpringAnimation.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/SpringAnimation.java @@ -24,14 +24,14 @@ import com.facebook.react.bridge.ReadableMap; private boolean mSpringStarted; // configuration - private double mSpringFriction; - private double mSpringTension; + private double mSpringStiffness; + private double mSpringDamping; + private double mSpringMass; + private double mInitialVelocity; private boolean mOvershootClampingEnabled; // all physics simulation objects are final and reused in each processing pass private final PhysicsState mCurrentState = new PhysicsState(); - private final PhysicsState mPreviousState = new PhysicsState(); - private final PhysicsState mTempState = new PhysicsState(); private double mStartValue; private double mEndValue; // thresholds for determining when the spring is at rest @@ -44,9 +44,11 @@ import com.facebook.react.bridge.ReadableMap; private double mOriginalValue; SpringAnimation(ReadableMap config) { - mSpringFriction = config.getDouble("friction"); - mSpringTension = config.getDouble("tension"); - mCurrentState.velocity = config.getDouble("initialVelocity"); + mSpringStiffness = config.getDouble("stiffness"); + mSpringDamping = config.getDouble("damping"); + mSpringMass = config.getDouble("mass"); + mInitialVelocity = config.getDouble("initialVelocity"); + mCurrentState.velocity = mInitialVelocity; mEndValue = config.getDouble("toValue"); mRestSpeedThreshold = config.getDouble("restSpeedThreshold"); mDisplacementFromRestThreshold = config.getDouble("restDisplacementThreshold"); @@ -65,6 +67,7 @@ import com.facebook.react.bridge.ReadableMap; } mStartValue = mCurrentState.position = mAnimatedValue.mValue; mLastTime = frameTimeMillis; + mTimeAccumulator = 0.0; mSpringStarted = true; } advance((frameTimeMillis - mLastTime) / 1000.0); @@ -97,7 +100,7 @@ import com.facebook.react.bridge.ReadableMap; private boolean isAtRest() { return Math.abs(mCurrentState.velocity) <= mRestSpeedThreshold && (getDisplacementDistanceForState(mCurrentState) <= mDisplacementFromRestThreshold || - mSpringTension == 0); + mSpringStiffness == 0); } /** @@ -105,31 +108,12 @@ import com.facebook.react.bridge.ReadableMap; * @return true if the spring is overshooting its target */ private boolean isOvershooting() { - return mSpringTension > 0 && + return mSpringStiffness > 0 && ((mStartValue < mEndValue && mCurrentState.position > mEndValue) || (mStartValue > mEndValue && mCurrentState.position < mEndValue)); } - /** - * linear interpolation between the previous and current physics state based on the amount of - * timestep remaining after processing the rendering delta time in timestep sized chunks. - * @param alpha from 0 to 1, where 0 is the previous state, 1 is the current state - */ - private void interpolate(double alpha) { - mCurrentState.position = mCurrentState.position * alpha + mPreviousState.position *(1-alpha); - mCurrentState.velocity = mCurrentState.velocity * alpha + mPreviousState.velocity *(1-alpha); - } - - /** - * advance the physics simulation in SOLVER_TIMESTEP_SEC sized chunks to fulfill the required - * realTimeDelta. - * The math is inlined inside the loop since it made a huge performance impact when there are - * several springs being advanced. - * @param time clock time - * @param realDeltaTime clock drift - */ private void advance(double realDeltaTime) { - if (isAtRest()) { return; } @@ -143,87 +127,55 @@ import com.facebook.react.bridge.ReadableMap; mTimeAccumulator += adjustedDeltaTime; - double tension = mSpringTension; - double friction = mSpringFriction; + double c = mSpringDamping; + double m = mSpringMass; + double k = mSpringStiffness; + double v0 = -mInitialVelocity; - double position = mCurrentState.position; - double velocity = mCurrentState.velocity; - double tempPosition = mTempState.position; - double tempVelocity = mTempState.velocity; + double zeta = c / (2 * Math.sqrt(k * m )); + double omega0 = Math.sqrt(k / m); + double omega1 = omega0 * Math.sqrt(1.0 - (zeta * zeta)); + double x0 = mEndValue - mStartValue; - double aVelocity, aAcceleration; - double bVelocity, bAcceleration; - double cVelocity, cAcceleration; - double dVelocity, dAcceleration; - - double dxdt, dvdt; - - // iterate over the true time - while (mTimeAccumulator >= SOLVER_TIMESTEP_SEC) { - /* begin debug - iterations++; - end debug */ - mTimeAccumulator -= SOLVER_TIMESTEP_SEC; - - if (mTimeAccumulator < SOLVER_TIMESTEP_SEC) { - // This will be the last iteration. Remember the previous state in case we need to - // interpolate - mPreviousState.position = position; - mPreviousState.velocity = velocity; - } - - // Perform an RK4 integration to provide better detection of the acceleration curve via - // sampling of Euler integrations at 4 intervals feeding each derivative into the calculation - // of the next and taking a weighted sum of the 4 derivatives as the final output. - - // This math was inlined since it made for big performance improvements when advancing several - // springs in one pass of the BaseSpringSystem. - - // The initial derivative is based on the current velocity and the calculated acceleration - aVelocity = velocity; - aAcceleration = (tension * (mEndValue - tempPosition)) - friction * velocity; - - // Calculate the next derivatives starting with the last derivative and integrating over the - // timestep - tempPosition = position + aVelocity * SOLVER_TIMESTEP_SEC * 0.5; - tempVelocity = velocity + aAcceleration * SOLVER_TIMESTEP_SEC * 0.5; - bVelocity = tempVelocity; - bAcceleration = (tension * (mEndValue - tempPosition)) - friction * tempVelocity; - - tempPosition = position + bVelocity * SOLVER_TIMESTEP_SEC * 0.5; - tempVelocity = velocity + bAcceleration * SOLVER_TIMESTEP_SEC * 0.5; - cVelocity = tempVelocity; - cAcceleration = (tension * (mEndValue - tempPosition)) - friction * tempVelocity; - - tempPosition = position + cVelocity * SOLVER_TIMESTEP_SEC; - tempVelocity = velocity + cAcceleration * SOLVER_TIMESTEP_SEC; - dVelocity = tempVelocity; - dAcceleration = (tension * (mEndValue - tempPosition)) - friction * tempVelocity; - - // Take the weighted sum of the 4 derivatives as the final output. - dxdt = 1.0/6.0 * (aVelocity + 2.0 * (bVelocity + cVelocity) + dVelocity); - dvdt = 1.0/6.0 * (aAcceleration + 2.0 * (bAcceleration + cAcceleration) + dAcceleration); - - position += dxdt * SOLVER_TIMESTEP_SEC; - velocity += dvdt * SOLVER_TIMESTEP_SEC; + double velocity; + double position; + double t = mTimeAccumulator; + if (zeta < 1) { + // Under damped + double envelope = Math.exp(-zeta * omega0 * t); + position = + mEndValue - + envelope * + ((v0 + zeta * omega0 * x0) / omega1 * Math.sin(omega1 * t) + + x0 * Math.cos(omega1 * t)); + // This looks crazy -- it's actually just the derivative of the + // oscillation function + velocity = + zeta * + omega0 * + envelope * + (Math.sin(omega1 * t) * (v0 + zeta * omega0 * x0) / omega1 + + x0 * Math.cos(omega1 * t)) - + envelope * + (Math.cos(omega1 * t) * (v0 + zeta * omega0 * x0) - + omega1 * x0 * Math.sin(omega1 * t)); + } else { + // Critically damped spring + double envelope = Math.exp(-omega0 * t); + position = mEndValue - envelope * (x0 + (v0 + omega0 * x0) * t); + velocity = + envelope * (v0 * (t * omega0 - 1) + t * x0 * (omega0 * omega0)); } - mTempState.position = tempPosition; - mTempState.velocity = tempVelocity; - mCurrentState.position = position; mCurrentState.velocity = velocity; - if (mTimeAccumulator > 0) { - interpolate(mTimeAccumulator / SOLVER_TIMESTEP_SEC); - } - // End the spring immediately if it is overshooting and overshoot clamping is enabled. // Also make sure that if the spring was considered within a resting threshold that it's now // snapped to its end value. if (isAtRest() || (mOvershootClampingEnabled && isOvershooting())) { // Don't call setCurrentValue because that forces a call to onSpringUpdate - if (tension > 0) { + if (mSpringStiffness > 0) { mStartValue = mEndValue; mCurrentState.position = mEndValue; } else { 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 0a172d193..a1cb882b0 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java @@ -284,31 +284,14 @@ public class NativeAnimatedNodeTraversalTest { verifyNoMoreInteractions(valueListener); } - @Test - public void testSpringAnimation() { + public void performSpringAnimationTestWithConfig(JavaOnlyMap config, boolean testForCriticallyDamped) { createSimpleAnimatedViewWithOpacity(1000, 0d); Callback animationCallback = mock(Callback.class); mNativeAnimatedNodesManager.startAnimatingNode( 1, 1, - JavaOnlyMap.of( - "type", - "spring", - "friction", - 7d, - "tension", - 40.0d, - "initialVelocity", - 0d, - "toValue", - 1d, - "restSpeedThreshold", - 0.001d, - "restDisplacementThreshold", - 0.001d, - "overshootClamping", - false), + config, animationCallback); ArgumentCaptor stylesCaptor = @@ -332,18 +315,76 @@ public class NativeAnimatedNodeTraversalTest { wasGreaterThanOne = true; } // verify that animation step is relatively small - assertThat(Math.abs(currentValue - previousValue)).isLessThan(0.1d); + assertThat(Math.abs(currentValue - previousValue)).isLessThan(0.12d); previousValue = currentValue; } // verify that we've reach the final value at the end of animation assertThat(previousValue).isEqualTo(1d); // verify that value has reached some maximum value that is greater than the final value (bounce) - assertThat(wasGreaterThanOne); + if (testForCriticallyDamped) { + assertThat(!wasGreaterThanOne); + } else { + assertThat(wasGreaterThanOne); + } reset(mUIImplementationMock); mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); verifyNoMoreInteractions(mUIImplementationMock); } + @Test + public void testUnderdampedSpringAnimation() { + performSpringAnimationTestWithConfig( + JavaOnlyMap.of( + "type", + "spring", + "stiffness", + 230.2d, + "damping", + 22d, + "mass", + 1d, + "initialVelocity", + 0d, + "toValue", + 1d, + "restSpeedThreshold", + 0.001d, + "restDisplacementThreshold", + 0.001d, + "overshootClamping", + false + ), + false + ); + } + + @Test + public void testCriticallyDampedSpringAnimation() { + performSpringAnimationTestWithConfig( + JavaOnlyMap.of( + "type", + "spring", + "stiffness", + 1000d, + "damping", + 500d, + "mass", + 3.0d, + "initialVelocity", + 0d, + "toValue", + 1d, + "restSpeedThreshold", + 0.001d, + "restDisplacementThreshold", + 0.001d, + "overshootClamping", + false + ), + true + ); + } + @Test public void testSpringAnimationLoopsFiveTimes() { createSimpleAnimatedViewWithOpacity(1000, 0d); @@ -355,10 +396,12 @@ public class NativeAnimatedNodeTraversalTest { JavaOnlyMap.of( "type", "spring", - "friction", - 7d, - "tension", - 40.0d, + "stiffness", + 230.2d, + "damping", + 22d, + "mass", + 1d, "initialVelocity", 0d, "toValue", @@ -370,7 +413,8 @@ public class NativeAnimatedNodeTraversalTest { "overshootClamping", false, "iterations", - 5), + 5 + ), animationCallback); ArgumentCaptor stylesCaptor = @@ -403,7 +447,7 @@ public class NativeAnimatedNodeTraversalTest { } // verify that an animation step is relatively small, unless it has come to rest and reset - if (!didComeToRest) assertThat(Math.abs(currentValue - previousValue)).isLessThan(0.1d); + if (!didComeToRest) assertThat(Math.abs(currentValue - previousValue)).isLessThan(0.12d); // record that the animation did come to rest when it rests on toValue