ng-table-virtual-scroll
Version:
Virtual scroll for for Angular Material Table
444 lines (436 loc) • 20.7 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, forwardRef, Directive, Input, ContentChild, NgModule } from '@angular/core';
import { VIRTUAL_SCROLL_STRATEGY } from '@angular/cdk/scrolling';
import { CdkTable } from '@angular/cdk/table';
import { Subject, BehaviorSubject, Subscription, ReplaySubject, merge, of, combineLatest, from } from 'rxjs';
import { distinctUntilChanged, map, startWith, delayWhen, tap, takeUntil, switchMap, take } from 'rxjs/operators';
import { MatTableDataSource } from '@angular/material/table';
import { DataSource } from '@angular/cdk/collections';
class FixedSizeTableVirtualScrollStrategy {
constructor() {
this.indexChange = new Subject();
this.stickyChange = new Subject();
this.renderedRangeStream = new BehaviorSubject({ start: 0, end: 0 });
this.scrolledIndexChange = this.indexChange.pipe(distinctUntilChanged());
this._dataLength = 0;
}
get dataLength() {
return this._dataLength;
}
set dataLength(value) {
if (value !== this._dataLength) {
this._dataLength = value;
this.onDataLengthChanged();
}
}
attach(viewport) {
this.viewport = viewport;
this.viewport.renderedRangeStream.subscribe(this.renderedRangeStream);
this.stickyChange.next(0);
this.onDataLengthChanged();
}
detach() {
this.indexChange.complete();
this.stickyChange.complete();
this.renderedRangeStream.complete();
}
onContentScrolled() {
this.updateContent();
}
onDataLengthChanged() {
if (this.viewport) {
const contentSize = this.dataLength * this.rowHeight + this.headerHeight + this.footerHeight;
this.viewport.setTotalContentSize(contentSize);
const viewportSize = this.viewport.getViewportSize();
if (this.viewport.measureScrollOffset() + viewportSize >= contentSize) {
this.viewport.scrollToOffset(contentSize - viewportSize);
}
}
this.updateContent();
}
onContentRendered() {
}
onRenderedOffsetChanged() {
// no-op
}
scrollToIndex(index, behavior) {
if (!this.viewport || !this.rowHeight) {
return;
}
this.viewport.scrollToOffset((index - 1) * this.rowHeight + this.headerHeight, behavior);
}
setConfig(configs) {
const { rowHeight, headerHeight, footerHeight, bufferMultiplier } = configs;
if (this.rowHeight === rowHeight
&& this.headerHeight === headerHeight
&& this.footerHeight === footerHeight
&& this.bufferMultiplier === bufferMultiplier) {
return;
}
this.rowHeight = rowHeight;
this.headerHeight = headerHeight;
this.footerHeight = footerHeight;
this.bufferMultiplier = bufferMultiplier;
this.onDataLengthChanged();
}
updateContent() {
if (!this.viewport || !this.rowHeight) {
return;
}
const renderedOffset = this.viewport.getOffsetToRenderedContentStart();
const start = renderedOffset / this.rowHeight;
const itemsDisplayed = Math.ceil(this.viewport.getViewportSize() / this.rowHeight);
const bufferItems = Math.ceil(itemsDisplayed * this.bufferMultiplier);
const end = start + itemsDisplayed + 2 * bufferItems;
const bufferOffset = renderedOffset + bufferItems * this.rowHeight;
const scrollOffset = this.viewport.measureScrollOffset();
// How far the scroll offset is from the lower buffer, which is usually where items start being displayed
const relativeScrollOffset = scrollOffset - bufferOffset;
const rowsScrolled = relativeScrollOffset / this.rowHeight;
const displayed = scrollOffset / this.rowHeight;
this.indexChange.next(displayed);
// Only bother updating the displayed information if we've scrolled more than a row
const rowSensitivity = 1.0;
if (Math.abs(rowsScrolled) < rowSensitivity) {
this.viewport.setRenderedContentOffset(renderedOffset);
this.viewport.setRenderedRange({ start, end });
return;
}
// Special case for the start of the table.
// At the top of the table, the first few rows are first rendered because they're visible, and then still rendered
// Because they move into the buffer. So we only need to change what's rendered once the user scrolls far enough down.
if (renderedOffset === 0 && rowsScrolled < 0) {
this.viewport.setRenderedContentOffset(renderedOffset);
this.viewport.setRenderedRange({ start, end });
return;
}
const rowsToMove = Math.sign(rowsScrolled) * Math.floor(Math.abs(rowsScrolled));
const adjustedRenderedOffset = Math.max(0, renderedOffset + rowsToMove * this.rowHeight);
this.viewport.setRenderedContentOffset(adjustedRenderedOffset);
const adjustedStart = Math.max(0, start + rowsToMove);
const adjustedEnd = adjustedStart + itemsDisplayed + 2 * bufferItems;
this.viewport.setRenderedRange({ start: adjustedStart, end: adjustedEnd });
this.stickyChange.next(adjustedRenderedOffset);
}
}
FixedSizeTableVirtualScrollStrategy.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: FixedSizeTableVirtualScrollStrategy, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
FixedSizeTableVirtualScrollStrategy.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: FixedSizeTableVirtualScrollStrategy });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: FixedSizeTableVirtualScrollStrategy, decorators: [{
type: Injectable
}] });
function isTVSDataSource(dataSource) {
return dataSource instanceof CdkTableVirtualScrollDataSource || dataSource instanceof TableVirtualScrollDataSource;
}
class CdkTableVirtualScrollDataSource extends DataSource {
constructor(initialData = []) {
super();
/** Stream emitting render data to the table (depends on ordered data changes). */
this._renderData = new BehaviorSubject([]);
/**
* Subscription to the changes that should trigger an update to the table's rendered rows, such
* as filtering, sorting, pagination, or base data changes.
*/
this._renderChangesSubscription = null;
this._data = new BehaviorSubject(initialData);
this._updateChangeSubscription();
}
/** Array of data that should be rendered by the table, where each object represents one row. */
get data() {
return this._data.value;
}
set data(data) {
data = Array.isArray(data) ? data : [];
this._data.next(data);
}
_updateChangeSubscription() {
this.initStreams();
this._renderChangesSubscription?.unsubscribe();
this._renderChangesSubscription = new Subscription();
this._renderChangesSubscription.add(this._data.subscribe(data => this.dataToRender$.next(data)));
this._renderChangesSubscription.add(this.dataOfRange$.subscribe(data => this._renderData.next(data)));
}
connect() {
if (!this._renderChangesSubscription) {
this._updateChangeSubscription();
}
return this._renderData;
}
disconnect() {
this._renderChangesSubscription?.unsubscribe();
this._renderChangesSubscription = null;
}
initStreams() {
if (!this.streamsReady) {
this.dataToRender$ = new ReplaySubject(1);
this.dataOfRange$ = new ReplaySubject(1);
this.streamsReady = true;
}
}
}
class TableVirtualScrollDataSource extends MatTableDataSource {
_updateChangeSubscription() {
this.initStreams();
const _sort = this['_sort'];
const _paginator = this['_paginator'];
const _internalPageChanges = this['_internalPageChanges'];
const _filter = this['_filter'];
const _renderData = this['_renderData'];
const sortChange = _sort ?
merge(_sort.sortChange, _sort.initialized) :
of(null);
const pageChange = _paginator ?
merge(_paginator.page, _internalPageChanges, _paginator.initialized) :
of(null);
const dataStream = this['_data'];
const filteredData = combineLatest([dataStream, _filter])
.pipe(map(([data]) => this._filterData(data)));
const orderedData = combineLatest([filteredData, sortChange])
.pipe(map(([data]) => this._orderData(data)));
const paginatedData = combineLatest([orderedData, pageChange])
.pipe(map(([data]) => this._pageData(data)));
this._renderChangesSubscription?.unsubscribe();
this._renderChangesSubscription = new Subscription();
this._renderChangesSubscription.add(paginatedData.subscribe(data => this.dataToRender$.next(data)));
this._renderChangesSubscription.add(this.dataOfRange$.subscribe(data => _renderData.next(data)));
}
initStreams() {
if (!this.streamsReady) {
this.dataToRender$ = new ReplaySubject(1);
this.dataOfRange$ = new ReplaySubject(1);
this.streamsReady = true;
}
}
}
function _tableVirtualScrollDirectiveStrategyFactory(tableDir) {
return tableDir.scrollStrategy;
}
function combineSelectors(...pairs) {
return pairs.map((selectors) => `${selectors.join(' ')}, ${selectors.join('')}`).join(', ');
}
const stickyHeaderSelector = combineSelectors(['.mat-mdc-header-row', '.mat-mdc-table-sticky'], ['.mat-header-row', '.mat-table-sticky'], ['.cdk-header-row', '.cdk-table-sticky']);
const stickyFooterSelector = combineSelectors(['.mat-mdc-footer-row', '.mat-mdc-table-sticky'], ['.mat-footer-row', '.mat-table-sticky'], ['.cdk-footer-row', '.cdk-table-sticky']);
function isMatTable(table) {
return table instanceof CdkTable && table['stickyCssClass'].includes('mat');
}
function isCdkTable(table) {
return table instanceof CdkTable && table['stickyCssClass'].includes('cdk');
}
const defaults = {
rowHeight: 48,
headerHeight: 56,
headerEnabled: true,
footerHeight: 48,
footerEnabled: false,
bufferMultiplier: 0.7
};
class TableItemSizeDirective {
constructor(zone) {
this.zone = zone;
this.destroyed$ = new Subject();
// eslint-disable-next-line @angular-eslint/no-input-rename
this.rowHeight = defaults.rowHeight;
this.headerEnabled = defaults.headerEnabled;
this.headerHeight = defaults.headerHeight;
this.footerEnabled = defaults.footerEnabled;
this.footerHeight = defaults.footerHeight;
this.bufferMultiplier = defaults.bufferMultiplier;
this.scrollStrategy = new FixedSizeTableVirtualScrollStrategy();
this.dataSourceChanges = new Subject();
this.resetStickyPositions = new Subject();
this.stickyEnabled = {
header: false,
footer: false
};
}
ngOnDestroy() {
this.destroyed$.next();
this.destroyed$.complete();
this.dataSourceChanges.complete();
}
ngAfterContentInit() {
const switchDataSourceOrigin = this.table['_switchDataSource'];
this.table['_switchDataSource'] = (dataSource) => {
switchDataSourceOrigin.call(this.table, dataSource);
this.connectDataSource(dataSource);
};
const updateStickyColumnStylesOrigin = this.table.updateStickyColumnStyles;
this.table.updateStickyColumnStyles = () => {
const stickyColumnStylesNeedReset = this.table['_stickyColumnStylesNeedReset'];
updateStickyColumnStylesOrigin.call(this.table);
if (stickyColumnStylesNeedReset) {
this.resetStickyPositions.next();
}
};
this.connectDataSource(this.table.dataSource);
combineLatest([
this.scrollStrategy.stickyChange,
this.resetStickyPositions.pipe(startWith(void 0), delayWhen(() => this.getScheduleObservable()), tap(() => {
this.stickyPositions = null;
}))
])
.pipe(takeUntil(this.destroyed$))
.subscribe(([stickyOffset]) => {
if (!this.stickyPositions) {
this.initStickyPositions();
}
if (this.stickyEnabled.header) {
this.setStickyHeader(stickyOffset);
}
if (this.stickyEnabled.footer) {
this.setStickyFooter(stickyOffset);
}
});
}
connectDataSource(dataSource) {
this.dataSourceChanges.next();
if (!isTVSDataSource(dataSource)) {
throw new Error('[tvsItemSize] requires TableVirtualScrollDataSource or CdkTableVirtualScrollDataSource be set as [dataSource] of the table');
}
if (isMatTable(this.table) && !(dataSource instanceof TableVirtualScrollDataSource)) {
throw new Error('[tvsItemSize] requires TableVirtualScrollDataSource be set as [dataSource] of [mat-table]');
}
if (isCdkTable(this.table) && !(dataSource instanceof CdkTableVirtualScrollDataSource)) {
throw new Error('[tvsItemSize] requires CdkTableVirtualScrollDataSource be set as [dataSource] of [cdk-table]');
}
dataSource
.dataToRender$
.pipe(distinctUntilChanged(), takeUntil(this.dataSourceChanges), takeUntil(this.destroyed$), tap(data => this.scrollStrategy.dataLength = data.length), switchMap(data => this.scrollStrategy
.renderedRangeStream
.pipe(map(({ start, end }) => typeof start !== 'number' || typeof end !== 'number' ? data : data.slice(start, end)))))
.subscribe(data => {
this.zone.run(() => {
dataSource.dataOfRange$.next(data);
});
});
}
ngOnChanges() {
const config = {
rowHeight: +this.rowHeight || defaults.rowHeight,
headerHeight: this.headerEnabled ? +this.headerHeight || defaults.headerHeight : 0,
footerHeight: this.footerEnabled ? +this.footerHeight || defaults.footerHeight : 0,
bufferMultiplier: +this.bufferMultiplier || defaults.bufferMultiplier
};
this.scrollStrategy.setConfig(config);
}
setStickyEnabled() {
if (!this.scrollStrategy.viewport) {
this.stickyEnabled = {
header: false,
footer: false
};
return;
}
const isEnabled = (rowDefs) => rowDefs
.map(def => def.sticky)
.reduce((prevState, state) => prevState && state, true);
this.stickyEnabled = {
header: isEnabled(this.table['_headerRowDefs']),
footer: isEnabled(this.table['_footerRowDefs']),
};
}
setStickyHeader(offset) {
this.scrollStrategy.viewport.elementRef.nativeElement.querySelectorAll(stickyHeaderSelector)
.forEach((el) => {
const parent = el.parentElement;
let baseOffset = 0;
if (this.stickyPositions.has(parent)) {
baseOffset = this.stickyPositions.get(parent);
}
el.style.top = `${baseOffset - offset}px`;
});
}
setStickyFooter(offset) {
this.scrollStrategy.viewport.elementRef.nativeElement.querySelectorAll(stickyFooterSelector)
.forEach((el) => {
const parent = el.parentElement;
let baseOffset = 0;
if (this.stickyPositions.has(parent)) {
baseOffset = this.stickyPositions.get(parent);
}
el.style.bottom = `${-baseOffset + offset}px`;
});
}
initStickyPositions() {
this.stickyPositions = new Map();
this.setStickyEnabled();
if (this.stickyEnabled.header) {
this.scrollStrategy.viewport.elementRef.nativeElement.querySelectorAll(stickyHeaderSelector)
.forEach(el => {
const parent = el.parentElement;
if (!this.stickyPositions.has(parent)) {
this.stickyPositions.set(parent, parent.offsetTop);
}
});
}
if (this.stickyEnabled.footer) {
this.scrollStrategy.viewport.elementRef.nativeElement.querySelectorAll(stickyFooterSelector)
.forEach(el => {
const parent = el.parentElement;
if (!this.stickyPositions.has(parent)) {
this.stickyPositions.set(parent, -parent.offsetTop);
}
});
}
}
getScheduleObservable() {
// Use onStable when in the context of an ongoing change detection cycle so that we
// do not accidentally trigger additional cycles.
return this.zone.isStable
? from(Promise.resolve(undefined))
: this.zone.onStable.pipe(take(1));
}
}
TableItemSizeDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: TableItemSizeDirective, deps: [{ token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Directive });
TableItemSizeDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "15.1.4", type: TableItemSizeDirective, selector: "cdk-virtual-scroll-viewport[tvsItemSize]", inputs: { rowHeight: ["tvsItemSize", "rowHeight"], headerEnabled: "headerEnabled", headerHeight: "headerHeight", footerEnabled: "footerEnabled", footerHeight: "footerHeight", bufferMultiplier: "bufferMultiplier" }, providers: [{
provide: VIRTUAL_SCROLL_STRATEGY,
useFactory: _tableVirtualScrollDirectiveStrategyFactory,
deps: [forwardRef(() => TableItemSizeDirective)]
}], queries: [{ propertyName: "table", first: true, predicate: CdkTable, descendants: true }], usesOnChanges: true, ngImport: i0 });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: TableItemSizeDirective, decorators: [{
type: Directive,
args: [{
selector: 'cdk-virtual-scroll-viewport[tvsItemSize]',
providers: [{
provide: VIRTUAL_SCROLL_STRATEGY,
useFactory: _tableVirtualScrollDirectiveStrategyFactory,
deps: [forwardRef(() => TableItemSizeDirective)]
}]
}]
}], ctorParameters: function () { return [{ type: i0.NgZone }]; }, propDecorators: { rowHeight: [{
type: Input,
args: ['tvsItemSize']
}], headerEnabled: [{
type: Input
}], headerHeight: [{
type: Input
}], footerEnabled: [{
type: Input
}], footerHeight: [{
type: Input
}], bufferMultiplier: [{
type: Input
}], table: [{
type: ContentChild,
args: [CdkTable, { static: false }]
}] } });
class TableVirtualScrollModule {
}
TableVirtualScrollModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: TableVirtualScrollModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
TableVirtualScrollModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "15.1.4", ngImport: i0, type: TableVirtualScrollModule, declarations: [TableItemSizeDirective], exports: [TableItemSizeDirective] });
TableVirtualScrollModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: TableVirtualScrollModule });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: TableVirtualScrollModule, decorators: [{
type: NgModule,
args: [{
declarations: [TableItemSizeDirective],
imports: [],
exports: [TableItemSizeDirective]
}]
}] });
/*
* Public API Surface of ng-fixed-size-table-virtual-scroll
*/
/**
* Generated bundle index. Do not edit.
*/
export { CdkTableVirtualScrollDataSource, FixedSizeTableVirtualScrollStrategy, TableItemSizeDirective, TableVirtualScrollDataSource, TableVirtualScrollModule, _tableVirtualScrollDirectiveStrategyFactory, isTVSDataSource };
//# sourceMappingURL=ng-table-virtual-scroll.mjs.map