Skip to content

Commit 31896c8

Browse files
authored
fix(material/sidenav): switch away from animations module (#30235)
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 5360899 commit 31896c8

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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {MatToolbarModule} from '@angular/material/toolbar';
2222
})
2323
export class SidenavDemo {
2424
isLaunched = false;
25-
fillerContent = Array(30);
25+
fillerContent = Array.from({length: 30}, (_, index) => index);
2626
fixed = false;
2727
coverHeader = false;
2828
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-behavior`
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: 84 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,27 @@ 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+
655+
if (event.target === element) {
656+
this._ngZone.run(() => {
657+
if (event.type === 'transitionrun') {
658+
this._animationStarted.next(event);
659+
} else {
660+
// Don't toggle the animating state on `transitioncancel` since another animation should
661+
// start afterwards. This prevents the drawer from blinking if an animation is interrupted.
662+
if (event.type === 'transitionend') {
663+
this._setIsAnimating(false);
664+
}
665+
666+
this._animationEnd.next(event);
667+
}
668+
});
669+
}
670+
};
650671
}
651672

652673
/**
@@ -680,6 +701,7 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
680701
private _ngZone = inject(NgZone);
681702
private _changeDetectorRef = inject(ChangeDetectorRef);
682703
private _animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
704+
_transitionsEnabled = false;
683705

684706
/** All drawers in the container. Includes drawers from inside nested containers. */
685707
@ContentChildren(MatDrawer, {
@@ -777,6 +799,7 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
777799
constructor(...args: unknown[]);
778800

779801
constructor() {
802+
const platform = inject(Platform);
780803
const viewportRuler = inject(ViewportRuler);
781804

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

797831
ngAfterContentInit() {
@@ -915,21 +949,10 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
915949
* is properly hidden.
916950
*/
917951
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-
});
952+
drawer._animationStarted.pipe(takeUntil(this._drawers.changes)).subscribe(() => {
953+
this.updateContentMargins();
954+
this._changeDetectorRef.markForCheck();
955+
});
933956

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