Skip to content

Commit

Permalink
feat: fixed size table virtual scroll (#123)
Browse files Browse the repository at this point in the history
Co-authored-by: Lihua Tang <lhtang@alauda.io>
  • Loading branch information
tunblr and Lihua Tang authored Jun 28, 2021
1 parent 8453e51 commit 0ad29cf
Show file tree
Hide file tree
Showing 7 changed files with 324 additions and 1 deletion.
107 changes: 107 additions & 0 deletions src/scrolling/fixed-size-table-virtual-scroll-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { ListRange } from '@angular/cdk/collections';
import {
CdkVirtualScrollViewport,
VirtualScrollStrategy,
} from '@angular/cdk/scrolling';
import { Injectable } from '@angular/core';
import { BehaviorSubject, Subject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';

@Injectable()
export class FixedSizeTableVirtualScrollStrategy
implements VirtualScrollStrategy {
private _rowHeight = 42;
private _headerHeight = 42;
private _bufferSize = 10;
private readonly _indexChange = new Subject<number>();
viewport: CdkVirtualScrollViewport;

scrolledIndexChange = this._indexChange.pipe(distinctUntilChanged());
stickyChange = new Subject<number>();

renderedRangeStream = new BehaviorSubject<ListRange>({ start: 0, end: 0 });

get dataLength(): number {
return this._dataLength;
}

set dataLength(value: number) {
if (value !== this._dataLength) {
this._dataLength = value;
this.onDataLengthChanged();
}
}

private _dataLength = 0;

attach(viewport: CdkVirtualScrollViewport): void {
this.viewport = viewport;
this.viewport.renderedRangeStream.subscribe(this.renderedRangeStream);
this.onDataLengthChanged();
}

detach(): void {
this._indexChange.complete();
this.renderedRangeStream.complete();
this.stickyChange.complete();
}

onContentScrolled(): void {
this._updateContent();
}

onDataLengthChanged(): void {
if (this.viewport) {
this.viewport.setTotalContentSize(this._rowHeight * this.dataLength);
}
this._updateContent();
}

onContentRendered(): void {
// no-op
}

onRenderedOffsetChanged(): void {
// no-op
}

scrollToIndex(index: number, behavior: ScrollBehavior): void {
if (!this.viewport || !this._rowHeight) {
return;
}
this.viewport.scrollToOffset(
(index - 1) * this._rowHeight + this._headerHeight,
behavior,
);
}

setConfig(rowHeight: number, headerHeight: number, bufferSize: number) {
this._rowHeight = rowHeight;
this._headerHeight = headerHeight;
this._bufferSize = bufferSize;
this.onDataLengthChanged();
}

private _updateContent() {
if (!this.viewport) {
return;
}
const newIndex =
Math.round(
(this.viewport.measureScrollOffset() - this._headerHeight) /
this._rowHeight,
) + 1;
const start = Math.max(0, newIndex - this._bufferSize);
const end = Math.min(
this._dataLength,
newIndex +
Math.ceil(this.viewport.getViewportSize() / this._rowHeight) +
this._bufferSize,
);
const renderedOffset = start * this._rowHeight;
this.viewport.setRenderedContentOffset(renderedOffset);
this.viewport.setRenderedRange({ start, end });
this.stickyChange.next(renderedOffset);
this._indexChange.next(newIndex);
}
}
149 changes: 149 additions & 0 deletions src/scrolling/fixed-size-table-virtual-scroll.directive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { VIRTUAL_SCROLL_STRATEGY } from '@angular/cdk/scrolling';
import { CdkHeaderRowDef } from '@angular/cdk/table';
import {
AfterContentInit,
ContentChild,
Directive,
Input,
OnChanges,
OnDestroy,
forwardRef,
SimpleChanges,
} from '@angular/core';
import { Subject } from 'rxjs';
import { filter, map, takeUntil, tap } from 'rxjs/operators';

import { TableComponent } from '../table/public-api';

import { FixedSizeTableVirtualScrollStrategy } from './fixed-size-table-virtual-scroll-strategy';

export function _tableVirtualScrollDirectiveStrategyFactory(
tableDir: FixedSizeTableVirtualScrollDirective,
) {
return tableDir.scrollStrategy;
}

const stickyHeaderSelector = '.aui-table__header-row.aui-table-sticky';

const defaults = {
rowHeight: 42,
headerHeight: 42,
buffer: 10,
};

@Directive({
// tslint:disable-next-line: directive-selector
selector: 'aui-virtual-scroll-viewport[fixedSize]',
exportAs: 'viewPort',
providers: [
{
provide: VIRTUAL_SCROLL_STRATEGY,
useFactory: _tableVirtualScrollDirectiveStrategyFactory,
deps: [forwardRef(() => FixedSizeTableVirtualScrollDirective)],
},
],
})
export class FixedSizeTableVirtualScrollDirective
implements AfterContentInit, OnChanges, OnDestroy {
private readonly onDestroy$ = new Subject();

@Input()
rowHeight: number = defaults.rowHeight;

@Input()
headerHeight: number = defaults.headerHeight;

@Input()
buffer: number = defaults.buffer;

@Input()
dataSource: readonly unknown[];

@ContentChild(TableComponent, { static: false })
table: TableComponent<any>;

scrollStrategy = new FixedSizeTableVirtualScrollStrategy();

private stickyPositions: Map<HTMLElement, number>;

ngAfterContentInit() {
this.scrollStrategy.stickyChange
.pipe(
filter(() => this.isStickyEnabled()),
tap(() => {
if (!this.stickyPositions) {
this.initStickyPositions();
}
}),
takeUntil(this.onDestroy$),
)
.subscribe(stickyOffset => {
this.setSticky(stickyOffset);
});
this.scrollStrategy.renderedRangeStream
.pipe(
map(({ start, end }) =>
typeof start !== 'number' || typeof end !== 'number'
? this.dataSource
: this.dataSource?.slice(start, end),
),
takeUntil(this.onDestroy$),
)
.subscribe(data => {
this.table.dataSource = data;
});
}

ngOnChanges({ dataSource, rowHeight, headerHeight, buffer }: SimpleChanges) {
if (dataSource) {
this.scrollStrategy.dataLength = this.dataSource?.length;
}
if (rowHeight || headerHeight || buffer) {
this.scrollStrategy.setConfig(
this.rowHeight,
this.headerHeight,
this.buffer,
);
}
}

ngOnDestroy() {
this.onDestroy$.next();
this.onDestroy$.complete();
}

private isStickyEnabled(): boolean {
return (
!!this.scrollStrategy.viewport &&
// @ts-expect-error
(this.table._headerRowDefs as CdkHeaderRowDef[])
.map(def => def.sticky)
.reduce((prev, curr) => prev && curr, true)
);
}

private initStickyPositions() {
this.stickyPositions = new Map<HTMLElement, number>();
this.scrollStrategy.viewport.elementRef.nativeElement
.querySelectorAll(stickyHeaderSelector)
.forEach(el => {
const parent = el.parentElement;
if (!this.stickyPositions.has(parent)) {
this.stickyPositions.set(parent, parent.offsetTop);
}
});
}

private setSticky(offset: number) {
this.scrollStrategy.viewport.elementRef.nativeElement
.querySelectorAll(stickyHeaderSelector)
.forEach((el: HTMLElement) => {
const parent = el.parentElement;
let baseOffset = 0;
if (this.stickyPositions.has(parent)) {
baseOffset = this.stickyPositions.get(parent);
}
el.style.top = `${baseOffset - offset}px`;
});
}
}
2 changes: 2 additions & 0 deletions src/scrolling/public-api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from './fixed-size-table-virtual-scroll-strategy';
export * from './fixed-size-table-virtual-scroll.directive';
export * from './fixed-size-virtual-scroll.directive';
export * from './scrolling.module';
export * from './virtual-scroll-viewport.component';
2 changes: 2 additions & 0 deletions src/scrolling/scrolling.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { NgModule } from '@angular/core';

import { FixedSizeTableVirtualScrollDirective } from './fixed-size-table-virtual-scroll.directive';
import { FixedSizeVirtualScrollDirective } from './fixed-size-virtual-scroll.directive';
import { VirtualForOfDirective } from './virtual-for-of.directive';
import { VirtualScrollViewportComponent } from './virtual-scroll-viewport.component';

const EXPORTABLE_COMPONENTS = [VirtualScrollViewportComponent];
const EXPORTABLE_DIRECTIVES = [
FixedSizeTableVirtualScrollDirective,
FixedSizeVirtualScrollDirective,
VirtualForOfDirective,
];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<aui-virtual-scroll-viewport
fixedSize
style="height: 400px"
[dataSource]="dataSource$$ | async"
>
<aui-table>
<ng-container auiTableColumnDef="id">
<aui-table-header-cell *auiTableHeaderCellDef>No.</aui-table-header-cell>
<aui-table-cell *auiTableCellDef="let item">
{{ item.id }}
</aui-table-cell>
</ng-container>
<ng-container auiTableColumnDef="name">
<aui-table-header-cell *auiTableHeaderCellDef>Name</aui-table-header-cell>
<aui-table-cell *auiTableCellDef="let item">
element{{ item.id }}
</aui-table-cell>
</ng-container>
<ng-container auiTableColumnDef="value">
<aui-table-header-cell *auiTableHeaderCellDef>
Value
</aui-table-header-cell>
<aui-table-cell *auiTableCellDef="let item"> Value </aui-table-cell>
</ng-container>
<ng-container auiTableColumnDef="detail">
<aui-table-header-cell *auiTableHeaderCellDef>
Detail
</aui-table-header-cell>
<aui-table-cell *auiTableCellDef="let item"> Detail info </aui-table-cell>
</ng-container>
<aui-table-header-row
*auiTableHeaderRowDef="['id', 'name', 'value', 'detail']; sticky: true"
></aui-table-header-row>
<aui-table-row
*auiTableRowDef="let row; columns: ['id', 'name', 'value', 'detail']"
></aui-table-row>
</aui-table>
</aui-virtual-scroll-viewport>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Component({
templateUrl: 'fixed-size-virtual-scroll-demo.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FixedSizeVirtualScrollDemoComponent {
dataSource$$ = new BehaviorSubject(
Array.from({ length: 50000 }).map((_, i) => ({ id: i + 1 })),
);
}
15 changes: 14 additions & 1 deletion stories/table/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { IconModule, SortModule, TableModule } from '@alauda/ui';
import {
IconModule,
ScrollingModule,
SortModule,
TableModule,
} from '@alauda/ui';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { boolean, object, withKnobs } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/angular';

import { ExpandDemoComponent } from './expand-demo/expand-demo.component';
import { FixedSizeVirtualScrollDemoComponent } from './fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component';
import { SortDemoComponent } from './sort-demo/sort-demo.component';
import { StickyColumnsDemoComponent } from './sticky-columns/sticky-columns-demo.component';

Expand Down Expand Up @@ -88,6 +94,13 @@ storiesOf('Table', module)
declarations: [StickyColumnsDemoComponent],
},
component: StickyColumnsDemoComponent,
}))
.add('virtual scroll', () => ({
moduleMetadata: {
imports: [TableModule, ScrollingModule],
declarations: [FixedSizeVirtualScrollDemoComponent],
},
component: FixedSizeVirtualScrollDemoComponent,
}));

export interface Element {
Expand Down

0 comments on commit 0ad29cf

Please sign in to comment.