Last active
June 1, 2023 22:41
-
-
Save gvergnaud/4654106fc07f52980ebdefff645016c3 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
export function usePrevious<T>(value: T): T | undefined { | |
const ref = React.useRef<T>(); | |
React.useEffect(() => { | |
ref.current = value; | |
}); | |
return ref.current; | |
} |
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 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, | |
); | |
} |
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
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