371 lines
11 KiB
JavaScript
371 lines
11 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @flow
|
|
* @format
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
import type {PlatformConfig} from '../AnimatedPlatformConfig';
|
|
import type AnimatedInterpolation from '../nodes/AnimatedInterpolation';
|
|
import type AnimatedValue from '../nodes/AnimatedValue';
|
|
import type AnimatedValueXY from '../nodes/AnimatedValueXY';
|
|
import type {AnimationConfig, EndCallback} from './Animation';
|
|
|
|
import NativeAnimatedHelper from '../NativeAnimatedHelper';
|
|
import AnimatedColor from '../nodes/AnimatedColor';
|
|
import * as SpringConfig from '../SpringConfig';
|
|
import Animation from './Animation';
|
|
import invariant from 'invariant';
|
|
|
|
export type SpringAnimationConfig = {
|
|
...AnimationConfig,
|
|
toValue:
|
|
| number
|
|
| AnimatedValue
|
|
| {
|
|
x: number,
|
|
y: number,
|
|
...
|
|
}
|
|
| AnimatedValueXY
|
|
| {
|
|
r: number,
|
|
g: number,
|
|
b: number,
|
|
a: number,
|
|
...
|
|
}
|
|
| AnimatedColor
|
|
| AnimatedInterpolation<number>,
|
|
overshootClamping?: boolean,
|
|
restDisplacementThreshold?: number,
|
|
restSpeedThreshold?: number,
|
|
velocity?:
|
|
| number
|
|
| {
|
|
x: number,
|
|
y: number,
|
|
...
|
|
},
|
|
bounciness?: number,
|
|
speed?: number,
|
|
tension?: number,
|
|
friction?: number,
|
|
stiffness?: number,
|
|
damping?: number,
|
|
mass?: number,
|
|
delay?: number,
|
|
};
|
|
|
|
export type SpringAnimationConfigSingle = {
|
|
...AnimationConfig,
|
|
toValue: number,
|
|
overshootClamping?: boolean,
|
|
restDisplacementThreshold?: number,
|
|
restSpeedThreshold?: number,
|
|
velocity?: number,
|
|
bounciness?: number,
|
|
speed?: number,
|
|
tension?: number,
|
|
friction?: number,
|
|
stiffness?: number,
|
|
damping?: number,
|
|
mass?: number,
|
|
delay?: number,
|
|
};
|
|
|
|
export default class SpringAnimation extends Animation {
|
|
_overshootClamping: boolean;
|
|
_restDisplacementThreshold: number;
|
|
_restSpeedThreshold: number;
|
|
_lastVelocity: number;
|
|
_startPosition: number;
|
|
_lastPosition: number;
|
|
_fromValue: number;
|
|
_toValue: 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;
|
|
_platformConfig: ?PlatformConfig;
|
|
|
|
constructor(config: SpringAnimationConfigSingle) {
|
|
super();
|
|
|
|
this._overshootClamping = config.overshootClamping ?? false;
|
|
this._restDisplacementThreshold = config.restDisplacementThreshold ?? 0.001;
|
|
this._restSpeedThreshold = config.restSpeedThreshold ?? 0.001;
|
|
this._initialVelocity = config.velocity ?? 0;
|
|
this._lastVelocity = config.velocity ?? 0;
|
|
this._toValue = config.toValue;
|
|
this._delay = config.delay ?? 0;
|
|
this._useNativeDriver = NativeAnimatedHelper.shouldUseNativeDriver(config);
|
|
this._platformConfig = config.platformConfig;
|
|
this.__isInteraction = config.isInteraction ?? !this._useNativeDriver;
|
|
this.__iterations = config.iterations ?? 1;
|
|
|
|
if (
|
|
config.stiffness !== undefined ||
|
|
config.damping !== undefined ||
|
|
config.mass !== undefined
|
|
) {
|
|
invariant(
|
|
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',
|
|
);
|
|
this._stiffness = config.stiffness ?? 100;
|
|
this._damping = config.damping ?? 10;
|
|
this._mass = 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(
|
|
config.bounciness ?? 8,
|
|
config.speed ?? 12,
|
|
);
|
|
this._stiffness = springConfig.stiffness;
|
|
this._damping = springConfig.damping;
|
|
this._mass = 1;
|
|
} else {
|
|
// Convert the origami tension/friction values to stiffness/damping
|
|
// We assume mass is 1.
|
|
const springConfig = SpringConfig.fromOrigamiTensionAndFriction(
|
|
config.tension ?? 40,
|
|
config.friction ?? 7,
|
|
);
|
|
this._stiffness = springConfig.stiffness;
|
|
this._damping = springConfig.damping;
|
|
this._mass = 1;
|
|
}
|
|
|
|
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(): {|
|
|
damping: number,
|
|
initialVelocity: number,
|
|
iterations: number,
|
|
mass: number,
|
|
platformConfig: ?PlatformConfig,
|
|
overshootClamping: boolean,
|
|
restDisplacementThreshold: number,
|
|
restSpeedThreshold: number,
|
|
stiffness: number,
|
|
toValue: any,
|
|
type: $TEMPORARY$string<'spring'>,
|
|
|} {
|
|
return {
|
|
type: 'spring',
|
|
overshootClamping: this._overshootClamping,
|
|
restDisplacementThreshold: this._restDisplacementThreshold,
|
|
restSpeedThreshold: this._restSpeedThreshold,
|
|
stiffness: this._stiffness,
|
|
damping: this._damping,
|
|
mass: this._mass,
|
|
initialVelocity: this._initialVelocity ?? this._lastVelocity,
|
|
toValue: this._toValue,
|
|
iterations: this.__iterations,
|
|
platformConfig: this._platformConfig,
|
|
};
|
|
}
|
|
|
|
start(
|
|
fromValue: number,
|
|
onUpdate: (value: number) => void,
|
|
onEnd: ?EndCallback,
|
|
previousAnimation: ?Animation,
|
|
animatedValue: AnimatedValue,
|
|
): void {
|
|
this.__active = true;
|
|
this._startPosition = fromValue;
|
|
this._lastPosition = this._startPosition;
|
|
|
|
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;
|
|
}
|
|
|
|
const start = () => {
|
|
if (this._useNativeDriver) {
|
|
this.__startNativeAnimation(animatedValue);
|
|
} else {
|
|
this.onUpdate();
|
|
}
|
|
};
|
|
|
|
// If this._delay is more than 0, we start after the timeout.
|
|
if (this._delay) {
|
|
this._timeout = setTimeout(start, this._delay);
|
|
} else {
|
|
start();
|
|
}
|
|
}
|
|
|
|
getInternalState(): Object {
|
|
return {
|
|
lastPosition: this._lastPosition,
|
|
lastVelocity: this._lastVelocity,
|
|
lastTime: this._lastTime,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// 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
|
|
// running at faster speed than jumping to the end.
|
|
const MAX_STEPS = 64;
|
|
let now = Date.now();
|
|
if (now > this._lastTime + MAX_STEPS) {
|
|
now = this._lastTime + MAX_STEPS;
|
|
}
|
|
|
|
const deltaTime = (now - this._lastTime) / 1000;
|
|
this._frameTime += deltaTime;
|
|
|
|
const c: number = this._damping;
|
|
const m: number = this._mass;
|
|
const k: number = this._stiffness;
|
|
const v0: number = -this._initialVelocity;
|
|
|
|
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
|
|
|
|
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;
|
|
this._lastPosition = position;
|
|
this._lastVelocity = velocity;
|
|
|
|
this._onUpdate(position);
|
|
if (!this.__active) {
|
|
// a listener might have stopped us in _onUpdate
|
|
return;
|
|
}
|
|
|
|
// Conditions for stopping the spring animation
|
|
let isOvershooting = false;
|
|
if (this._overshootClamping && this._stiffness !== 0) {
|
|
if (this._startPosition < this._toValue) {
|
|
isOvershooting = position > this._toValue;
|
|
} else {
|
|
isOvershooting = position < this._toValue;
|
|
}
|
|
}
|
|
const isVelocity = Math.abs(velocity) <= this._restSpeedThreshold;
|
|
let isDisplacement = true;
|
|
if (this._stiffness !== 0) {
|
|
isDisplacement =
|
|
Math.abs(this._toValue - position) <= this._restDisplacementThreshold;
|
|
}
|
|
|
|
if (isOvershooting || (isVelocity && isDisplacement)) {
|
|
if (this._stiffness !== 0) {
|
|
// Ensure that we end up with a round value
|
|
this._lastPosition = this._toValue;
|
|
this._lastVelocity = 0;
|
|
this._onUpdate(this._toValue);
|
|
}
|
|
|
|
this.__debouncedOnEnd({finished: true});
|
|
return;
|
|
}
|
|
// $FlowFixMe[method-unbinding] added when improving typing for this parameters
|
|
this._animationFrame = requestAnimationFrame(this.onUpdate.bind(this));
|
|
}
|
|
|
|
stop(): void {
|
|
super.stop();
|
|
this.__active = false;
|
|
clearTimeout(this._timeout);
|
|
global.cancelAnimationFrame(this._animationFrame);
|
|
this.__debouncedOnEnd({finished: false});
|
|
}
|
|
}
|