338 lines
10 KiB
JavaScript
338 lines
10 KiB
JavaScript
import { SubscriptionManager } from '../utils/subscription-manager.mjs';
|
|
import { velocityPerSecond } from '../utils/velocity-per-second.mjs';
|
|
import { warnOnce } from '../utils/warn-once.mjs';
|
|
import { frame, frameData } from '../frameloop/frame.mjs';
|
|
|
|
const isFloat = (value) => {
|
|
return !isNaN(parseFloat(value));
|
|
};
|
|
const collectMotionValues = {
|
|
current: undefined,
|
|
};
|
|
/**
|
|
* `MotionValue` is used to track the state and velocity of motion values.
|
|
*
|
|
* @public
|
|
*/
|
|
class MotionValue {
|
|
/**
|
|
* @param init - The initiating value
|
|
* @param config - Optional configuration options
|
|
*
|
|
* - `transformer`: A function to transform incoming values with.
|
|
*
|
|
* @internal
|
|
*/
|
|
constructor(init, options = {}) {
|
|
/**
|
|
* This will be replaced by the build step with the latest version number.
|
|
* When MotionValues are provided to motion components, warn if versions are mixed.
|
|
*/
|
|
this.version = "10.18.0";
|
|
/**
|
|
* Duration, in milliseconds, since last updating frame.
|
|
*
|
|
* @internal
|
|
*/
|
|
this.timeDelta = 0;
|
|
/**
|
|
* Timestamp of the last time this `MotionValue` was updated.
|
|
*
|
|
* @internal
|
|
*/
|
|
this.lastUpdated = 0;
|
|
/**
|
|
* Tracks whether this value can output a velocity. Currently this is only true
|
|
* if the value is numerical, but we might be able to widen the scope here and support
|
|
* other value types.
|
|
*
|
|
* @internal
|
|
*/
|
|
this.canTrackVelocity = false;
|
|
/**
|
|
* An object containing a SubscriptionManager for each active event.
|
|
*/
|
|
this.events = {};
|
|
this.updateAndNotify = (v, render = true) => {
|
|
this.prev = this.current;
|
|
this.current = v;
|
|
// Update timestamp
|
|
const { delta, timestamp } = frameData;
|
|
if (this.lastUpdated !== timestamp) {
|
|
this.timeDelta = delta;
|
|
this.lastUpdated = timestamp;
|
|
frame.postRender(this.scheduleVelocityCheck);
|
|
}
|
|
// Update update subscribers
|
|
if (this.prev !== this.current && this.events.change) {
|
|
this.events.change.notify(this.current);
|
|
}
|
|
// Update velocity subscribers
|
|
if (this.events.velocityChange) {
|
|
this.events.velocityChange.notify(this.getVelocity());
|
|
}
|
|
// Update render subscribers
|
|
if (render && this.events.renderRequest) {
|
|
this.events.renderRequest.notify(this.current);
|
|
}
|
|
};
|
|
/**
|
|
* Schedule a velocity check for the next frame.
|
|
*
|
|
* This is an instanced and bound function to prevent generating a new
|
|
* function once per frame.
|
|
*
|
|
* @internal
|
|
*/
|
|
this.scheduleVelocityCheck = () => frame.postRender(this.velocityCheck);
|
|
/**
|
|
* Updates `prev` with `current` if the value hasn't been updated this frame.
|
|
* This ensures velocity calculations return `0`.
|
|
*
|
|
* This is an instanced and bound function to prevent generating a new
|
|
* function once per frame.
|
|
*
|
|
* @internal
|
|
*/
|
|
this.velocityCheck = ({ timestamp }) => {
|
|
if (timestamp !== this.lastUpdated) {
|
|
this.prev = this.current;
|
|
if (this.events.velocityChange) {
|
|
this.events.velocityChange.notify(this.getVelocity());
|
|
}
|
|
}
|
|
};
|
|
this.hasAnimated = false;
|
|
this.prev = this.current = init;
|
|
this.canTrackVelocity = isFloat(this.current);
|
|
this.owner = options.owner;
|
|
}
|
|
/**
|
|
* Adds a function that will be notified when the `MotionValue` is updated.
|
|
*
|
|
* It returns a function that, when called, will cancel the subscription.
|
|
*
|
|
* When calling `onChange` inside a React component, it should be wrapped with the
|
|
* `useEffect` hook. As it returns an unsubscribe function, this should be returned
|
|
* from the `useEffect` function to ensure you don't add duplicate subscribers..
|
|
*
|
|
* ```jsx
|
|
* export const MyComponent = () => {
|
|
* const x = useMotionValue(0)
|
|
* const y = useMotionValue(0)
|
|
* const opacity = useMotionValue(1)
|
|
*
|
|
* useEffect(() => {
|
|
* function updateOpacity() {
|
|
* const maxXY = Math.max(x.get(), y.get())
|
|
* const newOpacity = transform(maxXY, [0, 100], [1, 0])
|
|
* opacity.set(newOpacity)
|
|
* }
|
|
*
|
|
* const unsubscribeX = x.on("change", updateOpacity)
|
|
* const unsubscribeY = y.on("change", updateOpacity)
|
|
*
|
|
* return () => {
|
|
* unsubscribeX()
|
|
* unsubscribeY()
|
|
* }
|
|
* }, [])
|
|
*
|
|
* return <motion.div style={{ x }} />
|
|
* }
|
|
* ```
|
|
*
|
|
* @param subscriber - A function that receives the latest value.
|
|
* @returns A function that, when called, will cancel this subscription.
|
|
*
|
|
* @deprecated
|
|
*/
|
|
onChange(subscription) {
|
|
if (process.env.NODE_ENV !== "production") {
|
|
warnOnce(false, `value.onChange(callback) is deprecated. Switch to value.on("change", callback).`);
|
|
}
|
|
return this.on("change", subscription);
|
|
}
|
|
on(eventName, callback) {
|
|
if (!this.events[eventName]) {
|
|
this.events[eventName] = new SubscriptionManager();
|
|
}
|
|
const unsubscribe = this.events[eventName].add(callback);
|
|
if (eventName === "change") {
|
|
return () => {
|
|
unsubscribe();
|
|
/**
|
|
* If we have no more change listeners by the start
|
|
* of the next frame, stop active animations.
|
|
*/
|
|
frame.read(() => {
|
|
if (!this.events.change.getSize()) {
|
|
this.stop();
|
|
}
|
|
});
|
|
};
|
|
}
|
|
return unsubscribe;
|
|
}
|
|
clearListeners() {
|
|
for (const eventManagers in this.events) {
|
|
this.events[eventManagers].clear();
|
|
}
|
|
}
|
|
/**
|
|
* Attaches a passive effect to the `MotionValue`.
|
|
*
|
|
* @internal
|
|
*/
|
|
attach(passiveEffect, stopPassiveEffect) {
|
|
this.passiveEffect = passiveEffect;
|
|
this.stopPassiveEffect = stopPassiveEffect;
|
|
}
|
|
/**
|
|
* Sets the state of the `MotionValue`.
|
|
*
|
|
* @remarks
|
|
*
|
|
* ```jsx
|
|
* const x = useMotionValue(0)
|
|
* x.set(10)
|
|
* ```
|
|
*
|
|
* @param latest - Latest value to set.
|
|
* @param render - Whether to notify render subscribers. Defaults to `true`
|
|
*
|
|
* @public
|
|
*/
|
|
set(v, render = true) {
|
|
if (!render || !this.passiveEffect) {
|
|
this.updateAndNotify(v, render);
|
|
}
|
|
else {
|
|
this.passiveEffect(v, this.updateAndNotify);
|
|
}
|
|
}
|
|
setWithVelocity(prev, current, delta) {
|
|
this.set(current);
|
|
this.prev = prev;
|
|
this.timeDelta = delta;
|
|
}
|
|
/**
|
|
* Set the state of the `MotionValue`, stopping any active animations,
|
|
* effects, and resets velocity to `0`.
|
|
*/
|
|
jump(v) {
|
|
this.updateAndNotify(v);
|
|
this.prev = v;
|
|
this.stop();
|
|
if (this.stopPassiveEffect)
|
|
this.stopPassiveEffect();
|
|
}
|
|
/**
|
|
* Returns the latest state of `MotionValue`
|
|
*
|
|
* @returns - The latest state of `MotionValue`
|
|
*
|
|
* @public
|
|
*/
|
|
get() {
|
|
if (collectMotionValues.current) {
|
|
collectMotionValues.current.push(this);
|
|
}
|
|
return this.current;
|
|
}
|
|
/**
|
|
* @public
|
|
*/
|
|
getPrevious() {
|
|
return this.prev;
|
|
}
|
|
/**
|
|
* Returns the latest velocity of `MotionValue`
|
|
*
|
|
* @returns - The latest velocity of `MotionValue`. Returns `0` if the state is non-numerical.
|
|
*
|
|
* @public
|
|
*/
|
|
getVelocity() {
|
|
// This could be isFloat(this.prev) && isFloat(this.current), but that would be wasteful
|
|
return this.canTrackVelocity
|
|
? // These casts could be avoided if parseFloat would be typed better
|
|
velocityPerSecond(parseFloat(this.current) -
|
|
parseFloat(this.prev), this.timeDelta)
|
|
: 0;
|
|
}
|
|
/**
|
|
* Registers a new animation to control this `MotionValue`. Only one
|
|
* animation can drive a `MotionValue` at one time.
|
|
*
|
|
* ```jsx
|
|
* value.start()
|
|
* ```
|
|
*
|
|
* @param animation - A function that starts the provided animation
|
|
*
|
|
* @internal
|
|
*/
|
|
start(startAnimation) {
|
|
this.stop();
|
|
return new Promise((resolve) => {
|
|
this.hasAnimated = true;
|
|
this.animation = startAnimation(resolve);
|
|
if (this.events.animationStart) {
|
|
this.events.animationStart.notify();
|
|
}
|
|
}).then(() => {
|
|
if (this.events.animationComplete) {
|
|
this.events.animationComplete.notify();
|
|
}
|
|
this.clearAnimation();
|
|
});
|
|
}
|
|
/**
|
|
* Stop the currently active animation.
|
|
*
|
|
* @public
|
|
*/
|
|
stop() {
|
|
if (this.animation) {
|
|
this.animation.stop();
|
|
if (this.events.animationCancel) {
|
|
this.events.animationCancel.notify();
|
|
}
|
|
}
|
|
this.clearAnimation();
|
|
}
|
|
/**
|
|
* Returns `true` if this value is currently animating.
|
|
*
|
|
* @public
|
|
*/
|
|
isAnimating() {
|
|
return !!this.animation;
|
|
}
|
|
clearAnimation() {
|
|
delete this.animation;
|
|
}
|
|
/**
|
|
* Destroy and clean up subscribers to this `MotionValue`.
|
|
*
|
|
* The `MotionValue` hooks like `useMotionValue` and `useTransform` automatically
|
|
* handle the lifecycle of the returned `MotionValue`, so this method is only necessary if you've manually
|
|
* created a `MotionValue` via the `motionValue` function.
|
|
*
|
|
* @public
|
|
*/
|
|
destroy() {
|
|
this.clearListeners();
|
|
this.stop();
|
|
if (this.stopPassiveEffect) {
|
|
this.stopPassiveEffect();
|
|
}
|
|
}
|
|
}
|
|
function motionValue(init, options) {
|
|
return new MotionValue(init, options);
|
|
}
|
|
|
|
export { MotionValue, collectMotionValues, motionValue };
|