Skip to content

Instantly share code, notes, and snippets.

@zalito12
Last active April 19, 2024 09:58
Show Gist options
  • Save zalito12/1bbdd3b986531346d1a4e710ac266dbe to your computer and use it in GitHub Desktop.
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
...
@NgModule({
imports: [RouterModule.forRoot(routes, { scrollPositionRestoration: 'enabled' })],
exports: [RouterModule]
})
export class AppRoutingModule {}
...
import { DrawerViewportScroller } from './providers/drawer-viewport-scroller';
@NgModule({
declarations: [AppComponent],
imports: [ ... ],
providers: [
...
{
provide: ViewportScroller,
useClass: DrawerViewportScroller
}
],
bootstrap: [AppComponent]
})
export class AppModule {
constructor() { }
}
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;
}
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
);
}
}
@zalito12
Copy link
Author

zalito12 commented Apr 18, 2024

I almost forgot, you have to provide the cdk virtual scrollable in your component:

@Component({
  imports: [ScrollingModule],
  providers: [{ provide: VIRTUAL_SCROLLABLE, useClass: CdkVirtualScrollableDrawer }],

Where you would have in your template something like:

<cdk-virtual-scroll-viewport
    scrollDrawer
    [itemSize]="rowSizePx">
    <div*cdkVirtualFor="let item of items">{{ item }}</div>
</cdk-virtual-scroll-viewport>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment