From d977e41266bc630122bd6fff215d34a4f1f12a7b Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 3 Dec 2025 11:13:10 +0100 Subject: [PATCH 01/10] feat(cdk/table): add virtual scrolling support Revives the changes from #21708 and brings the up to date with the current repo. Note that this is the initial step, we still need some fixes an cleanups. Co-Authored-By: Michael-James Parsons --- src/cdk/scrolling/virtual-scroll-viewport.ts | 16 +- src/cdk/table/public-api.ts | 1 + src/cdk/table/table-module.ts | 2 + src/cdk/table/table-virtual-scroll.ts | 247 ++++++++++++++++++ src/cdk/table/table.ts | 61 ++++- src/components-examples/cdk/table/BUILD.bazel | 1 + .../cdk-virtual-flex-table-example.css | 39 +++ .../cdk-virtual-flex-table-example.html | 35 +++ .../cdk-virtual-flex-table-example.ts | 45 ++++ .../cdk-virtual-table-example.css | 29 ++ .../cdk-virtual-table-example.html | 35 +++ .../cdk-virtual-table-example.ts | 46 ++++ src/components-examples/cdk/table/index.ts | 2 + src/dev-app/table/table-demo.html | 6 + src/dev-app/table/table-demo.ts | 4 + 15 files changed, 556 insertions(+), 13 deletions(-) create mode 100644 src/cdk/table/table-virtual-scroll.ts create mode 100644 src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.css create mode 100644 src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html create mode 100644 src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.ts create mode 100644 src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.css create mode 100644 src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.html create mode 100644 src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.ts diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts index bc241f022c2e..012425944793 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -35,10 +35,11 @@ import { asapScheduler, Observable, Observer, + OperatorFunction, 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'; @@ -103,6 +104,15 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On /** Emits when the rendered range changes. */ private readonly _renderedRangeSubject = new Subject(); + /** + * Emits the offset from the start of the viewport to the start of the rendered data (in pixels). + */ + private readonly _renderedContentOffsetRenderedSubject = new Subject(); + readonly _renderedContentOffsetRendered = this._renderedContentOffsetRenderedSubject.pipe( + filter(offset => offset !== null) as OperatorFunction, + distinctUntilChanged(), + ); + /** The direction the viewport scrolls. */ @Input() get orientation() { @@ -538,6 +548,10 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On // the `Number` function first to coerce it to a numeric value. this._contentWrapper.nativeElement.style.transform = this._renderedContentTransform; + // Emit the offset to rendered content start when it is in sync with what is rendered in the + // DOM. + this._renderedContentOffsetRenderedSubject.next(this.getOffsetToRenderedContentStart()); + afterNextRender( () => { this._changeDetectionNeeded.set(false); diff --git a/src/cdk/table/public-api.ts b/src/cdk/table/public-api.ts index 4e8b735c60d3..4641b353f9b7 100644 --- a/src/cdk/table/public-api.ts +++ b/src/cdk/table/public-api.ts @@ -13,6 +13,7 @@ export * from './table-module'; export * from './sticky-position-listener'; export * from './text-column'; export * from './tokens'; +export * from './table-virtual-scroll'; /** Re-export DataSource for a more intuitive experience for users of just the table. */ export {DataSource} from '../collections'; diff --git a/src/cdk/table/table-module.ts b/src/cdk/table/table-module.ts index c7ec0fab90e9..011ae4d41db1 100644 --- a/src/cdk/table/table-module.ts +++ b/src/cdk/table/table-module.ts @@ -36,6 +36,7 @@ import { } from './cell'; import {CdkTextColumn} from './text-column'; import {ScrollingModule} from '../scrolling'; +import {CdkTableVirtualScroll} from './table-virtual-scroll'; const EXPORTED_DECLARATIONS = [ CdkTable, @@ -60,6 +61,7 @@ const EXPORTED_DECLARATIONS = [ CdkNoDataRow, CdkRecycleRows, NoDataRowOutlet, + CdkTableVirtualScroll, ]; @NgModule({ diff --git a/src/cdk/table/table-virtual-scroll.ts b/src/cdk/table/table-virtual-scroll.ts new file mode 100644 index 000000000000..b994f8bbb533 --- /dev/null +++ b/src/cdk/table/table-virtual-scroll.ts @@ -0,0 +1,247 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ +import {Directive, inject, Input, OnDestroy} from '@angular/core'; +import {_RecycleViewRepeaterStrategy, _VIEW_REPEATER_STRATEGY, ListRange} from '../collections'; +import {BehaviorSubject, combineLatest, Observable, ReplaySubject, Subject} from 'rxjs'; +import {shareReplay, takeUntil} from 'rxjs/operators'; +import {CdkVirtualScrollRepeater, CdkVirtualScrollViewport} from '../scrolling'; +import { + StickyPositioningListener, + StickyUpdate, + STICKY_POSITIONING_LISTENER, +} from './sticky-position-listener'; +import {_TABLE_VIEW_CHANGE_STRATEGY, CdkTable, RenderRow, RowContext} from './table'; + +/** + * An implementation of {@link StickyPositioningListener} that forwards sticky updates to another + * listener. + * + * The {@link CdkTableVirtualScroll} directive cannot provide itself as a + * {@link StickyPositioningListener} because the providers for both entities would point to the same + * instance. The {@link CdkTable} depends on the sticky positioning listener and the table virtual + * scroll depends on the table. Since the sticky positioning listener and table virtual scroll would + * be the same instance, this would create a circular dependency. + * + * The {@link CdkTableVirtualScroll} instead provides this class and attaches itself as the + * receiving listener so {@link StickyPositioningListener} and {@link CdkTableVirtualScroll} are + * provided as separate instances. + * + * @docs-private + */ +export class _PositioningListenerProxy implements StickyPositioningListener { + private _listener?: StickyPositioningListener; + + setListener(listener: StickyPositioningListener) { + this._listener = listener; + } + + stickyColumnsUpdated(update: StickyUpdate): void { + this._listener?.stickyColumnsUpdated(update); + } + + stickyEndColumnsUpdated(update: StickyUpdate): void { + this._listener?.stickyEndColumnsUpdated(update); + } + + stickyFooterRowsUpdated(update: StickyUpdate): void { + this._listener?.stickyFooterRowsUpdated(update); + } + + stickyHeaderRowsUpdated(update: StickyUpdate): void { + this._listener?.stickyHeaderRowsUpdated(update); + } +} + +/** @docs-private */ +export const _TABLE_VIRTUAL_SCROLL_COLLECTION_VIEWER_FACTORY = () => + new BehaviorSubject({start: 0, end: 0}); + +/** + * A directive that enables virtual scroll for a {@link CdkTable}. + */ +@Directive({ + selector: 'cdk-table[virtualScroll], table[cdk-table][virtualScroll]', + exportAs: 'cdkVirtualScroll', + providers: [ + {provide: _VIEW_REPEATER_STRATEGY, useClass: _RecycleViewRepeaterStrategy}, + // The directive cannot provide itself as the sticky positions listener because it introduces + // a circular dependency. Use an intermediate listener as a proxy. + {provide: STICKY_POSITIONING_LISTENER, useClass: _PositioningListenerProxy}, + // Initially emit an empty range. The virtual scroll viewport will update the range after it is + // initialized. + { + provide: _TABLE_VIEW_CHANGE_STRATEGY, + useFactory: _TABLE_VIRTUAL_SCROLL_COLLECTION_VIEWER_FACTORY, + }, + ], + host: { + 'class': 'cdk-table-virtual-scroll', + }, +}) +export class CdkTableVirtualScroll + implements CdkVirtualScrollRepeater, OnDestroy, StickyPositioningListener +{ + private readonly _table = inject>(CdkTable); + private readonly _viewChange = inject>(_TABLE_VIEW_CHANGE_STRATEGY); + private readonly _viewRepeater = + inject<_RecycleViewRepeaterStrategy, RowContext>>(_VIEW_REPEATER_STRATEGY); + private readonly _viewport = inject(CdkVirtualScrollViewport); + + /** Emits when the component is destroyed. */ + private _destroyed = new ReplaySubject(1); + + /** 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(); + + /** + * Observable that emits the data source's complete data set. This exists to implement + * {@link CdkVirtualScrollRepeater}. + */ + get dataStream(): Observable { + return this._dataStream; + } + private _dataStream = this._table._dataStream.pipe(shareReplay(1)); + + /** + * The size of the cache used to store unused views. Setting the cache size to `0` will disable + * caching. + */ + @Input() + get viewCacheSize(): number { + return this._viewRepeater.viewCacheSize; + } + set viewCacheSize(size: number) { + this._viewRepeater.viewCacheSize = size; + } + + constructor() { + const positioningListener = inject<_PositioningListenerProxy>(STICKY_POSITIONING_LISTENER); + positioningListener.setListener(this); + + // Force the table to enable `fixedLayout` to prevent column widths from changing as the user + // scrolls. This also enables caching in the table's sticky styler which reduces calls to + // expensive DOM APIs, such as `getBoundingClientRect()`, and improves overall performance. + if (!this._table.fixedLayout && (typeof ngDevMode === 'undefined' || ngDevMode)) { + throw Error('[virtualScroll] requires input `fixedLayout` to be set on the table.'); + } + + // Update sticky styles for header rows when either the render range or sticky state change. + combineLatest([this._viewport._renderedContentOffsetRendered, this._headerRowStickyUpdates]) + .pipe(takeUntil(this._destroyed)) + .subscribe(([offset, update]) => { + this._stickHeaderRows(offset, update); + }); + + // Update sticky styles for footer rows when either the render range or sticky state change. + combineLatest([this._viewport._renderedContentOffsetRendered, this._footerRowStickyUpdates]) + .pipe(takeUntil(this._destroyed)) + .subscribe(([offset, update]) => { + this._stickFooterRows(offset, update); + }); + + // Forward the rendered range computed by the virtual scroll viewport to the table. + this._viewport.renderedRangeStream.pipe(takeUntil(this._destroyed)).subscribe(this._viewChange); + this._viewport.attach(this); + } + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } + + /** + * Measures the combined size (width for horizontal orientation, height for vertical) of all items + * in the specified range. + */ + measureRangeSize(range: ListRange, orientation: 'horizontal' | 'vertical'): number { + // TODO(michaeljamesparsons) Implement method so virtual tables can use the `autosize` virtual + // scroll strategy. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + throw new Error('autoSize is not supported for tables with virtual scroll enabled.'); + } + return 0; + } + + stickyColumnsUpdated(update: StickyUpdate): void { + // no-op + } + + stickyEndColumnsUpdated(update: StickyUpdate): void { + // no-op + } + + stickyHeaderRowsUpdated(update: StickyUpdate): void { + this._headerRowStickyUpdates.next(update); + } + + stickyFooterRowsUpdated(update: StickyUpdate): void { + this._footerRowStickyUpdates.next(update); + } + + /** + * The {@link StickyStyler} sticks elements by applying a `top` 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 header rows to also be offset by the + * distance from the top of the scroll viewport in addition to their `top` offset. This method + * negates the translation to move the header rows to their correct positions. + * + * @param offsetFromTop The distance scrolled from the top of the container. + * @param update Metadata about the sticky headers that changed in the last sticky update. + * @private + */ + private _stickHeaderRows(offsetFromTop: number, update: StickyUpdate) { + if (!update.sizes || !update.offsets || !update.elements) { + return; + } + + for (let i = 0; i < update.elements.length; i++) { + if (!update.elements[i]) { + continue; + } + let offset = + offsetFromTop !== 0 + ? Math.max(offsetFromTop - update.offsets[i]!, update.offsets[i]!) + : -update.offsets[i]!; + + this._stickCells(update.elements[i]!, 'top', -offset); + } + } + + /** + * The {@link StickyStyler} sticks elements by applying a `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 footer rows to also be offset by the + * distance from the top of the scroll viewport in addition to their `bottom` offset. This method + * negates the translation to move the footer rows to their correct positions. + * + * @param offsetFromTop The distance scrolled from the top of the container. + * @param update Metadata about the sticky footers that changed in the last sticky update. + * @private + */ + private _stickFooterRows(offsetFromTop: number, update: StickyUpdate) { + if (!update.sizes || !update.offsets || !update.elements) { + return; + } + + for (let i = 0; i < update.elements.length; i++) { + if (!update.elements[i]) { + continue; + } + this._stickCells(update.elements[i]!, 'bottom', offsetFromTop + update.offsets[i]!); + } + } + + private _stickCells(cells: HTMLElement[], position: 'bottom' | 'top', offset: number) { + for (const cell of cells) { + cell.style[position] = `${offset}px`; + } + } +} diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index 542d9295c387..5027888c05d6 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -17,6 +17,7 @@ import { _ViewRepeaterItemChange, _ViewRepeaterItemInsertArgs, _ViewRepeaterOperation, + ListRange, } from '../collections'; import {Platform} from '../platform'; import {ViewportRuler} from '../scrolling'; @@ -50,9 +51,11 @@ import { Injector, HostAttributeToken, DOCUMENT, + InjectionToken, } from '@angular/core'; import { BehaviorSubject, + combineLatest, isObservable, Observable, of as observableOf, @@ -101,6 +104,26 @@ export interface RowOutlet { /** Possible types that can be set as the data source for a `CdkTable`. */ export type CdkTableDataSourceInput = readonly T[] | DataSource | Observable; +/** A strategy that implements the behavior for the table's `viewChange` observable. */ +interface TableViewChangeStrategy { + /** + * A stream that emits whenever the table starts rendering a subset of the data. The `start` index + * is inclusive, while the `end` is exclusive. + */ + viewChange: BehaviorSubject; +} + +/** + * Injection token for the `CdkTable` view change strategy. + * + * The table will emit a `viewChange` range that spans the entire data set. This provider overrides + * its `viewChange` observable so its behavior can be overridden from another component or + * directive. + */ +export const _TABLE_VIEW_CHANGE_STRATEGY = new InjectionToken( + 'TABLE_VIEW_CHANGE_STRATEGY', +); + /** * Provides a handle for the table to grab the view container's ng-container to insert data rows. * @docs-private @@ -282,7 +305,8 @@ export class CdkTable private _platform = inject(Platform); protected _viewRepeater: _ViewRepeater, RowContext>; private readonly _viewportRuler = inject(ViewportRuler); - protected readonly _stickyPositioningListener = inject( + protected readonly _positioningListener = inject(STICKY_POSITIONING_LISTENER, {optional: true}); + protected readonly _parentPositioningListener = inject( STICKY_POSITIONING_LISTENER, {optional: true, skipSelf: true}, )!; @@ -292,6 +316,9 @@ export class CdkTable /** 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(); @@ -498,9 +525,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' @@ -554,18 +586,15 @@ 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 = + inject>(_TABLE_VIEW_CHANGE_STRATEGY, {optional: true}) || + new BehaviorSubject({start: 0, end: Number.MAX_VALUE}); // Outlets in the table's template where the header, data rows, and footer will be inserted. _rowOutlet: DataRowOutlet; @@ -610,6 +639,9 @@ export class CdkTable this._isServer = !this._platform.isBrowser; this._isNativeHtmlTable = this._elementRef.nativeElement.nodeName === 'TABLE'; + this._parentPositioningListener = this._positioningListener ?? this._parentPositioningListener; + this.viewChange = + this.viewChange ?? new BehaviorSubject({start: 0, end: 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 @@ -966,6 +998,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 +1014,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 +1189,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(); }); } @@ -1385,7 +1422,7 @@ export class CdkTable this._platform.isBrowser, this.needsPositionStickyOnElement, direction, - this._stickyPositioningListener, + this._parentPositioningListener, this._injector, ); (this._dir ? this._dir.change : observableOf()) diff --git a/src/components-examples/cdk/table/BUILD.bazel b/src/components-examples/cdk/table/BUILD.bazel index 9db86a6da7a7..c4adba90c79f 100644 --- a/src/components-examples/cdk/table/BUILD.bazel +++ b/src/components-examples/cdk/table/BUILD.bazel @@ -13,6 +13,7 @@ ng_project( "//:node_modules/@angular/core", "//:node_modules/rxjs", "//src/cdk/table", + "//src/cdk/scrolling", ], ) diff --git a/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.css b/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.css new file mode 100644 index 000000000000..c7dfc5ddccda --- /dev/null +++ b/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.css @@ -0,0 +1,39 @@ +.example-container { + height: 600px; + overflow: auto; +} + +.example-virtual-table { + width: 100%; +} + +.example-virtual-table .cdk-header-cell, +.example-virtual-table .cdk-footer-cell { + align-items: center; + background: #3f51b5; + color: white; + display: flex; + font-weight: bold; + justify-content: center; +} + +.example-virtual-table .cdk-cell, +.example-virtual-table .cdk-footer-cell, +.example-virtual-table .cdk-header-cell { + height: 48px; +} + +/** + * Add basic flex styling so that the cells evenly space themselves in the row. + */ +.example-virtual-table cdk-row, +.example-virtual-table cdk-header-row, +.example-virtual-table cdk-footer-row { + display: flex; +} + +.example-virtual-table cdk-cell, +.example-virtual-table cdk-header-cell, +.example-virtual-table cdk-footer-cell { + flex: 1; +} diff --git a/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html b/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html new file mode 100644 index 000000000000..f2afefcda84f --- /dev/null +++ b/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html @@ -0,0 +1,35 @@ + + + + + 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-virtual-flex-table/cdk-virtual-flex-table-example.ts b/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.ts new file mode 100644 index 000000000000..15a48a53466b --- /dev/null +++ b/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.ts @@ -0,0 +1,45 @@ +import {Component} from '@angular/core'; +import {CdkTableModule} from '@angular/cdk/table'; +import {CdkFixedSizeVirtualScroll, CdkVirtualScrollViewport} 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 < 100; x++) { + for (const entry of ELEMENT_DATA) { + EXPANDED_ELEMENT_DATA.push({...entry, position: entry.position + 10 * x}); + } +} + +/** + * @title Example of a flex table with virtual scroll enabled. + */ +@Component({ + selector: 'cdk-virtual-flex-table-example', + styleUrls: ['cdk-virtual-flex-table-example.css'], + templateUrl: 'cdk-virtual-flex-table-example.html', + imports: [CdkTableModule, CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll], +}) +export class CdkVirtualFlexTableExample { + 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/cdk-virtual-table/cdk-virtual-table-example.css b/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.css new file mode 100644 index 000000000000..c5e394c0bc43 --- /dev/null +++ b/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.css @@ -0,0 +1,29 @@ +.example-container { + height: 600px; + overflow: auto; +} + +.example-virtual-table { + width: 100%; +} + +.example-virtual-table td, +.example-virtual-table th { + height: 48px; + padding: 0; +} + +.example-virtual-table th.cdk-header-cell, +.example-virtual-table .cdk-footer-row th { + background: #3f51b5; + color: white; +} + +.example-virtual-table th.mat-column-position, +.example-virtual-table td.mat-column-position { + padding-left: 8px; +} + +.example-virtual-table .cdk-cell.cdk-table-sticky { + background: #f0f0f0; +} diff --git a/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.html b/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.html new file mode 100644 index 000000000000..af0f5285216b --- /dev/null +++ b/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
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-virtual-table/cdk-virtual-table-example.ts b/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.ts new file mode 100644 index 000000000000..c96aed9c4563 --- /dev/null +++ b/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.ts @@ -0,0 +1,46 @@ +import {CdkFixedSizeVirtualScroll, CdkVirtualScrollViewport} from '@angular/cdk/scrolling'; +import {CdkTableModule} from '@angular/cdk/table'; +import {ChangeDetectionStrategy, Component} from '@angular/core'; + +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 < 100; x++) { + for (const entry of ELEMENT_DATA) { + EXPANDED_ELEMENT_DATA.push({...entry, position: entry.position + 10 * x}); + } +} + +/** + * @title Example of a native table with virtual scroll enabled. + */ +@Component({ + selector: 'cdk-virtual-table-example', + styleUrls: ['cdk-virtual-table-example.css'], + templateUrl: 'cdk-virtual-table-example.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CdkTableModule, CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll], +}) +export class CdkVirtualTableExample { + 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..e1fe7d02a1cf 100644 --- a/src/components-examples/cdk/table/index.ts +++ b/src/components-examples/cdk/table/index.ts @@ -2,3 +2,5 @@ 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 {CdkVirtualFlexTableExample} from './cdk-virtual-flex-table/cdk-virtual-flex-table-example'; +export {CdkVirtualTableExample} from './cdk-virtual-table/cdk-virtual-table-example'; diff --git a/src/dev-app/table/table-demo.html b/src/dev-app/table/table-demo.html index 478cfd929d77..85c9537bdffb 100644 --- a/src/dev-app/table/table-demo.html +++ b/src/dev-app/table/table-demo.html @@ -84,3 +84,9 @@

Table wrapped in reusable component

Table wrapped re-orderable columns

+ +

Cdk virtual table

+ + +

Cdk virtual flex table

+ diff --git a/src/dev-app/table/table-demo.ts b/src/dev-app/table/table-demo.ts index 4fc0c2e850a0..3561a4449a12 100644 --- a/src/dev-app/table/table-demo.ts +++ b/src/dev-app/table/table-demo.ts @@ -11,6 +11,8 @@ import { CdkTableFixedLayoutExample, CdkTableFlexBasicExample, CdkTableRecycleRowsExample, + CdkVirtualFlexTableExample, + CdkVirtualTableExample, } from '@angular/components-examples/cdk/table'; import { TableBasicExample, @@ -73,6 +75,8 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; TableReorderableExample, TableRecycleRowsExample, TableFlexLargeRowExample, + CdkVirtualFlexTableExample, + CdkVirtualTableExample, ], changeDetection: ChangeDetectionStrategy.OnPush, }) From a7dfee1f28d938443eb3118ccba634ec1256f5db Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 3 Dec 2025 11:25:57 +0100 Subject: [PATCH 02/10] refactor(cdk/table): resolve virtual scroll rendering issue Fixes an issue where the table virtual scroller was jumping back to the top whenever it needs to re-render. --- src/cdk/table/table-virtual-scroll.ts | 29 ++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/cdk/table/table-virtual-scroll.ts b/src/cdk/table/table-virtual-scroll.ts index b994f8bbb533..3680abafe5a9 100644 --- a/src/cdk/table/table-virtual-scroll.ts +++ b/src/cdk/table/table-virtual-scroll.ts @@ -7,8 +7,16 @@ */ import {Directive, inject, Input, OnDestroy} from '@angular/core'; import {_RecycleViewRepeaterStrategy, _VIEW_REPEATER_STRATEGY, ListRange} from '../collections'; -import {BehaviorSubject, combineLatest, Observable, ReplaySubject, Subject} from 'rxjs'; -import {shareReplay, takeUntil} from 'rxjs/operators'; +import { + animationFrameScheduler, + asapScheduler, + BehaviorSubject, + combineLatest, + Observable, + ReplaySubject, + Subject, +} from 'rxjs'; +import {auditTime, shareReplay, takeUntil} from 'rxjs/operators'; import {CdkVirtualScrollRepeater, CdkVirtualScrollViewport} from '../scrolling'; import { StickyPositioningListener, @@ -61,6 +69,16 @@ export class _PositioningListenerProxy implements StickyPositioningListener { export const _TABLE_VIRTUAL_SCROLL_COLLECTION_VIEWER_FACTORY = () => new BehaviorSubject({start: 0, end: 0}); +/** + * Scheduler to be used for scroll events. Needs to fall back to + * something that doesn't rely on requestAnimationFrame on environments + * that don't support it (e.g. server-side rendering). + * + * This is identical to the scheduler used by the virtual scroll module. + */ +const SCROLL_SCHEDULER = + typeof requestAnimationFrame !== 'undefined' ? animationFrameScheduler : asapScheduler; + /** * A directive that enables virtual scroll for a {@link CdkTable}. */ @@ -148,7 +166,12 @@ export class CdkTableVirtualScroll }); // Forward the rendered range computed by the virtual scroll viewport to the table. - this._viewport.renderedRangeStream.pipe(takeUntil(this._destroyed)).subscribe(this._viewChange); + this._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, SCROLL_SCHEDULER), takeUntil(this._destroyed)) + .subscribe(this._viewChange); this._viewport.attach(this); } From 115d555c51f4a370534d7c570b657280526cc8f4 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 3 Dec 2025 11:30:20 +0100 Subject: [PATCH 03/10] refactor(cdk/table): avoid circular dependency workaround The table virtual scroller had a proxy class to work around a circular DI error. These changes rework the usage site so the workaround isn't required anymore. --- src/cdk/table/sticky-position-listener.ts | 4 +- src/cdk/table/sticky-styler.ts | 2 +- src/cdk/table/table-virtual-scroll.ts | 57 ++--------------------- src/cdk/table/table.ts | 18 ++++--- 4 files changed, 16 insertions(+), 65 deletions(-) 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-virtual-scroll.ts b/src/cdk/table/table-virtual-scroll.ts index 3680abafe5a9..a021c3c06858 100644 --- a/src/cdk/table/table-virtual-scroll.ts +++ b/src/cdk/table/table-virtual-scroll.ts @@ -25,46 +25,6 @@ import { } from './sticky-position-listener'; import {_TABLE_VIEW_CHANGE_STRATEGY, CdkTable, RenderRow, RowContext} from './table'; -/** - * An implementation of {@link StickyPositioningListener} that forwards sticky updates to another - * listener. - * - * The {@link CdkTableVirtualScroll} directive cannot provide itself as a - * {@link StickyPositioningListener} because the providers for both entities would point to the same - * instance. The {@link CdkTable} depends on the sticky positioning listener and the table virtual - * scroll depends on the table. Since the sticky positioning listener and table virtual scroll would - * be the same instance, this would create a circular dependency. - * - * The {@link CdkTableVirtualScroll} instead provides this class and attaches itself as the - * receiving listener so {@link StickyPositioningListener} and {@link CdkTableVirtualScroll} are - * provided as separate instances. - * - * @docs-private - */ -export class _PositioningListenerProxy implements StickyPositioningListener { - private _listener?: StickyPositioningListener; - - setListener(listener: StickyPositioningListener) { - this._listener = listener; - } - - stickyColumnsUpdated(update: StickyUpdate): void { - this._listener?.stickyColumnsUpdated(update); - } - - stickyEndColumnsUpdated(update: StickyUpdate): void { - this._listener?.stickyEndColumnsUpdated(update); - } - - stickyFooterRowsUpdated(update: StickyUpdate): void { - this._listener?.stickyFooterRowsUpdated(update); - } - - stickyHeaderRowsUpdated(update: StickyUpdate): void { - this._listener?.stickyHeaderRowsUpdated(update); - } -} - /** @docs-private */ export const _TABLE_VIRTUAL_SCROLL_COLLECTION_VIEWER_FACTORY = () => new BehaviorSubject({start: 0, end: 0}); @@ -84,12 +44,10 @@ const SCROLL_SCHEDULER = */ @Directive({ selector: 'cdk-table[virtualScroll], table[cdk-table][virtualScroll]', - exportAs: 'cdkVirtualScroll', + exportAs: 'cdkTableVirtualScroll', providers: [ {provide: _VIEW_REPEATER_STRATEGY, useClass: _RecycleViewRepeaterStrategy}, - // The directive cannot provide itself as the sticky positions listener because it introduces - // a circular dependency. Use an intermediate listener as a proxy. - {provide: STICKY_POSITIONING_LISTENER, useClass: _PositioningListenerProxy}, + {provide: STICKY_POSITIONING_LISTENER, useExisting: CdkTableVirtualScroll}, // Initially emit an empty range. The virtual scroll viewport will update the range after it is // initialized. { @@ -141,9 +99,6 @@ export class CdkTableVirtualScroll } constructor() { - const positioningListener = inject<_PositioningListenerProxy>(STICKY_POSITIONING_LISTENER); - positioningListener.setListener(this); - // Force the table to enable `fixedLayout` to prevent column widths from changing as the user // scrolls. This also enables caching in the table's sticky styler which reduces calls to // expensive DOM APIs, such as `getBoundingClientRect()`, and improves overall performance. @@ -193,13 +148,9 @@ export class CdkTableVirtualScroll return 0; } - stickyColumnsUpdated(update: StickyUpdate): void { - // no-op - } + stickyColumnsUpdated(): void {} - stickyEndColumnsUpdated(update: StickyUpdate): void { - // no-op - } + stickyEndColumnsUpdated(): void {} stickyHeaderRowsUpdated(update: StickyUpdate): void { this._headerRowStickyUpdates.next(update); diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index 5027888c05d6..04475400f67e 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -305,11 +305,7 @@ export class CdkTable private _platform = inject(Platform); protected _viewRepeater: _ViewRepeater, RowContext>; private readonly _viewportRuler = inject(ViewportRuler); - protected readonly _positioningListener = inject(STICKY_POSITIONING_LISTENER, {optional: true}); - protected readonly _parentPositioningListener = inject( - STICKY_POSITIONING_LISTENER, - {optional: true, skipSelf: true}, - )!; + private _injector = inject(Injector); private _document = inject(DOCUMENT); @@ -626,8 +622,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() { @@ -639,7 +633,6 @@ export class CdkTable this._isServer = !this._platform.isBrowser; this._isNativeHtmlTable = this._elementRef.nativeElement.nodeName === 'TABLE'; - this._parentPositioningListener = this._positioningListener ?? this._parentPositioningListener; this.viewChange = this.viewChange ?? new BehaviorSubject({start: 0, end: Number.MAX_VALUE}); @@ -1416,14 +1409,19 @@ export class CdkTable */ private _setupStickyStyler() { const direction: Direction = this._dir ? this._dir.value : 'ltr'; + const injector = this._injector; + const positioningListener = + injector.get(STICKY_POSITIONING_LISTENER, null, {optional: true}) || + injector.get(STICKY_POSITIONING_LISTENER, null, {optional: true, skipSelf: true}); + this._stickyStyler = new StickyStyler( this._isNativeHtmlTable, this.stickyCssClass, this._platform.isBrowser, this.needsPositionStickyOnElement, direction, - this._parentPositioningListener, - this._injector, + positioningListener, + injector, ); (this._dir ? this._dir.change : observableOf()) .pipe(takeUntil(this._onDestroy)) From 928fbccc5cea726d5ac145a95c00110eaf20715d Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 3 Dec 2025 11:31:36 +0100 Subject: [PATCH 04/10] refactor(cdk/table): remove unnecessary factory We can put the factory in-line, instead of having to export it. --- src/cdk/table/table-virtual-scroll.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/cdk/table/table-virtual-scroll.ts b/src/cdk/table/table-virtual-scroll.ts index a021c3c06858..d45383cdc082 100644 --- a/src/cdk/table/table-virtual-scroll.ts +++ b/src/cdk/table/table-virtual-scroll.ts @@ -25,10 +25,6 @@ import { } from './sticky-position-listener'; import {_TABLE_VIEW_CHANGE_STRATEGY, CdkTable, RenderRow, RowContext} from './table'; -/** @docs-private */ -export const _TABLE_VIRTUAL_SCROLL_COLLECTION_VIEWER_FACTORY = () => - new BehaviorSubject({start: 0, end: 0}); - /** * Scheduler to be used for scroll events. Needs to fall back to * something that doesn't rely on requestAnimationFrame on environments @@ -48,11 +44,11 @@ const SCROLL_SCHEDULER = providers: [ {provide: _VIEW_REPEATER_STRATEGY, useClass: _RecycleViewRepeaterStrategy}, {provide: STICKY_POSITIONING_LISTENER, useExisting: CdkTableVirtualScroll}, - // Initially emit an empty range. The virtual scroll viewport will update the range after it is - // initialized. { provide: _TABLE_VIEW_CHANGE_STRATEGY, - useFactory: _TABLE_VIRTUAL_SCROLL_COLLECTION_VIEWER_FACTORY, + // Initially emit an empty range. The virtual scroll + // viewport will update the range after it is initialized. + useFactory: () => new BehaviorSubject({start: 0, end: 0}), }, ], host: { From c9b6b02f7a63dc3621bf587f1e41ab0ab47af709 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 4 Dec 2025 13:22:03 +0100 Subject: [PATCH 05/10] refactor(cdk/table): move virtual scrolling logic into the table Combines the logic from the `CdkTableVirtualScroll` with the existing `CdkTable`. This allows us to remove the following steps from the virtual scrolling setup: 1. Having to import `CdkTableVirtualScroll`. 2. Having to set `fixedLayout` on the table. 3. Having to set `virtualScroll` on the table. --- src/cdk/table/public-api.ts | 1 - src/cdk/table/table-module.ts | 2 - src/cdk/table/table-virtual-scroll.ts | 217 ------------------ src/cdk/table/table.ts | 199 ++++++++++++---- .../cdk-virtual-flex-table-example.html | 2 +- .../cdk-virtual-table-example.html | 2 +- 6 files changed, 161 insertions(+), 262 deletions(-) delete mode 100644 src/cdk/table/table-virtual-scroll.ts diff --git a/src/cdk/table/public-api.ts b/src/cdk/table/public-api.ts index 4641b353f9b7..4e8b735c60d3 100644 --- a/src/cdk/table/public-api.ts +++ b/src/cdk/table/public-api.ts @@ -13,7 +13,6 @@ export * from './table-module'; export * from './sticky-position-listener'; export * from './text-column'; export * from './tokens'; -export * from './table-virtual-scroll'; /** Re-export DataSource for a more intuitive experience for users of just the table. */ export {DataSource} from '../collections'; diff --git a/src/cdk/table/table-module.ts b/src/cdk/table/table-module.ts index 011ae4d41db1..c7ec0fab90e9 100644 --- a/src/cdk/table/table-module.ts +++ b/src/cdk/table/table-module.ts @@ -36,7 +36,6 @@ import { } from './cell'; import {CdkTextColumn} from './text-column'; import {ScrollingModule} from '../scrolling'; -import {CdkTableVirtualScroll} from './table-virtual-scroll'; const EXPORTED_DECLARATIONS = [ CdkTable, @@ -61,7 +60,6 @@ const EXPORTED_DECLARATIONS = [ CdkNoDataRow, CdkRecycleRows, NoDataRowOutlet, - CdkTableVirtualScroll, ]; @NgModule({ diff --git a/src/cdk/table/table-virtual-scroll.ts b/src/cdk/table/table-virtual-scroll.ts deleted file mode 100644 index d45383cdc082..000000000000 --- a/src/cdk/table/table-virtual-scroll.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ -import {Directive, inject, Input, OnDestroy} from '@angular/core'; -import {_RecycleViewRepeaterStrategy, _VIEW_REPEATER_STRATEGY, ListRange} from '../collections'; -import { - animationFrameScheduler, - asapScheduler, - BehaviorSubject, - combineLatest, - Observable, - ReplaySubject, - Subject, -} from 'rxjs'; -import {auditTime, shareReplay, takeUntil} from 'rxjs/operators'; -import {CdkVirtualScrollRepeater, CdkVirtualScrollViewport} from '../scrolling'; -import { - StickyPositioningListener, - StickyUpdate, - STICKY_POSITIONING_LISTENER, -} from './sticky-position-listener'; -import {_TABLE_VIEW_CHANGE_STRATEGY, CdkTable, RenderRow, RowContext} from './table'; - -/** - * Scheduler to be used for scroll events. Needs to fall back to - * something that doesn't rely on requestAnimationFrame on environments - * that don't support it (e.g. server-side rendering). - * - * This is identical to the scheduler used by the virtual scroll module. - */ -const SCROLL_SCHEDULER = - typeof requestAnimationFrame !== 'undefined' ? animationFrameScheduler : asapScheduler; - -/** - * A directive that enables virtual scroll for a {@link CdkTable}. - */ -@Directive({ - selector: 'cdk-table[virtualScroll], table[cdk-table][virtualScroll]', - exportAs: 'cdkTableVirtualScroll', - providers: [ - {provide: _VIEW_REPEATER_STRATEGY, useClass: _RecycleViewRepeaterStrategy}, - {provide: STICKY_POSITIONING_LISTENER, useExisting: CdkTableVirtualScroll}, - { - provide: _TABLE_VIEW_CHANGE_STRATEGY, - // Initially emit an empty range. The virtual scroll - // viewport will update the range after it is initialized. - useFactory: () => new BehaviorSubject({start: 0, end: 0}), - }, - ], - host: { - 'class': 'cdk-table-virtual-scroll', - }, -}) -export class CdkTableVirtualScroll - implements CdkVirtualScrollRepeater, OnDestroy, StickyPositioningListener -{ - private readonly _table = inject>(CdkTable); - private readonly _viewChange = inject>(_TABLE_VIEW_CHANGE_STRATEGY); - private readonly _viewRepeater = - inject<_RecycleViewRepeaterStrategy, RowContext>>(_VIEW_REPEATER_STRATEGY); - private readonly _viewport = inject(CdkVirtualScrollViewport); - - /** Emits when the component is destroyed. */ - private _destroyed = new ReplaySubject(1); - - /** 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(); - - /** - * Observable that emits the data source's complete data set. This exists to implement - * {@link CdkVirtualScrollRepeater}. - */ - get dataStream(): Observable { - return this._dataStream; - } - private _dataStream = this._table._dataStream.pipe(shareReplay(1)); - - /** - * The size of the cache used to store unused views. Setting the cache size to `0` will disable - * caching. - */ - @Input() - get viewCacheSize(): number { - return this._viewRepeater.viewCacheSize; - } - set viewCacheSize(size: number) { - this._viewRepeater.viewCacheSize = size; - } - - constructor() { - // Force the table to enable `fixedLayout` to prevent column widths from changing as the user - // scrolls. This also enables caching in the table's sticky styler which reduces calls to - // expensive DOM APIs, such as `getBoundingClientRect()`, and improves overall performance. - if (!this._table.fixedLayout && (typeof ngDevMode === 'undefined' || ngDevMode)) { - throw Error('[virtualScroll] requires input `fixedLayout` to be set on the table.'); - } - - // Update sticky styles for header rows when either the render range or sticky state change. - combineLatest([this._viewport._renderedContentOffsetRendered, this._headerRowStickyUpdates]) - .pipe(takeUntil(this._destroyed)) - .subscribe(([offset, update]) => { - this._stickHeaderRows(offset, update); - }); - - // Update sticky styles for footer rows when either the render range or sticky state change. - combineLatest([this._viewport._renderedContentOffsetRendered, this._footerRowStickyUpdates]) - .pipe(takeUntil(this._destroyed)) - .subscribe(([offset, update]) => { - this._stickFooterRows(offset, update); - }); - - // Forward the rendered range computed by the virtual scroll viewport to the table. - this._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, SCROLL_SCHEDULER), takeUntil(this._destroyed)) - .subscribe(this._viewChange); - this._viewport.attach(this); - } - - ngOnDestroy() { - this._destroyed.next(); - this._destroyed.complete(); - } - - /** - * Measures the combined size (width for horizontal orientation, height for vertical) of all items - * in the specified range. - */ - measureRangeSize(range: ListRange, orientation: 'horizontal' | 'vertical'): number { - // TODO(michaeljamesparsons) Implement method so virtual tables can use the `autosize` virtual - // scroll strategy. - if (typeof ngDevMode === 'undefined' || ngDevMode) { - throw new Error('autoSize is not supported for tables with virtual scroll enabled.'); - } - return 0; - } - - stickyColumnsUpdated(): void {} - - stickyEndColumnsUpdated(): void {} - - stickyHeaderRowsUpdated(update: StickyUpdate): void { - this._headerRowStickyUpdates.next(update); - } - - stickyFooterRowsUpdated(update: StickyUpdate): void { - this._footerRowStickyUpdates.next(update); - } - - /** - * The {@link StickyStyler} sticks elements by applying a `top` 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 header rows to also be offset by the - * distance from the top of the scroll viewport in addition to their `top` offset. This method - * negates the translation to move the header rows to their correct positions. - * - * @param offsetFromTop The distance scrolled from the top of the container. - * @param update Metadata about the sticky headers that changed in the last sticky update. - * @private - */ - private _stickHeaderRows(offsetFromTop: number, update: StickyUpdate) { - if (!update.sizes || !update.offsets || !update.elements) { - return; - } - - for (let i = 0; i < update.elements.length; i++) { - if (!update.elements[i]) { - continue; - } - let offset = - offsetFromTop !== 0 - ? Math.max(offsetFromTop - update.offsets[i]!, update.offsets[i]!) - : -update.offsets[i]!; - - this._stickCells(update.elements[i]!, 'top', -offset); - } - } - - /** - * The {@link StickyStyler} sticks elements by applying a `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 footer rows to also be offset by the - * distance from the top of the scroll viewport in addition to their `bottom` offset. This method - * negates the translation to move the footer rows to their correct positions. - * - * @param offsetFromTop The distance scrolled from the top of the container. - * @param update Metadata about the sticky footers that changed in the last sticky update. - * @private - */ - private _stickFooterRows(offsetFromTop: number, update: StickyUpdate) { - if (!update.sizes || !update.offsets || !update.elements) { - return; - } - - for (let i = 0; i < update.elements.length; i++) { - if (!update.elements[i]) { - continue; - } - this._stickCells(update.elements[i]!, 'bottom', offsetFromTop + update.offsets[i]!); - } - } - - private _stickCells(cells: HTMLElement[], position: 'bottom' | 'top', offset: number) { - for (const cell of cells) { - cell.style[position] = `${offset}px`; - } - } -} diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index 04475400f67e..5eec4c5e0ae3 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -20,7 +20,11 @@ import { ListRange, } from '../collections'; import {Platform} from '../platform'; -import {ViewportRuler} from '../scrolling'; +import { + CDK_VIRTUAL_SCROLL_VIEWPORT, + type CdkVirtualScrollViewport, + ViewportRuler, +} from '../scrolling'; import { AfterContentChecked, @@ -51,9 +55,10 @@ import { Injector, HostAttributeToken, DOCUMENT, - InjectionToken, } from '@angular/core'; import { + animationFrameScheduler, + asapScheduler, BehaviorSubject, combineLatest, isObservable, @@ -62,7 +67,7 @@ import { Subject, Subscription, } from 'rxjs'; -import {takeUntil} from 'rxjs/operators'; +import {auditTime, filter, map, takeUntil} from 'rxjs/operators'; import {CdkColumnDef} from './cell'; import { BaseRowDef, @@ -83,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'; /** @@ -104,26 +113,6 @@ export interface RowOutlet { /** Possible types that can be set as the data source for a `CdkTable`. */ export type CdkTableDataSourceInput = readonly T[] | DataSource | Observable; -/** A strategy that implements the behavior for the table's `viewChange` observable. */ -interface TableViewChangeStrategy { - /** - * A stream that emits whenever the table starts rendering a subset of the data. The `start` index - * is inclusive, while the `end` is exclusive. - */ - viewChange: BehaviorSubject; -} - -/** - * Injection token for the `CdkTable` view change strategy. - * - * The table will emit a `viewChange` range that spans the entire data set. This provider overrides - * its `viewChange` observable so its behavior can be overridden from another component or - * directive. - */ -export const _TABLE_VIEW_CHANGE_STRATEGY = new InjectionToken( - 'TABLE_VIEW_CHANGE_STRATEGY', -); - /** * Provides a handle for the table to grab the view container's ng-container to insert data rows. * @docs-private @@ -296,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); @@ -306,6 +301,10 @@ export class CdkTable protected _viewRepeater: _ViewRepeater, RowContext>; private readonly _viewportRuler = inject(ViewportRuler); 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); @@ -462,6 +461,12 @@ 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(); + /** 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. @@ -558,7 +563,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._virtualScrollViewport ? true : this._fixedLayout; } set fixedLayout(value: boolean) { this._fixedLayout = value; @@ -588,9 +595,7 @@ export class CdkTable * * @docs-private */ - readonly viewChange = - inject>(_TABLE_VIEW_CHANGE_STRATEGY, {optional: true}) || - new BehaviorSubject({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; @@ -633,8 +638,10 @@ export class CdkTable this._isServer = !this._platform.isBrowser; this._isNativeHtmlTable = this._elementRef.nativeElement.nodeName === 'TABLE'; - this.viewChange = - this.viewChange ?? new BehaviorSubject({start: 0, end: Number.MAX_VALUE}); + this.viewChange = new BehaviorSubject({ + start: 0, + end: this._virtualScrollViewport ? 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 @@ -642,6 +649,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._virtualScrollViewport) { + this._setupVirtualScrolling(this._virtualScrollViewport); + } } ngOnInit() { @@ -656,9 +667,10 @@ export class CdkTable } ngAfterContentInit() { - this._viewRepeater = this.recycleRows - ? new _RecycleViewRepeaterStrategy() - : new _DisposeViewRepeaterStrategy(); + this._viewRepeater = + this.recycleRows || this._virtualScrollViewport + ? new _RecycleViewRepeaterStrategy() + : new _DisposeViewRepeaterStrategy(); this._hasInitialized = true; } @@ -689,6 +701,8 @@ export class CdkTable this._headerRowDefs = []; this._footerRowDefs = []; this._defaultRowDef = null; + this._headerRowStickyUpdates.complete(); + this._footerRowStickyUpdates.complete(); this._onDestroy.next(); this._onDestroy.complete(); @@ -871,7 +885,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( @@ -908,6 +922,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 @@ -1235,7 +1283,7 @@ export class CdkTable rows, stickyStartStates, stickyEndStates, - !this._fixedLayout || this._forceRecalculateCellWidths, + !this.fixedLayout || this._forceRecalculateCellWidths, ); } @@ -1410,9 +1458,6 @@ export class CdkTable private _setupStickyStyler() { const direction: Direction = this._dir ? this._dir.value : 'ltr'; const injector = this._injector; - const positioningListener = - injector.get(STICKY_POSITIONING_LISTENER, null, {optional: true}) || - injector.get(STICKY_POSITIONING_LISTENER, null, {optional: true, skipSelf: true}); this._stickyStyler = new StickyStyler( this._isNativeHtmlTable, @@ -1420,7 +1465,7 @@ export class CdkTable this._platform.isBrowser, this.needsPositionStickyOnElement, direction, - positioningListener, + this, injector, ); (this._dir ? this._dir.change : observableOf()) @@ -1431,6 +1476,80 @@ 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: () => { + // TODO(crisbeto): implement this method so autosizing works. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + throw new Error('autoSize is not supported for tables with virtual scroll enabled.'); + } + return 0; + }, + }); + + const offsetFromTopStream = this.viewChange.pipe( + map(() => viewport.getOffsetToRenderedContentStart()), + filter(offset => offset !== null), + ); + + // 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([offsetFromTopStream, 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([offsetFromTopStream, 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); diff --git a/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html b/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html index f2afefcda84f..983fe382c9c4 100644 --- a/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html +++ b/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html @@ -1,5 +1,5 @@ - + No. diff --git a/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.html b/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.html index af0f5285216b..4f338dabe8d4 100644 --- a/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.html +++ b/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.html @@ -1,5 +1,5 @@ - +
From e8668ddb1e02041308e411d5fec7f2ad724b7fa3 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 4 Dec 2025 14:31:56 +0100 Subject: [PATCH 06/10] refactor(cdk/scrolling): expose rendered content offset stream Exposes the `renderedContentOffset` stream on the virtual scroll viewport since it can be useful for users outside of the CDK table. --- goldens/cdk/scrolling/index.api.md | 1 + src/cdk/scrolling/virtual-scroll-viewport.ts | 24 ++++++++------------ 2 files changed, 11 insertions(+), 14 deletions(-) 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/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts index 012425944793..e3aeebeb8bd2 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -35,7 +35,6 @@ import { asapScheduler, Observable, Observer, - OperatorFunction, Subject, Subscription, } from 'rxjs'; @@ -103,15 +102,7 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On /** Emits when the rendered range changes. */ private readonly _renderedRangeSubject = new Subject(); - - /** - * Emits the offset from the start of the viewport to the start of the rendered data (in pixels). - */ - private readonly _renderedContentOffsetRenderedSubject = new Subject(); - readonly _renderedContentOffsetRendered = this._renderedContentOffsetRenderedSubject.pipe( - filter(offset => offset !== null) as OperatorFunction, - distinctUntilChanged(), - ); + private readonly _renderedContentOffsetSubject = new Subject(); /** The direction the viewport scrolls. */ @Input() @@ -151,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. */ @@ -547,10 +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; - - // Emit the offset to rendered content start when it is in sync with what is rendered in the - // DOM. - this._renderedContentOffsetRenderedSubject.next(this.getOffsetToRenderedContentStart()); + this._renderedContentOffsetSubject.next(this.getOffsetToRenderedContentStart()); afterNextRender( () => { From f6ae899a52e338be76cdf36bd270798f0e14dcdc Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 4 Dec 2025 14:33:36 +0100 Subject: [PATCH 07/10] refactor(cdk/table): implement range size measurement Implements the `measureRangeSize` method so the CDK table is compatible with auto-sizing. --- goldens/cdk/table/index.api.md | 16 +++++---- src/cdk/table/table.ts | 64 ++++++++++++++++++++++++++-------- 2 files changed, 58 insertions(+), 22 deletions(-) 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/table/table.ts b/src/cdk/table/table.ts index 5eec4c5e0ae3..b599e963d2f6 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -67,7 +67,7 @@ import { Subject, Subscription, } from 'rxjs'; -import {auditTime, filter, map, takeUntil} from 'rxjs/operators'; +import {auditTime, takeUntil} from 'rxjs/operators'; import {CdkColumnDef} from './cell'; import { BaseRowDef, @@ -1490,26 +1490,15 @@ export class CdkTable viewport.attach({ dataStream: this._dataStream, - measureRangeSize: () => { - // TODO(crisbeto): implement this method so autosizing works. - if (typeof ngDevMode === 'undefined' || ngDevMode) { - throw new Error('autoSize is not supported for tables with virtual scroll enabled.'); - } - return 0; - }, + measureRangeSize: (range, orientation) => this._measureRangeSize(range, orientation), }); - const offsetFromTopStream = this.viewChange.pipe( - map(() => viewport.getOffsetToRenderedContentStart()), - filter(offset => offset !== null), - ); - // 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([offsetFromTopStream, this._headerRowStickyUpdates]) + combineLatest([viewport.renderedContentOffset, this._headerRowStickyUpdates]) .pipe(takeUntil(this._onDestroy)) .subscribe(([offsetFromTop, update]) => { if (!update.sizes || !update.offsets || !update.elements) { @@ -1531,7 +1520,7 @@ export class CdkTable } }); - combineLatest([offsetFromTopStream, this._footerRowStickyUpdates]) + combineLatest([viewport.renderedContentOffset, this._footerRowStickyUpdates]) .pipe(takeUntil(this._onDestroy)) .subscribe(([offsetFromTop, update]) => { if (!update.sizes || !update.offsets || !update.elements) { @@ -1595,6 +1584,51 @@ 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; + } } /** Utility function that gets a merged list of the entries in an array and values of a Set. */ From d153d41275189ab05bf68294e7b84dbcd803d968 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Sun, 7 Dec 2025 08:18:23 +0100 Subject: [PATCH 08/10] test(cdk/table): add tests for virtual scrolling Sets up some tests for the virtual scrolling logic. --- src/cdk/table/BUILD.bazel | 2 + src/cdk/table/table.spec.ts | 180 ++++++++++++++++++++++++++++++++++-- 2 files changed, 173 insertions(+), 9 deletions(-) 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/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: ` + +
No.
+ + + + + + + + + + + + + + + + + + + + + +
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)); } From 5110a769f074e8105dbd5312e9837dd0329fbad5 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 8 Dec 2025 11:10:12 +0100 Subject: [PATCH 09/10] docs: add virtual scrolling examples Adds examples of virtual scrolling to the CDK and Material tables. --- src/cdk/table/table.md | 22 +++++++---- src/components-examples/cdk/table/BUILD.bazel | 2 +- .../cdk-table-virtual-scroll-example.css | 16 ++++++++ .../cdk-table-virtual-scroll-example.html} | 8 ++-- .../cdk-table-virtual-scroll-example.ts} | 16 ++++---- .../cdk-virtual-flex-table-example.css | 39 ------------------- .../cdk-virtual-flex-table-example.html | 35 ----------------- .../cdk-virtual-table-example.css | 29 -------------- src/components-examples/cdk/table/index.ts | 3 +- .../material/table/index.ts | 1 + .../table-virtual-scroll-example.css | 9 +++++ .../table-virtual-scroll-example.html | 37 ++++++++++++++++++ .../table-virtual-scroll-example.ts} | 21 +++++----- src/dev-app/table/table-demo.html | 20 +++++----- src/dev-app/table/table-demo.ts | 10 ++--- src/material/table/table.md | 10 +++++ 16 files changed, 128 insertions(+), 150 deletions(-) create mode 100644 src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.css rename src/components-examples/cdk/table/{cdk-virtual-table/cdk-virtual-table-example.html => cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.html} (83%) rename src/components-examples/cdk/table/{cdk-virtual-flex-table/cdk-virtual-flex-table-example.ts => cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.ts} (73%) delete mode 100644 src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.css delete mode 100644 src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html delete mode 100644 src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.css create mode 100644 src/components-examples/material/table/table-virtual-scroll/table-virtual-scroll-example.css create mode 100644 src/components-examples/material/table/table-virtual-scroll/table-virtual-scroll-example.html rename src/components-examples/{cdk/table/cdk-virtual-table/cdk-virtual-table-example.ts => material/table/table-virtual-scroll/table-virtual-scroll-example.ts} (65%) 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/components-examples/cdk/table/BUILD.bazel b/src/components-examples/cdk/table/BUILD.bazel index c4adba90c79f..bd4c87daebb3 100644 --- a/src/components-examples/cdk/table/BUILD.bazel +++ b/src/components-examples/cdk/table/BUILD.bazel @@ -12,8 +12,8 @@ ng_project( deps = [ "//:node_modules/@angular/core", "//:node_modules/rxjs", - "//src/cdk/table", "//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-virtual-table/cdk-virtual-table-example.html b/src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.html similarity index 83% rename from src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.html rename to src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.html index 4f338dabe8d4..ae33d6fa97f2 100644 --- a/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.html +++ b/src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.html @@ -1,5 +1,8 @@ +

Showing {{dataSource.length}} rows

+ + -
+
@@ -28,8 +31,7 @@ - + -
No. Symbol
diff --git a/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.ts b/src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.ts similarity index 73% rename from src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.ts rename to src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.ts index 15a48a53466b..401a266f2807 100644 --- a/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.ts +++ b/src/components-examples/cdk/table/cdk-table-virtual-scroll/cdk-table-virtual-scroll-example.ts @@ -1,6 +1,6 @@ import {Component} from '@angular/core'; import {CdkTableModule} from '@angular/cdk/table'; -import {CdkFixedSizeVirtualScroll, CdkVirtualScrollViewport} from '@angular/cdk/scrolling'; +import {ScrollingModule} from '@angular/cdk/scrolling'; export interface PeriodicElement { name: string; @@ -23,22 +23,22 @@ const ELEMENT_DATA: PeriodicElement[] = [ ]; const EXPANDED_ELEMENT_DATA: PeriodicElement[] = []; -for (let x = 0; x < 100; x++) { +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 flex table with virtual scroll enabled. + * @title Example of a CDK table with virtual scroll enabled. */ @Component({ - selector: 'cdk-virtual-flex-table-example', - styleUrls: ['cdk-virtual-flex-table-example.css'], - templateUrl: 'cdk-virtual-flex-table-example.html', - imports: [CdkTableModule, CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll], + 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 CdkVirtualFlexTableExample { +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/cdk-virtual-flex-table/cdk-virtual-flex-table-example.css b/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.css deleted file mode 100644 index c7dfc5ddccda..000000000000 --- a/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.css +++ /dev/null @@ -1,39 +0,0 @@ -.example-container { - height: 600px; - overflow: auto; -} - -.example-virtual-table { - width: 100%; -} - -.example-virtual-table .cdk-header-cell, -.example-virtual-table .cdk-footer-cell { - align-items: center; - background: #3f51b5; - color: white; - display: flex; - font-weight: bold; - justify-content: center; -} - -.example-virtual-table .cdk-cell, -.example-virtual-table .cdk-footer-cell, -.example-virtual-table .cdk-header-cell { - height: 48px; -} - -/** - * Add basic flex styling so that the cells evenly space themselves in the row. - */ -.example-virtual-table cdk-row, -.example-virtual-table cdk-header-row, -.example-virtual-table cdk-footer-row { - display: flex; -} - -.example-virtual-table cdk-cell, -.example-virtual-table cdk-header-cell, -.example-virtual-table cdk-footer-cell { - flex: 1; -} diff --git a/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html b/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html deleted file mode 100644 index 983fe382c9c4..000000000000 --- a/src/components-examples/cdk/table/cdk-virtual-flex-table/cdk-virtual-flex-table-example.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - 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-virtual-table/cdk-virtual-table-example.css b/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.css deleted file mode 100644 index c5e394c0bc43..000000000000 --- a/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.css +++ /dev/null @@ -1,29 +0,0 @@ -.example-container { - height: 600px; - overflow: auto; -} - -.example-virtual-table { - width: 100%; -} - -.example-virtual-table td, -.example-virtual-table th { - height: 48px; - padding: 0; -} - -.example-virtual-table th.cdk-header-cell, -.example-virtual-table .cdk-footer-row th { - background: #3f51b5; - color: white; -} - -.example-virtual-table th.mat-column-position, -.example-virtual-table td.mat-column-position { - padding-left: 8px; -} - -.example-virtual-table .cdk-cell.cdk-table-sticky { - background: #f0f0f0; -} diff --git a/src/components-examples/cdk/table/index.ts b/src/components-examples/cdk/table/index.ts index e1fe7d02a1cf..d97ca1aa7fe8 100644 --- a/src/components-examples/cdk/table/index.ts +++ b/src/components-examples/cdk/table/index.ts @@ -2,5 +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 {CdkVirtualFlexTableExample} from './cdk-virtual-flex-table/cdk-virtual-flex-table-example'; -export {CdkVirtualTableExample} from './cdk-virtual-table/cdk-virtual-table-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/cdk/table/cdk-virtual-table/cdk-virtual-table-example.ts b/src/components-examples/material/table/table-virtual-scroll/table-virtual-scroll-example.ts similarity index 65% rename from src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.ts rename to src/components-examples/material/table/table-virtual-scroll/table-virtual-scroll-example.ts index c96aed9c4563..2da07b078d3d 100644 --- a/src/components-examples/cdk/table/cdk-virtual-table/cdk-virtual-table-example.ts +++ b/src/components-examples/material/table/table-virtual-scroll/table-virtual-scroll-example.ts @@ -1,6 +1,6 @@ -import {CdkFixedSizeVirtualScroll, CdkVirtualScrollViewport} from '@angular/cdk/scrolling'; -import {CdkTableModule} from '@angular/cdk/table'; -import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {Component} from '@angular/core'; +import {MatTableModule} from '@angular/material/table'; +import {ScrollingModule} from '@angular/cdk/scrolling'; export interface PeriodicElement { name: string; @@ -23,23 +23,22 @@ const ELEMENT_DATA: PeriodicElement[] = [ ]; const EXPANDED_ELEMENT_DATA: PeriodicElement[] = []; -for (let x = 0; x < 100; x++) { +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 native table with virtual scroll enabled. + * @title Example of a Material table with virtual scroll enabled. */ @Component({ - selector: 'cdk-virtual-table-example', - styleUrls: ['cdk-virtual-table-example.css'], - templateUrl: 'cdk-virtual-table-example.html', - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CdkTableModule, CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll], + selector: 'table-virtual-scroll-example', + styleUrls: ['table-virtual-scroll-example.css'], + templateUrl: 'table-virtual-scroll-example.html', + imports: [MatTableModule, ScrollingModule], }) -export class CdkVirtualTableExample { +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 85c9537bdffb..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

@@ -84,9 +90,3 @@

Table wrapped in reusable component

Table wrapped re-orderable columns

- -

Cdk virtual table

- - -

Cdk virtual flex table

- diff --git a/src/dev-app/table/table-demo.ts b/src/dev-app/table/table-demo.ts index 3561a4449a12..8aad80b0c697 100644 --- a/src/dev-app/table/table-demo.ts +++ b/src/dev-app/table/table-demo.ts @@ -6,13 +6,13 @@ * found in the LICENSE file at https://angular.dev/license */ +import {ChangeDetectionStrategy, Component} from '@angular/core'; import { CdkTableBasicExample, CdkTableFixedLayoutExample, CdkTableFlexBasicExample, CdkTableRecycleRowsExample, - CdkVirtualFlexTableExample, - CdkVirtualTableExample, + CdkTableVirtualScrollExample, } from '@angular/components-examples/cdk/table'; import { TableBasicExample, @@ -39,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', @@ -50,6 +50,7 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; CdkTableBasicExample, CdkTableFixedLayoutExample, CdkTableRecycleRowsExample, + CdkTableVirtualScrollExample, TableFlexBasicExample, TableBasicExample, TableDynamicColumnsExample, @@ -61,6 +62,7 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; TableMultipleRowTemplateExample, TableOverviewExample, TablePaginationExample, + TableVirtualScrollExample, TableRowContextExample, TableSelectionExample, TableSortingExample, @@ -75,8 +77,6 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; TableReorderableExample, TableRecycleRowsExample, TableFlexLargeRowExample, - CdkVirtualFlexTableExample, - CdkVirtualTableExample, ], changeDetection: ChangeDetectionStrategy.OnPush, }) 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 From 27fe7951dfb27d7da9689510bd3d2dcee591382c Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 8 Dec 2025 13:04:11 +0100 Subject: [PATCH 10/10] refactor(cdk/table): add internal opt out for virtual scrolling Adds an opt out that we can use internally to opt apps out of virtual scrolling. This allows us to keep the public API clean for the majority of users. --- src/cdk/table/table.ts | 43 ++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index b599e963d2f6..16d3005c93b0 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -467,6 +467,14 @@ export class CdkTable /** 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. @@ -565,7 +573,7 @@ export class CdkTable get fixedLayout(): boolean { // Require a fixed layout when virtual scrolling is enabled, otherwise // the element the header can jump around as the user is scrolling. - return this._virtualScrollViewport ? true : this._fixedLayout; + return this._virtualScrollEnabled() ? true : this._fixedLayout; } set fixedLayout(value: boolean) { this._fixedLayout = value; @@ -640,7 +648,7 @@ export class CdkTable this._isNativeHtmlTable = this._elementRef.nativeElement.nodeName === 'TABLE'; this.viewChange = new BehaviorSubject({ start: 0, - end: this._virtualScrollViewport ? 0 : Number.MAX_VALUE, + end: this._virtualScrollEnabled() ? 0 : Number.MAX_VALUE, }); // Set up the trackBy function so that it uses the `RenderRow` as its identity by default. If @@ -649,10 +657,6 @@ 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._virtualScrollViewport) { - this._setupVirtualScrolling(this._virtualScrollViewport); - } } ngOnInit() { @@ -668,9 +672,14 @@ export class CdkTable ngAfterContentInit() { this._viewRepeater = - this.recycleRows || this._virtualScrollViewport + this.recycleRows || this._virtualScrollEnabled() ? new _RecycleViewRepeaterStrategy() : new _DisposeViewRepeaterStrategy(); + + if (this._virtualScrollEnabled()) { + this._setupVirtualScrolling(this._virtualScrollViewport!); + } + this._hasInitialized = true; } @@ -1039,24 +1048,22 @@ 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) - : []; + if (!Array.isArray(this._data) || !this._renderedRange) { + return []; + } + const renderRows: RenderRow[] = []; + const end = Math.min(this._data.length, this._renderedRange.end); // Store the cache and create a new one. Any re-used RenderRow objects will be moved into the // new cache while unused ones can be picked up by garbage collection. const prevCachedRenderRows = this._cachedRenderRowsMap; this._cachedRenderRowsMap = new Map(); - if (!this._data) { - return renderRows; - } - // 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 < dataWithinRange.length; i++) { - let data = dataWithinRange[i]; + for (let i = this._renderedRange.start; i < end; i++) { + const data = this._data[i]; const renderRowsForData = this._getRenderRowsForData(data, i, prevCachedRenderRows.get(data)); if (!this._cachedRenderRowsMap.has(data)) { @@ -1629,6 +1636,10 @@ export class CdkTable 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. */