Skip to content

Commit ea2016a

Browse files
thePunderWomanAndrewKushnir
authored andcommitted
feat(core): add support for nested animations
Now if there's a leave animation nested within a template, those will animate before the parent element is removed. This makes animating leaving elements a bit easier than before and adds a lot more flexibility to how animations can be structured. fixes: #66476
1 parent 24c0c5a commit ea2016a

File tree

13 files changed

+733
-79
lines changed

13 files changed

+733
-79
lines changed

packages/core/src/animation/queue.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,14 @@ export const ANIMATION_QUEUE = new InjectionToken<AnimationQueue>(
2424
typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationQueue' : '',
2525
{
2626
factory: () => {
27+
const injector = inject(EnvironmentInjector);
28+
const queue = new Set<VoidFunction>();
29+
injector.onDestroy(() => queue.clear());
2730
return {
28-
queue: new Set(),
31+
queue,
2932
isScheduled: false,
3033
scheduler: null,
31-
injector: inject(EnvironmentInjector), // should be the root injector
34+
injector,
3235
};
3336
},
3437
},
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {
10+
RunLeaveAnimationFn,
11+
LeaveNodeAnimations,
12+
AnimationLViewData,
13+
} from '../animation/interfaces';
14+
import {allLeavingAnimations} from '../animation/longest_animation';
15+
import {
16+
queueEnterAnimations,
17+
addToAnimationQueue,
18+
removeAnimationsFromQueue,
19+
} from '../animation/queue';
20+
import {Injector, INJECTOR} from '../di';
21+
import {CONTAINER_HEADER_OFFSET} from './interfaces/container';
22+
import {TNode, TNodeType} from './interfaces/node';
23+
import {RElement} from './interfaces/renderer_dom';
24+
import {isComponentHost, isLContainer} from './interfaces/type_checks';
25+
import {ANIMATIONS, ID, LView, TVIEW} from './interfaces/view';
26+
import {getComponentLViewByIndex} from './util/view_utils';
27+
28+
export function maybeQueueEnterAnimation(
29+
parentLView: LView | undefined,
30+
parent: RElement | null,
31+
tNode: TNode,
32+
injector: Injector,
33+
): void {
34+
const enterAnimations = parentLView?.[ANIMATIONS]?.enter;
35+
if (parent !== null && enterAnimations && enterAnimations.has(tNode.index)) {
36+
queueEnterAnimations(injector, enterAnimations);
37+
}
38+
}
39+
40+
export function runLeaveAnimationsWithCallback(
41+
lView: LView | undefined,
42+
tNode: TNode,
43+
injector: Injector,
44+
callback: Function,
45+
) {
46+
// It's possible that the AppRef has been destroyed, which would also destroy
47+
// the injector tree. If this happens, we will get an error when we try to
48+
// get the injector, so we catch it here and avoid the error and return
49+
// safely.
50+
try {
51+
injector.get(INJECTOR);
52+
} catch {
53+
return callback(false);
54+
}
55+
56+
const animations = lView?.[ANIMATIONS];
57+
58+
// regarding the TNode index to see if it is the same element.
59+
if (animations?.enter?.has(tNode.index)) {
60+
removeAnimationsFromQueue(injector, animations.enter.get(tNode.index)!.animateFns);
61+
}
62+
63+
// get all nodes in the current view that are descendants of tNode and have leave animations
64+
const nodesWithExitAnimations = aggregateDescendantAnimations(lView, tNode, animations);
65+
66+
if (nodesWithExitAnimations.size === 0) {
67+
let hasNestedAnimations = false;
68+
if (lView) {
69+
const nestedPromises: Promise<unknown>[] = [];
70+
collectNestedViewAnimations(lView, tNode, nestedPromises);
71+
hasNestedAnimations = nestedPromises.length > 0;
72+
}
73+
74+
if (!hasNestedAnimations) {
75+
return callback(false);
76+
}
77+
}
78+
79+
if (lView) allLeavingAnimations.add(lView[ID]);
80+
81+
addToAnimationQueue(
82+
injector,
83+
() =>
84+
executeLeaveAnimations(
85+
lView,
86+
tNode,
87+
animations || undefined,
88+
nodesWithExitAnimations,
89+
callback,
90+
),
91+
animations || undefined,
92+
);
93+
}
94+
95+
// Identifies all elements that are descendants of `tNode` *within the same component view*
96+
// (LView) and have active leave animations. Since `tNode` is being removed, its descendants
97+
// will also be removed. We must execute their leave animations and wait for them to finish
98+
// before physically removing `tNode` from the DOM.
99+
//
100+
// Instead of performing a potentially expensive downward traversal of the
101+
// entire `tNode` subtree to find animated descendants, we iterate over the `leaveAnimations`
102+
// map. This map contains all pending leave animations in the current LView and is typically
103+
// very small.
104+
//
105+
// Note: Animations across LView boundaries (e.g., in child components or embedded views)
106+
// are collected separately via `collectNestedViewAnimations`.
107+
function aggregateDescendantAnimations(
108+
lView: LView | undefined,
109+
tNode: TNode,
110+
animations: AnimationLViewData | null | undefined,
111+
): Map<number, LeaveNodeAnimations> {
112+
const nodesWithExitAnimations = new Map<number, LeaveNodeAnimations>();
113+
const leaveAnimations = animations?.leave;
114+
115+
if (leaveAnimations && leaveAnimations.has(tNode.index)) {
116+
nodesWithExitAnimations.set(tNode.index, leaveAnimations.get(tNode.index)!);
117+
}
118+
119+
if (lView && leaveAnimations) {
120+
for (const [index, animationData] of leaveAnimations) {
121+
if (nodesWithExitAnimations.has(index)) continue;
122+
123+
// Get the tNode for the animation. This node might be a descendant of the tNode we are removing.
124+
// If so, we need to run its leave animation as well.
125+
const nestedTNode = lView[TVIEW].data[index] as TNode;
126+
let parent = nestedTNode.parent;
127+
128+
// Traverse upward to check if `tNode` is an ancestor of `nestedTNode`
129+
// For each animation in the map, we retrieve its corresponding TNode (`nestedTNode`) and
130+
// traverse UP the tree using parent pointers. If we encounter `tNode` during this upward
131+
// traversal, we know the animated element is a descendant, and we add its animation data
132+
// to `nodesWithExitAnimations`.
133+
while (parent) {
134+
if (parent === tNode) {
135+
nodesWithExitAnimations.set(index, animationData);
136+
break;
137+
}
138+
parent = parent.parent;
139+
}
140+
}
141+
}
142+
return nodesWithExitAnimations;
143+
}
144+
145+
function executeLeaveAnimations(
146+
lView: LView | undefined,
147+
tNode: TNode,
148+
animations: AnimationLViewData | undefined,
149+
nodesWithExitAnimations: Map<number, LeaveNodeAnimations>,
150+
callback: Function,
151+
) {
152+
// it's possible that in the time between when the leave animation was
153+
// and the time it was executed, the data structure changed. So we need
154+
// to be safe here.
155+
const runningAnimations: Promise<unknown>[] = [];
156+
157+
if (animations && animations.leave) {
158+
for (const [index] of nodesWithExitAnimations) {
159+
if (!animations.leave.has(index)) continue;
160+
161+
const currentAnimationData = animations.leave.get(index)!;
162+
for (const animationFn of currentAnimationData.animateFns) {
163+
const {promise} = animationFn() as ReturnType<RunLeaveAnimationFn>;
164+
runningAnimations.push(promise);
165+
}
166+
animations.detachedLeaveAnimationFns = undefined;
167+
}
168+
}
169+
170+
// Also add nested view animations
171+
if (lView) {
172+
collectNestedViewAnimations(lView, tNode, runningAnimations);
173+
}
174+
175+
if (runningAnimations.length > 0) {
176+
const currentAnimations = animations || lView?.[ANIMATIONS];
177+
if (currentAnimations) {
178+
const prevRunning = currentAnimations.running;
179+
if (prevRunning) {
180+
runningAnimations.push(prevRunning);
181+
}
182+
currentAnimations.running = Promise.allSettled(runningAnimations);
183+
runAfterLeaveAnimations(lView!, currentAnimations.running, callback);
184+
} else {
185+
Promise.allSettled(runningAnimations).then(() => {
186+
if (lView) allLeavingAnimations.delete(lView[ID]);
187+
callback(true);
188+
});
189+
}
190+
} else {
191+
if (lView) allLeavingAnimations.delete(lView[ID]);
192+
callback(false);
193+
}
194+
}
195+
196+
/**
197+
* Collects leave animations from nested views (components and containers)
198+
* starting from the given TNode's children.
199+
*/
200+
function collectNestedViewAnimations(
201+
lView: LView,
202+
tNode: TNode,
203+
collectedPromises: Promise<unknown>[],
204+
) {
205+
if (isComponentHost(tNode)) {
206+
const componentView = getComponentLViewByIndex(tNode.index, lView);
207+
collectAllViewLeaveAnimations(componentView, collectedPromises);
208+
} else if (tNode.type & TNodeType.AnyContainer) {
209+
const lContainer = lView[tNode.index];
210+
if (isLContainer(lContainer)) {
211+
for (let i = CONTAINER_HEADER_OFFSET; i < lContainer.length; i++) {
212+
const subView = lContainer[i] as LView;
213+
collectAllViewLeaveAnimations(subView, collectedPromises);
214+
}
215+
}
216+
}
217+
218+
let child = tNode.child;
219+
while (child) {
220+
collectNestedViewAnimations(lView, child, collectedPromises);
221+
child = child.next;
222+
}
223+
}
224+
225+
/**
226+
* Recursively collects all leave animations from a view and its children.
227+
*/
228+
function collectAllViewLeaveAnimations(view: LView, collectedPromises: Promise<unknown>[]) {
229+
const animations = view[ANIMATIONS];
230+
if (animations && animations.leave) {
231+
for (const animationData of animations.leave.values()) {
232+
for (const animationFn of animationData.animateFns) {
233+
// We interpret the animation function to get the promise
234+
const {promise} = animationFn() as ReturnType<RunLeaveAnimationFn>;
235+
collectedPromises.push(promise);
236+
}
237+
}
238+
}
239+
240+
let child = view[TVIEW].firstChild;
241+
while (child) {
242+
collectNestedViewAnimations(view, child, collectedPromises);
243+
child = child.next;
244+
}
245+
}
246+
247+
function runAfterLeaveAnimations(
248+
lView: LView,
249+
runningAnimations: Promise<unknown>,
250+
callback: Function,
251+
) {
252+
runningAnimations.then(() => {
253+
// We only want to clear the running flag and the allLeavingAnimations set if
254+
// the current running animation is the same as the one we just waited for.
255+
// If it's different, it means another animation started while we were waiting,
256+
// and that other animation is now responsible for clearing the flag.
257+
if (lView[ANIMATIONS]?.running === runningAnimations) {
258+
lView[ANIMATIONS]!.running = undefined;
259+
allLeavingAnimations.delete(lView[ID]);
260+
}
261+
callback(true);
262+
});
263+
}

packages/core/src/render3/node_manipulation.ts

Lines changed: 1 addition & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,8 @@ import {assertTNodeType} from './node_assert';
8282
import {profiler} from './profiler';
8383
import {ProfilerEvent} from '../../primitives/devtools';
8484
import {getLViewParent, getNativeByTNode, unwrapRNode} from './util/view_utils';
85-
import {allLeavingAnimations} from '../animation/longest_animation';
8685
import {Injector} from '../di';
87-
import {
88-
addToAnimationQueue,
89-
queueEnterAnimations,
90-
removeAnimationsFromQueue,
91-
} from '../animation/queue';
92-
import {RunLeaveAnimationFn} from '../animation/interfaces';
86+
import {maybeQueueEnterAnimation, runLeaveAnimationsWithCallback} from './node_animations';
9387

9488
const enum WalkTNodeTreeAction {
9589
/** node create in the native environment. Run on initial creation. */
@@ -108,18 +102,6 @@ const enum WalkTNodeTreeAction {
108102
Destroy = 3,
109103
}
110104

111-
function maybeQueueEnterAnimation(
112-
parentLView: LView | undefined,
113-
parent: RElement | null,
114-
tNode: TNode,
115-
injector: Injector,
116-
): void {
117-
const enterAnimations = parentLView?.[ANIMATIONS]?.enter;
118-
if (parent !== null && enterAnimations && enterAnimations.has(tNode.index)) {
119-
queueEnterAnimations(injector, enterAnimations);
120-
}
121-
}
122-
123105
/**
124106
* NOTE: for performance reasons, the possible actions are inlined within the function instead of
125107
* being passed as an argument.
@@ -382,64 +364,6 @@ function cleanUpView(tView: TView, lView: LView): void {
382364
}
383365
}
384366

385-
function runLeaveAnimationsWithCallback(
386-
lView: LView | undefined,
387-
tNode: TNode,
388-
injector: Injector,
389-
callback: Function,
390-
) {
391-
const animations = lView?.[ANIMATIONS];
392-
if (animations?.enter?.has(tNode.index)) {
393-
removeAnimationsFromQueue(injector, animations.enter.get(tNode.index)!.animateFns);
394-
}
395-
396-
if (animations == null || animations.leave == undefined || !animations.leave.has(tNode.index))
397-
return callback(false);
398-
399-
if (lView) allLeavingAnimations.add(lView[ID]);
400-
401-
addToAnimationQueue(
402-
injector,
403-
() => {
404-
// it's possible that in the time between when the leave animation was
405-
// and the time it was executed, the data structure changed. So we need
406-
// to be safe here.
407-
if (animations.leave && animations.leave.has(tNode.index)) {
408-
const leaveAnimationMap = animations.leave;
409-
const leaveAnimations = leaveAnimationMap.get(tNode.index);
410-
const runningAnimations = [];
411-
if (leaveAnimations) {
412-
for (let index = 0; index < leaveAnimations.animateFns.length; index++) {
413-
const animationFn = leaveAnimations.animateFns[index];
414-
const {promise} = animationFn() as ReturnType<RunLeaveAnimationFn>;
415-
runningAnimations.push(promise);
416-
}
417-
animations.detachedLeaveAnimationFns = undefined;
418-
}
419-
animations.running = Promise.allSettled(runningAnimations);
420-
runAfterLeaveAnimations(lView!, callback);
421-
} else {
422-
if (lView) allLeavingAnimations.delete(lView[ID]);
423-
callback(false);
424-
}
425-
},
426-
animations,
427-
);
428-
}
429-
430-
function runAfterLeaveAnimations(lView: LView, callback: Function) {
431-
const runningAnimations = lView[ANIMATIONS]?.running;
432-
if (runningAnimations) {
433-
runningAnimations.then(() => {
434-
lView[ANIMATIONS]!.running = undefined;
435-
allLeavingAnimations.delete(lView[ID]);
436-
callback(true);
437-
});
438-
return;
439-
}
440-
callback(false);
441-
}
442-
443367
/** Removes listeners and unsubscribes from output subscriptions */
444368
function processCleanups(tView: TView, lView: LView): void {
445369
ngDevMode && assertNotReactive(processCleanups.name);

0 commit comments

Comments
 (0)