UNPKG

framer-motion

Version:

An open source and production-ready motion library for React on the web

253 lines (228 loc) • 8.02 kB
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 }