Skip to content

Commit 30d988c

Browse files
feat(cdk/table): Virtual scroll directive for tables
1 parent 0114ccd commit 30d988c

30 files changed

+747
-33
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@
136136
/src/cdk-experimental/menu/** @jelbourn @andy9775
137137
/src/cdk-experimental/popover-edit/** @kseamon @andrewseguin
138138
/src/cdk-experimental/scrolling/** @mmalerba
139+
/src/cdk-experimental/table/** @michaeljamesparsons @andrewseguin
139140
/src/cdk-experimental/table-scroll-container/** @kseamon @andrewseguin
140141
/src/cdk-experimental/listbox/** @nielsr98 @jelbourn
141142
/src/cdk-experimental/selection/** @yifange @jelbourn

src/cdk-experimental/config.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CDK_EXPERIMENTAL_ENTRYPOINTS = [
88
"popover-edit",
99
"scrolling",
1010
"selection",
11+
"table",
1112
"table-scroll-container",
1213
]
1314

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
load(
2+
"//tools:defaults.bzl",
3+
"ng_module",
4+
)
5+
6+
package(default_visibility = ["//visibility:public"])
7+
8+
ng_module(
9+
name = "table",
10+
srcs = glob(
11+
["**/*.ts"],
12+
exclude = ["**/*.spec.ts"],
13+
),
14+
deps = [
15+
"//src/cdk/bidi",
16+
"//src/cdk/platform",
17+
"//src/cdk/table",
18+
"@npm//@angular/common",
19+
"@npm//@angular/core",
20+
"@npm//rxjs",
21+
],
22+
)

src/cdk-experimental/table/index.ts

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

src/cdk/collections/recycle-view-repeater-strategy.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@ import {
3737
export class _RecycleViewRepeaterStrategy<T, R, C extends _ViewRepeaterItemContext<T>>
3838
implements _ViewRepeater<T, R, C> {
3939
/**
40-
* The size of the cache used to store unused views.
41-
* Setting the cache size to `0` will disable caching. Defaults to 20 views.
40+
* The size of the cache used to store unused views. Setting the cache size to `0` will disable
41+
* caching. Defaults to 100 views.
4242
*/
43-
viewCacheSize: number = 20;
43+
viewCacheSize: number = 100;
4444

4545
/**
4646
* View cache that stores embedded view instances that have been previously stamped out,

src/cdk/scrolling/virtual-for-of.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export class CdkVirtualForOf<T> implements
140140

141141
/**
142142
* The size of the cache used to store templates that are not being used for re-use later.
143-
* Setting the cache size to `0` will disable caching. Defaults to 20 templates.
143+
* Setting the cache size to `0` will disable caching. Defaults to 100 templates.
144144
*/
145145
@Input()
146146
get cdkVirtualForTemplateCacheSize() {

0 commit comments

Comments
 (0)