UNPKG

framer-motion

Version:

A simple and powerful React animation library

475 lines (472 loc) • 20.3 kB
import sync, { cancelSync } from 'framesync'; import { initPrefersReducedMotion } from '../utils/reduced-motion/index.mjs'; import { hasReducedMotionListener, prefersReducedMotion } from '../utils/reduced-motion/state.mjs'; import { motionValue } from '../value/index.mjs'; import { isWillChangeMotionValue } from '../value/use-will-change/is.mjs'; import { isMotionValue } from '../value/utils/is-motion-value.mjs'; import { variantPriorityOrder } from './utils/animation-state.mjs'; import { isVariantLabel } from './utils/is-variant-label.mjs'; import { createLifecycles } from './utils/lifecycles.mjs'; import { updateMotionValuesFromProps } from './utils/motion-values.mjs'; import { isControllingVariants, isVariantNode } from './utils/is-controlling-variants.mjs'; import { env } from '../utils/process.mjs'; import { invariant } from 'hey-listen'; import { featureDefinitions } from '../motion/features/definitions.mjs'; import { createElement } from 'react'; import { isRefObject } from '../utils/is-ref-object.mjs'; const featureNames = Object.keys(featureDefinitions); const numFeatures = featureNames.length; const visualElement = ({ treeType = "", build, getBaseTarget, makeTargetAnimatable, measureViewportBox, render: renderInstance, readValueFromInstance, removeValueFromRenderState, sortNodePosition, scrapeMotionValuesFromProps, }) => ({ parent, props, presenceId, blockInitialAnimation, visualState, reducedMotionConfig, }, options = {}) => { let isMounted = false; const { latestValues, renderState } = visualState; /** * The instance of the render-specific node that will be hydrated by the * exposed React ref. So for example, this visual element can host a * HTMLElement, plain object, or Three.js object. The functions provided * in VisualElementConfig allow us to interface with this instance. */ let instance; /** * Manages the subscriptions for a visual element's lifecycle, for instance * onRender */ const lifecycles = createLifecycles(); /** * A map of all motion values attached to this visual element. Motion * values are source of truth for any given animated value. A motion * value might be provided externally by the component via props. */ const values = new Map(); /** * A map of every subscription that binds the provided or generated * motion values onChange listeners to this visual element. */ const valueSubscriptions = new Map(); /** * A reference to the previously-provided motion values as returned * from scrapeMotionValuesFromProps. We use the keys in here to determine * if any motion values need to be removed after props are updated. */ let prevMotionValues = {}; /** * When values are removed from all animation props we need to search * for a fallback value to animate to. These values are tracked in baseTarget. */ const baseTarget = { ...latestValues, }; // Internal methods ======================== /** * On mount, this will be hydrated with a callback to disconnect * this visual element from its parent on unmount. */ let removeFromVariantTree; /** * Render the element with the latest styles outside of the React * render lifecycle */ function render() { if (!instance || !isMounted) return; triggerBuild(); renderInstance(instance, renderState, props.style, element.projection); } function triggerBuild() { build(element, renderState, latestValues, options, props); } function update() { lifecycles.notifyUpdate(latestValues); } /** * */ function bindToMotionValue(key, value) { const removeOnChange = value.onChange((latestValue) => { latestValues[key] = latestValue; props.onUpdate && sync.update(update, false, true); }); const removeOnRenderRequest = value.onRenderRequest(element.scheduleRender); valueSubscriptions.set(key, () => { removeOnChange(); removeOnRenderRequest(); }); } /** * Any motion values that are provided to the element when created * aren't yet bound to the element, as this would technically be impure. * However, we iterate through the motion values and set them to the * initial values for this component. * * TODO: This is impure and we should look at changing this to run on mount. * Doing so will break some tests but this isn't neccessarily a breaking change, * more a reflection of the test. */ const { willChange, ...initialMotionValues } = scrapeMotionValuesFromProps(props); for (const key in initialMotionValues) { const value = initialMotionValues[key]; if (latestValues[key] !== undefined && isMotionValue(value)) { value.set(latestValues[key], false); if (isWillChangeMotionValue(willChange)) { willChange.add(key); } } } /** * Determine what role this visual element should take in the variant tree. */ const isControllingVariants$1 = isControllingVariants(props); const isVariantNode$1 = isVariantNode(props); const element = { treeType, /** * This is a mirror of the internal instance prop, which keeps * VisualElement type-compatible with React's RefObject. */ current: null, /** * The depth of this visual element within the visual element tree. */ depth: parent ? parent.depth + 1 : 0, parent, children: new Set(), /** * */ presenceId, shouldReduceMotion: null, /** * If this component is part of the variant tree, it should track * any children that are also part of the tree. This is essentially * a shadow tree to simplify logic around how to stagger over children. */ variantChildren: isVariantNode$1 ? new Set() : undefined, /** * Whether this instance is visible. This can be changed imperatively * by the projection tree, is analogous to CSS's visibility in that * hidden elements should take up layout, and needs enacting by the configured * render function. */ isVisible: undefined, /** * Normally, if a component is controlled by a parent's variants, it can * rely on that ancestor to trigger animations further down the tree. * However, if a component is created after its parent is mounted, the parent * won't trigger that mount animation so the child needs to. * * TODO: This might be better replaced with a method isParentMounted */ manuallyAnimateOnMount: Boolean(parent === null || parent === void 0 ? void 0 : parent.isMounted()), /** * This can be set by AnimatePresence to force components that mount * at the same time as it to mount as if they have initial={false} set. */ blockInitialAnimation, /** * Determine whether this component has mounted yet. This is mostly used * by variant children to determine whether they need to trigger their * own animations on mount. */ isMounted: () => Boolean(instance), mount(newInstance) { isMounted = true; instance = element.current = newInstance; if (element.projection) { element.projection.mount(newInstance); } if (isVariantNode$1 && parent && !isControllingVariants$1) { removeFromVariantTree = parent === null || parent === void 0 ? void 0 : parent.addVariantChild(element); } values.forEach((value, key) => bindToMotionValue(key, value)); if (!hasReducedMotionListener.current) { initPrefersReducedMotion(); } element.shouldReduceMotion = reducedMotionConfig === "never" ? false : reducedMotionConfig === "always" ? true : prefersReducedMotion.current; parent === null || parent === void 0 ? void 0 : parent.children.add(element); element.setProps(props); }, /** * */ unmount() { var _a; (_a = element.projection) === null || _a === void 0 ? void 0 : _a.unmount(); cancelSync.update(update); cancelSync.render(render); valueSubscriptions.forEach((remove) => remove()); removeFromVariantTree === null || removeFromVariantTree === void 0 ? void 0 : removeFromVariantTree(); parent === null || parent === void 0 ? void 0 : parent.children.delete(element); lifecycles.clearAllListeners(); instance = undefined; isMounted = false; }, loadFeatures(renderedProps, isStrict, preloadedFeatures, projectionId, ProjectionNodeConstructor, initialLayoutGroupConfig) { const features = []; /** * If we're in development mode, check to make sure we're not rendering a motion component * as a child of LazyMotion, as this will break the file-size benefits of using it. */ if (env !== "production" && preloadedFeatures && isStrict) { invariant(false, "You have rendered a `motion` component within a `LazyMotion` component. This will break tree shaking. Import and render a `m` component instead."); } for (let i = 0; i < numFeatures; i++) { const name = featureNames[i]; const { isEnabled, Component } = featureDefinitions[name]; /** * It might be possible in the future to use this moment to * dynamically request functionality. In initial tests this * was producing a lot of duplication amongst bundles. */ if (isEnabled(props) && Component) { features.push(createElement(Component, { key: name, ...renderedProps, visualElement: element, })); } } if (!element.projection && ProjectionNodeConstructor) { element.projection = new ProjectionNodeConstructor(projectionId, element.getLatestValues(), parent && parent.projection); const { layoutId, layout, drag, dragConstraints, layoutScroll, } = renderedProps; element.projection.setOptions({ layoutId, layout, alwaysMeasureLayout: Boolean(drag) || (dragConstraints && isRefObject(dragConstraints)), visualElement: element, scheduleRender: () => element.scheduleRender(), /** * TODO: Update options in an effect. This could be tricky as it'll be too late * to update by the time layout animations run. * We also need to fix this safeToRemove by linking it up to the one returned by usePresence, * ensuring it gets called if there's no potential layout animations. * */ animationType: typeof layout === "string" ? layout : "both", initialPromotionConfig: initialLayoutGroupConfig, layoutScroll, }); } return features; }, /** * Add a child visual element to our set of children. */ addVariantChild(child) { var _a; const closestVariantNode = element.getClosestVariantNode(); if (closestVariantNode) { (_a = closestVariantNode.variantChildren) === null || _a === void 0 ? void 0 : _a.add(child); return () => closestVariantNode.variantChildren.delete(child); } }, sortNodePosition(other) { /** * If these nodes aren't even of the same type we can't compare their depth. */ if (!sortNodePosition || treeType !== other.treeType) return 0; return sortNodePosition(element.getInstance(), other.getInstance()); }, /** * Returns the closest variant node in the tree starting from * this visual element. */ getClosestVariantNode: () => isVariantNode$1 ? element : parent === null || parent === void 0 ? void 0 : parent.getClosestVariantNode(), /** * Expose the latest layoutId prop. */ getLayoutId: () => props.layoutId, /** * Returns the current instance. */ getInstance: () => instance, /** * Get/set the latest static values. */ getStaticValue: (key) => latestValues[key], setStaticValue: (key, value) => (latestValues[key] = value), /** * Returns the latest motion value state. Currently only used to take * a snapshot of the visual element - perhaps this can return the whole * visual state */ getLatestValues: () => latestValues, /** * Set the visiblity of the visual element. If it's changed, schedule * a render to reflect these changes. */ setVisibility(visibility) { if (element.isVisible === visibility) return; element.isVisible = visibility; element.scheduleRender(); }, /** * Make a target animatable by Popmotion. For instance, if we're * trying to animate width from 100px to 100vw we need to measure 100vw * in pixels to determine what we really need to animate to. This is also * pluggable to support Framer's custom value types like Color, * and CSS variables. */ makeTargetAnimatable(target, canMutate = true) { return makeTargetAnimatable(element, target, props, canMutate); }, /** * Measure the current viewport box with or without transforms. * Only measures axis-aligned boxes, rotate and skew must be manually * removed with a re-render to work. */ measureViewportBox() { return measureViewportBox(instance, props); }, // Motion values ======================== /** * Add a motion value and bind it to this visual element. */ addValue(key, value) { // Remove existing value if it exists if (element.hasValue(key)) element.removeValue(key); values.set(key, value); latestValues[key] = value.get(); bindToMotionValue(key, value); }, /** * Remove a motion value and unbind any active subscriptions. */ removeValue(key) { var _a; values.delete(key); (_a = valueSubscriptions.get(key)) === null || _a === void 0 ? void 0 : _a(); valueSubscriptions.delete(key); delete latestValues[key]; removeValueFromRenderState(key, renderState); }, /** * Check whether we have a motion value for this key */ hasValue: (key) => values.has(key), /** * Get a motion value for this key. If called with a default * value, we'll create one if none exists. */ getValue(key, defaultValue) { let value = values.get(key); if (value === undefined && defaultValue !== undefined) { value = motionValue(defaultValue); element.addValue(key, value); } return value; }, /** * Iterate over our motion values. */ forEachValue: (callback) => values.forEach(callback), /** * If we're trying to animate to a previously unencountered value, * we need to check for it in our state and as a last resort read it * directly from the instance (which might have performance implications). */ readValue: (key) => latestValues[key] !== undefined ? latestValues[key] : readValueFromInstance(instance, key, options), /** * Set the base target to later animate back to. This is currently * only hydrated on creation and when we first read a value. */ setBaseTarget(key, value) { baseTarget[key] = value; }, /** * Find the base target for a value thats been removed from all animation * props. */ getBaseTarget(key) { if (getBaseTarget) { const target = getBaseTarget(props, key); if (target !== undefined && !isMotionValue(target)) return target; } return baseTarget[key]; }, // Lifecyles ======================== ...lifecycles, /** * Build the renderer state based on the latest visual state. */ build() { triggerBuild(); return renderState; }, /** * Schedule a render on the next animation frame. */ scheduleRender() { sync.render(render, false, true); }, /** * Synchronously fire render. It's prefered that we batch renders but * in many circumstances, like layout measurement, we need to run this * synchronously. However in those instances other measures should be taken * to batch reads/writes. */ syncRender: render, /** * Update the provided props. Ensure any newly-added motion values are * added to our map, old ones removed, and listeners updated. */ setProps(newProps) { if (newProps.transformTemplate || props.transformTemplate) { element.scheduleRender(); } props = newProps; lifecycles.updatePropListeners(newProps); prevMotionValues = updateMotionValuesFromProps(element, scrapeMotionValuesFromProps(props), prevMotionValues); }, getProps: () => props, // Variants ============================== /** * Returns the variant definition with a given name. */ getVariant: (name) => { var _a; return (_a = props.variants) === null || _a === void 0 ? void 0 : _a[name]; }, /** * Returns the defined default transition on this component. */ getDefaultTransition: () => props.transition, getTransformPagePoint: () => { return props.transformPagePoint; }, /** * Used by child variant nodes to get the closest ancestor variant props. */ getVariantContext(startAtParent = false) { if (startAtParent) return parent === null || parent === void 0 ? void 0 : parent.getVariantContext(); if (!isControllingVariants$1) { const context = (parent === null || parent === void 0 ? void 0 : parent.getVariantContext()) || {}; if (props.initial !== undefined) { context.initial = props.initial; } return context; } const context = {}; for (let i = 0; i < numVariantProps; i++) { const name = variantProps[i]; const prop = props[name]; if (isVariantLabel(prop) || prop === false) { context[name] = prop; } } return context; }, }; return element; }; const variantProps = ["initial", ...variantPriorityOrder]; const numVariantProps = variantProps.length; export { visualElement };