Last active
April 19, 2024 09:58
-
-
Save zalito12/1bbdd3b986531346d1a4e710ac266dbe to your computer and use it in GitHub Desktop.
Angular Material v17 Virtual Scroll & Scroll Restoration: How to manage custom scroll element (in my case a side nav with header) with cdk scroll
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
... | |
@NgModule({ | |
imports: [RouterModule.forRoot(routes, { scrollPositionRestoration: 'enabled' })], | |
exports: [RouterModule] | |
}) | |
export class AppRoutingModule {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
... | |
import { DrawerViewportScroller } from './providers/drawer-viewport-scroller'; | |
@NgModule({ | |
declarations: [AppComponent], | |
imports: [ ... ], | |
providers: [ | |
... | |
{ | |
provide: ViewportScroller, | |
useClass: DrawerViewportScroller | |
} | |
], | |
bootstrap: [AppComponent] | |
}) | |
export class AppModule { | |
constructor() { } | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { DOCUMENT, ViewportScroller } from '@angular/common'; | |
import { Inject, Injectable } from '@angular/core'; | |
import { fromEvent, Observable } from 'rxjs'; | |
/** | |
* Custom Viewport Scroller to manage scroll restoration of Angular App | |
* when using custom element as scroll viewport. | |
* Based on `BrowserViewportScroller`. | |
* @see https://github.com/angular/angular/blob/main/packages/common/src/viewport_scroller.ts | |
*/ | |
@Injectable({ | |
providedIn: 'root' | |
}) | |
export class DrawerViewportScroller implements ViewportScroller { | |
private offset: () => [number, number] = () => [0, 0]; | |
private window: Window; | |
private scrollContainer: HTMLElement; | |
constructor(@Inject(DOCUMENT) private document: Document) { | |
this.window = window; | |
} | |
public onScroll(): Observable<Event> | undefined { | |
return fromEvent(this.getContainer(), 'scroll'); | |
} | |
public getContainer(): HTMLElement { | |
if (!this.scrollContainer) { | |
this.scrollContainer = this.document.querySelector('.mat-drawer-content'); | |
} | |
return this.scrollContainer || this.document.documentElement; | |
} | |
private getXOffset() { | |
const container = this.getContainer(); | |
return container.scrollLeft; | |
} | |
private getYOffset() { | |
const container = this.getContainer(); | |
return container.scrollTop; | |
} | |
/** | |
* Configures the top offset used when scrolling to an anchor. | |
* @param offset A position in screen coordinates (a tuple with x and y values) | |
* or a function that returns the top offset position. | |
* | |
*/ | |
setOffset(offset: [number, number] | (() => [number, number])): void { | |
if (Array.isArray(offset)) { | |
this.offset = () => offset; | |
} else { | |
this.offset = offset; | |
} | |
} | |
/** | |
* Retrieves the current scroll position. | |
* @returns The position in screen coordinates. | |
*/ | |
getScrollPosition(): [number, number] { | |
if (this.supportsScrolling()) { | |
return [this.getXOffset(), this.getYOffset()]; | |
} else { | |
return [0, 0]; | |
} | |
} | |
/** | |
* Sets the scroll position. | |
* @param position The new position in screen coordinates. | |
*/ | |
scrollToPosition(position: [number, number]): void { | |
if (this.supportsScrolling()) { | |
this.getContainer().scrollTo(position[0], position[1]); | |
} | |
} | |
/** | |
* Scrolls to an element and attempts to focus the element. | |
* | |
* Note that the function name here is misleading in that the target string may be an ID for a | |
* non-anchor element. | |
* | |
* @param target The ID of an element or name of the anchor. | |
* | |
* @see https://html.spec.whatwg.org/#the-indicated-part-of-the-document | |
* @see https://html.spec.whatwg.org/#scroll-to-fragid | |
*/ | |
scrollToAnchor(target: string): void { | |
if (!this.supportsScrolling()) { | |
return; | |
} | |
const elSelected = findAnchorFromDocument(this.document, target); | |
if (elSelected) { | |
this.scrollToElement(elSelected); | |
// After scrolling to the element, the spec dictates that we follow the focus steps for the | |
// target. Rather than following the robust steps, simply attempt focus. | |
// | |
// @see https://html.spec.whatwg.org/#get-the-focusable-area | |
// @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLOrForeignElement/focus | |
// @see https://html.spec.whatwg.org/#focusable-area | |
elSelected.focus(); | |
} | |
} | |
/** | |
* Disables automatic scroll restoration provided by the browser. | |
*/ | |
setHistoryScrollRestoration(scrollRestoration: 'auto' | 'manual'): void { | |
if (this.supportsScrolling()) { | |
this.window.history.scrollRestoration = scrollRestoration; | |
} | |
} | |
/** | |
* Scrolls to an element using the native offset and the specified offset set on this scroller. | |
* | |
* The offset can be used when we know that there is a floating header and scrolling naively to an | |
* element (ex: `scrollIntoView`) leaves the element hidden behind the floating header. | |
*/ | |
private scrollToElement(el: HTMLElement): void { | |
const rect = el.getBoundingClientRect(); | |
const left = rect.left + this.getXOffset(); | |
const top = rect.top + this.getYOffset(); | |
const offset = this.offset(); | |
this.getContainer().scrollTo(left - offset[0], top - offset[1]); | |
} | |
private supportsScrolling(): boolean { | |
const container = this.getContainer(); | |
try { | |
return !!container && !!container.scrollTo && ('scrollTop' in container || 'scrollX' in container); | |
} catch { | |
return false; | |
} | |
} | |
} | |
function findAnchorFromDocument(document: Document, target: string): HTMLElement | null { | |
const documentResult = document.getElementById(target) || document.getElementsByName(target)[0]; | |
if (documentResult) { | |
return documentResult; | |
} | |
// `getElementById` and `getElementsByName` won't pierce through the shadow DOM so we | |
// have to traverse the DOM manually and do the lookup through the shadow roots. | |
if ( | |
typeof document.createTreeWalker === 'function' && | |
document.body && | |
typeof document.body.attachShadow === 'function' | |
) { | |
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT); | |
let currentNode = treeWalker.currentNode as HTMLElement | null; | |
while (currentNode) { | |
const shadowRoot = currentNode.shadowRoot; | |
if (shadowRoot) { | |
// Note that `ShadowRoot` doesn't support `getElementsByName` | |
// so we have to fall back to `querySelector`. | |
const result = shadowRoot.getElementById(target) || shadowRoot.querySelector(`[name="${target}"]`); | |
if (result) { | |
return result; | |
} | |
} | |
currentNode = treeWalker.nextNode() as HTMLElement | null; | |
} | |
} | |
return null; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Directionality } from '@angular/cdk/bidi'; | |
import { CdkVirtualScrollable, ScrollDispatcher, VIRTUAL_SCROLLABLE } from '@angular/cdk/scrolling'; | |
import { Directive, ElementRef, NgZone, Optional } from '@angular/core'; | |
import { fromEvent, Observable, Observer } from 'rxjs'; | |
import { takeUntil } from 'rxjs/operators'; | |
import { DrawerViewportScroller } from '../providers/drawer-viewport-scroller'; | |
/** | |
* Provides a virtual scrollable for the drawer. | |
* Workaround when using [scrollWindow] doesn't work because you use a custom element as scroll viewport. | |
* Based on `CdkVirtualScrollableWindow`. | |
* @see https://github.com/angular/components/blob/17.3.4/src/cdk/scrolling/virtual-scrollable-window.ts | |
*/ | |
@Directive({ | |
selector: 'cdk-virtual-scroll-viewport[scrollDrawer]', // eslint-disable-line | |
providers: [{ provide: VIRTUAL_SCROLLABLE, useExisting: CdkVirtualScrollableDrawer }], | |
standalone: true | |
}) | |
export class CdkVirtualScrollableDrawer extends CdkVirtualScrollable { // eslint-disable-line | |
protected override _elementScrolled: Observable<Event> = new Observable((observer: Observer<Event>) => | |
this.ngZone.runOutsideAngular(() => | |
fromEvent(this.viewportScroller.getContainer(), 'scroll').pipe(takeUntil(this._destroyed)).subscribe(observer) | |
) | |
); | |
constructor( | |
private viewportScroller: DrawerViewportScroller, | |
scrollDispatcher: ScrollDispatcher, | |
ngZone: NgZone, | |
@Optional() dir: Directionality | |
) { | |
super(new ElementRef(viewportScroller.getContainer()), scrollDispatcher, ngZone, dir); | |
} | |
override measureBoundingClientRectWithScrollOffset(from: 'left' | 'top' | 'right' | 'bottom'): number { | |
return ( | |
this.getElementRef().nativeElement.getBoundingClientRect()[from] - this.getElementRef().nativeElement.scrollTop | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I almost forgot, you have to provide the cdk virtual scrollable in your component:
Where you would have in your template something like: