Skip to content

Instantly share code, notes, and snippets.

@gvergnaud
Last active September 22, 2023 02:54
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/515335f5258552cb5e195293f27cbdcd to your computer and use it in GitHub Desktop.
Save gvergnaud/515335f5258552cb5e195293f27cbdcd to your computer and use it in GitHub Desktop.
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;
}
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