Skip to content

Commit 06bff69

Browse files
committed
fix(material/snack-bar): switch away from animations module
Reworks the snack bar so it animates using CSS instead of the animations module.
1 parent e91d509 commit 06bff69

File tree

6 files changed

+94
-123
lines changed

6 files changed

+94
-123
lines changed

src/material/snack-bar/snack-bar-animations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
/**
1818
* Animations used by the Material snack bar.
1919
* @docs-private
20+
* @deprecated No longer used, will be removed.
21+
* @breaking-change 21.0.0
2022
*/
2123
export const matSnackBarAnimations: {
2224
readonly snackBarState: AnimationTriggerMetadata;

src/material/snack-bar/snack-bar-container.scss

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@
77

88
$_side-padding: 8px;
99

10+
@keyframes _mat-snack-bar-enter {
11+
from {
12+
transform: scale(0.8);
13+
opacity: 0;
14+
}
15+
16+
to {
17+
transform: scale(1);
18+
opacity: 1;
19+
}
20+
}
21+
22+
@keyframes _mat-snack-bar-exit {
23+
from {
24+
opacity: 1;
25+
}
26+
27+
to {
28+
opacity: 0;
29+
}
30+
}
31+
1032
.mat-mdc-snack-bar-container {
1133
display: flex;
1234
align-items: center;
@@ -20,6 +42,14 @@ $_side-padding: 8px;
2042
}
2143
}
2244

