framer-motion
Version:
An open source and production-ready motion library for React on the web
253 lines (228 loc) • 8.02 kB
text/typescript
import { getFrameData } from "framesync"
import {
circOut,
linear,
mix,
mixColor,
PlaybackControls,
progress as calcProgress,
} from "popmotion"
import { animate } from "../../../animation/animate"
import { ResolvedValues, VisualElement } from "../../../render/types"
import { EasingFunction, Transition } from "../../../types"
import { motionValue } from "../../../value"
export interface Crossfader {
isActive(): boolean
getCrossfadeState(element: VisualElement): ResolvedValues
toLead(transition?: Transition): PlaybackControls
fromLead(transition?: Transition): PlaybackControls
setOptions(options: CrossfadeAnimationOptions): void
reset(): void
stop(): void
getLatestValues(): ResolvedValues
}
export interface CrossfadeAnimationOptions {
lead?: VisualElement
follow?: VisualElement
prevValues?: ResolvedValues
crossfadeOpacity?: boolean
preserveFollowOpacity?: boolean
}
export function createCrossfader(): Crossfader {
/**
* The current state of the crossfade as a value between 0 and 1
*/
const progress = motionValue(1)
let options: CrossfadeAnimationOptions = {
lead: undefined,
follow: undefined,
crossfadeOpacity: false,
preserveFollowOpacity: false,
}
let leadState: ResolvedValues = {}
let followState: ResolvedValues = {}
/**
*
*/
let isActive = false
/**
*
*/
let finalCrossfadeFrame: number | null = null
/**
* Framestamp of the last frame we updated values at.
*/
let prevUpdate = 0
function startCrossfadeAnimation(target: number, transition?: Transition) {
const { lead, follow } = options
isActive = true
finalCrossfadeFrame = null
return animate(progress, target, {
...transition,
onUpdate: () => {
lead && lead.scheduleRender()
follow && follow.scheduleRender()
},
onComplete: () => {
isActive = false
/**
* If the crossfade animation is no longer active, flag a frame
* that we're still allowed to crossfade
*/
finalCrossfadeFrame = getFrameData().timestamp
},
} as any)
}
function updateCrossfade() {
/**
* We only want to compute the crossfade once per frame, so we
* compare the previous update framestamp with the current frame
* and early return if they're the same.
*/
const { timestamp } = getFrameData()
const { lead, follow } = options
if (timestamp === prevUpdate || !lead) return
prevUpdate = timestamp
/**
* Merge each component's latest values into our crossfaded state
* before crossfading.
*/
const latestLeadValues = lead.getLatestValues()
Object.assign(leadState, latestLeadValues)
const latestFollowValues = follow
? follow.getLatestValues()
: options.prevValues
Object.assign(followState, latestFollowValues)
const p = progress.get()
/**
* Crossfade the opacity between the two components. This will result
* in a different opacity for each component.
*/
const leadTargetOpacity = (latestLeadValues.opacity as number) ?? 1
const followTargetOpacity = (latestFollowValues?.opacity as number) ?? 1
if (options.crossfadeOpacity && follow) {
leadState.opacity = mix(0, leadTargetOpacity, easeCrossfadeIn(p))
followState.opacity = options.preserveFollowOpacity
? followTargetOpacity
: mix(followTargetOpacity, 0, easeCrossfadeOut(p))
} else if (!follow) {
leadState.opacity = mix(followTargetOpacity, leadTargetOpacity, p)
}
mixValues(
leadState,
followState,
latestLeadValues,
latestFollowValues || {},
Boolean(follow),
p
)
}
return {
isActive: () =>
leadState &&
(isActive || getFrameData().timestamp === finalCrossfadeFrame),
fromLead(transition) {
return startCrossfadeAnimation(0, transition)
},
toLead(transition) {
progress.set(options.follow ? 1 - progress.get() : 0)
return startCrossfadeAnimation(1, transition)
},
reset: () => progress.set(1),
stop: () => progress.stop(),
getCrossfadeState(element) {
updateCrossfade()
return element === options.lead ? leadState : followState
},
setOptions(newOptions) {
options = newOptions
leadState = {}
followState = {}
},
getLatestValues() {
return leadState
},
}
}
const easeCrossfadeIn = compress(0, 0.5, circOut)
const easeCrossfadeOut = compress(0.5, 0.95, linear)
function compress(
min: number,
max: number,
easing: EasingFunction
): EasingFunction {
return (p: number) => {
// Could replace ifs with clamp
if (p < min) return 0
if (p > max) return 1
return easing(calcProgress(min, max, p))
}
}
const borders = ["TopLeft", "TopRight", "BottomLeft", "BottomRight"]
const numBorders = borders.length
function mixValues(
leadState: ResolvedValues,
followState: ResolvedValues,
latestLeadValues: ResolvedValues,
latestFollowValues: ResolvedValues,
hasFollowElement: boolean,
p: number
): void {
/**
* Mix border radius
*/
for (let i = 0; i < numBorders; i++) {
const borderLabel = `border${borders[i]}Radius`
const followRadius = getRadius(latestFollowValues, borderLabel)
const leadRadius = getRadius(latestLeadValues, borderLabel)
/**
* Currently we're only crossfading between numerical border radius.
* It would be possible to crossfade between percentages for a little
* extra bundle size.
*/
if (
typeof followRadius === "number" &&
typeof leadRadius === "number"
) {
const radius = mix(followRadius, leadRadius, p)
leadState[borderLabel] = followState[borderLabel] = radius
}
}
/**
* Mix rotation
*/
if (latestFollowValues.rotate || latestLeadValues.rotate) {
const rotate = mix(
(latestFollowValues.rotate as number) || 0,
(latestLeadValues.rotate as number) || 0,
p
)
leadState.rotate = followState.rotate = rotate
}
/**
* We only want to mix the background color if there's a follow element
* that we're not crossfading opacity between. For instance with switch
* AnimateSharedLayout animations, this helps the illusion of a continuous
* element being animated but also cuts down on the number of paints triggered
* for elements where opacity is doing that work for us.
*/
if (
!hasFollowElement &&
latestLeadValues.backgroundColor &&
latestFollowValues.backgroundColor
) {
/**
* This isn't ideal performance-wise as mixColor is creating a new function every frame.
* We could probably create a mixer that runs at the start of the animation but
* the idea behind the crossfader is that it runs dynamically between two potentially
* changing targets (ie opacity or borderRadius may be animating independently via variants)
*/
leadState.backgroundColor = followState.backgroundColor = mixColor(
latestFollowValues.backgroundColor as string,
latestLeadValues.backgroundColor as string
)(p)
}
}
function getRadius(values: ResolvedValues, radiusName: string) {
return values[radiusName] ?? values.borderRadius ?? 0
}