framer-motion
Version:
An open source and production-ready motion library for React on the web
438 lines (384 loc) • 15.8 kB
text/typescript
import { isAnimationControls } from "../../animation/animation-controls"
import { isKeyframesTarget } from "../../animation/utils/is-keyframes-target"
import { VariantLabels } from "../../motion/types"
import { TargetAndTransition } from "../../types"
import { shallowCompare } from "../../utils/shallow-compare"
import { VisualElement } from "../types"
import {
animateVisualElement,
AnimationDefinition,
AnimationOptions,
} from "./animation"
import { isVariantLabel, isVariantLabels, resolveVariant } from "./variants"
export interface AnimationState {
animateChanges: (
options?: AnimationOptions,
type?: AnimationType
) => Promise<any>
setActive: (
type: AnimationType,
isActive: boolean,
options?: AnimationOptions
) => Promise<any>
setAnimateFunction: (fn: any) => void
isAnimated(key: string): boolean
getState: () => { [key: string]: AnimationTypeState }
}
interface DefinitionAndOptions {
animation: AnimationDefinition
options?: AnimationOptions
}
export enum AnimationType {
Animate = "animate",
Hover = "whileHover",
Tap = "whileTap",
Drag = "whileDrag",
Focus = "whileFocus",
Exit = "exit",
}
export type AnimationList = string[] | TargetAndTransition[]
export const variantPriorityOrder = [
AnimationType.Animate,
AnimationType.Hover,
AnimationType.Tap,
AnimationType.Drag,
AnimationType.Focus,
AnimationType.Exit,
]
const reversePriorityOrder = [...variantPriorityOrder].reverse()
const numAnimationTypes = variantPriorityOrder.length
function animateList(visualElement: VisualElement) {
return (animations: DefinitionAndOptions[]) =>
Promise.all(
animations.map(({ animation, options }) =>
animateVisualElement(visualElement, animation, options)
)
)
}
export function createAnimationState(
visualElement: VisualElement
): AnimationState {
let animate = animateList(visualElement)
const state = createState()
let allAnimatedKeys = {}
let isInitialRender = true
/**
* This function will be used to reduce the animation definitions for
* each active animation type into an object of resolved values for it.
*/
const buildResolvedTypeValues = (
acc: { [key: string]: any },
definition: string | TargetAndTransition | undefined
) => {
const resolved = resolveVariant(visualElement, definition)
if (resolved) {
const { transition, transitionEnd, ...target } = resolved
acc = { ...acc, ...target, ...transitionEnd }
}
return acc
}
function isAnimated(key: string) {
return allAnimatedKeys[key] !== undefined
}
/**
* This just allows us to inject mocked animation functions
* @internal
*/
function setAnimateFunction(makeAnimator: typeof animateList) {
animate = makeAnimator(visualElement)
}
/**
* When we receive new props, we need to:
* 1. Create a list of protected keys for each type. This is a directory of
* value keys that are currently being "handled" by types of a higher priority
* so that whenever an animation is played of a given type, these values are
* protected from being animated.
* 2. Determine if an animation type needs animating.
* 3. Determine if any values have been removed from a type and figure out
* what to animate those to.
*/
function animateChanges(
options?: AnimationOptions,
changedActiveType?: AnimationType
) {
const props = visualElement.getProps()
const context = visualElement.getVariantContext(true) || {}
/**
* A list of animations that we'll build into as we iterate through the animation
* types. This will get executed at the end of the function.
*/
const animations: DefinitionAndOptions[] = []
/**
* Keep track of which values have been removed. Then, as we hit lower priority
* animation types, we can check if they contain removed values and animate to that.
*/
const removedKeys = new Set<string>()
/**
* A dictionary of all encountered keys. This is an object to let us build into and
* copy it without iteration. Each time we hit an animation type we set its protected
* keys - the keys its not allowed to animate - to the latest version of this object.
*/
let encounteredKeys = {}
/**
* If a variant has been removed at a given index, and this component is controlling
* variant animations, we want to ensure lower-priority variants are forced to animate.
*/
let removedVariantIndex = Infinity
/**
* Iterate through all animation types in reverse priority order. For each, we want to
* detect which values it's handling and whether or not they've changed (and therefore
* need to be animated). If any values have been removed, we want to detect those in
* lower priority props and flag for animation.
*/
for (let i = 0; i < numAnimationTypes; i++) {
const type = reversePriorityOrder[i]
const typeState = state[type]
const prop = props[type] ?? context[type]
const propIsVariant = isVariantLabel(prop)
/**
* If this type has *just* changed isActive status, set activeDelta
* to that status. Otherwise set to null.
*/
const activeDelta =
type === changedActiveType ? typeState.isActive : null
if (activeDelta === false) removedVariantIndex = i
/**
* If this prop is an inherited variant, rather than been set directly on the
* component itself, we want to make sure we allow the parent to trigger animations.
*
* TODO: Can probably change this to a !isControllingVariants check
*/
let isInherited =
prop === context[type] && prop !== props[type] && propIsVariant
/**
*
*/
if (
isInherited &&
isInitialRender &&
visualElement.manuallyAnimateOnMount
) {
isInherited = false
}
/**
* Set all encountered keys so far as the protected keys for this type. This will
* be any key that has been animated or otherwise handled by active, higher-priortiy types.
*/
typeState.protectedKeys = { ...encounteredKeys }
// Check if we can skip analysing this prop early
if (
// If it isn't active and hasn't *just* been set as inactive
(!typeState.isActive && activeDelta === null) ||
// If we didn't and don't have any defined prop for this animation type
(!prop && !typeState.prevProp) ||
// Or if the prop doesn't define an animation
isAnimationControls(prop) ||
typeof prop === "boolean"
) {
continue
}
/**
* As we go look through the values defined on this type, if we detect
* a changed value or a value that was removed in a higher priority, we set
* this to true and add this prop to the animation list.
*/
let shouldAnimateType =
variantsHaveChanged(typeState.prevProp, prop) ||
// If we're making this variant active, we want to always make it active
(type === changedActiveType &&
typeState.isActive &&
!isInherited &&
propIsVariant) ||
// If we removed a higher-priority variant (i is in reverse order)
(i > removedVariantIndex && propIsVariant)
/**
* As animations can be set as variant lists, variants or target objects, we
* coerce everything to an array if it isn't one already
*/
const definitionList = Array.isArray(prop) ? prop : [prop]
/**
* Build an object of all the resolved values. We'll use this in the subsequent
* animateChanges calls to determine whether a value has changed.
*/
let resolvedValues = definitionList.reduce(
buildResolvedTypeValues,
{}
)
if (activeDelta === false) resolvedValues = {}
/**
* Now we need to loop through all the keys in the prev prop and this prop,
* and decide:
* 1. If the value has changed, and needs animating
* 2. If it has been removed, and needs adding to the removedKeys set
* 3. If it has been removed in a higher priority type and needs animating
* 4. If it hasn't been removed in a higher priority but hasn't changed, and
* needs adding to the type's protectedKeys list.
*/
const { prevResolvedValues = {} } = typeState
const allKeys = {
...prevResolvedValues,
...resolvedValues,
}
const markToAnimate = (key: string) => {
shouldAnimateType = true
removedKeys.delete(key)
typeState.needsAnimating[key] = true
}
for (const key in allKeys) {
const next = resolvedValues[key]
const prev = prevResolvedValues[key]
// If we've already handled this we can just skip ahead
if (encounteredKeys.hasOwnProperty(key)) continue
/**
* If the value has changed, we probably want to animate it.
*/
if (next !== prev) {
/**
* If both values are keyframes, we need to shallow compare them to
* detect whether any value has changed. If it has, we animate it.
*/
if (isKeyframesTarget(next) && isKeyframesTarget(prev)) {
if (!shallowCompare(next, prev)) {
markToAnimate(key)
} else {
/**
* If it hasn't changed, we want to ensure it doesn't animate by
* adding it to the list of protected keys.
*/
typeState.protectedKeys[key] = true
}
} else if (next !== undefined) {
// If next is defined and doesn't equal prev, it needs animating
markToAnimate(key)
} else {
// If it's undefined, it's been removed.
removedKeys.add(key)
}
} else if (next !== undefined && removedKeys.has(key)) {
/**
* If next hasn't changed and it isn't undefined, we want to check if it's
* been removed by a higher priority
*/
markToAnimate(key)
} else {
/**
* If it hasn't changed, we add it to the list of protected values
* to ensure it doesn't get animated.
*/
typeState.protectedKeys[key] = true
}
}
/**
* Update the typeState so next time animateChanges is called we can compare the
* latest prop and resolvedValues to these.
*/
typeState.prevProp = prop
typeState.prevResolvedValues = resolvedValues
/**
*
*/
if (typeState.isActive) {
encounteredKeys = { ...encounteredKeys, ...resolvedValues }
}
if (isInitialRender && visualElement.blockInitialAnimation) {
shouldAnimateType = false
}
/**
* If this is an inherited prop we want to hard-block animations
* TODO: Test as this should probably still handle animations triggered
* by removed values?
*/
if (shouldAnimateType && !isInherited) {
animations.push(
...definitionList.map((animation) => ({
animation: animation as AnimationDefinition,
options: { type, ...options },
}))
)
}
}
allAnimatedKeys = { ...encounteredKeys }
/**
* If there are some removed value that haven't been dealt with,
* we need to create a new animation that falls back either to the value
* defined in the style prop, or the last read value.
*/
if (removedKeys.size) {
const fallbackAnimation = {}
removedKeys.forEach((key) => {
const fallbackTarget = visualElement.getBaseTarget(key)
if (fallbackTarget !== undefined) {
fallbackAnimation[key] = fallbackTarget
}
})
animations.push({ animation: fallbackAnimation })
}
let shouldAnimate = Boolean(animations.length)
if (
isInitialRender &&
props.initial === false &&
!visualElement.manuallyAnimateOnMount
) {
shouldAnimate = false
}
isInitialRender = false
return shouldAnimate ? animate(animations) : Promise.resolve()
}
/**
* Change whether a certain animation type is active.
*/
function setActive(
type: AnimationType,
isActive: boolean,
options?: AnimationOptions
) {
// If the active state hasn't changed, we can safely do nothing here
if (state[type].isActive === isActive) return Promise.resolve()
// Propagate active change to children
visualElement.variantChildren?.forEach((child) =>
child.animationState?.setActive(type, isActive)
)
state[type].isActive = isActive
return animateChanges(options, type)
}
return {
isAnimated,
animateChanges,
setActive,
setAnimateFunction,
getState: () => state,
}
}
export function variantsHaveChanged(prev: any, next: any) {
if (typeof next === "string") {
return next !== prev
} else if (isVariantLabels(next)) {
return !shallowCompare(next, prev)
}
return false
}
export interface AnimationTypeState {
isActive: boolean
protectedKeys: { [key: string]: true }
needsAnimating: { [key: string]: boolean }
prevResolvedValues: { [key: string]: any }
prevProp?: VariantLabels | TargetAndTransition
}
function createTypeState(isActive = false): AnimationTypeState {
return {
isActive,
protectedKeys: {},
needsAnimating: {},
prevResolvedValues: {},
}
}
function createState() {
return {
[AnimationType.Animate]: createTypeState(true),
[AnimationType.Hover]: createTypeState(),
[AnimationType.Tap]: createTypeState(),
[AnimationType.Drag]: createTypeState(),
[AnimationType.Focus]: createTypeState(),
[AnimationType.Exit]: createTypeState(),
}
}