Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,139 @@ describe("animate", () => {
})
})

describe("Sequence callbacks", () => {
function waitForFrame(): Promise<void> {
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 })
Expand Down
51 changes: 49 additions & 2 deletions packages/framer-motion/src/animation/animate/sequence.ts
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
42 changes: 41 additions & 1 deletion packages/framer-motion/src/animation/sequence/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import { resolveSubjects } from "../animate/resolve-subjects"
import {
AnimationSequence,
At,
CallbackSegment,
ResolvedAnimationDefinitions,
SequenceCallbackData,
SequenceMap,
SequenceOptions,
ValueSequence,
Expand All @@ -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()
Expand Down Expand Up @@ -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

/**
Expand Down Expand Up @@ -390,6 +416,11 @@ export function createAnimationsFromSequence(
}
})

if (callbackData) {
callbackData.callbacks.sort((a, b) => a.time - b.time)
callbackData.totalDuration = totalDuration
}

return animationDefinitions
}

Expand Down Expand Up @@ -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])
}
27 changes: 27 additions & 0 deletions packages/framer-motion/src/animation/sequence/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ export type ObjectSegmentWithTransition<O extends {} = {}> = [
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
Expand All @@ -67,6 +79,7 @@ export type Segment =
| MotionValueSegmentWithTransition
| DOMSegment
| DOMSegmentWithTransition
| CallbackSegment

export type AnimationSequence = Segment[]

Expand Down Expand Up @@ -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
}