From 06bff69640bda08c4f622cecc242316d5e84436d Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 24 Jan 2025 07:08:03 +0100 Subject: [PATCH] fix(material/snack-bar): switch away from animations module Reworks the snack bar so it animates using CSS instead of the animations module. --- .../snack-bar/snack-bar-animations.ts | 2 + .../snack-bar/snack-bar-container.scss | 30 +++++ src/material/snack-bar/snack-bar-container.ts | 103 +++++++++--------- src/material/snack-bar/snack-bar.spec.ts | 64 +---------- src/material/snack-bar/snack-bar.ts | 10 +- tools/public_api_guard/material/snack-bar.md | 8 +- 6 files changed, 94 insertions(+), 123 deletions(-) diff --git a/src/material/snack-bar/snack-bar-animations.ts b/src/material/snack-bar/snack-bar-animations.ts index 4b9486075cbe..e836f75f78c9 100644 --- a/src/material/snack-bar/snack-bar-animations.ts +++ b/src/material/snack-bar/snack-bar-animations.ts @@ -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; diff --git a/src/material/snack-bar/snack-bar-container.scss b/src/material/snack-bar/snack-bar-container.scss index 87aecb67f3f2..332a08775f63 100644 --- a/src/material/snack-bar/snack-bar-container.scss +++ b/src/material/snack-bar/snack-bar-container.scss @@ -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; diff --git a/src/material/snack-bar/snack-bar-container.ts b/src/material/snack-bar/snack-bar-container.ts index 9180b4aaefe9..9ab0c21c1aa8 100644 --- a/src/material/snack-bar/snack-bar-container.ts +++ b/src/material/snack-bar/snack-bar-container.ts @@ -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); - private _changeDetectorRef = inject(ChangeDetectorRef); private _platform = inject(Platform); + private _enterFallback: ReturnType | undefined; + private _exitFallback: ReturnType | 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; + private _announceTimeoutId: ReturnType | 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 = 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 { - // 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 diff --git a/src/material/snack-bar/snack-bar.spec.ts b/src/material/snack-bar/snack-bar.spec.ts index 17a6b72b5915..451b1e29ba30 100644 --- a/src/material/snack-bar/snack-bar.spec.ts +++ b/src/material/snack-bar/snack-bar.spec.ts @@ -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); diff --git a/src/material/snack-bar/snack-bar.ts b/src/material/snack-bar/snack-bar.ts index 8947692b8779..80031dbe5469 100644 --- a/src/material/snack-bar/snack-bar.ts +++ b/src/material/snack-bar/snack-bar.ts @@ -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!)); - } } /** diff --git a/tools/public_api_guard/material/snack-bar.md b/tools/public_api_guard/material/snack-bar.md index b42922741409..bbd1d6c01c33 100644 --- a/tools/public_api_guard/material/snack-bar.md +++ b/tools/public_api_guard/material/snack-bar.md @@ -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; } -// @public +// @public @deprecated export const matSnackBarAnimations: { readonly snackBarState: AnimationTriggerMetadata; }; @@ -96,7 +95,9 @@ export class MatSnackBarConfig { // @public export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy { constructor(...args: unknown[]); - _animationState: string; + protected _animationDone(event: AnimationEvent): void; + // (undocumented) + protected _animationsDisabled: boolean; attachComponentPortal(portal: ComponentPortal): ComponentRef; // @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; readonly _onEnter: Subject; readonly _onExit: Subject;