Skip to content

Commit bb7fd59

Browse files
feat(cdk/table): Virtual scroll directive for tables
1 parent a5fb8f8 commit bb7fd59

26 files changed

+813
-49
lines changed

.github/CODEOWNERS

+1
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

+1
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

+22
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

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

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ export class _RecycleViewRepeaterStrategy<T, R, C extends _ViewRepeaterItemConte
3737
implements _ViewRepeater<T, R, C>
3838
{
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 20 views.
4242
*/
4343
viewCacheSize: number = 20;
4444

0 commit comments

Comments
 (0)