Skip to content

Commit 5e73736

Browse files
committed
fix(material/sidenav): switch away from animations module
Reworks the sidenav to animate using CSS, rather than the animations module. This requires less JavaScript, is simpler to maintain and avoids some memory leaks caused by the animations module.
1 parent a6a70f6 commit 5e73736

File tree

6 files changed

+108
-83
lines changed

6 files changed

+108
-83
lines changed

src/dev-app/sidenav/sidenav-demo.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import {MatToolbarModule} from '@angular/material/toolbar';
2222
})
2323
export class SidenavDemo {
2424
isLaunched = false;
25-
fillerContent = Array(30);
25+
fillerContent = Array(30)
26+
.fill(null)
27+
.map((_, index) => index);
2628
fixed = false;
2729
coverHeader = false;
2830
showHeader = false;

src/material/sidenav/drawer-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 drawers.
1919
* @docs-private
20+
* @deprecated No longer used, will be removed.
21+
* @breaking-change 21.0.0
2022
*/
2123
export const matDrawerAnimations: {
2224
readonly transformDrawer: AnimationTriggerMetadata;

src/material/sidenav/drawer.scss

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -211,15 +211,26 @@ $drawer-over-drawer-z-index: 4;
211211
}
212212
}
213213

214-
// Usually the `visibility: hidden` added by the animation is enough to prevent focus from
215-
// entering the hidden drawer content, but children with their own `visibility` can override it.
216-
// This is a fallback that completely hides the content when the element becomes hidden.
217-
// Note that we can't do this in the animation definition, because the style gets recomputed too
218-
// late, breaking the animation because Angular didn't have time to figure out the target
219-
// transform. This can also be achieved with JS, but it has issues when starting an
220-
// animation before the previous one has finished.
221-
&[style*='visibility: hidden'] {
222-
display: none;
214+
.mat-drawer-transition & {
215+
transition: transform 400ms cubic-bezier(0.25, 0.8, 0.25, 1);
216+
}
217+
218+
&:not(.mat-drawer-opened):not(.mat-drawer-animating) {
219+
// Stops the sidenav from poking out (e.g. with the box shadow) while it's off-screen.
220+
// We can't use `display` because it interrupts the transition and `transition-behaviof`
221+
// isn't available in all browsers.
222+
visibility: hidden;
223+
224+
// The `visibility` above should prevent focus from entering the sidenav, but if a child
225+
// element has `visibility`, it'll override the inherited value. This guarantees that the
226+
// content won't be focusable.
227+
.mat-drawer-inner-container {
228+
display: none;
229+
}
230+
}
231+
232+
&.mat-drawer-opened {
233+
transform: none;
223234
}
224235
}
225236

src/material/sidenav/drawer.ts

