framer-motion
Version:
An open source and production-ready motion library for React on the web
306 lines (265 loc) • 10.2 kB
text/typescript
import { number, px, ValueType } from "style-value-types"
import { Target, TargetWithKeyframes } from "../../../types"
import { isKeyframesTarget } from "../../../animation/utils/is-keyframes-target"
import { invariant } from "hey-listen"
import { MotionValue } from "../../../value"
import { transformProps } from "./transform"
import { AxisBox2D } from "../../../types/geometry"
import { findDimensionValueType } from "./value-types"
import { VisualElement } from "../../types"
const positionalKeys = new Set([
"width",
"height",
"top",
"left",
"right",
"bottom",
"x",
"y",
])
const isPositionalKey = (key: string) => positionalKeys.has(key)
const hasPositionalKey = (target: TargetWithKeyframes) => {
return Object.keys(target).some(isPositionalKey)
}
const setAndResetVelocity = (value: MotionValue, to: string | number) => {
// Looks odd but setting it twice doesn't render, it'll just
// set both prev and current to the latest value
value.set(to, false)
value.set(to)
}
const isNumOrPxType = (v?: ValueType): v is ValueType =>
v === number || v === px
export enum BoundingBoxDimension {
width = "width",
height = "height",
left = "left",
right = "right",
top = "top",
bottom = "bottom",
}
type GetActualMeasurementInPixels = (
bbox: AxisBox2D,
computedStyle: Partial<CSSStyleDeclaration>
) => number
const getPosFromMatrix = (matrix: string, pos: number) =>
parseFloat(matrix.split(", ")[pos])
const getTranslateFromMatrix = (
pos2: number,
pos3: number
): GetActualMeasurementInPixels => (_bbox, { transform }) => {
if (transform === "none" || !transform) return 0
const matrix3d = transform.match(/^matrix3d\((.+)\)$/)
if (matrix3d) {
return getPosFromMatrix(matrix3d[1], pos3)
} else {
const matrix = transform.match(/^matrix\((.+)\)$/) as string[]
if (matrix) {
return getPosFromMatrix(matrix[1], pos2)
} else {
return 0
}
}
}
const transformKeys = new Set(["x", "y", "z"])
const nonTranslationalTransformKeys = transformProps.filter(
(key) => !transformKeys.has(key)
)
type RemovedTransforms = [string, string | number][]
function removeNonTranslationalTransform(visualElement: VisualElement) {
const removedTransforms: RemovedTransforms = []
nonTranslationalTransformKeys.forEach((key) => {
const value:
| MotionValue<string | number>
| undefined = visualElement.getValue(key)
if (value !== undefined) {
removedTransforms.push([key, value.get()])
value.set(key.startsWith("scale") ? 1 : 0)
}
})
// Apply changes to element before measurement
if (removedTransforms.length) visualElement.syncRender()
return removedTransforms
}
const positionalValues: { [key: string]: GetActualMeasurementInPixels } = {
// Dimensions
width: ({ x }) => x.max - x.min,
height: ({ y }) => y.max - y.min,
top: (_bbox, { top }) => parseFloat(top as string),
left: (_bbox, { left }) => parseFloat(left as string),
bottom: ({ y }, { top }) => parseFloat(top as string) + (y.max - y.min),
right: ({ x }, { left }) => parseFloat(left as string) + (x.max - x.min),
// Transform
x: getTranslateFromMatrix(4, 13),
y: getTranslateFromMatrix(5, 14),
}
const convertChangedValueTypes = (
target: TargetWithKeyframes,
visualElement: VisualElement,
changedKeys: string[]
) => {
const originBbox = visualElement.measureViewportBox()
const element = visualElement.getInstance()
const elementComputedStyle = getComputedStyle(element)
const {
display,
top,
left,
bottom,
right,
transform,
} = elementComputedStyle
const originComputedStyle = { top, left, bottom, right, transform }
// If the element is currently set to display: "none", make it visible before
// measuring the target bounding box
if (display === "none") {
visualElement.setStaticValue(
"display",
(target.display as string) || "block"
)
}
// Apply the latest values (as set in checkAndConvertChangedValueTypes)
visualElement.syncRender()
const targetBbox = visualElement.measureViewportBox()
changedKeys.forEach((key) => {
// Restore styles to their **calculated computed style**, not their actual
// originally set style. This allows us to animate between equivalent pixel units.
const value = visualElement.getValue(key) as MotionValue
setAndResetVelocity(
value,
positionalValues[key](originBbox, originComputedStyle)
)
target[key] = positionalValues[key](targetBbox, elementComputedStyle)
})
return target
}
const checkAndConvertChangedValueTypes = (
visualElement: VisualElement,
target: TargetWithKeyframes,
origin: Target = {},
transitionEnd: Target = {}
): { target: TargetWithKeyframes; transitionEnd: Target } => {
target = { ...target }
transitionEnd = { ...transitionEnd }
const targetPositionalKeys = Object.keys(target).filter(isPositionalKey)
// We want to remove any transform values that could affect the element's bounding box before
// it's measured. We'll reapply these later.
let removedTransformValues: RemovedTransforms = []
let hasAttemptedToRemoveTransformValues = false
const changedValueTypeKeys: string[] = []
targetPositionalKeys.forEach((key) => {
const value = visualElement.getValue(key) as MotionValue<
number | string
>
if (!visualElement.hasValue(key)) return
const from = origin[key]
const to = target[key]
const fromType = findDimensionValueType(from)
let toType
// TODO: The current implementation of this basically throws an error
// if you try and do value conversion via keyframes. There's probably
// a way of doing this but the performance implications would need greater scrutiny,
// as it'd be doing multiple resize-remeasure operations.
if (isKeyframesTarget(to)) {
const numKeyframes = to.length
for (let i = to[0] === null ? 1 : 0; i < numKeyframes; i++) {
if (!toType) {
toType = findDimensionValueType(to[i])
invariant(
toType === fromType ||
(isNumOrPxType(fromType) && isNumOrPxType(toType)),
"Keyframes must be of the same dimension as the current value"
)
} else {
invariant(
findDimensionValueType(to[i]) === toType,
"All keyframes must be of the same type"
)
}
}
} else {
toType = findDimensionValueType(to)
}
if (fromType !== toType) {
// If they're both just number or px, convert them both to numbers rather than
// relying on resize/remeasure to convert (which is wasteful in this situation)
if (isNumOrPxType(fromType) && isNumOrPxType(toType)) {
const current = value.get()
if (typeof current === "string") {
value.set(parseFloat(current))
}
if (typeof to === "string") {
target[key] = parseFloat(to)
} else if (Array.isArray(to) && toType === px) {
target[key] = to.map(parseFloat)
}
} else if (
fromType?.transform &&
toType?.transform &&
(from === 0 || to === 0)
) {
// If one or the other value is 0, it's safe to coerce it to the
// type of the other without measurement
if (from === 0) {
value.set((toType as any).transform(from))
} else {
target[key] = (fromType as any).transform(to)
}
} else {
// If we're going to do value conversion via DOM measurements, we first
// need to remove non-positional transform values that could affect the bbox measurements.
if (!hasAttemptedToRemoveTransformValues) {
removedTransformValues = removeNonTranslationalTransform(
visualElement
)
hasAttemptedToRemoveTransformValues = true
}
changedValueTypeKeys.push(key)
transitionEnd[key] =
transitionEnd[key] !== undefined
? transitionEnd[key]
: target[key]
setAndResetVelocity(value, to)
}
}
})
if (changedValueTypeKeys.length) {
const convertedTarget = convertChangedValueTypes(
target,
visualElement,
changedValueTypeKeys
)
// If we removed transform values, reapply them before the next render
if (removedTransformValues.length) {
removedTransformValues.forEach(([key, value]) => {
visualElement.getValue(key)!.set(value)
})
}
// Reapply original values
visualElement.syncRender()
return { target: convertedTarget, transitionEnd }
} else {
return { target, transitionEnd }
}
}
/**
* Convert value types for x/y/width/height/top/left/bottom/right
*
* Allows animation between `'auto'` -> `'100%'` or `0` -> `'calc(50% - 10vw)'`
*
* @internal
*/
export function unitConversion(
visualElement: VisualElement,
target: TargetWithKeyframes,
origin?: Target,
transitionEnd?: Target
): { target: TargetWithKeyframes; transitionEnd?: Target } {
return hasPositionalKey(target)
? checkAndConvertChangedValueTypes(
visualElement,
target,
origin,
transitionEnd
)
: { target, transitionEnd }
}