diff --git a/src/scrolling/fixed-size-table-virtual-scroll-strategy.ts b/src/scrolling/fixed-size-table-virtual-scroll-strategy.ts new file mode 100644 index 000000000..977eb2557 --- /dev/null +++ b/src/scrolling/fixed-size-table-virtual-scroll-strategy.ts @@ -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(); + viewport: CdkVirtualScrollViewport; + + scrolledIndexChange = this._indexChange.pipe(distinctUntilChanged()); + stickyChange = new Subject(); + + renderedRangeStream = new BehaviorSubject({ 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); + } +} diff --git a/src/scrolling/fixed-size-table-virtual-scroll.directive.ts b/src/scrolling/fixed-size-table-virtual-scroll.directive.ts new file mode 100644 index 000000000..2c5cbd630 --- /dev/null +++ b/src/scrolling/fixed-size-table-virtual-scroll.directive.ts @@ -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; + + scrollStrategy = new FixedSizeTableVirtualScrollStrategy(); + + private stickyPositions: Map; + + 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(); + 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`; + }); + } +} diff --git a/src/scrolling/public-api.ts b/src/scrolling/public-api.ts index f14178ab7..355fed73c 100644 --- a/src/scrolling/public-api.ts +++ b/src/scrolling/public-api.ts @@ -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'; diff --git a/src/scrolling/scrolling.module.ts b/src/scrolling/scrolling.module.ts index f5f1c3182..bf96dfad6 100644 --- a/src/scrolling/scrolling.module.ts +++ b/src/scrolling/scrolling.module.ts @@ -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, ]; diff --git a/stories/table/fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component.html b/stories/table/fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component.html new file mode 100644 index 000000000..777809705 --- /dev/null +++ b/stories/table/fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component.html @@ -0,0 +1,38 @@ + + + + No. + + {{ item.id }} + + + + Name + + element{{ item.id }} + + + + + Value + + Value + + + + Detail + + Detail info + + + + + diff --git a/stories/table/fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component.ts b/stories/table/fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component.ts new file mode 100644 index 000000000..220852646 --- /dev/null +++ b/stories/table/fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component.ts @@ -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 })), + ); +} diff --git a/stories/table/index.ts b/stories/table/index.ts index 17860088f..8b7e3c6be 100644 --- a/stories/table/index.ts +++ b/stories/table/index.ts @@ -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'; @@ -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 {