Thanks to visit codestin.com
Credit goes to github.com

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
37 changes: 30 additions & 7 deletions packages/core/src/render3/instructions/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const areAnimationSupported =

const noOpAnimationComplete = () => {};

// Tracks the list of classes added to a DOM node from `animate.enter` calls to ensure
// we remove all of the classes in the case of animation composition via host bindings.
const enterClassMap = new WeakMap<HTMLElement, string[]>();

/**
* Instruction to handle the `animate.enter` behavior for class bindings.
*
Expand Down Expand Up @@ -76,7 +80,6 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn
// This also allows us to setup cancellation of animations in progress if the
// gets removed early.
const handleAnimationStart = (event: AnimationEvent | TransitionEvent) => {
setupAnimationCancel(event, activeClasses, renderer);
const eventName = event instanceof AnimationEvent ? 'animationend' : 'transitionend';
ngZone.runOutsideAngular(() => {
cleanupFns.push(renderer.listen(nativeElement, eventName, handleInAnimationEnd));
Expand All @@ -85,7 +88,7 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn

// When the longest animation ends, we can remove all the classes
const handleInAnimationEnd = (event: AnimationEvent | TransitionEvent) => {
animationEnd(event, nativeElement, activeClasses, renderer, cleanupFns);
animationEnd(event, nativeElement, renderer, cleanupFns);
};

// We only need to add these event listeners if there are actual classes to apply
Expand All @@ -95,6 +98,8 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn
cleanupFns.push(renderer.listen(nativeElement, 'transitionstart', handleAnimationStart));
});

trackEnterClasses(nativeElement, activeClasses);

for (const klass of activeClasses) {
renderer.addClass(nativeElement as HTMLElement, klass);
}
Expand All @@ -103,6 +108,23 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn
return ɵɵanimateEnter; // For chaining
}

/**
* trackEnterClasses is necessary in the case of composition where animate.enter
* is used on the same element in multiple places, like on the element and in a
* host binding. When removing classes, we need the entire list of animation classes
* added to properly remove them when the longest animation fires.
*/
function trackEnterClasses(el: HTMLElement, classes: string[]) {
const classlist = enterClassMap.get(el);
if (classlist) {
for (const klass of classes) {
classlist.push(klass);
}
} else {
enterClassMap.set(el, classes);
}
}

/**
* Instruction to handle the `(animate.enter)` behavior for event bindings, aka when
* a user wants to use a custom animation function rather than a class.
Expand Down Expand Up @@ -390,22 +412,23 @@ function isLongestAnimation(
function animationEnd(
event: AnimationEvent | TransitionEvent,
nativeElement: HTMLElement,
classList: string[] | null,
renderer: Renderer,
cleanupFns: Function[],
) {
const classList = enterClassMap.get(nativeElement);
if (!classList) return;
setupAnimationCancel(event, classList, renderer);
const longestAnimation = getLongestAnimation(event);
if (isLongestAnimation(event, nativeElement, longestAnimation)) {
// Now that we've found the longest animation, there's no need
// to keep bubbling up this event as it's not going to apply to
// other elements further up. We don't want it to inadvertently
// affect any other animations on the page.
event.stopImmediatePropagation();
if (classList !== null) {
for (const klass of classList) {
renderer.removeClass(nativeElement, klass);
}
for (const klass of classList) {
renderer.removeClass(nativeElement, klass);
}
enterClassMap.delete(nativeElement);
for (const fn of cleanupFns) {
fn();
}
Expand Down
93 changes: 93 additions & 0 deletions packages/core/test/acceptance/animation_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,13 @@ describe('Animation', () => {
cmp.show.set(true);
fixture.detectChanges();
expect(cmp.show()).toBeTruthy();
const paragraph = fixture.debugElement.query(By.css('p'));
expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in"');
paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
paragraph.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'fade-in'}),
);
expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in fade-in"');
});

it('should support string arrays', () => {
Expand Down Expand Up @@ -671,13 +677,74 @@ describe('Animation', () => {
}
TestBed.configureTestingModule({animationsEnabled: true});

const fixture = TestBed.createComponent(TestComponent);
const cmp = fixture.componentInstance;
fixture.detectChanges();
expect(cmp.show()).toBeFalsy();
cmp.show.set(true);
fixture.detectChanges();
const paragraph = fixture.debugElement.query(By.css('p'));
expect(cmp.show()).toBeTruthy();
expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in fade-in"');
paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
paragraph.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'fade-in'}),
);
expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in fade-in"');
});

it('should support multple classes as a single string separated by a space', () => {
const multiple = `
.slide-in {
animation: slide-in 1ms;
}
.fade-in {
animation: fade-in 2ms;
}
@keyframes slide-in {
from {
transform: translateX(-10px);
}
to {
transform: translateX(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`;
@Component({
selector: 'test-cmp',
styles: multiple,
template:
'<div>@if (show()) {<p animate.enter="slide-in fade-in" #el>I should slide in</p>}</div>',
encapsulation: ViewEncapsulation.None,
})
class TestComponent {
show = signal(false);
@ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>;
}
TestBed.configureTestingModule({animationsEnabled: true});

const fixture = TestBed.createComponent(TestComponent);
const cmp = fixture.componentInstance;
fixture.detectChanges();
cmp.show.set(true);
fixture.detectChanges();
const paragraph = fixture.debugElement.query(By.css('p'));
expect(cmp.show()).toBeTruthy();
expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in fade-in"');
fixture.detectChanges();
paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
paragraph.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'fade-in'}),
);
expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in fade-in"');
});

it('should support multple classes as a single string separated by a space', () => {
Expand Down Expand Up @@ -762,6 +829,12 @@ describe('Animation', () => {
const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
expect(fixture.debugElement.nativeElement.outerHTML).toContain('class="slide-in"');
const paragraph = fixture.debugElement.query(By.css('p'));
paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
paragraph.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'slide-in'}),
);
expect(fixture.debugElement.nativeElement.outerHTML).toContain('class="slide-in"');
});

it('should compose class list when host binding and regular binding', () => {
Expand All @@ -781,6 +854,7 @@ describe('Animation', () => {
styles: styles,
imports: [ChildComponent],
template: '<child-cmp [animate.enter]="fadeExp" />',
encapsulation: ViewEncapsulation.None,
})
class TestComponent {
fadeExp = 'fade-in';
Expand All @@ -793,6 +867,15 @@ describe('Animation', () => {

expect(childCmp.nativeElement.className).toContain('slide-in');
expect(childCmp.nativeElement.className).toContain('fade-in');
childCmp.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
childCmp.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'fade-in'}),
);
childCmp.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'slide-in'}),
);
expect(childCmp.nativeElement.className).not.toContain('slide-in');
expect(childCmp.nativeElement.className).not.toContain('fade-in');
});

it('should compose class list when host binding a string and regular class strings', () => {
Expand All @@ -810,6 +893,7 @@ describe('Animation', () => {
styles: styles,
imports: [ChildComponent],
template: '<child-cmp animate.enter="fade-in" />',
encapsulation: ViewEncapsulation.None,
})
class TestComponent {}
TestBed.configureTestingModule({animationsEnabled: true});
Expand All @@ -819,6 +903,15 @@ describe('Animation', () => {
const childCmp = fixture.debugElement.query(By.css('child-cmp'));

expect(childCmp.nativeElement.className).toContain('slide-in fade-in');
childCmp.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
childCmp.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'fade-in'}),
);
childCmp.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'slide-in'}),
);
fixture.detectChanges();
expect(childCmp.nativeElement.className).not.toContain('slide-in fade-in');
});
});
});