/** * 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, 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}); } }