Skip to content

Commit c577149

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 c577149

File tree

6 files changed

+114
-83
lines changed

6 files changed

+114
-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: 82 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,7 @@ export class MatDrawerContent extends CdkScrollable implements AfterContentInit
152149
selector: 'mat-drawer',
153150
exportAs: 'matDrawer',
154151
templateUrl: 'drawer.html',
155-
animations: [matDrawerAnimations.transformDrawer],
152+
styleUrl: 'drawer.css',
156153
host: {
157154
'class': 'mat-drawer',
158155
// must prevent the browser from aligning text based on value
@@ -161,17 +158,13 @@ export class MatDrawerContent extends CdkScrollable implements AfterContentInit
161158
'[class.mat-drawer-over]': 'mode === "over"',
162159
'[class.mat-drawer-push]': 'mode === "push"',
163160
'[class.mat-drawer-side]': 'mode === "side"',
164-
'[class.mat-drawer-opened]': 'opened',
165161
'tabIndex': '-1',
166-
'[@transform]': '_animationState',
167-
'(@transform.start)': '_animationStarted.next($event)',
168-
'(@transform.done)': '_animationEnd.next($event)',
169162
},
170163
changeDetection: ChangeDetectionStrategy.OnPush,
171164
encapsulation: ViewEncapsulation.None,
172165
imports: [CdkScrollable],
173166
})
174-
export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy {
167+
export class MatDrawer implements AfterViewInit, OnDestroy {
175168
private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
176169
private _focusTrapFactory = inject(FocusTrapFactory);
177170
private _focusMonitor = inject(FocusMonitor);
@@ -184,9 +177,7 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
184177

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

191182
/** Whether the view of the component has been attached. */
192183
private _isAttached: boolean;
@@ -284,13 +275,10 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
284275
private _openedVia: FocusOrigin | null;
285276

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

289280
/** 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';
281+
readonly _animationEnd = new Subject();
294282

295283
/** Event emitted when the drawer open state is changed. */
296284
@Output() readonly openedChange: EventEmitter<boolean> =
@@ -307,7 +295,7 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
307295
/** Event emitted when the drawer has started opening. */
308296
@Output()
309297
readonly openedStart: Observable<void> = this._animationStarted.pipe(
310-
filter(e => e.fromState !== e.toState && e.toState.indexOf('open') === 0),
298+
filter(() => this.opened),
311299
mapTo(undefined),
312300
);
313301

@@ -321,7 +309,7 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
321309
/** Event emitted when the drawer has started closing. */
322310
@Output()
323311
readonly closedStart: Observable<void> = this._animationStarted.pipe(
324-
filter(e => e.fromState !== e.toState && e.toState === 'void'),
312+
filter(() => !this.opened),
325313
mapTo(undefined),
326314
);
327315

@@ -364,7 +352,9 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
364352
* and we don't have close disabled.
365353
*/
366354
this._ngZone.runOutsideAngular(() => {
367-
(fromEvent(this._elementRef.nativeElement, 'keydown') as Observable<KeyboardEvent>)
355+
const element = this._elementRef.nativeElement;
356+
357+
(fromEvent(element, 'keydown') as Observable<KeyboardEvent>)
368358
.pipe(
369359
filter(event => {
370360
return event.keyCode === ESCAPE && !this.disableClose && !hasModifierKey(event);
@@ -378,17 +368,16 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
378368
event.preventDefault();
379369
}),
380370
);
381-
});
382371

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

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

@@ -508,17 +497,8 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
508497
}
509498
}
510499

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-
521500
ngOnDestroy() {
501+
this._eventCleanups.forEach(cleanup => cleanup());
522502
this._focusTrap?.destroy();
523503
this._anchor?.remove();
524504
this._anchor = null;
@@ -588,15 +568,28 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
588568
restoreFocus: boolean,
589569
focusOrigin: Exclude<FocusOrigin, null>,
590570
): Promise<MatDrawerToggleResult> {
571+
if (isOpen === this._opened) {
572+
return Promise.resolve(isOpen ? 'open' : 'close');
573+
}
574+
591575
this._opened = isOpen;
592576

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

602595
// Needed to ensure that the closing sequence fires off correctly.
@@ -608,8 +601,13 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
608601
});
609602
}
610603

604+
/** Toggles whether the drawer is currently animating. */
605+
private _setIsAnimating(isAnimating: boolean) {
606+
this._elementRef.nativeElement.classList.toggle('mat-drawer-animating', isAnimating);
607+
}
608+
611609
_getWidth(): number {
612-
return this._elementRef.nativeElement ? this._elementRef.nativeElement.offsetWidth || 0 : 0;
610+
return this._elementRef.nativeElement.offsetWidth || 0;
613611
}
614612

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

652671
/**
@@ -680,6 +699,7 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
680699
private _ngZone = inject(NgZone);
681700
private _changeDetectorRef = inject(ChangeDetectorRef);
682701
private _animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
702+
_transitionsEnabled = false;
683703

684704
/** All drawers in the container. Includes drawers from inside nested containers. */
685705
@ContentChildren(MatDrawer, {
@@ -777,6 +797,7 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
777797
constructor(...args: unknown[]);
778798

779799
constructor() {
800+
const platform = inject(Platform);
780801
const viewportRuler = inject(ViewportRuler);
781802

782803
// If a `Dir` directive exists up the tree, listen direction changes
@@ -792,6 +813,17 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
792813
.change()
793814
.pipe(takeUntil(this._destroyed))
794815
.subscribe(() => this.updateContentMargins());
816+
817+
if (this._animationMode !== 'NoopAnimations' && platform.isBrowser) {
818+
this._ngZone.runOutsideAngular(() => {
819+
// Enable the animations after a delay in order to skip
820+
// the initial transition if a drawer is open by default.
821+
setTimeout(() => {
822+
this._element.nativeElement.classList.add('mat-drawer-transition');
823+
this._transitionsEnabled = true;
824+
}, 200);
825+
});
826+
}
795827
}
796828

797829
ngAfterContentInit() {
@@ -915,21 +947,10 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
915947
* is properly hidden.
916948
*/
917949
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-
});
950+
drawer._animationStarted.pipe(takeUntil(this._drawers.changes)).subscribe(() => {
951+
this.updateContentMargins();
952+
this._changeDetectorRef.markForCheck();
953+
});
933954

934955
if (drawer.mode !== 'side') {
935956
drawer.openedChange

src/material/sidenav/sidenav.ts

Lines changed: 1 addition & 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,7 @@ export class MatSidenavContent extends MatDrawerContent {}
4645
selector: 'mat-sidenav',
4746
exportAs: 'matSidenav',
4847
templateUrl: 'drawer.html',
49-
animations: [matDrawerAnimations.transformDrawer],
48+
styleUrl: 'drawer.css',
5049
host: {
5150
'class': 'mat-drawer mat-sidenav',
5251
'tabIndex': '-1',
@@ -56,7 +55,6 @@ export class MatSidenavContent extends MatDrawerContent {}
5655
'[class.mat-drawer-over]': 'mode === "over"',
5756
'[class.mat-drawer-push]': 'mode === "push"',
5857
'[class.mat-drawer-side]': 'mode === "side"',
59-
'[class.mat-drawer-opened]': 'opened',
6058
'[class.mat-sidenav-fixed]': 'fixedInViewport',
6159
'[style.top.px]': 'fixedInViewport ? fixedTopGap : null',
6260
'[style.bottom.px]': 'fixedInViewport ? fixedBottomGap : null',

0 commit comments

Comments
 (0)