diff --git a/goldens/cdk/scrolling/index.api.md b/goldens/cdk/scrolling/index.api.md index 5226e9315d79..81b2cde9f891 100644 --- a/goldens/cdk/scrolling/index.api.md +++ b/goldens/cdk/scrolling/index.api.md @@ -199,6 +199,7 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On ngOnInit(): void; get orientation(): "horizontal" | "vertical"; set orientation(orientation: 'horizontal' | 'vertical'); + readonly renderedContentOffset: Observable; readonly renderedRangeStream: Observable; // (undocumented) scrollable: CdkVirtualScrollable; diff --git a/goldens/cdk/table/index.api.md b/goldens/cdk/table/index.api.md index e24a0fc593dc..e78862330054 100644 --- a/goldens/cdk/table/index.api.md +++ b/goldens/cdk/table/index.api.md @@ -291,7 +291,7 @@ export class CdkRowDef extends BaseRowDef { } // @public -export class CdkTable implements AfterContentInit, AfterContentChecked, CollectionViewer, OnDestroy, OnInit { +export class CdkTable implements AfterContentInit, AfterContentChecked, CollectionViewer, OnDestroy, OnInit, StickyPositioningListener { constructor(...args: unknown[]); addColumnDef(columnDef: CdkColumnDef): void; addFooterRowDef(footerRowDef: CdkFooterRowDef): void; @@ -307,6 +307,8 @@ export class CdkTable implements AfterContentInit, AfterContentChecked, Colle protected _data: readonly T[] | undefined; get dataSource(): CdkTableDataSourceInput; set dataSource(dataSource: CdkTableDataSourceInput); + readonly _dataSourceChanges: Subject>; + readonly _dataStream: Subject; // (undocumented) protected readonly _differs: IterableDiffers; // (undocumented) @@ -352,22 +354,22 @@ export class CdkTable implements AfterContentInit, AfterContentChecked, Colle removeFooterRowDef(footerRowDef: CdkFooterRowDef): void; removeHeaderRowDef(headerRowDef: CdkHeaderRowDef): void; removeRowDef(rowDef: CdkRowDef): void; + protected _renderedRange?: ListRange; renderRows(): void; // (undocumented) _rowOutlet: DataRowOutlet; setNoDataRow(noDataRow: CdkNoDataRow | null): void; + stickyColumnsUpdated(update: StickyUpdate): void; protected stickyCssClass: string; - // (undocumented) - protected readonly _stickyPositioningListener: StickyPositioningListener; + stickyEndColumnsUpdated(update: StickyUpdate): void; + stickyFooterRowsUpdated(update: StickyUpdate): void; + stickyHeaderRowsUpdated(update: StickyUpdate): void; get trackBy(): TrackByFunction; set trackBy(fn: TrackByFunction); updateStickyColumnStyles(): void; updateStickyFooterRowStyles(): void; updateStickyHeaderRowStyles(): void; - readonly viewChange: BehaviorSubject<{ - start: number; - end: number; - }>; + readonly viewChange: BehaviorSubject; // (undocumented) protected _viewRepeater: _ViewRepeater, RowContext>; // (undocumented) diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts index bc241f022c2e..e3aeebeb8bd2 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -38,7 +38,7 @@ import { Subject, Subscription, } from 'rxjs'; -import {auditTime, startWith, takeUntil} from 'rxjs/operators'; +import {auditTime, distinctUntilChanged, filter, startWith, takeUntil} from 'rxjs/operators'; import {CdkScrollable, ExtendedScrollToOptions} from './scrollable'; import {ViewportRuler} from './viewport-ruler'; import {CdkVirtualScrollRepeater} from './virtual-scroll-repeater'; @@ -102,6 +102,7 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On /** Emits when the rendered range changes. */ private readonly _renderedRangeSubject = new Subject(); + private readonly _renderedContentOffsetSubject = new Subject(); /** The direction the viewport scrolls. */ @Input() @@ -141,6 +142,14 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On /** A stream that emits whenever the rendered range changes. */ readonly renderedRangeStream: Observable = this._renderedRangeSubject; + /** + * Emits the offset from the start of the viewport to the start of the rendered data (in pixels). + */ + readonly renderedContentOffset: Observable = this._renderedContentOffsetSubject.pipe( + filter(offset => offset !== null), + distinctUntilChanged(), + ); + /** * The total size of all content (in pixels), including content that is not currently rendered. */ @@ -537,6 +546,7 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On // string literals, a variable that can only be 'X' or 'Y', and user input that is run through // the `Number` function first to coerce it to a numeric value. this._contentWrapper.nativeElement.style.transform = this._renderedContentTransform; + this._renderedContentOffsetSubject.next(this.getOffsetToRenderedContentStart()); afterNextRender( () => { diff --git a/src/cdk/table/BUILD.bazel b/src/cdk/table/BUILD.bazel index 1aa89d910769..99d3ea1a0f05 100644 --- a/src/cdk/table/BUILD.bazel +++ b/src/cdk/table/BUILD.bazel @@ -48,6 +48,8 @@ ng_project( "//:node_modules/rxjs", "//src/cdk/bidi", "//src/cdk/collections", + "//src/cdk/scrolling", + "//src/cdk/testing/private", ], ) diff --git a/src/cdk/table/sticky-position-listener.ts b/src/cdk/table/sticky-position-listener.ts index dffc3c60fdba..1c9b1f1d7b39 100644 --- a/src/cdk/table/sticky-position-listener.ts +++ b/src/cdk/table/sticky-position-listener.ts @@ -9,7 +9,9 @@ import {InjectionToken} from '@angular/core'; /** The injection token used to specify the StickyPositioningListener. */ -export const STICKY_POSITIONING_LISTENER = new InjectionToken('CDK_SPL'); +export const STICKY_POSITIONING_LISTENER = new InjectionToken( + 'STICKY_POSITIONING_LISTENER', +); export type StickySize = number | null | undefined; export type StickyOffset = number | null | undefined; diff --git a/src/cdk/table/sticky-styler.ts b/src/cdk/table/sticky-styler.ts index 372b37da1b32..90c301d53cb4 100644 --- a/src/cdk/table/sticky-styler.ts +++ b/src/cdk/table/sticky-styler.ts @@ -64,7 +64,7 @@ export class StickyStyler { private _isBrowser = true, private readonly _needsPositionStickyOnElement = true, public direction: Direction, - private readonly _positionListener: StickyPositioningListener, + private readonly _positionListener: StickyPositioningListener | null, private readonly _tableInjector: Injector, ) { this._borderCellCss = { diff --git a/src/cdk/table/table.md b/src/cdk/table/table.md index 62d105ad649b..db12c29f913f 100644 --- a/src/cdk/table/table.md +++ b/src/cdk/table/table.md @@ -19,7 +19,7 @@ top of the CDK data-table. The first step to writing the data-table template is to define the columns. A column definition is specified via an `` with the `cdkColumnDef` directive, giving the column a name. Each column definition can contain a header-cell template -(`cdkHeaderCellDef`), data-cell template (`cdkCellDef`), and footer-cell +(`cdkHeaderCellDef`), data-cell template (`cdkCellDef`), and footer-cell template (`cdkFooterCellDef`). ```html @@ -120,9 +120,9 @@ cells that are displayed in the column `name` will be given the class `cdk-colum columns to be given styles that will match across the header and rows. Since columns can be given any string for its name, its possible that it cannot be directly applied -to the CSS class (e.g. `*nameColumn!`). In these cases, the special characters will be replaced by +to the CSS class (e.g. `*nameColumn!`). In these cases, the special characters will be replaced by the `-` character. For example, cells container in a column named `*nameColumn!` will be given -the class `cdk-column--nameColumn-`. +the class `cdk-column--nameColumn-`. #### Connecting the table to a data source @@ -158,15 +158,23 @@ table how to uniquely identify rows to track how the data changes with each upda ``` ##### `recycleRows` -By default, `CdkTable` creates and destroys an internal Angular view for each row. This allows rows -to participate in animations and to toggle between different row templates with `cdkRowDefWhen`. If -you don't need these features, you can instruct the table to cache and recycle rows by specifying +By default, `CdkTable` creates and destroys an internal Angular view for each row. This allows rows +to participate in animations and to toggle between different row templates with `cdkRowDefWhen`. If +you don't need these features, you can instruct the table to cache and recycle rows by specifying `recycleRows`. ```html ``` +### Virtual scrolling + +If you're showing a large amount of data in your table, you can use virtual scrolling to ensure a +smooth experience for the user. To enable virtual scrolling, you have to wrap the CDK table in a +`cdk-virtual-scroll-viewport` element and add some CSS to make it scrollable. + + + ### Alternate HTML to using native table The CDK table does not require that you use a native HTML table. If you want to have full control @@ -174,7 +182,7 @@ over the style of the table, it may be easier to follow an alternative template not use the native table element tags. This alternative approach replaces the native table element tags with the CDK table directive -selectors. For example, `
` becomes ``; ` becomes +selectors. For example, `
` becomes ``; ` becomes ``. The following shows a previous example using this alternative template: ```html diff --git a/src/cdk/table/table.spec.ts b/src/cdk/table/table.spec.ts index 291cc6037520..99dea6982ef5 100644 --- a/src/cdk/table/table.spec.ts +++ b/src/cdk/table/table.spec.ts @@ -13,9 +13,17 @@ import { Type, ViewChild, inject, + signal, } from '@angular/core'; import {By} from '@angular/platform-browser'; -import {ComponentFixture, TestBed, fakeAsync, flush, waitForAsync} from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, + fakeAsync, + flush, + tick, + waitForAsync, +} from '@angular/core/testing'; import {BehaviorSubject, Observable, combineLatest, of as observableOf} from 'rxjs'; import {map} from 'rxjs/operators'; import {CdkColumnDef} from './cell'; @@ -36,6 +44,8 @@ import { getTableUnknownDataSourceError, } from './table-errors'; import {NgClass} from '@angular/common'; +import {CdkVirtualScrollViewport, ScrollingModule} from '../scrolling'; +import {dispatchFakeEvent} from '../testing/private'; describe('CdkTable', () => { let fixture: ComponentFixture; @@ -1995,6 +2005,107 @@ describe('CdkTable', () => { expect(noDataRow).toBeTruthy(); expect(noDataRow.getAttribute('colspan')).toEqual('3'); }); + + describe('virtual scrolling', () => { + let fixture: ComponentFixture; + let table: HTMLTableElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(TableWithVirtualScroll); + + // Init logic copied from the virtual scroll tests. + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + flush(); + tick(16); + flush(); + fixture.detectChanges(); + table = fixture.nativeElement.querySelector('table'); + })); + + function triggerScroll(offset: number) { + const viewport = fixture.componentInstance.viewport; + viewport.scrollToOffset(offset); + dispatchFakeEvent(viewport.scrollable!.getElementRef().nativeElement, 'scroll'); + tick(16); + } + + it('should not render the full data set when using virtual scrolling', fakeAsync(() => { + expect(fixture.componentInstance.dataSource.data.length).toBeGreaterThan(2000); + expect(getRows(table).length).toBe(10); + })); + + it('should maintain a limited amount of data as the user is scrolling', fakeAsync(() => { + expect(getRows(table).length).toBe(10); + + triggerScroll(500); + expect(getRows(table).length).toBe(13); + + triggerScroll(500); + expect(getRows(table).length).toBe(13); + + triggerScroll(1000); + expect(getRows(table).length).toBe(12); + })); + + it('should update the table data as the user is scrolling', fakeAsync(() => { + expectTableToMatchContent(table, [ + ['Column A', 'Column B', 'Column C'], + ['a_1', 'b_1', 'c_1'], + ['a_2', 'b_2', 'c_2'], + ['a_3', 'b_3', 'c_3'], + ['a_4', 'b_4', 'c_4'], + ['a_5', 'b_5', 'c_5'], + ['a_6', 'b_6', 'c_6'], + ['a_7', 'b_7', 'c_7'], + ['a_8', 'b_8', 'c_8'], + ['a_9', 'b_9', 'c_9'], + ['a_10', 'b_10', 'c_10'], + ['Footer A', 'Footer B', 'Footer C'], + ]); + + triggerScroll(1000); + + expectTableToMatchContent(table, [ + ['Column A', 'Column B', 'Column C'], + ['a_18', 'b_18', 'c_18'], + ['a_19', 'b_19', 'c_19'], + ['a_20', 'b_20', 'c_20'], + ['a_21', 'b_21', 'c_21'], + ['a_22', 'b_22', 'c_22'], + ['a_23', 'b_23', 'c_23'], + ['a_24', 'b_24', 'c_24'], + ['a_25', 'b_25', 'c_25'], + ['a_26', 'b_26', 'c_26'], + ['a_27', 'b_27', 'c_27'], + ['a_28', 'b_28', 'c_28'], + ['a_29', 'b_29', 'c_29'], + ['Footer A', 'Footer B', 'Footer C'], + ]); + })); + + it('should update the position of sticky cells as the user is scrolling', fakeAsync(() => { + const assertStickyOffsets = (position: number) => { + getHeaderCells(table).forEach(cell => expect(cell.style.top).toBe(`${position * -1}px`)); + getFooterCells(table).forEach(cell => expect(cell.style.bottom).toBe(`${position}px`)); + }; + + assertStickyOffsets(0); + triggerScroll(1000); + assertStickyOffsets(884); + })); + + it('should force tables with virtual scrolling to have a fixed layout', fakeAsync(() => { + expect(fixture.componentInstance.isFixedLayout()).toBe(true); + expect(table.classList).toContain('cdk-table-fixed-layout'); + + fixture.componentInstance.isFixedLayout.set(false); + fixture.detectChanges(); + + expect(table.classList).toContain('cdk-table-fixed-layout'); + })); + }); }); interface TestData { @@ -2032,15 +2143,18 @@ class FakeDataSource extends DataSource { this.isConnected = false; } - addData() { - const nextIndex = this.data.length + 1; - + addData(amount = 1) { let copiedData = this.data.slice(); - copiedData.push({ - a: `a_${nextIndex}`, - b: `b_${nextIndex}`, - c: `c_${nextIndex}`, - }); + + for (let i = 0; i < amount; i++) { + const nextIndex = copiedData.length + 1; + + copiedData.push({ + a: `a_${nextIndex}`, + b: `b_${nextIndex}`, + c: `c_${nextIndex}`, + }); + } this.data = copiedData; } @@ -3176,6 +3290,54 @@ class WrapNativeHtmlTableAppOnPush { dataSource = new FakeDataSource(); } +@Component({ + template: ` + +
+ + + + + + + + + + + + + + + + + + + + + +
Column A {{row.a}}Footer AColumn B {{row.b}}Footer BColumn C {{row.c}}Footer C
+ + `, + imports: [CdkTableModule, ScrollingModule], + styles: ` + .scroll-container { + height: 300px; + overflow: auto; + } + `, +}) +class TableWithVirtualScroll { + @ViewChild(CdkTable) table: CdkTable; + @ViewChild(CdkVirtualScrollViewport) viewport: CdkVirtualScrollViewport; + dataSource = new FakeDataSource(); + columnsToRender = ['column_a', 'column_b', 'column_c']; + isFixedLayout = signal(true); + + constructor() { + this.dataSource.addData(2000); + } +} + function getElements(element: Element, query: string): HTMLElement[] { return [].slice.call(element.querySelectorAll(query)); } diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index 542d9295c387..54581b4a8460 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -17,9 +17,14 @@ import { _ViewRepeaterItemChange, _ViewRepeaterItemInsertArgs, _ViewRepeaterOperation, + ListRange, } from '../collections'; import {Platform} from '../platform'; -import {ViewportRuler} from '../scrolling'; +import { + CDK_VIRTUAL_SCROLL_VIEWPORT, + type CdkVirtualScrollViewport, + ViewportRuler, +} from '../scrolling'; import { AfterContentChecked, @@ -52,14 +57,17 @@ import { DOCUMENT, } from '@angular/core'; import { + animationFrameScheduler, + asapScheduler, BehaviorSubject, + combineLatest, isObservable, Observable, of as observableOf, Subject, Subscription, } from 'rxjs'; -import {takeUntil} from 'rxjs/operators'; +import {auditTime, takeUntil} from 'rxjs/operators'; import {CdkColumnDef} from './cell'; import { BaseRowDef, @@ -80,7 +88,11 @@ import { getTableUnknownColumnError, getTableUnknownDataSourceError, } from './table-errors'; -import {STICKY_POSITIONING_LISTENER, StickyPositioningListener} from './sticky-position-listener'; +import { + STICKY_POSITIONING_LISTENER, + StickyPositioningListener, + StickyUpdate, +} from './sticky-position-listener'; import {CDK_TABLE} from './tokens'; /** @@ -273,7 +285,13 @@ export interface RenderRow { imports: [HeaderRowOutlet, DataRowOutlet, NoDataRowOutlet, FooterRowOutlet], }) export class CdkTable - implements AfterContentInit, AfterContentChecked, CollectionViewer, OnDestroy, OnInit + implements + AfterContentInit, + AfterContentChecked, + CollectionViewer, + OnDestroy, + OnInit, + StickyPositioningListener { protected readonly _differs = inject(IterableDiffers); protected readonly _changeDetectorRef = inject(ChangeDetectorRef); @@ -282,16 +300,20 @@ export class CdkTable private _platform = inject(Platform); protected _viewRepeater: _ViewRepeater, RowContext>; private readonly _viewportRuler = inject(ViewportRuler); - protected readonly _stickyPositioningListener = inject( - STICKY_POSITIONING_LISTENER, - {optional: true, skipSelf: true}, - )!; + private _injector = inject(Injector); + private _virtualScrollViewport = inject(CDK_VIRTUAL_SCROLL_VIEWPORT, {optional: true}); + private _positionListener = + inject(STICKY_POSITIONING_LISTENER, {optional: true}) || + inject(STICKY_POSITIONING_LISTENER, {optional: true, skipSelf: true}); private _document = inject(DOCUMENT); /** Latest data provided by the data source. */ protected _data: readonly T[] | undefined; + /** Latest range of data rendered. */ + protected _renderedRange?: ListRange; + /** Subject that emits when the component has been destroyed. */ private readonly _onDestroy = new Subject(); @@ -439,6 +461,20 @@ export class CdkTable /** Whether the table is done initializing. */ private _hasInitialized = false; + /** Emits when the header rows sticky state changes. */ + private readonly _headerRowStickyUpdates = new Subject(); + + /** Emits when the footer rows sticky state changes. */ + private readonly _footerRowStickyUpdates = new Subject(); + + /** + * Whether to explicitly disable virtual scrolling even if there is a virtual scroll viewport + * parent. This can't be changed externally, whereas internally it is turned into an input that + * we use to opt out existing apps that were implementing virtual scroll before we added support + * for it. + */ + private readonly _disableVirtualScrolling = false; + /** Aria role to apply to the table's cells based on the table's own role. */ _getCellRole(): string | null { // Perform this lazily in case the table's role was updated by a directive after construction. @@ -498,9 +534,14 @@ export class CdkTable set dataSource(dataSource: CdkTableDataSourceInput) { if (this._dataSource !== dataSource) { this._switchDataSource(dataSource); + this._changeDetectorRef.markForCheck(); } } private _dataSource: CdkTableDataSourceInput; + /** Emits when the data source changes. */ + readonly _dataSourceChanges = new Subject>(); + /** Observable that emits the data source's complete data set. */ + readonly _dataStream = new Subject(); /** * Whether to allow multiple rows per data object by evaluating which rows evaluate their 'when' @@ -530,7 +571,9 @@ export class CdkTable */ @Input({transform: booleanAttribute}) get fixedLayout(): boolean { - return this._fixedLayout; + // Require a fixed layout when virtual scrolling is enabled, otherwise + // the element the header can jump around as the user is scrolling. + return this._virtualScrollEnabled() ? true : this._fixedLayout; } set fixedLayout(value: boolean) { this._fixedLayout = value; @@ -554,18 +597,13 @@ export class CdkTable @Output() readonly contentChanged = new EventEmitter(); - // TODO(andrewseguin): Remove max value as the end index - // and instead calculate the view on init and scroll. /** * Stream containing the latest information on what rows are being displayed on screen. * Can be used by the data source to as a heuristic of what data should be provided. * * @docs-private */ - readonly viewChange = new BehaviorSubject<{start: number; end: number}>({ - start: 0, - end: Number.MAX_VALUE, - }); + readonly viewChange: BehaviorSubject; // Outlets in the table's template where the header, data rows, and footer will be inserted. _rowOutlet: DataRowOutlet; @@ -597,8 +635,6 @@ export class CdkTable /** Row definition that will only be rendered if there's no data in the table. */ @ContentChild(CdkNoDataRow) _noDataRow: CdkNoDataRow; - private _injector = inject(Injector); - constructor(...args: unknown[]); constructor() { @@ -610,6 +646,10 @@ export class CdkTable this._isServer = !this._platform.isBrowser; this._isNativeHtmlTable = this._elementRef.nativeElement.nodeName === 'TABLE'; + this.viewChange = new BehaviorSubject({ + start: 0, + end: this._virtualScrollEnabled() ? 0 : Number.MAX_VALUE, + }); // Set up the trackBy function so that it uses the `RenderRow` as its identity by default. If // the user has provided a custom trackBy, return the result of that function as evaluated @@ -617,6 +657,10 @@ export class CdkTable this._dataDiffer = this._differs.find([]).create((_i: number, dataRow: RenderRow) => { return this.trackBy ? this.trackBy(dataRow.dataIndex, dataRow.data) : dataRow; }); + + if (this._virtualScrollEnabled()) { + this._setupVirtualScrolling(this._virtualScrollViewport!); + } } ngOnInit() { @@ -631,9 +675,10 @@ export class CdkTable } ngAfterContentInit() { - this._viewRepeater = this.recycleRows - ? new _RecycleViewRepeaterStrategy() - : new _DisposeViewRepeaterStrategy(); + this._viewRepeater = + this.recycleRows || this._virtualScrollEnabled() + ? new _RecycleViewRepeaterStrategy() + : new _DisposeViewRepeaterStrategy(); this._hasInitialized = true; } @@ -664,6 +709,8 @@ export class CdkTable this._headerRowDefs = []; this._footerRowDefs = []; this._defaultRowDef = null; + this._headerRowStickyUpdates.complete(); + this._footerRowStickyUpdates.complete(); this._onDestroy.next(); this._onDestroy.complete(); @@ -846,7 +893,7 @@ export class CdkTable // In a table using a fixed layout, row content won't affect column width, so sticky styles // don't need to be cleared unless either the sticky column config changes or one of the row // defs change. - if ((this._isNativeHtmlTable && !this._fixedLayout) || this._stickyColumnStylesNeedReset) { + if ((this._isNativeHtmlTable && !this.fixedLayout) || this._stickyColumnStylesNeedReset) { // Clear the left and right positioning from all columns in the table across all rows since // sticky columns span across all table sections (header, data, footer) this._stickyStyler.clearStickyPositioning( @@ -883,6 +930,40 @@ export class CdkTable Array.from(this._columnDefsByName.values()).forEach(def => def.resetStickyChanged()); } + /** + * Implemented as a part of `StickyPositioningListener`. + * @docs-private + */ + stickyColumnsUpdated(update: StickyUpdate): void { + this._positionListener?.stickyColumnsUpdated(update); + } + + /** + * Implemented as a part of `StickyPositioningListener`. + * @docs-private + */ + stickyEndColumnsUpdated(update: StickyUpdate): void { + this._positionListener?.stickyEndColumnsUpdated(update); + } + + /** + * Implemented as a part of `StickyPositioningListener`. + * @docs-private + */ + stickyHeaderRowsUpdated(update: StickyUpdate): void { + this._headerRowStickyUpdates.next(update); + this._positionListener?.stickyHeaderRowsUpdated(update); + } + + /** + * Implemented as a part of `StickyPositioningListener`. + * @docs-private + */ + stickyFooterRowsUpdated(update: StickyUpdate): void { + this._footerRowStickyUpdates.next(update); + this._positionListener?.stickyFooterRowsUpdated(update); + } + /** Invoked whenever an outlet is created and has been assigned to the table. */ _outletAssigned(): void { // Trigger the first render once all outlets have been assigned. We do it this way, as @@ -966,6 +1047,9 @@ export class CdkTable * so that the differ equates their references. */ private _getAllRenderRows(): RenderRow[] { + const dataWithinRange = this._renderedRange + ? (this._data || []).slice(this._renderedRange.start, this._renderedRange.end) + : []; const renderRows: RenderRow[] = []; // Store the cache and create a new one. Any re-used RenderRow objects will be moved into the @@ -979,8 +1063,8 @@ export class CdkTable // For each data object, get the list of rows that should be rendered, represented by the // respective `RenderRow` object which is the pair of `data` and `CdkRowDef`. - for (let i = 0; i < this._data.length; i++) { - let data = this._data[i]; + for (let i = 0; i < dataWithinRange.length; i++) { + let data = dataWithinRange[i]; const renderRowsForData = this._getRenderRowsForData(data, i, prevCachedRenderRows.get(data)); if (!this._cachedRenderRowsMap.has(data)) { @@ -1154,10 +1238,12 @@ export class CdkTable throw getTableUnknownDataSourceError(); } - this._renderChangeSubscription = dataStream! + this._renderChangeSubscription = combineLatest([dataStream!, this.viewChange]) .pipe(takeUntil(this._onDestroy)) - .subscribe(data => { + .subscribe(([data, range]) => { this._data = data || []; + this._renderedRange = range; + this._dataStream.next(data); this.renderRows(); }); } @@ -1205,7 +1291,7 @@ export class CdkTable rows, stickyStartStates, stickyEndStates, - !this._fixedLayout || this._forceRecalculateCellWidths, + !this.fixedLayout || this._forceRecalculateCellWidths, ); } @@ -1379,14 +1465,16 @@ export class CdkTable */ private _setupStickyStyler() { const direction: Direction = this._dir ? this._dir.value : 'ltr'; + const injector = this._injector; + this._stickyStyler = new StickyStyler( this._isNativeHtmlTable, this.stickyCssClass, this._platform.isBrowser, this.needsPositionStickyOnElement, direction, - this._stickyPositioningListener, - this._injector, + this, + injector, ); (this._dir ? this._dir.change : observableOf()) .pipe(takeUntil(this._onDestroy)) @@ -1396,6 +1484,69 @@ export class CdkTable }); } + private _setupVirtualScrolling(viewport: CdkVirtualScrollViewport) { + const virtualScrollScheduler = + typeof requestAnimationFrame !== 'undefined' ? animationFrameScheduler : asapScheduler; + + // Forward the rendered range computed by the virtual scroll viewport to the table. + viewport.renderedRangeStream + // We need the scheduler here, because the virtual scrolling module uses an identical + // one for scroll listeners. Without it the two go out of sync and the list starts + // jumping back to the beginning whenever it needs to re-render. + .pipe(auditTime(0, virtualScrollScheduler), takeUntil(this._onDestroy)) + .subscribe(this.viewChange); + + viewport.attach({ + dataStream: this._dataStream, + measureRangeSize: (range, orientation) => this._measureRangeSize(range, orientation), + }); + + // The `StyickyStyler` sticks elements by applying a `top` or `bottom` position offset to + // them. However, the virtual scroll viewport applies a `translateY` offset to a container + // div that encapsulates the table. The translation causes the rows to also be offset by the + // distance from the top of the scroll viewport in addition to their `top` offset. This logic + // negates the translation to move the rows to their correct positions. + combineLatest([viewport.renderedContentOffset, this._headerRowStickyUpdates]) + .pipe(takeUntil(this._onDestroy)) + .subscribe(([offsetFromTop, update]) => { + if (!update.sizes || !update.offsets || !update.elements) { + return; + } + + for (let i = 0; i < update.elements.length; i++) { + const cells = update.elements[i]; + + if (cells) { + const current = update.offsets[i]!; + const offset = + offsetFromTop !== 0 ? Math.max(offsetFromTop - current, current) : -current; + + for (const cell of cells) { + cell.style.top = `${-offset}px`; + } + } + } + }); + + combineLatest([viewport.renderedContentOffset, this._footerRowStickyUpdates]) + .pipe(takeUntil(this._onDestroy)) + .subscribe(([offsetFromTop, update]) => { + if (!update.sizes || !update.offsets || !update.elements) { + return; + } + + for (let i = 0; i < update.elements.length; i++) { + const cells = update.elements[i]; + + if (cells) { + for (const cell of cells) { + cell.style.bottom = `${offsetFromTop + update.offsets[i]!}px`; + } + } + } + }); + } + /** Filters definitions that belong to this table from a QueryList. */ private _getOwnDefs(items: QueryList): I[] { return items.filter(item => !item._table || item._table === this); @@ -1441,6 +1592,55 @@ export class CdkTable this._changeDetectorRef.markForCheck(); } + + /** + * Measures the size of the rendered range in the table. + * This is used for virtual scrolling when auto-sizing is enabled. + */ + private _measureRangeSize(range: ListRange, orientation: 'horizontal' | 'vertical'): number { + if (range.start >= range.end || orientation !== 'vertical') { + return 0; + } + + const renderedRange = this.viewChange.value; + const viewContainerRef = this._rowOutlet.viewContainer; + + if ( + (range.start < renderedRange.start || range.end > renderedRange.end) && + (typeof ngDevMode === 'undefined' || ngDevMode) + ) { + throw Error(`Error: attempted to measure an item that isn't rendered.`); + } + + const renderedStartIndex = range.start - renderedRange.start; + const rangeLen = range.end - range.start; + let firstNode: HTMLElement | undefined; + let lastNode: HTMLElement | undefined; + + for (let i = 0; i < rangeLen; i++) { + const view = viewContainerRef.get(i + renderedStartIndex) as EmbeddedViewRef | null; + if (view && view.rootNodes.length) { + firstNode = lastNode = view.rootNodes[0]; + break; + } + } + + for (let i = rangeLen - 1; i > -1; i--) { + const view = viewContainerRef.get(i + renderedStartIndex) as EmbeddedViewRef | null; + if (view && view.rootNodes.length) { + lastNode = view.rootNodes[view.rootNodes.length - 1]; + break; + } + } + + const startRect = firstNode?.getBoundingClientRect?.(); + const endRect = lastNode?.getBoundingClientRect?.(); + return startRect && endRect ? endRect.bottom - startRect.top : 0; + } + + private _virtualScrollEnabled(): boolean { + return !this._disableVirtualScrolling && this._virtualScrollViewport != null; + } } /** Utility function that gets a merged list of the entries in an array and values of a Set. */ diff --git a/src/components-examples/cdk/table/BUILD.bazel b/src/components-examples/cdk/table/BUILD.bazel index 9db86a6da7a7..bd4c87daebb3 100644 --- a/src/components-examples/cdk/table/BUILD.bazel +++ b/src/components-examples/cdk/table/BUILD.bazel @@ -12,6 +12,7 @@ ng_project( deps = [ "//:node_modules/@angular/core", "//:node_modules/rxjs", + "//src/cdk/scrolling", "//src/cdk/table", ], ) diff --git a/src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.css b/src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.css new file mode 100644 index 000000000000..148a534b9340 --- /dev/null +++ b/src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.css @@ -0,0 +1,16 @@ +/* Make the container scrollable */ +.example-container { + height: 600px; + overflow: auto; +} + +.example-container table { + width: 100%; +} + +.example-container td, +.example-container th { + height: 48px; + padding: 0; + text-align: left; +} diff --git a/src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.html b/src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.html new file mode 100644 index 000000000000..ae33d6fa97f2 --- /dev/null +++ b/src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.html @@ -0,0 +1,37 @@ +

Showing {{dataSource.length}} rows

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No. {{element.position}} No. Name {{element.name}} Name Weight {{element.weight}} Weight Symbol {{element.symbol}} Symbol
+
diff --git a/src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.ts b/src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.ts new file mode 100644 index 000000000000..401a266f2807 --- /dev/null +++ b/src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.ts @@ -0,0 +1,45 @@ +import {Component} from '@angular/core'; +import {CdkTableModule} from '@angular/cdk/table'; +import {ScrollingModule} from '@angular/cdk/scrolling'; + +export interface PeriodicElement { + name: string; + position: number; + weight: number; + symbol: string; +} + +const ELEMENT_DATA: PeriodicElement[] = [ + {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H'}, + {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He'}, + {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li'}, + {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be'}, + {position: 5, name: 'Boron', weight: 10.811, symbol: 'B'}, + {position: 6, name: 'Carbon', weight: 12.0107, symbol: 'C'}, + {position: 7, name: 'Nitrogen', weight: 14.0067, symbol: 'N'}, + {position: 8, name: 'Oxygen', weight: 15.9994, symbol: 'O'}, + {position: 9, name: 'Fluorine', weight: 18.9984, symbol: 'F'}, + {position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne'}, +]; + +const EXPANDED_ELEMENT_DATA: PeriodicElement[] = []; +for (let x = 0; x < 250; x++) { + for (const entry of ELEMENT_DATA) { + EXPANDED_ELEMENT_DATA.push({...entry, position: entry.position + 10 * x}); + } +} + +/** + * @title Example of a CDK table with virtual scroll enabled. + */ +@Component({ + selector: 'cdk-table-virtual-scroll-example', + styleUrls: ['cdk-table-virtual-scroll-example.css'], + templateUrl: 'cdk-table-virtual-scroll-example.html', + imports: [CdkTableModule, ScrollingModule], +}) +export class CdkTableVirtualScrollExample { + displayedColumns: string[] = ['position', 'name', 'weight', 'symbol']; + dataSource = EXPANDED_ELEMENT_DATA; + trackBy = (index: number, el: PeriodicElement) => el.position; +} diff --git a/src/components-examples/cdk/table/index.ts b/src/components-examples/cdk/table/index.ts index e5ec2c63fcd7..d97ca1aa7fe8 100644 --- a/src/components-examples/cdk/table/index.ts +++ b/src/components-examples/cdk/table/index.ts @@ -2,3 +2,4 @@ export {CdkTableFlexBasicExample} from './cdk-table-flex-basic/cdk-table-flex-ba export {CdkTableBasicExample} from './cdk-table-basic/cdk-table-basic-example'; export {CdkTableFixedLayoutExample} from './cdk-table-fixed-layout/cdk-table-fixed-layout-example'; export {CdkTableRecycleRowsExample} from './cdk-table-recycle-rows/cdk-table-recycle-rows-example'; +export {CdkTableVirtualScrollExample} from './cdk-table-virtual-scroll/cdk-table-virtual-scroll-example'; diff --git a/src/components-examples/material/table/index.ts b/src/components-examples/material/table/index.ts index ef7a2c516b9d..b9842617e977 100644 --- a/src/components-examples/material/table/index.ts +++ b/src/components-examples/material/table/index.ts @@ -30,3 +30,4 @@ export {TableDynamicArrayDataExample} from './table-dynamic-array-data/table-dyn export {TableDynamicObservableDataExample} from './table-dynamic-observable-data/table-dynamic-observable-data-example'; export {TableGeneratedColumnsExample} from './table-generated-columns/table-generated-columns-example'; export {TableFlexLargeRowExample} from './table-flex-large-row/table-flex-large-row-example'; +export {TableVirtualScrollExample} from './table-virtual-scroll/table-virtual-scroll-example'; diff --git a/src/components-examples/material/table/table-virtual-scroll/table-virtual-scroll-example.css b/src/components-examples/material/table/table-virtual-scroll/table-virtual-scroll-example.css new file mode 100644 index 000000000000..c92ab5df423b --- /dev/null +++ b/src/components-examples/material/table/table-virtual-scroll/table-virtual-scroll-example.css @@ -0,0 +1,9 @@ +/* Make the container scrollable */ +.example-container { + height: 600px; + overflow: auto; +} + +.example-container table { + width: 100%; +} diff --git a/src/components-examples/material/table/table-virtual-scroll/table-virtual-scroll-example.html b/src/components-examples/material/table/table-virtual-scroll/table-virtual-scroll-example.html new file mode 100644 index 000000000000..166ddc5f68e6 --- /dev/null +++ b/src/components-examples/material/table/table-virtual-scroll/table-virtual-scroll-example.html @@ -0,0 +1,37 @@ +

Showing {{dataSource.length}} rows

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No. {{element.position}} No. Name {{element.name}} Name Weight {{element.weight}} Weight Symbol {{element.symbol}} Symbol
+
diff --git a/src/components-examples/material/table/table-virtual-scroll/table-virtual-scroll-example.ts b/src/components-examples/material/table/table-virtual-scroll/table-virtual-scroll-example.ts new file mode 100644 index 000000000000..2da07b078d3d --- /dev/null +++ b/src/components-examples/material/table/table-virtual-scroll/table-virtual-scroll-example.ts @@ -0,0 +1,45 @@ +import {Component} from '@angular/core'; +import {MatTableModule} from '@angular/material/table'; +import {ScrollingModule} from '@angular/cdk/scrolling'; + +export interface PeriodicElement { + name: string; + position: number; + weight: number; + symbol: string; +} + +const ELEMENT_DATA: PeriodicElement[] = [ + {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H'}, + {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He'}, + {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li'}, + {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be'}, + {position: 5, name: 'Boron', weight: 10.811, symbol: 'B'}, + {position: 6, name: 'Carbon', weight: 12.0107, symbol: 'C'}, + {position: 7, name: 'Nitrogen', weight: 14.0067, symbol: 'N'}, + {position: 8, name: 'Oxygen', weight: 15.9994, symbol: 'O'}, + {position: 9, name: 'Fluorine', weight: 18.9984, symbol: 'F'}, + {position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne'}, +]; + +const EXPANDED_ELEMENT_DATA: PeriodicElement[] = []; +for (let x = 0; x < 250; x++) { + for (const entry of ELEMENT_DATA) { + EXPANDED_ELEMENT_DATA.push({...entry, position: entry.position + 10 * x}); + } +} + +/** + * @title Example of a Material table with virtual scroll enabled. + */ +@Component({ + selector: 'table-virtual-scroll-example', + styleUrls: ['table-virtual-scroll-example.css'], + templateUrl: 'table-virtual-scroll-example.html', + imports: [MatTableModule, ScrollingModule], +}) +export class TableVirtualScrollExample { + displayedColumns: string[] = ['position', 'name', 'weight', 'symbol']; + dataSource = EXPANDED_ELEMENT_DATA; + trackBy = (index: number, el: PeriodicElement) => el.position; +} diff --git a/src/dev-app/table/table-demo.html b/src/dev-app/table/table-demo.html index 478cfd929d77..e4cafbb90c7b 100644 --- a/src/dev-app/table/table-demo.html +++ b/src/dev-app/table/table-demo.html @@ -1,15 +1,18 @@ -

Cdk table basic

+

CDK table basic

-

Cdk table with recycled rows

+

CDK table with recycled rows

-

Cdk table basic with fixed column widths

+

CDK table basic with fixed column widths

-

Cdk table basic flex

+

CDK table basic flex

+

CDK table with virtual scrolling

+ +

Table basic

@@ -49,6 +52,9 @@

Table row context

Table with pagination

+

Table with virtual scrolling

+ +

Table with selection

diff --git a/src/dev-app/table/table-demo.ts b/src/dev-app/table/table-demo.ts index 4fc0c2e850a0..8aad80b0c697 100644 --- a/src/dev-app/table/table-demo.ts +++ b/src/dev-app/table/table-demo.ts @@ -6,11 +6,13 @@ * found in the LICENSE file at https://angular.dev/license */ +import {ChangeDetectionStrategy, Component} from '@angular/core'; import { CdkTableBasicExample, CdkTableFixedLayoutExample, CdkTableFlexBasicExample, CdkTableRecycleRowsExample, + CdkTableVirtualScrollExample, } from '@angular/components-examples/cdk/table'; import { TableBasicExample, @@ -37,9 +39,9 @@ import { TableStickyHeaderExample, TableTextColumnAdvancedExample, TableTextColumnExample, + TableVirtualScrollExample, TableWrappedExample, } from '@angular/components-examples/material/table'; -import {ChangeDetectionStrategy, Component} from '@angular/core'; @Component({ templateUrl: './table-demo.html', @@ -48,6 +50,7 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; CdkTableBasicExample, CdkTableFixedLayoutExample, CdkTableRecycleRowsExample, + CdkTableVirtualScrollExample, TableFlexBasicExample, TableBasicExample, TableDynamicColumnsExample, @@ -59,6 +62,7 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; TableMultipleRowTemplateExample, TableOverviewExample, TablePaginationExample, + TableVirtualScrollExample, TableRowContextExample, TableSelectionExample, TableSortingExample, diff --git a/src/material/table/table.md b/src/material/table/table.md index c578e5625ea0..84370e2d4c87 100644 --- a/src/material/table/table.md +++ b/src/material/table/table.md @@ -175,6 +175,16 @@ and its interface is not tied to any one specific implementation. +#### Virtual scrolling + +An alternative approach to showing a large amount of data inside a Material table is to use +virtual scrolling which will only render the the visible rows in the DOM as the user is scrolling. + +To enable virtual scrolling you have to wrap the Material table in a `` +element and add CSS to make the viewport scrollable. + + + #### Sorting To add sorting behavior to the table, add the `matSort` directive to the table and add