Lines changed: 77 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.dev/license
77
*/
8-
import {AnimationEvent} from '@angular/animations';
98
import {
109
FocusMonitor,
1110
FocusOrigin,
@@ -20,7 +19,6 @@ import {Platform} from '@angular/cdk/platform';
2019
import {CdkScrollable, ScrollDispatcher, ViewportRuler} from '@angular/cdk/scrolling';
2120
import {DOCUMENT} from '@angular/common';
2221
import {
23-
AfterContentChecked,
2422
AfterContentInit,
2523
afterNextRender,
2624
AfterRenderPhase,
@@ -48,7 +46,6 @@ import {
4846
} from '@angular/core';
4947
import {fromEvent, merge, Observable, Subject} from 'rxjs';
5048
import {debounceTime, filter, map, mapTo, startWith, take, takeUntil} from 'rxjs/operators';
51-
import {matDrawerAnimations} from './drawer-animations';
5249

5350
/**
5451
* Throws an exception when two MatDrawer are matching the same position.
@@ -152,7 +149,6 @@ export class MatDrawerContent extends CdkScrollable implements AfterContentInit
152149
selector: 'mat-drawer',
153150
exportAs: 'matDrawer',
154151
templateUrl: 'drawer.html',
155-
animations: [matDrawerAnimations.transformDrawer],
156152
host: {
157153
'class': 'mat-drawer',
158154
// must prevent the browser from aligning text based on value
@@ -161,17 +157,13 @@ export class MatDrawerContent extends CdkScrollable implements AfterContentInit
161157
'[class.mat-drawer-over]': 'mode === "over"',
162158
'[class.mat-drawer-push]': 'mode === "push"',
163159
'[class.mat-drawer-side]': 'mode === "side"',
164-
'[class.mat-drawer-opened]': 'opened',
165160
'tabIndex': '-1',
166-
'[@transform]': '_animationState',
167-
'(@transform.start)': '_animationStarted.next($event)',
168-
'(@transform.done)': '_animationEnd.next($event)',
169161
},
170162
changeDetection: ChangeDetectionStrategy.OnPush,
171163
encapsulation: ViewEncapsulation.None,
172164
imports: [CdkScrollable],
173165
})
174-
export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy {
166+
export class MatDrawer implements AfterViewInit, OnDestroy {
175167
private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
176168
private _focusTrapFactory = inject(FocusTrapFactory);
177169
private _focusMonitor = inject(FocusMonitor);
@@ -184,9 +176,7 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
184176

185177
private _focusTrap: FocusTrap | null = null;
186178
private _elementFocusedBeforeDrawerWasOpened: HTMLElement | null = null;
187-
188-
/** Whether the drawer is initialized. Used for disabling the initial animation. */
189-
private _enableAnimations = false;
179+
private _eventCleanups: (() => void)[];
190180

191181
/** Whether the view of the component has been attached. */
192182
private _isAttached: boolean;
@@ -284,13 +274,10 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
284274
private _openedVia: FocusOrigin | null;
285275

286276
/** Emits whenever the drawer has started animating. */
287-
readonly _animationStarted = new Subject<AnimationEvent>();
277+
readonly _animationStarted = new Subject();
288278

289279
/** Emits whenever the drawer is done animating. */
290-
readonly _animationEnd = new Subject<AnimationEvent>();
291-
292-
/** Current state of the sidenav animation. */
293-
_animationState: 'open-instant' | 'open' | 'void' = 'void';
280+
readonly _animationEnd = new Subject();
294281

295282
/** Event emitted when the drawer open state is changed. */
296283
@Output() readonly openedChange: EventEmitter<boolean> =
@@ -307,7 +294,7 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
307294
/** Event emitted when the drawer has started opening. */
308295
@Output()
309296
readonly openedStart: Observable<void> = this._animationStarted.pipe(
310-
filter(e => e.fromState !== e.toState && e.toState.indexOf('open') === 0),
297+
filter(() => this.opened),
311298
mapTo(undefined),
312299
);
313300

@@ -321,7 +308,7 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
321308
/** Event emitted when the drawer has started closing. */
322309
@Output()
323310
readonly closedStart: Observable<void> = this._animationStarted.pipe(
324-
filter(e => e.fromState !== e.toState && e.toState === 'void'),
311+
filter(() => !this.opened),
325312
mapTo(undefined),
326313
);
327314

@@ -364,7 +351,9 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
364351
* and we don't have close disabled.
365352
*/
366353
this._ngZone.runOutsideAngular(() => {
367-
(fromEvent(this._elementRef.nativeElement, 'keydown') as Observable<KeyboardEvent>)
354+
const element = this._elementRef.nativeElement;
355+
356+
(fromEvent(element, 'keydown') as Observable<KeyboardEvent>)
368357
.pipe(
369358
filter(event => {
370359
return event.keyCode === ESCAPE && !this.disableClose && !hasModifierKey(event);
@@ -378,17 +367,16 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
378367
event.preventDefault();
379368
}),
380369
);
381-
});
382370

383-
this._animationEnd.subscribe((event: AnimationEvent) => {
384-
const {fromState, toState} = event;
371+
this._eventCleanups = [
372+
this._renderer.listen(element, 'transitionrun', this._handleTransitionEvent),
373+
this._renderer.listen(element, 'transitionend', this._handleTransitionEvent),
374+
this._renderer.listen(element, 'transitioncancel', this._handleTransitionEvent),
375+
];
376+
});
385377

386-
if (
387-
(toState.indexOf('open') === 0 && fromState === 'void') ||
388-
(toState === 'void' && fromState.indexOf('open') === 0)
389-
) {
390-
this.openedChange.emit(this._opened);
391-
}
378+
this._animationEnd.subscribe(() => {
379+
this.openedChange.emit(this._opened);
392380
});
393381
}
394382

@@ -508,17 +496,8 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
508496
}
509497
}
510498

511-
ngAfterContentChecked() {
512-
// Enable the animations after the lifecycle hooks have run, in order to avoid animating
513-
// drawers that are open by default. When we're on the server, we shouldn't enable the
514-
// animations, because we don't want the drawer to animate the first time the user sees
515-
// the page.
516-
if (this._platform.isBrowser) {
517-
this._enableAnimations = true;
518-
}
519-
}
520-
521499
ngOnDestroy() {
500+
this._eventCleanups.forEach(cleanup => cleanup());
522501
this._focusTrap?.destroy();
523502
this._anchor?.remove();
524503
this._anchor = null;
@@ -590,13 +569,22 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
590569
): Promise<MatDrawerToggleResult> {
591570
this._opened = isOpen;
592571

593-
if (isOpen) {
594-
this._animationState = this._enableAnimations ? 'open' : 'open-instant';
572+
if (this._container?._transitionsEnabled) {
573+
// Note: it's importatnt to set this as early as possible,
574+
// otherwise the animation can look glitchy in some cases.
575+
this._setIsAnimating(true);
595576
} else {
596-
this._animationState = 'void';
597-
if (restoreFocus) {
598-
this._restoreFocus(focusOrigin);
599-
}
577+
// Simulate the animation events if animations are disabled.
578+
setTimeout(() => {
579+
this._animationStarted.next();
580+
this._animationEnd.next();
581+
});
582+
}
583+
584+
this._elementRef.nativeElement.classList.toggle('mat-drawer-opened', isOpen);
585+
586+
if (!isOpen && restoreFocus) {
587+
this._restoreFocus(focusOrigin);
600588
}
601589

602590
// Needed to ensure that the closing sequence fires off correctly.
@@ -608,8 +596,13 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
608596
});
609597
}
610598

599+
/** Toggles whether the drawer is currently animating. */
600+
private _setIsAnimating(isAnimating: boolean) {
601+
this._elementRef.nativeElement.classList.toggle('mat-drawer-animating', isAnimating);
602+
}
603+
611604
_getWidth(): number {
612-
return this._elementRef.nativeElement ? this._elementRef.nativeElement.offsetWidth || 0 : 0;
605+
return this._elementRef.nativeElement.offsetWidth || 0;
613606
}
614607

615608
/** Updates the enabled state of the focus trap. */
@@ -647,6 +640,27 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
647640
this._anchor.parentNode!.insertBefore(element, this._anchor);
648641
}
649642
}
643+
644+
/** Event handler for animation events. */
645+
private _handleTransitionEvent = (event: TransitionEvent) => {
646+
const element = this._elementRef.nativeElement;
647+
648+
if (event.target === element) {
649+
this._ngZone.run(() => {
650+
if (event.type === 'transitionrun') {
651+
this._animationStarted.next(event);
652+
} else {
653+
// Don't toggle the animating state on `transitioncancel` since another animation should
654+
// start afterwards. This prevents the drawer from blinking if an animation is interrupted.
655+
if (event.type === 'transitionend') {
656+
this._setIsAnimating(false);
657+
}
658+
659+
this._animationEnd.next(event);
660+
}
661+
});
662+
}
663+
};
650664
}
651665

