diff --git a/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx b/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx index ea1f0fddd7..a8039bef99 100644 --- a/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx +++ b/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx @@ -373,6 +373,139 @@ describe("animate", () => { }) }) +describe("Sequence callbacks", () => { + function waitForFrame(): Promise { + return new Promise((resolve) => setTimeout(resolve, 50)) + } + + test("Scrubbing fires enter/exit at correct thresholds", async () => { + const element = document.createElement("div") + let enterCount = 0 + let exitCount = 0 + + const animation = animate([ + [element, { opacity: 1 }, { duration: 1 }], + [ + { + enter: () => enterCount++, + exit: () => exitCount++, + }, + {}, + ], + [element, { opacity: 0 }, { duration: 1 }], + ]) + + expect(animation.duration).toBe(2) + + animation.pause() + + // Scrub to 0.5 - enter not called (callback is at t=1) + animation.time = 0.5 + await waitForFrame() + expect(enterCount).toBe(0) + expect(exitCount).toBe(0) + + // Scrub to 1 - enter called + animation.time = 1 + await waitForFrame() + expect(enterCount).toBe(1) + expect(exitCount).toBe(0) + + // Scrub to 1.5 - enter still called once (no re-fire) + animation.time = 1.5 + await waitForFrame() + expect(enterCount).toBe(1) + expect(exitCount).toBe(0) + + // Scrub back to 0.5 - exit called once + animation.time = 0.5 + await waitForFrame() + expect(enterCount).toBe(1) + expect(exitCount).toBe(1) + + // Scrub to 1.5 again - enter called twice total + animation.time = 1.5 + await waitForFrame() + expect(enterCount).toBe(2) + expect(exitCount).toBe(1) + }) + + test("complete() fires enter once", async () => { + const element = document.createElement("div") + let enterCount = 0 + let exitCount = 0 + + const animation = animate([ + [element, { opacity: 1 }, { duration: 1 }], + [ + { + enter: () => enterCount++, + exit: () => exitCount++, + }, + {}, + ], + [element, { opacity: 0 }, { duration: 1 }], + ]) + + animation.complete() + await waitForFrame() + + expect(enterCount).toBe(1) + expect(exitCount).toBe(0) + }) + + test("cancel() without scrubbing fires neither enter nor exit", async () => { + const element = document.createElement("div") + let enterCount = 0 + let exitCount = 0 + + const animation = animate([ + [element, { opacity: 1 }, { duration: 1 }], + [ + { + enter: () => enterCount++, + exit: () => exitCount++, + }, + {}, + ], + [element, { opacity: 0 }, { duration: 1 }], + ]) + + animation.cancel() + + expect(enterCount).toBe(0) + expect(exitCount).toBe(0) + }) + + test("cancel() after scrubbing forward fires exit", async () => { + const element = document.createElement("div") + let enterCount = 0 + let exitCount = 0 + + const animation = animate([ + [element, { opacity: 1 }, { duration: 1 }], + [ + { + enter: () => enterCount++, + exit: () => exitCount++, + }, + {}, + ], + [element, { opacity: 0 }, { duration: 1 }], + ]) + + animation.pause() + animation.time = 1.5 + await waitForFrame() + + expect(enterCount).toBe(1) + + animation.cancel() + + expect(exitCount).toBe(1) + }) +}) + describe("animate: Objects", () => { test("Types: Object to object", () => { animate({ x: 100 }, { x: 200 }) diff --git a/packages/framer-motion/src/animation/animate/sequence.ts b/packages/framer-motion/src/animation/animate/sequence.ts index b26fbc4c47..4fe9a3808d 100644 --- a/packages/framer-motion/src/animation/animate/sequence.ts +++ b/packages/framer-motion/src/animation/animate/sequence.ts @@ -1,29 +1,76 @@ import { + animateSingleValue, AnimationPlaybackControlsWithThen, AnimationScope, spring, } from "motion-dom" import { createAnimationsFromSequence } from "../sequence/create" -import { AnimationSequence, SequenceOptions } from "../sequence/types" +import { + AnimationSequence, + ResolvedSequenceCallback, + SequenceCallbackData, + SequenceOptions, +} from "../sequence/types" import { animateSubject } from "./subject" +/** + * Creates an onUpdate callback that fires sequence callbacks when time crosses their thresholds. + * Tracks previous progress to detect direction (forward/backward). + */ +function createCallbackUpdater( + callbacks: ResolvedSequenceCallback[], + totalDuration: number +) { + let prevProgress = 0 + + return (progress: number) => { + const currentTime = progress * totalDuration + + for (const callback of callbacks) { + const prevTime = prevProgress * totalDuration + + if (prevTime < callback.time && currentTime >= callback.time) { + callback.enter?.() + } else if (prevTime >= callback.time && currentTime < callback.time) { + callback.exit?.() + } + } + + prevProgress = progress + } +} + export function animateSequence( sequence: AnimationSequence, options?: SequenceOptions, scope?: AnimationScope ) { const animations: AnimationPlaybackControlsWithThen[] = [] + const callbackData: SequenceCallbackData = { callbacks: [], totalDuration: 0 } const animationDefinitions = createAnimationsFromSequence( sequence, options, scope, - { spring } + { spring }, + callbackData ) animationDefinitions.forEach(({ keyframes, transition }, subject) => { animations.push(...animateSubject(subject, keyframes, transition)) }) + if (callbackData.callbacks.length) { + const callbackAnimation = animateSingleValue(0, 1, { + duration: callbackData.totalDuration, + ease: "linear", + onUpdate: createCallbackUpdater( + callbackData.callbacks, + callbackData.totalDuration + ), + }) + animations.push(callbackAnimation) + } + return animations } diff --git a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts index 75d031dd87..d3595a61f4 100644 --- a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts +++ b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts @@ -838,3 +838,43 @@ describe("createAnimationsFromSequence", () => { expect(animations.size).toBe(0) }) }) + +describe("Sequence callbacks", () => { + const a = document.createElement("div") + const b = document.createElement("div") + + test("Callbacks don't affect animation timing", () => { + const animations = createAnimationsFromSequence( + [ + [a, { x: 100 }, { duration: 1 }], + [{ enter: () => {} }, {}], + [{ enter: () => {} }, {}], + [{ enter: () => {} }, {}], + [b, { y: 200 }, { duration: 1 }], + ], + undefined, + undefined, + { spring } + ) + + expect(animations.get(a)!.transition.x.duration).toBe(2) + expect(animations.get(a)!.transition.x.times).toEqual([0, 0.5, 1]) + expect(animations.get(b)!.transition.y.times).toEqual([0, 0.5, 1]) + }) + + test("Callback segments are skipped in animation definitions", () => { + const animations = createAnimationsFromSequence( + [ + [a, { x: 100 }, { duration: 1 }], + [{ enter: () => {} }, { at: 0.5 }], + ], + undefined, + undefined, + { spring } + ) + + // Only the element animation, no callback artifacts + expect(animations.size).toBe(1) + expect(animations.has(a)).toBe(true) + }) +}) diff --git a/packages/framer-motion/src/animation/sequence/create.ts b/packages/framer-motion/src/animation/sequence/create.ts index 07ead3c85b..b14d90e57d 100644 --- a/packages/framer-motion/src/animation/sequence/create.ts +++ b/packages/framer-motion/src/animation/sequence/create.ts @@ -24,7 +24,9 @@ import { resolveSubjects } from "../animate/resolve-subjects" import { AnimationSequence, At, + CallbackSegment, ResolvedAnimationDefinitions, + SequenceCallbackData, SequenceMap, SequenceOptions, ValueSequence, @@ -43,7 +45,8 @@ export function createAnimationsFromSequence( sequence: AnimationSequence, { defaultTransition = {}, ...sequenceTransition }: SequenceOptions = {}, scope?: AnimationScope, - generators?: { [key: string]: GeneratorFactory } + generators?: { [key: string]: GeneratorFactory }, + callbackData?: SequenceCallbackData ): ResolvedAnimationDefinitions { const defaultDuration = defaultTransition.duration || 0.3 const animationDefinitions: ResolvedAnimationDefinitions = new Map() @@ -77,6 +80,29 @@ export function createAnimationsFromSequence( continue } + /** + * If this is a callback segment, extract the callback and its timing + */ + if (isCallbackSegment(segment)) { + const [callback, options] = segment + const callbackTime = + options.at !== undefined + ? calcNextTime( + currentTime, + options.at, + prevTime, + timeLabels + ) + : currentTime + + callbackData?.callbacks.push({ + time: callbackTime, + enter: callback.enter, + exit: callback.exit, + }) + continue + } + let [subject, keyframes, transition = {}] = segment /** @@ -390,6 +416,11 @@ export function createAnimationsFromSequence( } }) + if (callbackData) { + callbackData.callbacks.sort((a, b) => a.time - b.time) + callbackData.totalDuration = totalDuration + } + return animationDefinitions } @@ -428,3 +459,12 @@ const isNumber = (keyframe: unknown) => typeof keyframe === "number" const isNumberKeyframesArray = ( keyframes: UnresolvedValueKeyframe[] ): keyframes is number[] => keyframes.every(isNumber) + +/** + * Check if a segment is a callback segment: [{ enter?, exit? }, { at? }] + */ +function isCallbackSegment( + segment: any[] +): segment is CallbackSegment { + return segment[0] && ("enter" in segment[0] || "exit" in segment[0]) +} diff --git a/packages/framer-motion/src/animation/sequence/types.ts b/packages/framer-motion/src/animation/sequence/types.ts index 2797db4e4d..da8fdc1925 100644 --- a/packages/framer-motion/src/animation/sequence/types.ts +++ b/packages/framer-motion/src/animation/sequence/types.ts @@ -58,6 +58,18 @@ export type ObjectSegmentWithTransition = [ DynamicAnimationOptions & At ] +/** + * Callback to be invoked at a specific point in the sequence. + * - `enter`: Called when time crosses this point moving forward + * - `exit`: Called when time crosses this point moving backward (for scrubbing) + */ +export interface SequenceCallback { + enter?: VoidFunction + exit?: VoidFunction +} + +export type CallbackSegment = [SequenceCallback, At] + export type Segment = | ObjectSegment | ObjectSegmentWithTransition @@ -67,6 +79,7 @@ export type Segment = | MotionValueSegmentWithTransition | DOMSegment | DOMSegmentWithTransition + | CallbackSegment export type AnimationSequence = Segment[] @@ -98,3 +111,17 @@ export type ResolvedAnimationDefinitions = Map< Element | MotionValue, ResolvedAnimationDefinition > + +/** + * A callback positioned at an absolute time in the sequence + */ +export interface ResolvedSequenceCallback { + time: number + enter?: VoidFunction + exit?: VoidFunction +} + +export interface SequenceCallbackData { + callbacks: ResolvedSequenceCallback[] + totalDuration: number +}