45+
.mat-snack-bar-animations-enabled {
46+
animation: _mat-snack-bar-enter 150ms cubic-bezier(0, 0, 0.2, 1);
47+
48+
&[mat-exit] {
49+
animation: _mat-snack-bar-exit 75ms cubic-bezier(0.4, 0, 1, 1);
50+
}
51+
}
52+
2353
.mat-mdc-snackbar-surface {
2454
@include elevation.elevation(6);
2555
display: flex;

src/material/snack-bar/snack-bar-container.ts

Lines changed: 52 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
*/
88

99
import {
10+
ANIMATION_MODULE_TYPE,
1011
ChangeDetectionStrategy,
11-
ChangeDetectorRef,
1212
Component,
1313
ComponentRef,
1414
ElementRef,
@@ -20,7 +20,6 @@ import {
2020
ViewEncapsulation,
2121
} from '@angular/core';
2222
import {DOCUMENT} from '@angular/common';
23-
import {matSnackBarAnimations} from './snack-bar-animations';
2423
import {
2524
BasePortalOutlet,
2625
CdkPortalOutlet,
@@ -31,7 +30,6 @@ import {
3130
import {Observable, Subject} from 'rxjs';
3231
import {_IdGenerator, AriaLivePoliteness} from '@angular/cdk/a11y';
3332
import {Platform} from '@angular/cdk/platform';
34-
import {AnimationEvent} from '@angular/animations';
3533
import {MatSnackBarConfig} from './snack-bar-config';
3634

3735
/**
@@ -48,19 +46,21 @@ import {MatSnackBarConfig} from './snack-bar-config';
4846
// tslint:disable-next-line:validate-decorators
4947
changeDetection: ChangeDetectionStrategy.Default,
5048
encapsulation: ViewEncapsulation.None,
51-
animations: [matSnackBarAnimations.snackBarState],
5249
imports: [CdkPortalOutlet],
5350
host: {
5451
'class': 'mdc-snackbar mat-mdc-snack-bar-container',
55-
'[@state]': '_animationState',
56-
'(@state.done)': 'onAnimationEnd($event)',
52+
'[class.mat-snack-bar-animations-enabled]': '!_animationsDisabled',
53+
'(animationend)': '_animationDone($event)',
5754
},
5855
})
5956
export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy {
6057
private _ngZone = inject(NgZone);
6158
private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
62-
private _changeDetectorRef = inject(ChangeDetectorRef);
6359
private _platform = inject(Platform);
60+
private _enterFallback: ReturnType<typeof setTimeout> | undefined;
61+
private _exitFallback: ReturnType<typeof setTimeout> | undefined;
62+
protected _animationsDisabled =
63+
inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations';
6464
snackBarConfig = inject(MatSnackBarConfig);
6565

6666
private _document = inject(DOCUMENT);
@@ -70,7 +70,7 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
7070
private readonly _announceDelay: number = 150;
7171

7272
/** The timeout for announcing the snack bar's content. */
73-
private _announceTimeoutId: ReturnType<typeof setTimeout>;
73+
private _announceTimeoutId: ReturnType<typeof setTimeout> | undefined;
7474

7575
/** Whether the component has been destroyed. */
7676
private _destroyed = false;
@@ -87,9 +87,6 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
8787
/** Subject for notifying that the snack bar has finished entering the view. */
8888
readonly _onEnter: Subject<void> = new Subject();
8989

90-
/** The state of the snack bar animations. */
91-
_animationState = 'void';
92-
9390
/** aria-live value for the live region. */
9491
_live: AriaLivePoliteness;
9592

@@ -166,78 +163,82 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
166163
};
167164

168165
/** Handle end of animations, updating the state of the snackbar. */
169-
onAnimationEnd(event: AnimationEvent) {
170-
const {fromState, toState} = event;
171-
172-
if ((toState === 'void' && fromState !== 'void') || toState === 'hidden') {
166+
protected _animationDone(event: AnimationEvent) {
167+
if (event.animationName === '_mat-snack-bar-enter') {
168+
this._completeEnter();
169+
} else if (event.animationName === '_mat-snack-bar-exit') {
173170
this._completeExit();
174171
}
175-
176-
if (toState === 'visible') {
177-
// Note: we shouldn't use `this` inside the zone callback,
178-
// because it can cause a memory leak.
179-
const onEnter = this._onEnter;
180-
181-
this._ngZone.run(() => {
182-
onEnter.next();
183-
onEnter.complete();
184-
});
185-
}
186172
}
187173

188174
/** Begin animation of snack bar entrance into view. */
189175
enter(): void {
190176
if (!this._destroyed) {
191-
this._animationState = 'visible';
192-
// _animationState lives in host bindings and `detectChanges` does not refresh host bindings
193-
// so we have to call `markForCheck` to ensure the host view is refreshed eventually.
194-
this._changeDetectorRef.markForCheck();
195-
this._changeDetectorRef.detectChanges();
196177
this._screenReaderAnnounce();
178+
179+
if (this._animationsDisabled) {
180+
this._completeEnter();
181+
} else {
182+
// Guarantees that the animation-related events will
183+
// fire even if something interrupts the animation.
184+
clearTimeout(this._enterFallback);
185+
this._enterFallback = setTimeout(this._completeEnter, 200);
186+
}
197187
}
198188
}
199189

200190
/** Begin animation of the snack bar exiting from view. */
201191
exit(): Observable<void> {
202-
// It's common for snack bars to be opened by random outside calls like HTTP requests or
203-
// errors. Run inside the NgZone to ensure that it functions correctly.
204-
this._ngZone.run(() => {
205-
// Note: this one transitions to `hidden`, rather than `void`, in order to handle the case
206-
// where multiple snack bars are opened in quick succession (e.g. two consecutive calls to
207-
// `MatSnackBar.open`).
208-
this._animationState = 'hidden';
209-
this._changeDetectorRef.markForCheck();
210-
211-
// Mark this element with an 'exit' attribute to indicate that the snackbar has
212-
// been dismissed and will soon be removed from the DOM. This is used by the snackbar
213-
// test harness.
214-
this._elementRef.nativeElement.setAttribute('mat-exit', '');
215-
216-
// If the snack bar hasn't been announced by the time it exits it wouldn't have been open
217-
// long enough to visually read it either, so clear the timeout for announcing.
218-
clearTimeout(this._announceTimeoutId);
219-
});
192+
// Mark this element with an 'exit' attribute to indicate that the snackbar has
193+
// been dismissed and will soon be removed from the DOM. This is used by the snackbar
194+
// test harness.
195+
this._elementRef.nativeElement.setAttribute('mat-exit', '');
196+
197+
// If the snack bar hasn't been announced by the time it exits it wouldn't have been open
198+
// long enough to visually read it either, so clear the timeout for announcing.
199+
clearTimeout(this._announceTimeoutId);
200+
201+
if (this._animationsDisabled) {
202+
// It's common for snack bars to be opened by random outside calls like HTTP requests or
203+
// errors. Run inside the NgZone to ensure that it functions correctly.
204+
this._ngZone.run(this._completeExit);
205+
} else {
206+
// Guarantees that the animation-related events will
207+
// fire even if something interrupts the animation.
208+
clearTimeout(this._exitFallback);
209+
this._exitFallback = setTimeout(this._completeExit, 150);
210+
}
220211

221212
return this._onExit;
222213
}
223214

224215
/** Makes sure the exit callbacks have been invoked when the element is destroyed. */
225216
ngOnDestroy() {
217+
clearTimeout(this._enterFallback);
226218
this._destroyed = true;
227219
this._clearFromModals();
228220
this._completeExit();
229221
}
230222

223+
private _completeEnter = () => {
224+
clearTimeout(this._enterFallback);
225+
this._ngZone.run(() => {
226+
this._onEnter.next();
227+
this._onEnter.complete();
228+
});
229+
};
230+
231231
/**
232232
* Removes the element in a microtask. Helps prevent errors where we end up
233233
* removing an element which is in the middle of an animation.
234234
*/
235-
private _completeExit() {
235+
private _completeExit = () => {
236+
clearTimeout(this._exitFallback);
236237
queueMicrotask(() => {
237238
this._onExit.next();
238239
this._onExit.complete();
239240
});
240-
}
241+
};
241242

242243
/**
243244
* Called after the portal contents have been attached. Can be

src/material/snack-bar/snack-bar.spec.ts

Lines changed: 1 addition & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
MAT_SNACK_BAR_DATA,
1818
MatSnackBar,
1919
MatSnackBarConfig,
20-
MatSnackBarContainer,
2120
MatSnackBarModule,
2221
MatSnackBarRef,
2322
SimpleSnackBar,
@@ -360,67 +359,6 @@ describe('MatSnackBar', () => {
360359
.toBe(0);
361360
}));
362361

363-
it('should set the animation state to visible on entry', () => {
364-
const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
365-
const snackBarRef = snackBar.open(simpleMessage, undefined, config);
366-
367-
viewContainerFixture.detectChanges();
368-
const container = snackBarRef.containerInstance as MatSnackBarContainer;
369-
expect(container._animationState)
370-
.withContext(`Expected the animation state would be 'visible'.`)
371-
.toBe('visible');
372-
snackBarRef.dismiss();
373-
374-
viewContainerFixture.detectChanges();
375-
expect(container._animationState)
376-
.withContext(`Expected the animation state would be 'hidden'.`)
377-
.toBe('hidden');
378-
});
379-
380-
it('should set the animation state to complete on exit', () => {
381-
const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
382-
const snackBarRef = snackBar.open(simpleMessage, undefined, config);
383-
snackBarRef.dismiss();
384-
385-
viewContainerFixture.detectChanges();
386-
const container = snackBarRef.containerInstance as MatSnackBarContainer;
387-
expect(container._animationState)
388-
.withContext(`Expected the animation state would be 'hidden'.`)
389-
.toBe('hidden');
390-
});
391-
392-
it(`should set the old snack bar animation state to complete and the new snack bar animation
393-
state to visible on entry of new snack bar`, fakeAsync(() => {
394-
const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
395-
const snackBarRef = snackBar.open(simpleMessage, undefined, config);
396-
const dismissCompleteSpy = jasmine.createSpy('dismiss complete spy');
397-
398-
viewContainerFixture.detectChanges();
399-
400-
const containerElement = document.querySelector('mat-snack-bar-container')!;
401-
expect(containerElement.classList).toContain('ng-animating');
402-
const container1 = snackBarRef.containerInstance as MatSnackBarContainer;
403-
expect(container1._animationState)
404-
.withContext(`Expected the animation state would be 'visible'.`)
405-
.toBe('visible');
406-
407-
const config2 = {viewContainerRef: testViewContainerRef};
408-
const snackBarRef2 = snackBar.open(simpleMessage, undefined, config2);
409-
410-
viewContainerFixture.detectChanges();
411-
snackBarRef.afterDismissed().subscribe({complete: dismissCompleteSpy});
412-
flush();
413-
414-
expect(dismissCompleteSpy).toHaveBeenCalled();
415-
const container2 = snackBarRef2.containerInstance as MatSnackBarContainer;
416-
expect(container1._animationState)
417-
.withContext(`Expected the animation state would be 'hidden'.`)
418-
.toBe('hidden');
419-
expect(container2._animationState)
420-
.withContext(`Expected the animation state would be 'visible'.`)
421-
.toBe('visible');
422-
}));
423-
424362
it('should open a new snackbar after dismissing a previous snackbar', fakeAsync(() => {
425363
let config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
426364
let snackBarRef = snackBar.open(simpleMessage, 'Dismiss', config);
@@ -610,9 +548,9 @@ describe('MatSnackBar', () => {
610548
it('should cap the timeout to the maximum accepted delay in setTimeout', fakeAsync(() => {
611549
const config = new MatSnackBarConfig();
612550
config.duration = Infinity;
551+
spyOn(window, 'setTimeout').and.callThrough();
613552
snackBar.open('content', 'test', config);
614553
viewContainerFixture.detectChanges();
615-
spyOn(window, 'setTimeout').and.callThrough();
616554
tick(100);
617555

618556
expect(window.setTimeout).toHaveBeenCalledWith(jasmine.any(Function), Math.pow(2, 31) - 1);

src/material/snack-bar/snack-bar.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ export class MatSnackBar implements OnDestroy {
242242
}
243243
});
244244

245+
// If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened.
246+
if (config.duration && config.duration > 0) {
247+
snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!));
248+
}
249+
245250
if (this._openedSnackBarRef) {
246251
// If a snack bar is already in view, dismiss it and enter the
247252
// new snack bar after exit animation is complete.
@@ -253,11 +258,6 @@ export class MatSnackBar implements OnDestroy {
253258
// If no snack bar is in view, enter the new snack bar.
254259
snackBarRef.containerInstance.enter();
255260
}
256-
257-
// If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened.
258-
if (config.duration && config.duration > 0) {
259-
snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!));
260-
}
261261
}
262262

263263
/**

tools/public_api_guard/material/snack-bar.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
55
```ts
66

7-
import { AnimationEvent as AnimationEvent_2 } from '@angular/animations';
87
import { AnimationTriggerMetadata } from '@angular/animations';
98
import { AriaLivePoliteness } from '@angular/cdk/a11y';
109
import { BasePortalOutlet } from '@angular/cdk/portal';
@@ -75,7 +74,7 @@ export class MatSnackBarActions {
7574
static ɵfac: i0.ɵɵFactoryDeclaration<MatSnackBarActions, never>;
7675
}
7776

78-
// @public
77+
// @public @deprecated
7978
export const matSnackBarAnimations: {
8079
readonly snackBarState: AnimationTriggerMetadata;
8180
};
@@ -96,7 +95,9 @@ export class MatSnackBarConfig<D = any> {
9695
// @public
9796
export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy {
9897
constructor(...args: unknown[]);
99-
_animationState: string;
98+
protected _animationDone(event: AnimationEvent): void;
99+
// (undocumented)
100+
protected _animationsDisabled: boolean;
100101
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T>;
101102
// @deprecated
102103
attachDomPortal: (portal: DomPortal) => void;
@@ -107,7 +108,6 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
107108
_live: AriaLivePoliteness;
108109
readonly _liveElementId: string;
109110
ngOnDestroy(): void;
110-
onAnimationEnd(event: AnimationEvent_2): void;
111111
readonly _onAnnounce: Subject<void>;
112112
readonly _onEnter: Subject<void>;
113113
readonly _onExit: Subject<void>;

0 commit comments

Comments
 (0)