652666
/**
@@ -680,6 +694,7 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
680694
private _ngZone = inject(NgZone);
681695
private _changeDetectorRef = inject(ChangeDetectorRef);
682696
private _animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
697+
_transitionsEnabled = false;
683698

684699
/** All drawers in the container. Includes drawers from inside nested containers. */
685700
@ContentChildren(MatDrawer, {
@@ -777,6 +792,7 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
777792
constructor(...args: unknown[]);
778793

779794
constructor() {
795+
const platform = inject(Platform);
780796
const viewportRuler = inject(ViewportRuler);
781797

782798
// If a `Dir` directive exists up the tree, listen direction changes
@@ -792,6 +808,17 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
792808
.change()
793809
.pipe(takeUntil(this._destroyed))
794810
.subscribe(() => this.updateContentMargins());
811+
812+
if (this._animationMode !== 'NoopAnimations' && platform.isBrowser) {
813+
this._ngZone.runOutsideAngular(() => {
814+
// Enable the animations after a delay in order to skip
815+
// the initial transition if a drawer is open by default.
816+
setTimeout(() => {
817+
this._element.nativeElement.classList.add('mat-drawer-transition');
818+
this._transitionsEnabled = true;
819+
}, 200);
820+
});
821+
}
795822
}
796823

797824
ngAfterContentInit() {
@@ -915,21 +942,10 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
915942
* is properly hidden.
916943
*/
917944
private _watchDrawerToggle(drawer: MatDrawer): void {
918-
drawer._animationStarted
919-
.pipe(
920-
filter((event: AnimationEvent) => event.fromState !== event.toState),
921-
takeUntil(this._drawers.changes),
922-
)
923-
.subscribe((event: AnimationEvent) => {
924-
// Set the transition class on the container so that the animations occur. This should not
925-
// be set initially because animations should only be triggered via a change in state.
926-
if (event.toState !== 'open-instant' && this._animationMode !== 'NoopAnimations') {
927-
this._element.nativeElement.classList.add('mat-drawer-transition');
928-
}
929-
930-
this.updateContentMargins();
931-
this._changeDetectorRef.markForCheck();
932-
});
945+
drawer._animationStarted.pipe(takeUntil(this._drawers.changes)).subscribe(() => {
946+
this.updateContentMargins();
947+
this._changeDetectorRef.markForCheck();
948+
});
933949

934950
if (drawer.mode !== 'side') {
935951
drawer.openedChange

src/material/sidenav/sidenav.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
QueryList,
1717
} from '@angular/core';
1818
import {MatDrawer, MatDrawerContainer, MatDrawerContent, MAT_DRAWER_CONTAINER} from './drawer';
19-
import {matDrawerAnimations} from './drawer-animations';
2019
import {
2120
BooleanInput,
2221
coerceBooleanProperty,
@@ -46,7 +45,6 @@ export class MatSidenavContent extends MatDrawerContent {}
4645
selector: 'mat-sidenav',
4746
exportAs: 'matSidenav',
4847
templateUrl: 'drawer.html',
49-
animations: [matDrawerAnimations.transformDrawer],
5048
host: {
5149
'class': 'mat-drawer mat-sidenav',
5250
'tabIndex': '-1',
@@ -56,7 +54,6 @@ export class MatSidenavContent extends MatDrawerContent {}
5654
'[class.mat-drawer-over]': 'mode === "over"',
5755
'[class.mat-drawer-push]': 'mode === "push"',
5856
'[class.mat-drawer-side]': 'mode === "side"',
59-
'[class.mat-drawer-opened]': 'opened',
6057
'[class.mat-sidenav-fixed]': 'fixedInViewport',
6158
'[style.top.px]': 'fixedInViewport ? fixedTopGap : null',
6259
'[style.bottom.px]': 'fixedInViewport ? fixedBottomGap : null',

0 commit comments

Comments
 (0)