Skip to content

Commit 9113846

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 9113846

File tree

6 files changed

+129
-83
lines changed

6 files changed

+129
-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: 97 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,8 @@ 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)[];
184+
private _fallbackTimer: ReturnType<typeof setTimeout> | undefined;
190185

191186
/** Whether the view of the component has been attached. */
192187
private _isAttached: boolean;
@@ -284,13 +279,10 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
284279
private _openedVia: FocusOrigin | null;
285280

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

289284
/** 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';
285+
readonly _animationEnd = new Subject();
294286

295287
/** Event emitted when the drawer open state is changed. */
296288
@Output() readonly openedChange: EventEmitter<boolean> =
@@ -307,7 +299,7 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
307299
/** Event emitted when the drawer has started opening. */
308300
@Output()
309301
readonly openedStart: Observable<void> = this._animationStarted.pipe(
310-
filter(e => e.fromState !== e.toState && e.toState.indexOf('open') === 0),
302+
filter(() => this.opened),
311303
mapTo(undefined),
312304
);
313305

@@ -321,7 +313,7 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
321313
/** Event emitted when the drawer has started closing. */
322314
@Output()
323315
readonly closedStart: Observable<void> = this._animationStarted.pipe(
324-
filter(e => e.fromState !== e.toState && e.toState === 'void'),
316+
filter(() => !this.opened),
325317
mapTo(undefined),
326318
);
327319

@@ -364,7 +356,8 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
364356
* and we don't have close disabled.
365357
*/
366358
this._ngZone.runOutsideAngular(() => {
367-
(fromEvent(this._elementRef.nativeElement, 'keydown') as Observable<KeyboardEvent>)
359+
const element = this._elementRef.nativeElement;
360+
(fromEvent(element, 'keydown') as Observable<KeyboardEvent>)
368361
.pipe(
369362
filter(event => {
370363
return event.keyCode === ESCAPE && !this.disableClose && !hasModifierKey(event);
@@ -378,17 +371,16 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
378371
event.preventDefault();
379372
}),
380373
);
381-
});
382374

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

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

@@ -508,17 +500,9 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
508500
}
509501
}
510502

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-
521503
ngOnDestroy() {
504+
clearTimeout(this._fallbackTimer);
505+
this._eventCleanups.forEach(cleanup => cleanup());
522506
this._focusTrap?.destroy();
523507
this._anchor?.remove();
524508
this._anchor = null;
@@ -588,15 +572,28 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
588572
restoreFocus: boolean,
589573
focusOrigin: Exclude<FocusOrigin, null>,
590574
): Promise<MatDrawerToggleResult> {
575+
if (isOpen === this._opened) {
576+
return Promise.resolve(isOpen ? 'open' : 'close');
577+
}
578+
591579
this._opened = isOpen;
592580

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

602599
// Needed to ensure that the closing sequence fires off correctly.
@@ -608,8 +605,23 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
608605
});
609606
}
610607

608+
/** Toggles whether the drawer is currently animating. */
609+
private _setIsAnimating(isAnimating: boolean) {
610+
clearTimeout(this._fallbackTimer);
611+
this._elementRef.nativeElement.classList.toggle('mat-drawer-animating', isAnimating);
612+
613+
// Some internal integration tests disable animations by setting `* {transition: none}`. This
614+
// will stop transition events from firing and prevent this class from being removed. Since
615+
// it's somewhat load-bearing we need a fallback for such cases.
616+
if (isAnimating) {
617+
this._fallbackTimer = this._ngZone.runOutsideAngular(() =>
618+
setTimeout(() => this._setIsAnimating(false), 500),
619+
);
620+
}
621+
}
622+
611623
_getWidth(): number {
612-
return this._elementRef.nativeElement ? this._elementRef.nativeElement.offsetWidth || 0 : 0;
624+
return this._elementRef.nativeElement.offsetWidth || 0;
613625
}
614626

615627
/** Updates the enabled state of the focus trap. */
@@ -647,6 +659,28 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
647659
this._anchor.parentNode!.insertBefore(element, this._anchor);
648660
}
649661
}
662+
663+
/** Event handler for animation events. */
664+
private _handleTransitionEvent = (event: TransitionEvent) => {
665+
const element = this._elementRef.nativeElement;
666+
console.log(event);
667+
668+
if (event.target === element) {
669+
this._ngZone.run(() => {
670+
if (event.type === 'transitionrun') {
671+
this._animationStarted.next(event);
672+
} else {
673+
// Don't toggle the animating state on `transitioncancel` since another animation should
674+
// start afterwards. This prevents the drawer from blinking if an animation is interrupted.
675+
if (event.type === 'transitionend') {
676+
this._setIsAnimating(false);
677+
}
678+
679+
this._animationEnd.next(event);
680+
}
681+
});
682+
}
683+
};
650684
}
651685

652686
/**
@@ -680,6 +714,7 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
680714
private _ngZone = inject(NgZone);
681715
private _changeDetectorRef = inject(ChangeDetectorRef);
682716
private _animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
717+
_transitionsEnabled = false;
683718

684719
/** All drawers in the container. Includes drawers from inside nested containers. */
685720
@ContentChildren(MatDrawer, {
@@ -777,6 +812,7 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
777812
constructor(...args: unknown[]);
778813

779814
constructor() {
815+
const platform = inject(Platform);
780816
const viewportRuler = inject(ViewportRuler);
781817

782818
// If a `Dir` directive exists up the tree, listen direction changes
@@ -792,6 +828,17 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
792828
.change()
793829
.pipe(takeUntil(this._destroyed))
794830
.subscribe(() => this.updateContentMargins());
831+
832+
if (this._animationMode !== 'NoopAnimations' && platform.isBrowser) {
833+
this._ngZone.runOutsideAngular(() => {
834+
// Enable the animations after a delay in order to skip
835+
// the initial transition if a drawer is open by default.
836+
setTimeout(() => {
837+
this._element.nativeElement.classList.add('mat-drawer-transition');
838+
this._transitionsEnabled = true;
839+
}, 200);
840+
});
841+
}
795842
}
796843

797844
ngAfterContentInit() {
@@ -915,21 +962,10 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
915962
* is properly hidden.
916963
*/
917964
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-
});
965+
drawer._animationStarted.pipe(takeUntil(this._drawers.changes)).subscribe(() => {
966+
this.updateContentMargins();
967+
this._changeDetectorRef.markForCheck();
968+
});
933969

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