Last active
September 22, 2023 02:54
-
-
Save gvergnaud/515335f5258552cb5e195293f27cbdcd to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import mapValues from 'lodash/mapValues'; | |
export type Step<Anims> = { | |
[K in keyof Anims]: { value: number; progress: number }; | |
}; | |
export type Values<Anims> = { [K in keyof Anims]: number }; | |
export interface SpringOptions { | |
/** | |
* the "friction" of the environment on the object in motion. | |
* numbers between 10 and 30 are usually a good fit. | |
* defaults to 20. | |
*/ | |
damping?: number; | |
/** | |
* the "attraction" towards the destination. | |
* numbers between 100 and 200 are usually a good fit. | |
* defaults to 170. | |
*/ | |
stiffness?: number; | |
/** | |
* the "weight" of the moving object. | |
* numbers between 1 and 5 are usually a good fit. | |
* defaults to 1. | |
*/ | |
mass?: number; | |
/** | |
* Precision with which we consider the animation as complete. | |
* When the animated value reaches `destValue +- precision`, | |
* the animation stops. | |
*/ | |
precision?: number; | |
/** | |
* This default to the initial value | |
*/ | |
initialValue?: number; | |
} | |
type CancelFn = () => void; | |
type Animate<Anims> = ((values: Values<Anims>) => CancelFn) & { | |
resetAnimation: (key: keyof Anims) => void; | |
}; | |
export const springs = < | |
Anims extends Record<string, Omit<SpringOptions, 'resetDeps'>>, | |
>( | |
animations: Anims, | |
callback: (values: Step<Anims>) => void, | |
): Animate<Anims> => { | |
const velocities = mapValues(animations, () => 0); | |
let values = mapValues(animations, (): null | number => null); | |
let startValues = mapValues(animations, (): null | number => null); | |
let destValues = mapValues(animations, (): null | number => null); | |
let id: number; | |
let lastFrameStart: number | undefined = Date.now(); | |
const onRaf = () => { | |
const now = Date.now(); | |
const secondsSinceLastFrame = lastFrameStart | |
? Math.max(5, Math.min(32, now - lastFrameStart)) / 1000 | |
: 0.016; // 16ms | |
lastFrameStart = now; | |
const animationsSteps = mapValues( | |
animations, | |
( | |
{ | |
stiffness = 170, | |
damping = 20, | |
mass = 1, | |
precision = 0.01, | |
initialValue, | |
}, | |
key: keyof Anims, | |
): { | |
isComplete: boolean; | |
value: number; | |
progress: number; | |
} => { | |
const destValue = destValues[key]!; | |
const prevValue = values[key] ?? initialValue ?? destValue; | |
const prevVelocity = velocities[key] ?? 0; | |
const startValue = | |
startValues![key] ?? initialValue ?? destValue; | |
const { | |
isComplete, | |
value: nextValue, | |
velocity: nextVelocity, | |
} = stepper( | |
prevValue, | |
prevVelocity, | |
destValue, | |
stiffness, | |
damping, | |
mass, | |
secondsSinceLastFrame, | |
precision, | |
); | |
values![key] = nextValue; | |
velocities[key] = nextVelocity; | |
const progress = | |
destValue === startValue | |
? 1 | |
: (nextValue - startValue) / (destValue - startValue); | |
return { | |
isComplete, | |
progress, | |
value: nextValue, | |
}; | |
}, | |
); | |
callback(animationsSteps); | |
const steps = Object.values(animationsSteps); | |
if (steps.length && !steps.every((step) => step.isComplete)) { | |
id = requestAnimationFrame(onRaf); | |
} | |
}; | |
const interpolate = (newValues: Values<Anims>) => { | |
if (values === null) { | |
values = newValues; | |
} | |
startValues = { ...values }; | |
destValues = newValues; | |
cancelAnimationFrame(id); | |
id = requestAnimationFrame(onRaf); | |
return () => { | |
cancelAnimationFrame(id); | |
}; | |
}; | |
return Object.assign(interpolate, { | |
resetAnimation: (key: keyof Anims) => { | |
values[key] = animations[key]?.initialValue ?? values[key] ?? 0; | |
velocities[key] = 0; | |
}, | |
}); | |
}; | |
// (Perf) To avoid creating an object per frame that will be destructured right | |
// away and give work to the garbage collector, we can safely reuse the | |
// same object container between all animation instances because we | |
// know it's never going to be mutated outside of the stepper function. | |
const reusedResultObject = { isComplete: false, value: 0, velocity: 0 }; | |
function stepper( | |
value: number, | |
velocity: number, | |
destValue: number, | |
stiffness: number, | |
damping: number, | |
mass: number, | |
timeInterval: number, // time interval since last step in seconds | |
precision: number, | |
): { isComplete: boolean; value: number; velocity: number } { | |
// How far away the value is from its end position | |
const displacement = value - destValue; | |
/** | |
* Force with which the spring tries to returns to its original position. | |
* The tension of a spring is proportinal to its displacement, | |
* by an arbitrary `-stiffness` coefficient provided as argument. | |
*/ | |
const tension = -stiffness * displacement; | |
/** | |
* Force with which the environment tries to prevent the object from moving. | |
* The friction is proportional to the current velocity of the object, | |
* by an arbitrary `-damping` coefficient provided as argument. | |
*/ | |
const friction = -damping * velocity; | |
/** | |
* Since a force (F) is a mass (m) times an acceleration (a): F = m * a | |
* the acceleration is `F / m`, where F is the addition of the tension | |
* force and the friction force. | |
*/ | |
const acceleration = (tension + friction) / mass; | |
/** | |
* An acceleration is a change in velocity over some amount of time. | |
* we can transform the acceleration into a velocity delta by multiplying it by | |
* the time interval since the previous step. | |
*/ | |
const newVelocity = velocity + acceleration * timeInterval; | |
/** | |
* A velocity is a change in position over some amount of time. | |
* we can transform the velocity into a position delta by multiplying it by | |
* the time interval since the previous step. | |
*/ | |
const newValue = value + newVelocity * timeInterval; | |
if ( | |
Math.abs(newVelocity) < precision && | |
Math.abs(newValue - destValue) < precision | |
) { | |
reusedResultObject.isComplete = true; | |
reusedResultObject.value = destValue; | |
reusedResultObject.velocity = 0; | |
return reusedResultObject; | |
} | |
reusedResultObject.isComplete = false; | |
reusedResultObject.value = newValue; | |
reusedResultObject.velocity = newVelocity; | |
return reusedResultObject; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import mapValues from 'lodash/mapValues'; | |
import omit from 'lodash/omit'; | |
import * as React from 'react'; | |
import type { SpringOptions, Step } from './springs'; | |
import { springs } from './springs'; | |
import { useSyncRef } from './use-sync-ref'; | |
import { usePrevious } from './use-previous'; | |
import { useStableCallback } from './use-stable-callback'; | |
export interface SpringHookOptions extends SpringOptions { | |
/** | |
* Current target value | |
*/ | |
value: number; | |
/** | |
* If something in this array of dependencies changes, | |
* the animation will be stopped and the value will jump to | |
* its target position. | |
*/ | |
resetDeps?: unknown[]; | |
} | |
/** | |
* Hook to interpolate a value using spring physics. | |
* @param destValue: the value we should reach at the end of the animation | |
* @param callback: function run at every animation frame | |
* while the animation isn't complete. | |
* @param deps: array of dependencies of the function | |
* @param options: Options to control the spring animation | |
*/ | |
export function useSpringAnimations< | |
Anims extends Record<string, SpringHookOptions>, | |
>(animations: Anims, callback: (values: Step<Anims>) => void, deps: unknown[]) { | |
const animationsRef = useSyncRef(animations); | |
const stableCallback = useStableCallback(callback); | |
const interpolate = React.useMemo(() => { | |
return springs(animationsRef.current, stableCallback); | |
}, [animationsRef, stableCallback]); | |
const allResetDeps = mapValues(animations, (a) => a.resetDeps); | |
const prevAllResetDeps = usePrevious(allResetDeps); | |
const flatResetDeps = Object.values(allResetDeps).flat(); | |
// Reset the animation to its initial value if something | |
// in the animation.resetDeps changed. | |
React.useEffect(() => { | |
Object.keys(allResetDeps).forEach((key) => { | |
const resetDeps = allResetDeps[key]; | |
const prevResetDeps = prevAllResetDeps?.[key]; | |
const depsHaveChanged = | |
resetDeps?.length !== prevResetDeps?.length || | |
resetDeps?.some((dep, index) => dep !== prevResetDeps?.[index]); | |
if (depsHaveChanged) { | |
interpolate.resetAnimation(key); | |
} | |
}); | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, flatResetDeps); | |
const interpolateDeps = [ | |
JSON.stringify(mapValues(animations, (a) => omit(a, 'resetDeps'))), | |
...deps, | |
]; | |
React.useEffect(() => { | |
return interpolate(mapValues(animations, (a) => a.value)); | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, interpolateDeps); | |
} | |
export function useSpringAnimation( | |
destValue: number, | |
callback: (value: number, progress: number) => void, | |
deps: unknown[], | |
{ | |
stiffness = 170, | |
damping = 20, | |
mass = 1, | |
precision = 0.01, | |
initialValue = destValue, | |
resetDeps, | |
}: Omit<SpringHookOptions, 'value'> = {}, | |
) { | |
return useSpringAnimations( | |
{ | |
animation: { | |
value: destValue, | |
stiffness, | |
damping, | |
mass, | |
precision, | |
initialValue, | |
resetDeps, | |
}, | |
}, | |
({ animation }) => { | |
callback(animation.value, animation.progress); | |
}, | |
deps, | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment