Skip to content

Commit

Permalink
fix(material/sidenav): switch away from animations module
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
crisbeto committed Dec 29, 2024
1 parent a6a70f6 commit 9fb285d
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 83 deletions.
4 changes: 3 additions & 1 deletion src/dev-app/sidenav/sidenav-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import {MatToolbarModule} from '@angular/material/toolbar';
})
export class SidenavDemo {
isLaunched = false;
fillerContent = Array(30);
fillerContent = Array(30)
.fill(null)
.map((_, index) => index);
fixed = false;
coverHeader = false;
showHeader = false;
Expand Down
2 changes: 2 additions & 0 deletions src/material/sidenav/drawer-animations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
/**
* Animations used by the Material drawers.
* @docs-private
* @deprecated No longer used, will be removed.
* @breaking-change 21.0.0
*/
export const matDrawerAnimations: {
readonly transformDrawer: AnimationTriggerMetadata;
Expand Down
30 changes: 21 additions & 9 deletions src/material/sidenav/drawer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -211,15 +211,27 @@ $drawer-over-drawer-z-index: 4;
}
}

// Usually the `visibility: hidden` added by the animation is enough to prevent focus from
// entering the hidden drawer content, but children with their own `visibility` can override it.
// This is a fallback that completely hides the content when the element becomes hidden.
// Note that we can't do this in the animation definition, because the style gets recomputed too
// late, breaking the animation because Angular didn't have time to figure out the target
// transform. This can also be achieved with JS, but it has issues when starting an
// animation before the previous one has finished.
&[style*='visibility: hidden'] {
display: none;
.mat-drawer-transition & {
transition: transform 400ms cubic-bezier(0.25, 0.8, 0.25, 1);
}

&:not(.mat-drawer-opened):not(.mat-drawer-animating) {
// Stops the sidenav from poking out (e.g. with the box shadow) while it's off-screen.
// We can't use `display` because it interrupts the transition and `transition-behaviof`
// isn't available in all browsers.
visibility: hidden;
box-shadow: none;

// The `visibility` above should prevent focus from entering the sidenav, but if a child
// element has `visibility`, it'll override the inherited value. This guarantees that the
// content won't be focusable.
.mat-drawer-inner-container {
display: none;
}
}

&.mat-drawer-opened {
transform: none;
}
}

Expand Down
146 changes: 85 additions & 61 deletions src/material/sidenav/drawer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {AnimationEvent} from '@angular/animations';
import {
FocusMonitor,
FocusOrigin,
Expand All @@ -20,7 +19,6 @@ import {Platform} from '@angular/cdk/platform';
import {CdkScrollable, ScrollDispatcher, ViewportRuler} from '@angular/cdk/scrolling';
import {DOCUMENT} from '@angular/common';
import {
AfterContentChecked,
AfterContentInit,
afterNextRender,
AfterRenderPhase,
Expand Down Expand Up @@ -48,7 +46,6 @@ import {
} from '@angular/core';
import {fromEvent, merge, Observable, Subject} from 'rxjs';
import {debounceTime, filter, map, mapTo, startWith, take, takeUntil} from 'rxjs/operators';
import {matDrawerAnimations} from './drawer-animations';

/**
* Throws an exception when two MatDrawer are matching the same position.
Expand Down Expand Up @@ -152,7 +149,6 @@ export class MatDrawerContent extends CdkScrollable implements AfterContentInit
selector: 'mat-drawer',
exportAs: 'matDrawer',
templateUrl: 'drawer.html',
animations: [matDrawerAnimations.transformDrawer],
host: {
'class': 'mat-drawer',
// must prevent the browser from aligning text based on value
Expand All @@ -161,17 +157,17 @@ export class MatDrawerContent extends CdkScrollable implements AfterContentInit
'[class.mat-drawer-over]': 'mode === "over"',
'[class.mat-drawer-push]': 'mode === "push"',
'[class.mat-drawer-side]': 'mode === "side"',
'[class.mat-drawer-opened]': 'opened',
// The styles that render the sidenav off-screen come from the drawer container. Prior to #30235
// this was also done by the animations module which some internal tests seem to depend on.
// Simulate it by toggling the `hidden` attribute instead.
'[style.visibility]': '(!_container && !opened) ? "hidden" : null',
'tabIndex': '-1',
'[@transform]': '_animationState',
'(@transform.start)': '_animationStarted.next($event)',
'(@transform.done)': '_animationEnd.next($event)',
},
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
imports: [CdkScrollable],
})
export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy {
export class MatDrawer implements AfterViewInit, OnDestroy {
private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private _focusTrapFactory = inject(FocusTrapFactory);
private _focusMonitor = inject(FocusMonitor);
Expand All @@ -184,9 +180,7 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy

private _focusTrap: FocusTrap | null = null;
private _elementFocusedBeforeDrawerWasOpened: HTMLElement | null = null;

/** Whether the drawer is initialized. Used for disabling the initial animation. */
private _enableAnimations = false;
private _eventCleanups: (() => void)[];

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

/** Emits whenever the drawer has started animating. */
readonly _animationStarted = new Subject<AnimationEvent>();
readonly _animationStarted = new Subject();

/** Emits whenever the drawer is done animating. */
readonly _animationEnd = new Subject<AnimationEvent>();

/** Current state of the sidenav animation. */
_animationState: 'open-instant' | 'open' | 'void' = 'void';
readonly _animationEnd = new Subject();

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

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

Expand Down Expand Up @@ -364,7 +355,8 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
* and we don't have close disabled.
*/
this._ngZone.runOutsideAngular(() => {
(fromEvent(this._elementRef.nativeElement, 'keydown') as Observable<KeyboardEvent>)
const element = this._elementRef.nativeElement;
(fromEvent(element, 'keydown') as Observable<KeyboardEvent>)
.pipe(
filter(event => {
return event.keyCode === ESCAPE && !this.disableClose && !hasModifierKey(event);
Expand All @@ -378,17 +370,16 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
event.preventDefault();
}),
);
});

this._animationEnd.subscribe((event: AnimationEvent) => {
const {fromState, toState} = event;
this._eventCleanups = [
this._renderer.listen(element, 'transitionrun', this._handleTransitionEvent),
this._renderer.listen(element, 'transitionend', this._handleTransitionEvent),
this._renderer.listen(element, 'transitioncancel', this._handleTransitionEvent),
];
});

if (
(toState.indexOf('open') === 0 && fromState === 'void') ||
(toState === 'void' && fromState.indexOf('open') === 0)
) {
this.openedChange.emit(this._opened);
}
this._animationEnd.subscribe(() => {
this.openedChange.emit(this._opened);
});
}

Expand Down Expand Up @@ -508,17 +499,8 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
}
}

ngAfterContentChecked() {
// Enable the animations after the lifecycle hooks have run, in order to avoid animating
// drawers that are open by default. When we're on the server, we shouldn't enable the
// animations, because we don't want the drawer to animate the first time the user sees
// the page.
if (this._platform.isBrowser) {
this._enableAnimations = true;
}
}

ngOnDestroy() {
this._eventCleanups.forEach(cleanup => cleanup());
this._focusTrap?.destroy();
this._anchor?.remove();
this._anchor = null;
Expand Down Expand Up @@ -588,15 +570,28 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
restoreFocus: boolean,
focusOrigin: Exclude<FocusOrigin, null>,
): Promise<MatDrawerToggleResult> {
if (isOpen === this._opened) {
return Promise.resolve(isOpen ? 'open' : 'close');
}

this._opened = isOpen;

if (isOpen) {
this._animationState = this._enableAnimations ? 'open' : 'open-instant';
if (this._container?._transitionsEnabled) {
// Note: it's importatnt to set this as early as possible,
// otherwise the animation can look glitchy in some cases.
this._setIsAnimating(true);
} else {
this._animationState = 'void';
if (restoreFocus) {
this._restoreFocus(focusOrigin);
}
// Simulate the animation events if animations are disabled.
setTimeout(() => {
this._animationStarted.next();
this._animationEnd.next();
});
}

this._elementRef.nativeElement.classList.toggle('mat-drawer-opened', isOpen);

if (!isOpen && restoreFocus) {
this._restoreFocus(focusOrigin);
}

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

/** Toggles whether the drawer is currently animating. */
private _setIsAnimating(isAnimating: boolean) {
this._elementRef.nativeElement.classList.toggle('mat-drawer-animating', isAnimating);
}

_getWidth(): number {
return this._elementRef.nativeElement ? this._elementRef.nativeElement.offsetWidth || 0 : 0;
return this._elementRef.nativeElement.offsetWidth || 0;
}

/** Updates the enabled state of the focus trap. */
Expand Down Expand Up @@ -647,6 +647,28 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy
this._anchor.parentNode!.insertBefore(element, this._anchor);
}
}

/** Event handler for animation events. */
private _handleTransitionEvent = (event: TransitionEvent) => {
const element = this._elementRef.nativeElement;
console.log(event);

if (event.target === element) {
this._ngZone.run(() => {
if (event.type === 'transitionrun') {
this._animationStarted.next(event);
} else {
// Don't toggle the animating state on `transitioncancel` since another animation should
// start afterwards. This prevents the drawer from blinking if an animation is interrupted.
if (event.type === 'transitionend') {
this._setIsAnimating(false);
}

this._animationEnd.next(event);
}
});
}
};
}

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

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

constructor() {
const platform = inject(Platform);
const viewportRuler = inject(ViewportRuler);

// If a `Dir` directive exists up the tree, listen direction changes
Expand All @@ -792,6 +816,17 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
.change()
.pipe(takeUntil(this._destroyed))
.subscribe(() => this.updateContentMargins());

if (this._animationMode !== 'NoopAnimations' && platform.isBrowser) {
this._ngZone.runOutsideAngular(() => {
// Enable the animations after a delay in order to skip
// the initial transition if a drawer is open by default.
setTimeout(() => {
this._element.nativeElement.classList.add('mat-drawer-transition');
this._transitionsEnabled = true;
}, 200);
});
}
}

ngAfterContentInit() {
Expand Down Expand Up @@ -915,21 +950,10 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
* is properly hidden.
*/
private _watchDrawerToggle(drawer: MatDrawer): void {
drawer._animationStarted
.pipe(
filter((event: AnimationEvent) => event.fromState !== event.toState),
takeUntil(this._drawers.changes),
)
.subscribe((event: AnimationEvent) => {
// Set the transition class on the container so that the animations occur. This should not
// be set initially because animations should only be triggered via a change in state.
if (event.toState !== 'open-instant' && this._animationMode !== 'NoopAnimations') {
this._element.nativeElement.classList.add('mat-drawer-transition');
}

this.updateContentMargins();
this._changeDetectorRef.markForCheck();
});
drawer._animationStarted.pipe(takeUntil(this._drawers.changes)).subscribe(() => {
this.updateContentMargins();
this._changeDetectorRef.markForCheck();
});

if (drawer.mode !== 'side') {
drawer.openedChange
Expand Down
3 changes: 0 additions & 3 deletions src/material/sidenav/sidenav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
QueryList,
} from '@angular/core';
import {MatDrawer, MatDrawerContainer, MatDrawerContent, MAT_DRAWER_CONTAINER} from './drawer';
import {matDrawerAnimations} from './drawer-animations';
import {
BooleanInput,
coerceBooleanProperty,
Expand Down Expand Up @@ -46,7 +45,6 @@ export class MatSidenavContent extends MatDrawerContent {}
selector: 'mat-sidenav',
exportAs: 'matSidenav',
templateUrl: 'drawer.html',
animations: [matDrawerAnimations.transformDrawer],
host: {
'class': 'mat-drawer mat-sidenav',
'tabIndex': '-1',
Expand All @@ -56,7 +54,6 @@ export class MatSidenavContent extends MatDrawerContent {}
'[class.mat-drawer-over]': 'mode === "over"',
'[class.mat-drawer-push]': 'mode === "push"',
'[class.mat-drawer-side]': 'mode === "side"',
'[class.mat-drawer-opened]': 'opened',
'[class.mat-sidenav-fixed]': 'fixedInViewport',
'[style.top.px]': 'fixedInViewport ? fixedTopGap : null',
'[style.bottom.px]': 'fixedInViewport ? fixedBottomGap : null',
Expand Down
Loading

0 comments on commit 9fb285d

Please sign in to comment.