Skip to content

Commit d977e41

Browse files
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 <MichaelJamesParsons@users.noreply.github.com>
1 parent 52720a3 commit d977e41

File tree

15 files changed

+556
-13
lines changed

15 files changed

+556
-13
lines changed

src/cdk/scrolling/virtual-scroll-viewport.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,11 @@ import {
3535
asapScheduler,
3636
Observable,
3737
Observer,
38+
OperatorFunction,
3839
Subject,
3940
Subscription,
4041
} from 'rxjs';
41-
import {auditTime, startWith, takeUntil} from 'rxjs/operators';
42+
import {auditTime, distinctUntilChanged, filter, startWith, takeUntil} from 'rxjs/operators';
4243
import {CdkScrollable, ExtendedScrollToOptions} from './scrollable';
4344
import {ViewportRuler} from './viewport-ruler';
4445
import {CdkVirtualScrollRepeater} from './virtual-scroll-repeater';
@@ -103,6 +104,15 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On
103104
/** Emits when the rendered range changes. */
104105
private readonly _renderedRangeSubject = new Subject<ListRange>();
105106

107+
/**
108+
* Emits the offset from the start of the viewport to the start of the rendered data (in pixels).
109+
*/
110+
private readonly _renderedContentOffsetRenderedSubject = new Subject<number | null>();
111+
readonly _renderedContentOffsetRendered = this._renderedContentOffsetRenderedSubject.pipe(
112+
filter(offset => offset !== null) as OperatorFunction<number | null, number>,
113+
distinctUntilChanged(),
114+
);
115+
106116
/** The direction the viewport scrolls. */
107117
@Input()
108118
get orientation() {
@@ -538,6 +548,10 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On
538548
// the `Number` function first to coerce it to a numeric value.
539549
this._contentWrapper.nativeElement.style.transform = this._renderedContentTransform;
540550

551+
// Emit the offset to rendered content start when it is in sync with what is rendered in the
552+
// DOM.
553+
this._renderedContentOffsetRenderedSubject.next(this.getOffsetToRenderedContentStart());
554+
541555
afterNextRender(
542556
() => {
543557
this._changeDetectionNeeded.set(false);

src/cdk/table/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export * from './table-module';
1313
export * from './sticky-position-listener';
1414
export * from './text-column';
1515
export * from './tokens';
16+
export * from './table-virtual-scroll';
1617

1718
/** Re-export DataSource for a more intuitive experience for users of just the table. */
1819
export {DataSource} from '../collections';

src/cdk/table/table-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
} from './cell';
3737
import {CdkTextColumn} from './text-column';
3838
import {ScrollingModule} from '../scrolling';
39+
import {CdkTableVirtualScroll} from './table-virtual-scroll';
3940

4041
const EXPORTED_DECLARATIONS = [
4142
CdkTable,
@@ -60,6 +61,7 @@ const EXPORTED_DECLARATIONS = [
6061
CdkNoDataRow,
6162
CdkRecycleRows,
6263
NoDataRowOutlet,
64+
CdkTableVirtualScroll,
6365
];
6466

6567
@NgModule({
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
import {Directive, inject, Input, OnDestroy} from '@angular/core';
9+
import {_RecycleViewRepeaterStrategy, _VIEW_REPEATER_STRATEGY, ListRange} from '../collections';
10+
import {BehaviorSubject, combineLatest, Observable, ReplaySubject, Subject} from 'rxjs';
11+
import {shareReplay, takeUntil} from 'rxjs/operators';
12+
import {CdkVirtualScrollRepeater, CdkVirtualScrollViewport} from '../scrolling';
13+
import {
14+
StickyPositioningListener,
15+
StickyUpdate,
16+
STICKY_POSITIONING_LISTENER,
17+
} from './sticky-position-listener';
18+
import {_TABLE_VIEW_CHANGE_STRATEGY, CdkTable, RenderRow, RowContext} from './table';
19+
20+
/**
21+
* An implementation of {@link StickyPositioningListener} that forwards sticky updates to another
22+
* listener.
23+
*
24+
* The {@link CdkTableVirtualScroll} directive cannot provide itself as a
25+
* {@link StickyPositioningListener} because the providers for both entities would point to the same
26+
* instance. The {@link CdkTable} depends on the sticky positioning listener and the table virtual
27+
* scroll depends on the table. Since the sticky positioning listener and table virtual scroll would
28+
* be the same instance, this would create a circular dependency.
29+
*
30+
* The {@link CdkTableVirtualScroll} instead provides this class and attaches itself as the
31+
* receiving listener so {@link StickyPositioningListener} and {@link CdkTableVirtualScroll} are
32+
* provided as separate instances.
33+
*
34+
* @docs-private
35+
*/
36+
export class _PositioningListenerProxy implements StickyPositioningListener {
37+
private _listener?: StickyPositioningListener;
38+
39+
setListener(listener: StickyPositioningListener) {
40+
this._listener = listener;
41+
}
42+
43+
stickyColumnsUpdated(update: StickyUpdate): void {
44+
this._listener?.stickyColumnsUpdated(update);
45+
}
46+
47+
stickyEndColumnsUpdated(update: StickyUpdate): void {
48+
this._listener?.stickyEndColumnsUpdated(update);
49+
}
50+
51+
stickyFooterRowsUpdated(update: StickyUpdate): void {
52+
this._listener?.stickyFooterRowsUpdated(update);
53+
}
54+
55+
stickyHeaderRowsUpdated(update: StickyUpdate): void {
56+
this._listener?.stickyHeaderRowsUpdated(update);
57+
}
58+
}
59+
60+
/** @docs-private */
61+
export const _TABLE_VIRTUAL_SCROLL_COLLECTION_VIEWER_FACTORY = () =>
62+
new BehaviorSubject<ListRange>({start: 0, end: 0});
63+
64+
/**
65+
* A directive that enables virtual scroll for a {@link CdkTable}.
66+
*/
67+
@Directive({
68+
selector: 'cdk-table[virtualScroll], table[cdk-table][virtualScroll]',
69+
exportAs: 'cdkVirtualScroll',
70+
providers: [
71+
{provide: _VIEW_REPEATER_STRATEGY, useClass: _RecycleViewRepeaterStrategy},
72+
// The directive cannot provide itself as the sticky positions listener because it introduces
73+
// a circular dependency. Use an intermediate listener as a proxy.
74+
{provide: STICKY_POSITIONING_LISTENER, useClass: _PositioningListenerProxy},
75+
// Initially emit an empty range. The virtual scroll viewport will update the range after it is
76+
// initialized.
77+
{
78+
provide: _TABLE_VIEW_CHANGE_STRATEGY,
79+
useFactory: _TABLE_VIRTUAL_SCROLL_COLLECTION_VIEWER_FACTORY,
80+
},
81+
],
82+
host: {
83+
'class': 'cdk-table-virtual-scroll',
84+
},
85+
})
86+
export class CdkTableVirtualScroll<T>
87+
implements CdkVirtualScrollRepeater<T>, OnDestroy, StickyPositioningListener
88+
{
89+
private readonly _table = inject<CdkTable<T>>(CdkTable);
90+
private readonly _viewChange = inject<BehaviorSubject<ListRange>>(_TABLE_VIEW_CHANGE_STRATEGY);
91+
private readonly _viewRepeater =
92+
inject<_RecycleViewRepeaterStrategy<T, RenderRow<T>, RowContext<T>>>(_VIEW_REPEATER_STRATEGY);
93+
private readonly _viewport = inject(CdkVirtualScrollViewport);
94+
95+
/** Emits when the component is destroyed. */
96+
private _destroyed = new ReplaySubject<void>(1);
97+
98+
/** Emits when the header rows sticky state changes. */
99+
private readonly _headerRowStickyUpdates = new Subject<StickyUpdate>();
100+
101+
/** Emits when the footer rows sticky state changes. */
102+
private readonly _footerRowStickyUpdates = new Subject<StickyUpdate>();
103+
104+
/**
105+
* Observable that emits the data source's complete data set. This exists to implement
106+
* {@link CdkVirtualScrollRepeater}.
107+
*/
108+
get dataStream(): Observable<readonly T[]> {
109+
return this._dataStream;
110+
}
111+
private _dataStream = this._table._dataStream.pipe(shareReplay(1));
112+
113+
/**
114+
* The size of the cache used to store unused views. Setting the cache size to `0` will disable
115+
* caching.
116+
*/
117+
@Input()
118+
get viewCacheSize(): number {
119+
return this._viewRepeater.viewCacheSize;
120+
}
121+
set viewCacheSize(size: number) {
122+
this._viewRepeater.viewCacheSize = size;
123+
}
124+
125+
constructor() {
126+
const positioningListener = inject<_PositioningListenerProxy>(STICKY_POSITIONING_LISTENER);
127+
positioningListener.setListener(this);
128+
129+
// Force the table to enable `fixedLayout` to prevent column widths from changing as the user
130+
// scrolls. This also enables caching in the table's sticky styler which reduces calls to
131+
// expensive DOM APIs, such as `getBoundingClientRect()`, and improves overall performance.
132+
if (!this._table.fixedLayout && (typeof ngDevMode === 'undefined' || ngDevMode)) {
133+
throw Error('[virtualScroll] requires input `fixedLayout` to be set on the table.');
134+
}
135+
136+
// Update sticky styles for header rows when either the render range or sticky state change.
137+
combineLatest([this._viewport._renderedContentOffsetRendered, this._headerRowStickyUpdates])
138+
.pipe(takeUntil(this._destroyed))
139+
.subscribe(([offset, update]) => {
140+
this._stickHeaderRows(offset, update);
141+
});
142+
143+
// Update sticky styles for footer rows when either the render range or sticky state change.
144+
combineLatest([this._viewport._renderedContentOffsetRendered, this._footerRowStickyUpdates])
145+
.pipe(takeUntil(this._destroyed))
146+
.subscribe(([offset, update]) => {
147+
this._stickFooterRows(offset, update);
148+
});
149+
150+
// Forward the rendered range computed by the virtual scroll viewport to the table.
151+
this._viewport.renderedRangeStream.pipe(takeUntil(this._destroyed)).subscribe(this._viewChange);
152+
this._viewport.attach(this);
153+
}
154+
155+
ngOnDestroy() {
156+
this._destroyed.next();
157+
this._destroyed.complete();
158+
}
159+
160+
/**
161+
* Measures the combined size (width for horizontal orientation, height for vertical) of all items
162+
* in the specified range.
163+
*/
164+
measureRangeSize(range: ListRange, orientation: 'horizontal' | 'vertical'): number {
165+
// TODO(michaeljamesparsons) Implement method so virtual tables can use the `autosize` virtual
166+
// scroll strategy.
167+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
168+
throw new Error('autoSize is not supported for tables with virtual scroll enabled.');
169+
}
170+
return 0;
171+
}
172+
173+
stickyColumnsUpdated(update: StickyUpdate): void {
174+
// no-op
175+
}
176+
177+
stickyEndColumnsUpdated(update: StickyUpdate): void {
178+
// no-op
179+
}
180+
181+
stickyHeaderRowsUpdated(update: StickyUpdate): void {
182+
this._headerRowStickyUpdates.next(update);
183+
}
184+
185+
stickyFooterRowsUpdated(update: StickyUpdate): void {
186+
this._footerRowStickyUpdates.next(update);
187+
}
188+
189+
/**
190+
* The {@link StickyStyler} sticks elements by applying a `top` position offset to them. However,
191+
* the virtual scroll viewport applies a `translateY` offset to a container div that
192+
* encapsulates the table. The translation causes the header rows to also be offset by the
193+
* distance from the top of the scroll viewport in addition to their `top` offset. This method
194+
* negates the translation to move the header rows to their correct positions.
195+
*
196+
* @param offsetFromTop The distance scrolled from the top of the container.
197+
* @param update Metadata about the sticky headers that changed in the last sticky update.
198+
* @private
199+
*/
200+
private _stickHeaderRows(offsetFromTop: number, update: StickyUpdate) {
201+
if (!update.sizes || !update.offsets || !update.elements) {
202+
return;
203+
}
204+
205+
for (let i = 0; i < update.elements.length; i++) {
206+
if (!update.elements[i]) {
207+
continue;
208+
}
209+
let offset =
210+
offsetFromTop !== 0
211+
? Math.max(offsetFromTop - update.offsets[i]!, update.offsets[i]!)
212+
: -update.offsets[i]!;
213+
214+
this._stickCells(update.elements[i]!, 'top', -offset);
215+
}
216+
}
217+
218+
/**
219+
* The {@link StickyStyler} sticks elements by applying a `bottom` position offset to them.
220+
* However, the virtual scroll viewport applies a `translateY` offset to a container div that
221+
* encapsulates the table. The translation causes the footer rows to also be offset by the
222+
* distance from the top of the scroll viewport in addition to their `bottom` offset. This method
223+
* negates the translation to move the footer rows to their correct positions.
224+
*
225+
* @param offsetFromTop The distance scrolled from the top of the container.
226+
* @param update Metadata about the sticky footers that changed in the last sticky update.
227+
* @private
228+
*/
229+
private _stickFooterRows(offsetFromTop: number, update: StickyUpdate) {
230+
if (!update.sizes || !update.offsets || !update.elements) {
231+
return;
232+
}
233+
234+
for (let i = 0; i < update.elements.length; i++) {
235+
if (!update.elements[i]) {
236+
continue;
237+
}
238+
this._stickCells(update.elements[i]!, 'bottom', offsetFromTop + update.offsets[i]!);
239+
}
240+
}
241+
242+
private _stickCells(cells: HTMLElement[], position: 'bottom' | 'top', offset: number) {
243+
for (const cell of cells) {
244+
cell.style[position] = `${offset}px`;
245+
}
246+
}
247+
}

0 commit comments

Comments
 (0)