Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions packages/framer-motion/src/motion/__tests__/delay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,120 @@ describe("delay attr", () => {
return expect(promise).resolves.toBe(0)
})
})

describe("onAnimationPlay", () => {
test("fires immediately when there is no delay", async () => {
const promise = new Promise<void>((resolve) => {
const onAnimationPlay = jest.fn()
const onAnimationComplete = () => {
expect(onAnimationPlay).toBeCalledTimes(1)
resolve()
}
const Component = () => (
<motion.div
animate={{ x: 20 }}
transition={{ type: false }}
onAnimationPlay={onAnimationPlay}
onAnimationComplete={onAnimationComplete}
/>
)
const { rerender } = render(<Component />)
rerender(<Component />)
})
return promise
})

test("fires after delay when delay is set", async () => {
const promise = new Promise<void>((resolve) => {
const onAnimationStart = jest.fn()
const onAnimationPlay = jest.fn()
let startTime = 0
let playTime = 0

const Component = () => (
<motion.div
animate={{ x: 20 }}
transition={{ delay: 0.1, type: false }}
onAnimationStart={() => {
onAnimationStart()
startTime = performance.now()
}}
onAnimationPlay={() => {
onAnimationPlay()
playTime = performance.now()
}}
onAnimationComplete={() => {
// onAnimationStart should fire before onAnimationPlay
expect(onAnimationStart).toBeCalledTimes(1)
expect(onAnimationPlay).toBeCalledTimes(1)
// There should be a delay between start and play
expect(playTime - startTime).toBeGreaterThanOrEqual(90)
resolve()
}}
/>
)
const { rerender } = render(<Component />)
rerender(<Component />)
})
return promise
})

test("fires once even with multiple properties", async () => {
const promise = new Promise<void>((resolve) => {
const onAnimationPlay = jest.fn()
const Component = () => (
<motion.div
animate={{ x: 20, y: 20, opacity: 0.5 }}
transition={{ type: false }}
onAnimationPlay={onAnimationPlay}
onAnimationComplete={() => {
// Should only fire once, not once per property
expect(onAnimationPlay).toBeCalledTimes(1)
resolve()
}}
/>
)
const { rerender } = render(<Component />)
rerender(<Component />)
})
return promise
})

test("fires with definition argument", async () => {
const promise = new Promise<void>((resolve) => {
const Component = () => (
<motion.div
animate={{ x: 20 }}
transition={{ type: false }}
onAnimationPlay={(definition) => {
expect(definition).toEqual({ x: 20 })
resolve()
}}
/>
)
const { rerender } = render(<Component />)
rerender(<Component />)
})
return promise
})

test("fires with variant name when using variants", async () => {
const promise = new Promise<void>((resolve) => {
const Component = () => (
<motion.div
variants={{
visible: { x: 10, transition: { type: false } },
}}
animate="visible"
onAnimationPlay={(definition) => {
expect(definition).toBe("visible")
resolve()
}}
/>
)
const { rerender } = render(<Component />)
rerender(<Component />)
})
return promise
})
})
13 changes: 13 additions & 0 deletions packages/motion-dom/src/animation/JSAnimation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ export class JSAnimation<T extends number | string>

private mirroredGenerator: KeyframeGenerator<T> | undefined

/**
* Tracks whether we've exited the delay phase to ensure onDelayComplete
* fires only once.
*/
private hasExitedDelayPhase = false

constructor(options: ValueAnimationOptions<T>) {
super()
activeAnimations.mainThread++
Expand Down Expand Up @@ -198,6 +204,7 @@ export class JSAnimation<T extends number | string>
repeatDelay,
type,
onUpdate,
onDelayComplete,
finalKeyframe,
} = this.options

Expand Down Expand Up @@ -231,6 +238,12 @@ export class JSAnimation<T extends number | string>
: timeWithoutDelay > totalDuration
this.currentTime = Math.max(timeWithoutDelay, 0)

// Fire onDelayComplete when we first exit the delay phase
if (!isInDelayPhase && !this.hasExitedDelayPhase) {
this.hasExitedDelayPhase = true
onDelayComplete?.()
}

// If this animation has finished, set the current time to the total duration.
if (this.state === "finished" && this.holdTime === null) {
this.currentTime = totalDuration
Expand Down
31 changes: 31 additions & 0 deletions packages/motion-dom/src/animation/NativeAnimation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export class NativeAnimation<T extends AnyResolvedKeyframe>
*/
protected manualStartTime: number | null = null

/**
* Timeout ID for the onDelayComplete callback, so we can clear it on stop/cancel.
*/
private delayCompleteTimeout: ReturnType<typeof setTimeout> | undefined

constructor(options?: NativeAnimationOptions) {
super()

Expand All @@ -65,6 +70,8 @@ export class NativeAnimation<T extends AnyResolvedKeyframe>
allowFlatten = false,
finalKeyframe,
onComplete,
onDelayComplete,
delay = 0,
} = options as any

this.isPseudoElement = Boolean(pseudoElement)
Expand Down Expand Up @@ -92,6 +99,17 @@ export class NativeAnimation<T extends AnyResolvedKeyframe>
this.animation.pause()
}

// Set up onDelayComplete callback
if (onDelayComplete) {
if (delay <= 0) {
// No delay, fire immediately on next microtask
Promise.resolve().then(onDelayComplete)
} else {
// Fire after delay duration
this.delayCompleteTimeout = setTimeout(onDelayComplete, delay)
}
}

this.animation.onfinish = () => {
this.finishedTime = this.time

Expand Down Expand Up @@ -142,6 +160,12 @@ export class NativeAnimation<T extends AnyResolvedKeyframe>
}

cancel() {
// Clear the delay complete timeout
if (this.delayCompleteTimeout) {
clearTimeout(this.delayCompleteTimeout)
this.delayCompleteTimeout = undefined
}

try {
this.animation.cancel()
} catch (e) {}
Expand All @@ -150,6 +174,13 @@ export class NativeAnimation<T extends AnyResolvedKeyframe>
stop() {
if (this.isStopped) return
this.isStopped = true

// Clear the delay complete timeout
if (this.delayCompleteTimeout) {
clearTimeout(this.delayCompleteTimeout)
this.delayCompleteTimeout = undefined
}

const { state } = this

if (state === "idle" || state === "finished") {
Expand Down
5 changes: 5 additions & 0 deletions packages/motion-dom/src/animation/interfaces/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,9 @@ export interface VisualElementAnimationOptions {
transitionOverride?: Transition
custom?: any
type?: AnimationType
/**
* Callback that fires when the first animation exits its delay phase.
* This is used internally to fire the AnimationPlay event.
*/
onDelayComplete?: () => void
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function shouldBlockAnimation(
export function animateTarget(
visualElement: VisualElement,
targetAndTransition: TargetAndTransition,
{ delay = 0, transitionOverride, type }: VisualElementAnimationOptions = {}
{ delay = 0, transitionOverride, type, onDelayComplete }: VisualElementAnimationOptions = {}
): AnimationPlaybackControlsWithThen[] {
let {
transition = visualElement.getDefaultTransition(),
Expand All @@ -48,6 +48,18 @@ export function animateTarget(
visualElement.animationState &&
visualElement.animationState.getState()[type]

// Create a "fire once" wrapper for onDelayComplete so it only fires
// when the first animation exits its delay phase
let hasDelayCompleted = false
const onFirstDelayComplete = onDelayComplete
? () => {
if (!hasDelayCompleted) {
hasDelayCompleted = true
onDelayComplete()
}
}
: undefined

for (const key in target) {
const value = visualElement.getValue(
key,
Expand All @@ -66,6 +78,7 @@ export function animateTarget(
const valueTransition = {
delay,
...getValueTransition(transition || {}, key),
onDelayComplete: onFirstDelayComplete,
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,21 @@ function animateChildren(
) {
const animations: Promise<any>[] = []

// Don't pass parent's onDelayComplete to children - each child gets its own
const { onDelayComplete: _parentOnDelayComplete, ...childOptions } = options

for (const child of visualElement.variantChildren!) {
child.notify("AnimationStart", variant)

// Create onDelayComplete callback for this specific child
const onDelayComplete = () => {
child.notify("AnimationPlay", variant)
}

animations.push(
animateVariant(child, variant, {
...options,
...childOptions,
onDelayComplete,
delay:
delay +
(typeof delayChildren === "function" ? 0 : delayChildren) +
Expand Down
14 changes: 11 additions & 3 deletions packages/motion-dom/src/animation/interfaces/visual-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,31 @@ export function animateVisualElement(
options: VisualElementAnimationOptions = {}
) {
visualElement.notify("AnimationStart", definition)

// Create onDelayComplete callback that fires "AnimationPlay" event once
const onDelayComplete = () => {
visualElement.notify("AnimationPlay", definition)
}

const optionsWithDelayComplete = { ...options, onDelayComplete }

let animation: Promise<any>

if (Array.isArray(definition)) {
const animations = definition.map((variant) =>
animateVariant(visualElement, variant, options)
animateVariant(visualElement, variant, optionsWithDelayComplete)
)
animation = Promise.all(animations)
} else if (typeof definition === "string") {
animation = animateVariant(visualElement, definition, options)
animation = animateVariant(visualElement, definition, optionsWithDelayComplete)
} else {
const resolvedDefinition =
typeof definition === "function"
? resolveVariant(visualElement, definition, options.custom)
: definition

animation = Promise.all(
animateTarget(visualElement, resolvedDefinition, options)
animateTarget(visualElement, resolvedDefinition, optionsWithDelayComplete)
)
}

Expand Down
7 changes: 7 additions & 0 deletions packages/motion-dom/src/animation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,13 @@ export interface AnimationPlaybackLifecycles<V> {
onRepeat?: () => void
onStop?: () => void

/**
* Callback that fires when the animation exits its delay phase and
* actually begins animating. If there is no delay, this fires immediately
* when the animation starts.
*/
onDelayComplete?: () => void

// @internal
onCancel?: () => void
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export function isTransitionDefined({
repeatDelay,
from,
elapsed,
onDelayComplete: _onDelayComplete,
...transition
}: Transition & { elapsed?: number; from?: AnyResolvedKeyframe }) {
}: Transition & { elapsed?: number; from?: AnyResolvedKeyframe; onDelayComplete?: () => void }) {
return !!Object.keys(transition).length
}
27 changes: 27 additions & 0 deletions packages/motion-dom/src/node/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,33 @@ export interface MotionNodeEventOptions {
* ```
*/
onAnimationComplete?(definition: AnimationDefinition): void

/**
* Callback when animation defined in `animate` actually begins playing,
* after any delays have completed.
*
* This is useful when you want to know when the animation truly starts
* in the DOM, accounting for any `delay` or `staggerChildren` settings.
*
* The provided callback will be called with the triggering animation definition.
* If this is a variant, it'll be the variant name, and if a target object
* then it'll be the target object.
*
* ```jsx
* function onPlay() {
* console.log("Animation is now playing")
* }
*
* <motion.div
* animate={{ x: 100 }}
* transition={{ delay: 0.5 }}
* onAnimationPlay={definition => {
* console.log('Animation started playing after delay', definition)
* }}
* />
* ```
*/
onAnimationPlay?(definition: AnimationDefinition): void
onBeforeLayoutMeasure?(box: Box): void

onLayoutMeasure?(box: Box, prevBox: Box): void
Expand Down
1 change: 1 addition & 0 deletions packages/motion-dom/src/render/VisualElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { Feature } from "./Feature"
const propEventHandlers = [
"AnimationStart",
"AnimationComplete",
"AnimationPlay",
"Update",
"BeforeLayoutMeasure",
"LayoutMeasure",
Expand Down
1 change: 1 addition & 0 deletions packages/motion-dom/src/render/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export interface VisualElementEventCallbacks {
Update: (latest: ResolvedValues) => void
AnimationStart: (definition: AnimationDefinition) => void
AnimationComplete: (definition: AnimationDefinition) => void
AnimationPlay: (definition: AnimationDefinition) => void
LayoutAnimationStart: () => void
LayoutAnimationComplete: () => void
SetAxisTarget: () => void
Expand Down