Skip to content

Commit 9fb285d

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 9fb285d

File tree

6 files changed

+117
-83
lines changed

6 files changed

+117
-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: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -211,15 +211,27 @@ $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+
box-shadow: none;
224+
225+
// The `visibility` above should prevent focus from entering the sidenav, but if a child
226+
// element has `visibility`, it'll override the inherited value. This guarantees that the
227+
// content won't be focusable.
228+
.mat-drawer-inner-container {
229+
display: none;
230+
}
231+
}
232+
233+
&.mat-drawer-opened {
234+
transform: none;
223235
}
224236
}
225237

src/material/sidenav/drawer.ts

Lines changed: 85 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,17 @@ 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',
160+
// The styles that render the sidenav off-screen come from the drawer container. Prior to #30235
161+
// this was also done by the animations module which some internal tests seem to depend on.
162+
// Simulate it by toggling the `hidden` attribute instead.
163+
'[style.visibility]': '(!_container && !opened) ? "hidden" : null',
165164
'tabIndex': '-1',
166-
'[@transform]': '_animationState',
167-
'(@transform.start)': '_animationStarted.next($event)',
168-
'(@transform.done)': '_animationEnd.next($event)',
169165
},
170166
changeDetection: ChangeDetectionStrategy.OnPush,
171167
encapsulation: ViewEncapsulation.None,
172168
imports: [CdkScrollable],
173169
})
174-
export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy {
170+
export class MatDrawer implements AfterViewInit, OnDestroy {
175171
private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
176172
private _focusTrapFactory = inject(FocusTrapFactory);
177173
private _focusMonitor = inject(FocusMonitor);
@@ -184,9 +180,7 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
184180

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

191185
/** Whether the view of the component has been attached. */
192186
private _isAttached: boolean;
@@ -284,13 +278,10 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
284278
private _openedVia: FocusOrigin | null;
285279

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

289283
/** 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';
284+
readonly _animationEnd = new Subject();
294285

295286
/** Event emitted when the drawer open state is changed. */
296287
@Output() readonly openedChange: EventEmitter<boolean> =
@@ -307,7 +298,7 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
307298
/** Event emitted when the drawer has started opening. */
308299
@Output()
309300
readonly openedStart: Observable<void> = this._animationStarted.pipe(
310-
filter(e => e.fromState !== e.toState && e.toState.indexOf('open') === 0),
301+
filter(() => this.opened),
311302
mapTo(undefined),
312303
);
313304

@@ -321,7 +312,7 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
321312
/** Event emitted when the drawer has started closing. */
322313
@Output()
323314
readonly closedStart: Observable<void> = this._animationStarted.pipe(
324-
filter(e => e.fromState !== e.toState && e.toState === 'void'),
315+
filter(() => !this.opened),
325316
mapTo(undefined),
326317
);
327318

@@ -364,7 +355,8 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
364355
* and we don't have close disabled.
365356
*/
366357
this._ngZone.runOutsideAngular(() => {
367-
(fromEvent(this._elementRef.nativeElement, 'keydown') as Observable<KeyboardEvent>)
358+
const element = this._elementRef.nativeElement;
359+
(fromEvent(element, 'keydown') as Observable<KeyboardEvent>)
368360
.pipe(
369361
filter(event => {
370362
return event.keyCode === ESCAPE && !this.disableClose && !hasModifierKey(event);
@@ -378,17 +370,16 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
378370
event.preventDefault();
379371
}),
380372
);
381-
});
382373

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

386-
if (
387-
(toState.indexOf('open') === 0 && fromState === 'void') ||
388-
(toState === 'void' && fromState.indexOf('open') === 0)
389-
) {
390-
this.openedChange.emit(this._opened);
391-
}
381+
this._animationEnd.subscribe(() => {
382+
this.openedChange.emit(this._opened);
392383
});
393384
}
394385

@@ -508,17 +499,8 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
508499
}
509500
}
510501

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-
521502
ngOnDestroy() {
503+
this._eventCleanups.forEach(cleanup => cleanup());
522504
this._focusTrap?.destroy();
523505
this._anchor?.remove();
524506
this._anchor = null;
@@ -588,15 +570,28 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
588570
restoreFocus: boolean,
589571
focusOrigin: Exclude<FocusOrigin, null>,
590572
): Promise<MatDrawerToggleResult> {
573+
if (isOpen === this._opened) {
574+
return Promise.resolve(isOpen ? 'open' : 'close');
575+
}
576+
591577
this._opened = isOpen;
592578

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

602597
// Needed to ensure that the closing sequence fires off correctly.
@@ -608,8 +603,13 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
608603
});
609604
}
610605

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

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

652674
/**
@@ -680,6 +702,7 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
680702
private _ngZone = inject(NgZone);
681703
private _changeDetectorRef = inject(ChangeDetectorRef);
682704
private _animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
705+
_transitionsEnabled = false;
683706

684707
/** All drawers in the container. Includes drawers from inside nested containers. */
685708
@ContentChildren(MatDrawer, {
@@ -777,6 +800,7 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
777800
constructor(...args: unknown[]);
778801

779802
constructor() {
803+
const platform = inject(Platform);
780804
const viewportRuler = inject(ViewportRuler);
781805

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

797832
ngAfterContentInit() {
@@ -915,21 +950,10 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
915950
* is properly hidden.
916951
*/
917952
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-
});
953+
drawer._animationStarted.pipe(takeUntil(this._drawers.changes)).subscribe(() => {
954+
this.updateContentMargins();
955+
this._changeDetectorRef.markForCheck();
956+
});
933957

934958
if (drawer.mode !== 'side') {
935959
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)