framer-motion
Version:
An open source and production-ready motion library for React on the web
948 lines (815 loc) • 31.4 kB
text/typescript
import sync, { cancelSync } from "framesync"
import { pipe } from "popmotion"
import { Presence } from "../components/AnimateSharedLayout/types"
import { Crossfader } from "../components/AnimateSharedLayout/utils/crossfader"
import { MotionStyle } from "../motion/types"
import { eachAxis } from "../utils/each-axis"
import { copyAxisBox } from "../utils/geometry"
import {
applyBoxTransforms,
removeBoxTransforms,
} from "../utils/geometry/delta-apply"
import { updateBoxDelta } from "../utils/geometry/delta-calc"
import { isRefObject } from "../utils/is-ref-object"
import { motionValue, MotionValue } from "../value"
import { isMotionValue } from "../value/utils/is-motion-value"
import { buildLayoutProjectionTransform } from "./dom/utils/build-transform"
import {
VisualElement,
VisualElementConfig,
MotionPoint,
VisualElementOptions,
} from "./types"
import { variantPriorityOrder } from "./utils/animation-state"
import { createLifecycles } from "./utils/lifecycles"
import { updateMotionValuesFromProps } from "./utils/motion-values"
import { updateLayoutDeltas } from "./utils/projection"
import {
createLayoutState,
createProjectionState,
createVisualState,
} from "./utils/state"
import { checkIfControllingVariants, isVariantLabel } from "./utils/variants"
export const visualElement = <Instance, MutableState, Options>({
treeType = "",
createRenderState,
build,
getBaseTarget,
makeTargetAnimatable,
measureViewportBox,
onMount,
render: renderInstance,
readValueFromInstance,
resetTransform,
restoreTransform,
removeValueFromMutableState,
sortNodePosition,
scrapeMotionValuesFromProps,
}: VisualElementConfig<Instance, MutableState, Options>) => (
{
parent,
ref: externalRef,
props,
isStatic,
presenceId,
blockInitialAnimation,
}: VisualElementOptions<Instance>,
options: Options = {} as Options
) => {
/**
* 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: Instance
/**
* A set of all children of this visual element. We use this to traverse
* the tree when updating layout projections.
*/
const children = new Set<VisualElement>()
/**
* Manages the subscriptions for a visual element's lifecycle, for instance
* onRender and onViewportBoxUpdate.
*/
const lifecycles = createLifecycles()
/**
*
*/
const projection = createProjectionState()
/**
* The latest resolved motion values.
*/
let latestValues = createVisualState(props, parent, blockInitialAnimation)
/**
* This is a reference to the visual state of the "lead" visual element.
* Usually, this will be this visual element. But if it shares a layoutId
* with other visual elements, only one of them will be designated lead by
* AnimateSharedLayout. All the other visual elements will take on the visual
* appearance of the lead while they crossfade to it.
*/
let leadProjection = projection
let leadLatestValues = latestValues
let unsubscribeFromLeadVisualElement: Function
/**
* The latest layout measurements and calculated projections. This
* is seperate from the target projection data in visualState as
* many visual elements might point to the same piece of visualState as
* a target, whereas they might each have different layouts and thus
* projection calculations needed to project into the same viewport box.
*/
const layoutState = createLayoutState()
/**
* Each visual element creates a pool of renderer-specific mutable state
* which allows renderer-specific calculations to occur while reducing GC.
*/
let renderState = createRenderState()
/**
*
*/
let crossfader: Crossfader
/**
* Keep track of whether the viewport box has been updated since the
* last time the layout projection was re-calculated.
*/
let hasViewportBoxUpdated = false
/**
* 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<string, MotionValue>()
/**
* A map of every subscription that binds the provided or generated
* motion values onChange listeners to this visual element.
*/
const valueSubscriptions = new Map<string, () => void>()
/**
* 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: MotionStyle = {}
/**
* x/y motion values that track the progress of initiated layout
* animations.
*
* TODO: Target for removal
*/
let projectionTargetProgress: MotionPoint
/**
* 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: { [key: string]: string | number | null } = {
...latestValues,
}
// Internal methods ========================
/**
* On mount, this will be hydrated with a callback to disconnect
* this visual element from its parent on unmount.
*/
let removeFromMotionTree: undefined | (() => void)
let removeFromVariantTree: undefined | (() => void)
/**
*
*/
function mount() {
element.pointTo(element)
removeFromMotionTree = parent?.addChild(element)
if (isVariantNode && parent && !isControllingVariants) {
removeFromVariantTree = parent?.addVariantChild(element)
}
onMount?.(element, instance, renderState)
}
/**
*
*/
function unmount() {
cancelSync.update(update)
cancelSync.render(render)
cancelSync.preRender(element.updateLayoutProjection)
valueSubscriptions.forEach((remove) => remove())
element.stopLayoutAnimation()
removeFromMotionTree?.()
removeFromVariantTree?.()
unsubscribeFromLeadVisualElement?.()
lifecycles.clearAllListeners()
}
/**
*
*/
function isProjecting() {
return projection.isEnabled && layoutState.isHydrated
}
/**
*
*/
function render() {
if (!instance) return
if (isProjecting()) {
/**
* Apply the latest user-set transforms to the targetBox to produce the targetBoxFinal.
* This is the final box that we will then project into by calculating a transform delta and
* applying it to the corrected box.
*/
applyBoxTransforms(
leadProjection.targetFinal,
leadProjection.target,
leadLatestValues
)
/**
* Update the delta between the corrected box and the final target box, after
* user-set transforms are applied to it. This will be used by the renderer to
* create a transform style that will reproject the element from its actual layout
* into the desired bounding box.
*/
updateBoxDelta(
layoutState.deltaFinal,
layoutState.layoutCorrected,
leadProjection.targetFinal,
latestValues
)
}
triggerBuild()
renderInstance(instance, renderState)
}
function triggerBuild() {
let valuesToRender = latestValues
if (crossfader && crossfader.isActive()) {
valuesToRender = crossfader.getCrossfadeState(element)
}
build(
element,
renderState,
valuesToRender,
leadProjection,
layoutState,
options,
props
)
}
function update() {
lifecycles.notifyUpdate(latestValues)
}
function updateLayoutProjection() {
const { delta, treeScale } = layoutState
const prevTreeScaleX = treeScale.x
const prevTreeScaleY = treeScale.x
const prevDeltaTransform = layoutState.deltaTransform
updateLayoutDeltas(
layoutState,
leadProjection,
element.path,
latestValues
)
hasViewportBoxUpdated &&
element.notifyViewportBoxUpdate(leadProjection.target, delta)
hasViewportBoxUpdated = false
const deltaTransform = buildLayoutProjectionTransform(delta, treeScale)
if (
deltaTransform !== prevDeltaTransform ||
// Also compare calculated treeScale, for values that rely on this only for scale correction
prevTreeScaleX !== treeScale.x ||
prevTreeScaleY !== treeScale.y
) {
element.scheduleRender()
}
layoutState.deltaTransform = deltaTransform
}
/**
*
*/
function bindToMotionValue(key: string, value: MotionValue) {
const removeOnChange = value.onChange(
(latestValue: string | number) => {
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 initialMotionValues = scrapeMotionValuesFromProps(props)
for (const key in initialMotionValues) {
const value = initialMotionValues[key]
if (latestValues[key] !== undefined && isMotionValue(value)) {
value.set(latestValues[key], false)
}
}
/**
* Determine what role this visual element should take in the variant tree.
*/
const isControllingVariants = checkIfControllingVariants(props)
const definesInitialVariant = isVariantLabel(props.initial)
const isVariantNode = Boolean(
definesInitialVariant || isControllingVariants || props.variants
)
const element: VisualElement<Instance> = {
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,
/**
* An ancestor path back to the root visual element. This is used
* by layout projection to quickly recurse back up the tree.
*/
path: parent ? [...parent.path, parent] : [],
/**
*
*/
presenceId,
projection,
/**
* 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 ? new Set() : undefined,
/**
* Whether this instance is visible. This can be changed imperatively
* by AnimateSharedLayout, 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?.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,
/**
* If a visual element is static, it's essentially in "pure" mode with
* no additional functionality like animations or gestures loaded in.
* This can be considered Framer canvas mode.
*/
isStatic,
/**
* A boolean that can be used to determine whether to respect hover events.
* For layout measurements we often have to reposition the instance by
* removing its transform. This can trigger hover events, which is
* undesired.
*/
isHoverEventsEnabled: true,
/**
* 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),
/**
* Add a child visual element to our set of children.
*/
addChild(child) {
children.add(child)
return () => children.delete(child)
},
/**
* Add a child visual element to our set of children.
*/
addVariantChild(child) {
const closestVariantNode = element.getClosestVariantNode()
if (closestVariantNode) {
closestVariantNode.variantChildren?.add(child)
return () => closestVariantNode.variantChildren!.delete(child)
}
},
sortNodePosition(other: VisualElement) {
/**
* 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() as Instance,
other.getInstance()
)
},
/**
* Returns the closest variant node in the tree starting from
* this visual element.
*/
getClosestVariantNode: () =>
isVariantNode ? element : parent?.getClosestVariantNode(),
/**
* A method that schedules an update to layout projections throughout
* the tree. We inherit from the parent so there's only ever one
* job scheduled on the next frame - that of the root visual element.
*/
scheduleUpdateLayoutProjection: parent
? parent.scheduleUpdateLayoutProjection
: () => sync.preRender(element.updateLayoutProjection, false, true),
/**
* 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,
/**
* Replaces the current mutable states with fresh ones. This is used
* in static mode where rather than creating a new visual element every
* render we can just make fresh state.
*/
clearState(newProps) {
values.clear()
props = newProps
leadLatestValues = latestValues = createVisualState(
props,
parent,
blockInitialAnimation
)
renderState = createRenderState()
},
/**
* 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)
},
/**
* Temporarily suspend hover events while we remove transforms in order to measure the layout.
*
* This seems like an odd bit of scheduling but what we're doing is saying after
* the next render, wait 10 milliseconds before reenabling hover events. Waiting until
* the next frame results in missed, valid hover events. But triggering on the postRender
* frame is too soon to avoid triggering events with layout measurements.
*
* Note: If we figure out a way of measuring layout while transforms remain applied, this can be removed.
*/
suspendHoverEvents() {
element.isHoverEventsEnabled = false
sync.postRender(() =>
setTimeout(() => (element.isHoverEventsEnabled = true), 10)
)
},
// 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) {
values.delete(key)
valueSubscriptions.get(key)?.()
valueSubscriptions.delete(key)
delete latestValues[key]
removeValueFromMutableState(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: string, defaultValue?: string | number) {
let value = values.get(key)
if (value === undefined && defaultValue !== undefined) {
value = motionValue(defaultValue)
element.addValue(key, value)
}
return value as MotionValue
},
/**
* 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: string) =>
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,
/**
* A ref function to be provided to the mounting React component.
* This is used to hydrated the instance and run mount/unmount lifecycles.
*/
ref(mountingElement: any) {
instance = element.current = mountingElement
mountingElement ? mount() : unmount()
if (!externalRef) return
if (typeof externalRef === "function") {
externalRef(mountingElement)
} else if (isRefObject(externalRef)) {
;(externalRef as any).current = mountingElement
}
},
/**
* 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) {
props = newProps
lifecycles.updatePropListeners(newProps)
prevMotionValues = updateMotionValuesFromProps(
element,
scrapeMotionValuesFromProps(props),
prevMotionValues
)
},
getProps: () => props,
// Variants ==============================
/**
* Returns the variant definition with a given name.
*/
getVariant: (name) => props.variants?.[name],
/**
* Returns the defined default transition on this component.
*/
getDefaultTransition: () => props.transition,
/**
* Used by child variant nodes to get the closest ancestor variant props.
*/
getVariantContext(startAtParent = false) {
if (startAtParent) return parent?.getVariantContext()
if (!isControllingVariants) {
const context = parent?.getVariantContext() || {}
if (props.initial !== undefined) {
context.initial = props.initial as any
}
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
},
// Layout projection ==============================
/**
* Enable layout projection for this visual element. Won't actually
* occur until we also have hydrated layout measurements.
*/
enableLayoutProjection() {
projection.isEnabled = true
},
/**
* Lock the projection target, for instance when dragging, so
* nothing else can try and animate it.
*/
lockProjectionTarget() {
projection.isTargetLocked = true
},
unlockProjectionTarget() {
element.stopLayoutAnimation()
projection.isTargetLocked = false
},
/**
* Record the viewport box as it was before an expected mutation/re-render
*/
snapshotViewportBox() {
// TODO: Store this snapshot in LayoutState
element.prevViewportBox = element.measureViewportBox(false)
/**
* Update targetBox to match the prevViewportBox. This is just to ensure
* that targetBox is affected by scroll in the same way as the measured box
*/
element.rebaseProjectionTarget(false, element.prevViewportBox)
},
getLayoutState: () => layoutState,
setCrossfader(newCrossfader) {
crossfader = newCrossfader
},
/**
* Start a layout animation on a given axis.
* TODO: This could be better.
*/
startLayoutAnimation(axis, transition) {
const progress = element.getProjectionAnimationProgress()[axis]
const { min, max } = projection.target[axis]
const length = max - min
progress.clearListeners()
progress.set(min)
progress.set(min) // Set twice to hard-reset velocity
progress.onChange((v) =>
element.setProjectionTargetAxis(axis, v, v + length)
)
return element.animateMotionValue!(axis, progress, 0, transition)
},
/**
* Stop layout animations.
*/
stopLayoutAnimation() {
eachAxis((axis) =>
element.getProjectionAnimationProgress()[axis].stop()
)
},
/**
* 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(withTransform = true) {
const viewportBox = measureViewportBox(instance, options)
if (!withTransform) removeBoxTransforms(viewportBox, latestValues)
return viewportBox
},
/**
* Update the layoutState by measuring the DOM layout. This
* should be called after resetting any layout-affecting transforms.
*/
updateLayoutMeasurement() {
element.notifyBeforeLayoutMeasure(layoutState.layout)
layoutState.isHydrated = true
layoutState.layout = element.measureViewportBox()
layoutState.layoutCorrected = copyAxisBox(layoutState.layout)
element.notifyLayoutMeasure(
layoutState.layout,
element.prevViewportBox || layoutState.layout
)
sync.update(() => element.rebaseProjectionTarget())
},
/**
* Get the motion values tracking the layout animations on each
* axis. Lazy init if not already created.
*/
getProjectionAnimationProgress() {
projectionTargetProgress ||= {
x: motionValue(0),
y: motionValue(0),
}
return projectionTargetProgress
},
/**
* Update the projection of a single axis. Schedule an update to
* the tree layout projection.
*/
setProjectionTargetAxis(axis, min, max) {
const target = projection.target[axis]
target.min = min
target.max = max
// Flag that we want to fire the onViewportBoxUpdate event handler
hasViewportBoxUpdated = true
lifecycles.notifySetAxisTarget()
},
/**
* Rebase the projection target on top of the provided viewport box
* or the measured layout. This ensures that non-animating elements
* don't fall out of sync differences in measurements vs projections
* after a page scroll or other relayout.
*/
rebaseProjectionTarget(force, box = layoutState.layout) {
const { x, y } = element.getProjectionAnimationProgress()
const shouldRebase =
!projection.isTargetLocked &&
!x.isAnimating() &&
!y.isAnimating()
if (force || shouldRebase) {
eachAxis((axis) => {
const { min, max } = box[axis]
element.setProjectionTargetAxis(axis, min, max)
})
}
},
/**
* Notify the visual element that its layout is up-to-date.
* Currently Animate.tsx uses this to check whether a layout animation
* needs to be performed.
*/
notifyLayoutReady(config) {
element.notifyLayoutUpdate(
layoutState.layout,
element.prevViewportBox || layoutState.layout,
config
)
},
/**
* Temporarily reset the transform of the instance.
*/
resetTransform: () => resetTransform(element, instance, props),
/**
* Perform the callback after temporarily unapplying the transform
* upwards through the tree.
*/
withoutTransform(callback) {
const { isEnabled } = projection
isEnabled && element.resetTransform()
parent ? parent.withoutTransform(callback) : callback()
isEnabled && restoreTransform(instance, renderState)
},
updateLayoutProjection() {
isProjecting() && updateLayoutProjection()
children.forEach(fireUpdateLayoutProjection)
},
/**
*
*/
pointTo(newLead) {
leadProjection = newLead.projection
leadLatestValues = newLead.getLatestValues()
/**
* Subscribe to lead component's layout animations
*/
unsubscribeFromLeadVisualElement?.()
unsubscribeFromLeadVisualElement = pipe(
newLead.onSetAxisTarget(element.scheduleUpdateLayoutProjection),
newLead.onLayoutAnimationComplete(() => {
if (element.isPresent) {
element.presence = Presence.Present
} else {
element.layoutSafeToRemove?.()
}
})
)
},
// TODO: Clean this up
isPresent: true,
presence: Presence.Entering,
}
return element
}
function fireUpdateLayoutProjection(child: VisualElement) {
child.updateLayoutProjection()
}
const variantProps = ["initial", ...variantPriorityOrder]
const numVariantProps = variantProps.length