Skip to content

Instantly share code, notes, and snippets.

@gvergnaud
Last active June 1, 2023 22:41
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gvergnaud/4654106fc07f52980ebdefff645016c3 to your computer and use it in GitHub Desktop.
Save gvergnaud/4654106fc07f52980ebdefff645016c3 to your computer and use it in GitHub Desktop.
export function usePrevious<T>(value: T): T | undefined {
const ref = React.useRef<T>();
React.useEffect(() => {
ref.current = value;
});
return ref.current;
}
import clone from 'lodash/clone';
import mapValues from 'lodash/mapValues';
import omit from 'lodash/omit';
import * as React from 'react';
import { useSyncRef } from './use-sync-ref';
import { usePrevious } from './use-previous';
// (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;
}
type 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;
/**
* If something in this array of dependencies changes,
* the animation will be stopped and the value will jump to
* its target position.
*/
resetDeps?: unknown[];
/**
* This default to the initial value
*/
initialValue?: number;
};
type Step<Anims extends Record<string, { value: number } & SpringOptions>> = {
[K in keyof Anims]: { value: number; progress: number };
};
/**
* 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, { value: number } & SpringOptions>,
>(animations: Anims, callback: (values: Step<Anims>) => void, deps: unknown[]) {
const valuesRef = React.useRef<Record<string, number>>(
React.useMemo(
() =>
mapValues(
animations,
(animation) => animation.initialValue ?? animation.value,
),
[],
),
);
const velocitiesRef = React.useRef<Record<string, number>>(
React.useMemo(() => mapValues(animations, () => 0), []),
);
const animationsRef = useSyncRef(animations);
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]);
const animation = animationsRef.current[key];
if (depsHaveChanged) {
valuesRef.current[key] =
animation?.initialValue ?? animation?.value ?? 0;
velocitiesRef.current[key] = 0;
}
});
}, flatResetDeps);
React.useEffect(() => {
let id: number;
const startValues = clone(valuesRef.current);
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,
(
{
value: destValue,
stiffness = 170,
damping = 20,
mass = 1,
precision = 0.01,
initialValue = destValue,
},
key,
): {
isComplete: boolean;
value: number;
progress: number;
} => {
const prevValue = valuesRef.current[key] ?? initialValue;
const prevVelocity = velocitiesRef.current[key] ?? 0;
const startValue = startValues[key] ?? initialValue;
const {
isComplete,
value: nextValue,
velocity: nextVelocity,
} = stepper(
prevValue,
prevVelocity,
destValue,
stiffness,
damping,
mass,
secondsSinceLastFrame,
precision,
);
valuesRef.current[key] = nextValue;
velocitiesRef.current[key] = nextVelocity;
const progress =
destValue === startValue
? 1
: (nextValue - startValue) /
(destValue - startValue);
return {
isComplete,
progress,
value: nextValue,
};
},
);
callback(animationsSteps as Step<Anims>);
const steps = Object.values(animationsSteps);
if (steps.length && !steps.every((step) => step.isComplete)) {
id = requestAnimationFrame(onRaf);
}
};
id = requestAnimationFrame(onRaf);
callback(
mapValues(startValues, (value) => ({
value,
progress: 0,
})) as Step<Anims>,
);
return () => cancelAnimationFrame(id);
}, [
JSON.stringify(mapValues(animations, (a) => omit(a, 'resetDeps'))),
...deps,
]);
}
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,
}: SpringOptions = {},
) {
return useSpringAnimations(
{
animation: {
value: destValue,
stiffness,
damping,
mass,
precision,
initialValue,
resetDeps,
},
},
({ animation }) => {
callback(animation.value, animation.progress);
},
deps,
);
}
export function useSyncRef<T>(value: T) {
const ref = React.useRef(value);
ref.current = value;
return ref;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment