Skip to content

Commit fb50d9c

Browse files
authored
Merge pull request #3335 from motiondivision/feature/stagger-new-children
Stagger entering children
2 parents 7923545 + 8319c56 commit fb50d9c

File tree

6 files changed

+161
-41
lines changed

6 files changed

+161
-41
lines changed

packages/framer-motion/src/animation/interfaces/visual-element-variant.ts

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { DynamicOption } from "motion-dom"
22
import { resolveVariant } from "../../render/utils/resolve-dynamic-variants"
33
import { VisualElement } from "../../render/VisualElement"
4+
import { calcChildStagger } from "../utils/calc-child-stagger"
45
import { VisualElementAnimationOptions } from "./types"
56
import { animateTarget } from "./visual-element-target"
67

@@ -84,36 +85,25 @@ function animateChildren(
8485
options: VisualElementAnimationOptions
8586
) {
8687
const animations: Promise<any>[] = []
87-
const numChildren = visualElement.variantChildren!.size
8888

89-
const maxStaggerDuration = (numChildren - 1) * staggerChildren
90-
const delayIsFunction = typeof delayChildren === "function"
91-
92-
const generateStaggerDuration = delayIsFunction
93-
? (i: number) => delayChildren(i, numChildren)
94-
: // Support deprecated staggerChildren
95-
staggerDirection === 1
96-
? (i = 0) => i * staggerChildren
97-
: (i = 0) => maxStaggerDuration - i * staggerChildren
98-
99-
Array.from(visualElement.variantChildren!)
100-
.sort(sortByTreeOrder)
101-
.forEach((child, i) => {
102-
child.notify("AnimationStart", variant)
103-
animations.push(
104-
animateVariant(child, variant, {
105-
...options,
106-
delay:
107-
delay +
108-
(delayIsFunction ? 0 : delayChildren) +
109-
generateStaggerDuration(i),
110-
}).then(() => child.notify("AnimationComplete", variant))
111-
)
112-
})
89+
for (const child of visualElement.variantChildren!) {
90+
child.notify("AnimationStart", variant)
91+
animations.push(
92+
animateVariant(child, variant, {
93+
...options,
94+
delay:
95+
delay +
96+
(typeof delayChildren === "function" ? 0 : delayChildren) +
97+
calcChildStagger(
98+
visualElement.variantChildren!,
99+
child,
100+
delayChildren,
101+
staggerChildren,
102+
staggerDirection
103+
),
104+
}).then(() => child.notify("AnimationComplete", variant))
105+
)
106+
}
113107

114108
return Promise.all(animations)
115109
}
116-
117-
export function sortByTreeOrder(a: VisualElement, b: VisualElement) {
118-
return a.sortNodePosition(b)
119-
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { DynamicOption } from "motion-dom"
2+
import { VisualElement } from "../../render/VisualElement"
3+
4+
export function calcChildStagger(
5+
children: Set<VisualElement>,
6+
child: VisualElement,
7+
delayChildren?: number | DynamicOption<number>,
8+
staggerChildren: number = 0,
9+
staggerDirection: number = 1
10+
): number {
11+
const index = Array.from(children)
12+
.sort((a, b) => a.sortNodePosition(b))
13+
.indexOf(child)
14+
const numChildren = children.size
15+
const maxStaggerDuration = (numChildren - 1) * staggerChildren
16+
const delayIsFunction = typeof delayChildren === "function"
17+
18+
return delayIsFunction
19+
? delayChildren(index, numChildren)
20+
: staggerDirection === 1
21+
? index * staggerChildren
22+
: maxStaggerDuration - index * staggerChildren
23+
}

packages/framer-motion/src/motion/__tests__/variant.test.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,4 +1379,58 @@ describe("animate prop as variant", () => {
13791379

13801380
expect(element).toHaveStyle("transform: translateX(100px)")
13811381
})
1382+
1383+
test("staggerChildren is calculated correctly for new children", async () => {
1384+
const Component = ({ items }: { items: string[] }) => {
1385+
return (
1386+
<motion.div
1387+
animate="enter"
1388+
variants={{
1389+
enter: { transition: { delayChildren: stagger(0.1) } },
1390+
}}
1391+
>
1392+
{items.map((item) => (
1393+
<motion.div
1394+
key={item}
1395+
id={item}
1396+
className="item"
1397+
variants={{ enter: { opacity: 1 } }}
1398+
initial={{ opacity: 0 }}
1399+
/>
1400+
))}
1401+
</motion.div>
1402+
)
1403+
}
1404+
1405+
const { rerender } = render(<Component items={["1", "2"]} />)
1406+
1407+
await nextFrame()
1408+
await nextFrame()
1409+
await nextFrame()
1410+
await nextFrame()
1411+
1412+
rerender(<Component items={["1", "2", "3", "4", "5"]} />)
1413+
1414+
await nextFrame()
1415+
await nextFrame()
1416+
await nextFrame()
1417+
await nextFrame()
1418+
await nextFrame()
1419+
await nextFrame()
1420+
await nextFrame()
1421+
await nextFrame()
1422+
await nextFrame()
1423+
await nextFrame()
1424+
1425+
const elements = document.querySelectorAll(".item")
1426+
1427+
// Check that none of the opacities are the same
1428+
const opacities = Array.from(elements).map((el) =>
1429+
parseFloat(window.getComputedStyle(el).opacity)
1430+
)
1431+
1432+
// All opacities should be unique
1433+
const uniqueOpacities = new Set(opacities)
1434+
expect(uniqueOpacities.size).toBe(opacities.length)
1435+
})
13821436
})

packages/framer-motion/src/motion/utils/use-visual-element.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,12 @@ export function useVisualElement<
145145

146146
wantsHandoff.current = false
147147
}
148+
149+
/**
150+
* Now we've finished triggering animations for this element we
151+
* can wipe the enteringChildren set for the next render.
152+
*/
153+
visualElement.enteringChildren = undefined
148154
})
149155

150156
return visualElement!

packages/framer-motion/src/render/VisualElement.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,13 @@ export abstract class VisualElement<
188188
*/
189189
children = new Set<VisualElement>()
190190

191+
/**
192+
* A set containing the latest children of this VisualElement. This is flushed
193+
* at the start of every commit. We use it to calculate the stagger delay
194+
* for newly-added children.
195+
*/
196+
enteringChildren?: Set<VisualElement>
197+
191198
/**
192199
* The depth of this VisualElement within the overall VisualElement tree.
193200
*/
@@ -422,7 +429,8 @@ export abstract class VisualElement<
422429
)
423430
}
424431

425-
if (this.parent) this.parent.children.add(this)
432+
this.parent?.addChild(this)
433+
426434
this.update(this.props, this.presenceContext)
427435
}
428436

@@ -433,7 +441,7 @@ export abstract class VisualElement<
433441
this.valueSubscriptions.forEach((remove) => remove())
434442
this.valueSubscriptions.clear()
435443
this.removeFromVariantTree && this.removeFromVariantTree()
436-
this.parent && this.parent.children.delete(this)
444+
this.parent?.removeChild(this)
437445

438446
for (const key in this.events) {
439447
this.events[key].clear()
@@ -449,6 +457,17 @@ export abstract class VisualElement<
449457
this.current = null
450458
}
451459

460+
addChild(child: VisualElement) {
461+
this.children.add(child)
462+
this.enteringChildren ??= new Set()
463+
this.enteringChildren.add(child)
464+
}
465+
466+
removeChild(child: VisualElement) {
467+
this.children.delete(child)
468+
this.enteringChildren && this.enteringChildren.delete(child)
469+
}
470+
452471
private bindToMotionValue(key: string, value: MotionValue) {
453472
if (this.valueSubscriptions.has(key)) {
454473
this.valueSubscriptions.get(key)!()

packages/framer-motion/src/render/utils/animation-state.ts

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AnimationDefinition, TargetAndTransition } from "motion-dom"
22
import { VisualElementAnimationOptions } from "../../animation/interfaces/types"
33
import { animateVisualElement } from "../../animation/interfaces/visual-element"
4+
import { calcChildStagger } from "../../animation/utils/calc-child-stagger"
45
import { isAnimationControls } from "../../animation/utils/is-animation-controls"
56
import { isKeyframesTarget } from "../../animation/utils/is-keyframes-target"
67
import { VariantLabels } from "../../motion/types"
@@ -158,9 +159,6 @@ export function createAnimationState(
158159
prop !== props[type] &&
159160
propIsVariant
160161

161-
/**
162-
*
163-
*/
164162
if (
165163
isInherited &&
166164
isInitialRender &&
@@ -301,9 +299,6 @@ export function createAnimationState(
301299
typeState.prevProp = prop
302300
typeState.prevResolvedValues = resolvedValues
303301

304-
/**
305-
*
306-
*/
307302
if (typeState.isActive) {
308303
encounteredKeys = { ...encounteredKeys, ...resolvedValues }
309304
}
@@ -320,10 +315,43 @@ export function createAnimationState(
320315
const needsAnimating = !willAnimateViaParent || handledRemovedValues
321316
if (shouldAnimateType && needsAnimating) {
322317
animations.push(
323-
...definitionList.map((animation) => ({
324-
animation: animation as AnimationDefinition,
325-
options: { type },
326-
}))
318+
...definitionList.map((animation) => {
319+
const options: VisualElementAnimationOptions = { type }
320+
321+
/**
322+
* If we're performing the initial animation, but we're not
323+
* rendering at the same time as the variant-controlling parent,
324+
* we want to use the parent's transition to calculate the stagger.
325+
*/
326+
if (
327+
typeof animation === "string" &&
328+
isInitialRender &&
329+
!willAnimateViaParent &&
330+
visualElement.manuallyAnimateOnMount &&
331+
visualElement.parent
332+
) {
333+
const { parent } = visualElement
334+
const parentVariant = resolveVariant(
335+
parent,
336+
animation
337+
)
338+
339+
if (parent.enteringChildren && parentVariant) {
340+
const { delayChildren } =
341+
parentVariant.transition || {}
342+
options.delay = calcChildStagger(
343+
parent.enteringChildren,
344+
visualElement,
345+
delayChildren
346+
)
347+
}
348+
}
349+
350+
return {
351+
animation: animation as AnimationDefinition,
352+
options,
353+
}
354+
})
327355
)
328356
}
329357
}

0 commit comments

Comments
 (0)