Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(material/snack-bar): switch away from animations module
Browse files Browse the repository at this point in the history
Reworks the snack bar so it animates using CSS instead of the animations module.
crisbeto committed Jan 24, 2025
1 parent e91d509 commit 06bff69
Showing 6 changed files with 94 additions and 123 deletions.
2 changes: 2 additions & 0 deletions src/material/snack-bar/snack-bar-animations.ts
Original file line number Diff line number Diff line change
@@ -17,6 +17,8 @@ import {
/**
* Animations used by the Material snack bar.
* @docs-private
* @deprecated No longer used, will be removed.
* @breaking-change 21.0.0
*/
export const matSnackBarAnimations: {
readonly snackBarState: AnimationTriggerMetadata;
30 changes: 30 additions & 0 deletions src/material/snack-bar/snack-bar-container.scss
Original file line number Diff line number Diff line change
@@ -7,6 +7,28 @@

$_side-padding: 8px;

@keyframes _mat-snack-bar-enter {
from {
transform: scale(0.8);
opacity: 0;
}

to {
transform: scale(1);
opacity: 1;
}
}

@keyframes _mat-snack-bar-exit {
from {
opacity: 1;
}

to {
opacity: 0;
}
}

.mat-mdc-snack-bar-container {
display: flex;
align-items: center;
@@ -20,6 +42,14 @@ $_side-padding: 8px;
}
}

.mat-snack-bar-animations-enabled {
animation: _mat-snack-bar-enter 150ms cubic-bezier(0, 0, 0.2, 1);

&[mat-exit] {
animation: _mat-snack-bar-exit 75ms cubic-bezier(0.4, 0, 1, 1);
}
}

.mat-mdc-snackbar-surface {
@include elevation.elevation(6);
display: flex;
103 changes: 52 additions & 51 deletions src/material/snack-bar/snack-bar-container.ts
Original file line number Diff line number Diff line change
@@ -7,8 +7,8 @@
*/

import {
ANIMATION_MODULE_TYPE,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ComponentRef,
ElementRef,
@@ -20,7 +20,6 @@ import {
ViewEncapsulation,
} from '@angular/core';
import {DOCUMENT} from '@angular/common';
import {matSnackBarAnimations} from './snack-bar-animations';
import {
BasePortalOutlet,
CdkPortalOutlet,
@@ -31,7 +30,6 @@ import {
import {Observable, Subject} from 'rxjs';
import {_IdGenerator, AriaLivePoliteness} from '@angular/cdk/a11y';
import {Platform} from '@angular/cdk/platform';
import {AnimationEvent} from '@angular/animations';
import {MatSnackBarConfig} from './snack-bar-config';

/**
@@ -48,19 +46,21 @@ import {MatSnackBarConfig} from './snack-bar-config';
// tslint:disable-next-line:validate-decorators
changeDetection: ChangeDetectionStrategy.Default,
encapsulation: ViewEncapsulation.None,
animations: [matSnackBarAnimations.snackBarState],
imports: [CdkPortalOutlet],
host: {
'class': 'mdc-snackbar mat-mdc-snack-bar-container',
'[@state]': '_animationState',
'(@state.done)': 'onAnimationEnd($event)',
'[class.mat-snack-bar-animations-enabled]': '!_animationsDisabled',
'(animationend)': '_animationDone($event)',
},
})
export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy {
private _ngZone = inject(NgZone);
private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private _changeDetectorRef = inject(ChangeDetectorRef);
private _platform = inject(Platform);
private _enterFallback: ReturnType<typeof setTimeout> | undefined;
private _exitFallback: ReturnType<typeof setTimeout> | undefined;
protected _animationsDisabled =
inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations';
snackBarConfig = inject(MatSnackBarConfig);

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

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

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

/** The state of the snack bar animations. */
_animationState = 'void';

/** aria-live value for the live region. */
_live: AriaLivePoliteness;

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

/** Handle end of animations, updating the state of the snackbar. */
onAnimationEnd(event: AnimationEvent) {
const {fromState, toState} = event;

if ((toState === 'void' && fromState !== 'void') || toState === 'hidden') {
protected _animationDone(event: AnimationEvent) {
if (event.animationName === '_mat-snack-bar-enter') {
this._completeEnter();
} else if (event.animationName === '_mat-snack-bar-exit') {
this._completeExit();
}

if (toState === 'visible') {
// Note: we shouldn't use `this` inside the zone callback,
// because it can cause a memory leak.
const onEnter = this._onEnter;

this._ngZone.run(() => {
onEnter.next();
onEnter.complete();
});
}
}

/** Begin animation of snack bar entrance into view. */
enter(): void {
if (!this._destroyed) {
this._animationState = 'visible';
// _animationState lives in host bindings and `detectChanges` does not refresh host bindings
// so we have to call `markForCheck` to ensure the host view is refreshed eventually.
this._changeDetectorRef.markForCheck();
this._changeDetectorRef.detectChanges();
this._screenReaderAnnounce();

if (this._animationsDisabled) {
this._completeEnter();
} else {
// Guarantees that the animation-related events will
// fire even if something interrupts the animation.
clearTimeout(this._enterFallback);
this._enterFallback = setTimeout(this._completeEnter, 200);
}
}
}

/** Begin animation of the snack bar exiting from view. */
exit(): Observable<void> {
// It's common for snack bars to be opened by random outside calls like HTTP requests or
// errors. Run inside the NgZone to ensure that it functions correctly.
this._ngZone.run(() => {
// Note: this one transitions to `hidden`, rather than `void`, in order to handle the case
// where multiple snack bars are opened in quick succession (e.g. two consecutive calls to
// `MatSnackBar.open`).
this._animationState = 'hidden';
this._changeDetectorRef.markForCheck();

// Mark this element with an 'exit' attribute to indicate that the snackbar has
// been dismissed and will soon be removed from the DOM. This is used by the snackbar
// test harness.
this._elementRef.nativeElement.setAttribute('mat-exit', '');

// If the snack bar hasn't been announced by the time it exits it wouldn't have been open
// long enough to visually read it either, so clear the timeout for announcing.
clearTimeout(this._announceTimeoutId);
});
// Mark this element with an 'exit' attribute to indicate that the snackbar has
// been dismissed and will soon be removed from the DOM. This is used by the snackbar
// test harness.
this._elementRef.nativeElement.setAttribute('mat-exit', '');

// If the snack bar hasn't been announced by the time it exits it wouldn't have been open
// long enough to visually read it either, so clear the timeout for announcing.
clearTimeout(this._announceTimeoutId);

if (this._animationsDisabled) {
// It's common for snack bars to be opened by random outside calls like HTTP requests or
// errors. Run inside the NgZone to ensure that it functions correctly.
this._ngZone.run(this._completeExit);
} else {
// Guarantees that the animation-related events will
// fire even if something interrupts the animation.
clearTimeout(this._exitFallback);
this._exitFallback = setTimeout(this._completeExit, 150);
}

return this._onExit;
}

/** Makes sure the exit callbacks have been invoked when the element is destroyed. */
ngOnDestroy() {
clearTimeout(this._enterFallback);
this._destroyed = true;
this._clearFromModals();
this._completeExit();
}

private _completeEnter = () => {
clearTimeout(this._enterFallback);
this._ngZone.run(() => {
this._onEnter.next();
this._onEnter.complete();
});
};

/**
* Removes the element in a microtask. Helps prevent errors where we end up
* removing an element which is in the middle of an animation.
*/
private _completeExit() {
private _completeExit = () => {
clearTimeout(this._exitFallback);
queueMicrotask(() => {
this._onExit.next();
this._onExit.complete();
});
}
};

/**
* Called after the portal contents have been attached. Can be
64 changes: 1 addition & 63 deletions src/material/snack-bar/snack-bar.spec.ts
Original file line number Diff line number Diff line change
@@ -17,7 +17,6 @@ import {
MAT_SNACK_BAR_DATA,
MatSnackBar,
MatSnackBarConfig,
MatSnackBarContainer,
MatSnackBarModule,
MatSnackBarRef,
SimpleSnackBar,
@@ -360,67 +359,6 @@ describe('MatSnackBar', () => {
.toBe(0);
}));

it('should set the animation state to visible on entry', () => {
const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
const snackBarRef = snackBar.open(simpleMessage, undefined, config);

viewContainerFixture.detectChanges();
const container = snackBarRef.containerInstance as MatSnackBarContainer;
expect(container._animationState)
.withContext(`Expected the animation state would be 'visible'.`)
.toBe('visible');
snackBarRef.dismiss();

viewContainerFixture.detectChanges();
expect(container._animationState)
.withContext(`Expected the animation state would be 'hidden'.`)
.toBe('hidden');
});

it('should set the animation state to complete on exit', () => {
const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
const snackBarRef = snackBar.open(simpleMessage, undefined, config);
snackBarRef.dismiss();

viewContainerFixture.detectChanges();
const container = snackBarRef.containerInstance as MatSnackBarContainer;
expect(container._animationState)
.withContext(`Expected the animation state would be 'hidden'.`)
.toBe('hidden');
});

it(`should set the old snack bar animation state to complete and the new snack bar animation
state to visible on entry of new snack bar`, fakeAsync(() => {
const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
const snackBarRef = snackBar.open(simpleMessage, undefined, config);
const dismissCompleteSpy = jasmine.createSpy('dismiss complete spy');

viewContainerFixture.detectChanges();

const containerElement = document.querySelector('mat-snack-bar-container')!;
expect(containerElement.classList).toContain('ng-animating');
const container1 = snackBarRef.containerInstance as MatSnackBarContainer;
expect(container1._animationState)
.withContext(`Expected the animation state would be 'visible'.`)
.toBe('visible');

const config2 = {viewContainerRef: testViewContainerRef};
const snackBarRef2 = snackBar.open(simpleMessage, undefined, config2);

viewContainerFixture.detectChanges();
snackBarRef.afterDismissed().subscribe({complete: dismissCompleteSpy});
flush();

expect(dismissCompleteSpy).toHaveBeenCalled();
const container2 = snackBarRef2.containerInstance as MatSnackBarContainer;
expect(container1._animationState)
.withContext(`Expected the animation state would be 'hidden'.`)
.toBe('hidden');
expect(container2._animationState)
.withContext(`Expected the animation state would be 'visible'.`)
.toBe('visible');
}));

it('should open a new snackbar after dismissing a previous snackbar', fakeAsync(() => {
let config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
let snackBarRef = snackBar.open(simpleMessage, 'Dismiss', config);
@@ -610,9 +548,9 @@ describe('MatSnackBar', () => {
it('should cap the timeout to the maximum accepted delay in setTimeout', fakeAsync(() => {
const config = new MatSnackBarConfig();
config.duration = Infinity;
spyOn(window, 'setTimeout').and.callThrough();
snackBar.open('content', 'test', config);
viewContainerFixture.detectChanges();
spyOn(window, 'setTimeout').and.callThrough();
tick(100);

expect(window.setTimeout).toHaveBeenCalledWith(jasmine.any(Function), Math.pow(2, 31) - 1);
10 changes: 5 additions & 5 deletions src/material/snack-bar/snack-bar.ts
Original file line number Diff line number Diff line change
@@ -242,6 +242,11 @@ export class MatSnackBar implements OnDestroy {
}
});

// If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened.
if (config.duration && config.duration > 0) {
snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!));
}

if (this._openedSnackBarRef) {
// If a snack bar is already in view, dismiss it and enter the
// new snack bar after exit animation is complete.
@@ -253,11 +258,6 @@ export class MatSnackBar implements OnDestroy {
// If no snack bar is in view, enter the new snack bar.
snackBarRef.containerInstance.enter();
}

// If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened.
if (config.duration && config.duration > 0) {
snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!));
}
}

/**
8 changes: 4 additions & 4 deletions tools/public_api_guard/material/snack-bar.md
Original file line number Diff line number Diff line change
@@ -4,7 +4,6 @@
```ts

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

// @public
// @public @deprecated
export const matSnackBarAnimations: {
readonly snackBarState: AnimationTriggerMetadata;
};
@@ -96,7 +95,9 @@ export class MatSnackBarConfig<D = any> {
// @public
export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy {
constructor(...args: unknown[]);
_animationState: string;
protected _animationDone(event: AnimationEvent): void;
// (undocumented)
protected _animationsDisabled: boolean;
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T>;
// @deprecated
attachDomPortal: (portal: DomPortal) => void;
@@ -107,7 +108,6 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
_live: AriaLivePoliteness;
readonly _liveElementId: string;
ngOnDestroy(): void;
onAnimationEnd(event: AnimationEvent_2): void;
readonly _onAnnounce: Subject<void>;
readonly _onEnter: Subject<void>;
readonly _onExit: Subject<void>;

0 comments on commit 06bff69

Please sign in to comment.