UNPKG

react-native-reanimated

Version:

More powerful alternative to Animated library for React Native.

375 lines (320 loc) 10.5 kB
'use strict'; import { logger } from '../../common'; import type { AnimatableValue, Animation, Timestamp } from '../../commonTypes'; import type { SpringConfig } from './springConfigs'; // This type contains all the properties from SpringConfig, which are changed to be required, // except for optional 'reduceMotion' and 'clamp' export type DefaultSpringConfig = { [K in keyof Required<SpringConfig>]: K extends 'reduceMotion' | 'clamp' ? Required<SpringConfig>[K] | undefined : Required<SpringConfig>[K]; }; export type WithSpringConfig = SpringConfig; export interface SpringConfigInner { useDuration: boolean; skipAnimation: boolean; } export interface SpringAnimation extends Animation<SpringAnimation> { current: AnimatableValue; toValue: AnimatableValue; velocity: number; lastTimestamp: Timestamp; startTimestamp: Timestamp; startValue: number; zeta: number; omega0: number; omega1: number; initialEnergy: number; } export interface InnerSpringAnimation extends Omit<SpringAnimation, 'toValue' | 'current'> { toValue: number; current: number; } export function checkIfConfigIsValid(config: DefaultSpringConfig): boolean { 'worklet'; let errorMessage = ''; ( ['stiffness', 'damping', 'dampingRatio', 'mass', 'energyThreshold'] as const ).forEach((prop) => { const value = config[prop]; if (value <= 0) { errorMessage += `, ${prop} must be grater than zero but got ${value}`; } }); if (config.duration < 0) { errorMessage += `, duration can't be negative, got ${config.duration}`; } if ( config.clamp?.min && config.clamp?.max && config.clamp.min > config.clamp.max ) { errorMessage += `, clamp.min should be lower than clamp.max, got clamp: {min: ${config.clamp.min}, max: ${config.clamp.max}} `; } if (errorMessage !== '') { logger.warn('Invalid spring config' + errorMessage); } return errorMessage === ''; } function bisectRoot({ min, max, func, precision, maxIterations = 20, }: { min: number; max: number; func: (x: number) => number; precision: number; maxIterations?: number; }) { 'worklet'; const direction = func(max) >= func(min) ? 1 : -1; let idx = maxIterations; let current = (max + min) / 2; while (Math.abs(func(current)) > precision && idx > 0) { idx -= 1; if (func(current) * direction < 0) { min = current; } else { max = current; } current = (min + max) / 2; } return current; } export function initialCalculations( stiffness = 0, config: DefaultSpringConfig & SpringConfigInner ): { zeta: number; omega0: number; omega1: number; } { 'worklet'; if (config.skipAnimation) { return { zeta: 0, omega0: 0, omega1: 0 }; } if (config.useDuration) { const { mass: m, dampingRatio: zeta } = config; /** * Omega0 and omega1 denote angular frequency and natural angular frequency, * see this link for formulas: * https://courses.lumenlearning.com/suny-osuniversityphysics/chapter/15-5-damped-oscillations/ */ const omega0 = Math.sqrt(stiffness / m); const omega1 = omega0 * Math.sqrt(1 - zeta ** 2); return { zeta, omega0, omega1 }; } else { const { damping: c, mass: m, stiffness: k } = config; 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 - zeta ** 2); // exponential decay return { zeta, omega0, omega1 }; } } /** * We make an assumption that we can manipulate zeta without changing duration * of movement. According to theory this change is small and tests shows that we * can indeed ignore it. */ export function scaleZetaToMatchClamps( animation: SpringAnimation, clamp: { min?: number; max?: number } ): number { 'worklet'; const { zeta, toValue, startValue } = animation; const toValueNum = Number(toValue); if (startValue === 0) { return zeta; } const [firstBound, secondBound] = startValue <= 0 ? [clamp.min, clamp.max] : [clamp.max, clamp.min]; /** * The extrema we get from equation below are relative (we obtain a ratio), To * get absolute extrema we convert it as follows: * * AbsoluteExtremum = startValue ± RelativeExtremum * (toValue - startValue) * Where ± denotes: * * - If extremum is over the target * - Otherwise */ const relativeExtremum1 = secondBound !== undefined ? Math.abs((secondBound - toValueNum) / startValue) : undefined; const relativeExtremum2 = firstBound !== undefined ? Math.abs((firstBound - toValueNum) / startValue) : undefined; /** * Use this formula http://hyperphysics.phy-astr.gsu.edu/hbase/oscda.html to * calculate first two extrema. These extrema are located where cos = +- 1 * * Therefore the first two extrema are: * * Math.exp(-zeta * Math.PI); (over the target) * Math.exp(-zeta * 2 * Math.PI); (before the target) */ const newZeta1 = relativeExtremum1 !== undefined ? Math.abs(Math.log(relativeExtremum1) / Math.PI) : undefined; const newZeta2 = relativeExtremum2 !== undefined ? Math.abs(Math.log(relativeExtremum2) / (2 * Math.PI)) : undefined; const zetaSatisfyingClamp = [newZeta1, newZeta2].filter( (x: number | undefined): x is number => x !== undefined ); // The bigger is zeta the smaller are bounces, we return the biggest one // because it should satisfy all conditions return Math.max(...zetaSatisfyingClamp, zeta); } export function getEnergy( displacement: number, velocity: number, stiffness: number, mass: number ) { 'worklet'; const potentialEnergy = 0.5 * stiffness * displacement ** 2; const kineticEnergy = 0.5 * mass * velocity ** 2; return potentialEnergy + kineticEnergy; } /** Runs before initial */ export function calculateNewStiffnessToMatchDuration( x0: number, config: DefaultSpringConfig & SpringConfigInner, v0: number ) { 'worklet'; if (config.skipAnimation) { return 0; } /** * Use this formula: * https://phys.libretexts.org/Bookshelves/University_Physics/Book%3A_University_Physics_(OpenStax)/Book%3A_University_Physics_I_-_Mechanics_Sound_Oscillations_and_Waves_(OpenStax)/15%3A_Oscillations/15.06%3A_Damped_Oscillations * to find the asymptote and estimate the damping that gives us the expected * duration * * ⎛ ⎛ c⎞ ⎞ * ⎜-⎜──⎟ ⋅ duration⎟ * ⎝ ⎝2m⎠ ⎠ * A ⋅ e = threshold */ const { dampingRatio: zeta, energyThreshold: threshold, mass: m, duration: targetDuration, } = config; const energyDiffForStiffness = (stiffness: number) => { 'worklet'; const perceptualCoefficient = 1.5; const MILLISECONDS_IN_SECOND = 1000; const settlingDuration = (targetDuration * perceptualCoefficient) / MILLISECONDS_IN_SECOND; const omega0 = Math.sqrt(stiffness / m) * zeta; const xtk = (x0 + (v0 + x0 * omega0) * settlingDuration) * Math.exp(-omega0 * settlingDuration); const vtk = (x0 + (v0 + x0 * omega0) * settlingDuration) * Math.exp(-omega0 * settlingDuration) * -omega0 + (v0 + x0 * omega0) * Math.exp(-omega0 * settlingDuration); const e0 = getEnergy(x0, v0, stiffness, m); const etk = getEnergy(xtk, vtk, stiffness, m); const energyFraction = etk / e0; return energyFraction - threshold; }; const precision = config.energyThreshold * 1e-3; // Experimentally seems to be good enough. // Bisection turns out to be much faster than Newton's method in our case return bisectRoot({ min: Number.EPSILON, max: 8e3 /* Stiffness for 8ms animation doesn't exceed 2e3, we add some safety margin on top of that. */, func: energyDiffForStiffness, precision, maxIterations: 100, }); } export function criticallyDampedSpringCalculations( animation: InnerSpringAnimation, precalculatedValues: { v0: number; x0: number; omega0: number; t: number; } ): { position: number; velocity: number } { 'worklet'; const { toValue } = animation; const { v0, x0, omega0, t } = precalculatedValues; const criticallyDampedEnvelope = Math.exp(-omega0 * t); const criticallyDampedPosition = toValue + criticallyDampedEnvelope * (x0 + (v0 + omega0 * x0) * t); const criticallyDampedVelocity = criticallyDampedEnvelope * -omega0 * (x0 + (v0 + omega0 * x0) * t) + criticallyDampedEnvelope * (v0 + omega0 * x0); return { position: criticallyDampedPosition, velocity: criticallyDampedVelocity, }; } export function underDampedSpringCalculations( animation: InnerSpringAnimation, precalculatedValues: { zeta: number; v0: number; x0: number; omega0: number; omega1: number; t: number; } ): { position: number; velocity: number } { 'worklet'; const { toValue } = animation; const { zeta, t, omega0, omega1, x0, v0 } = precalculatedValues; const sin1 = Math.sin(omega1 * t); const cos1 = Math.cos(omega1 * t); // under damped const underDampedEnvelope = Math.exp(-zeta * omega0 * t); const underDampedFrag1 = underDampedEnvelope * (sin1 * ((v0 + zeta * omega0 * x0) / omega1) + x0 * cos1); const underDampedPosition = toValue + underDampedFrag1; // This looks crazy -- it's actually just the derivative of the oscillation function const underDampedVelocity = -zeta * omega0 * underDampedFrag1 + underDampedEnvelope * (cos1 * (v0 + zeta * omega0 * x0) - omega1 * x0 * sin1); return { position: underDampedPosition, velocity: underDampedVelocity }; } export function isAnimationTerminatingCalculation( animation: InnerSpringAnimation, config: DefaultSpringConfig & SpringConfigInner ): boolean { 'worklet'; const { toValue, velocity, startValue, current, initialEnergy } = animation; if (config.overshootClamping) { const leftBound = startValue >= 0 ? toValue : toValue + startValue; const rightBound = leftBound + Math.abs(startValue); if (current < leftBound || current > rightBound) { return true; } } const currentEnergy = getEnergy( toValue - current, velocity, config.stiffness, config.mass ); return ( initialEnergy === 0 || currentEnergy / initialEnergy <= config.energyThreshold ); }