Skip to content

Commit e7b6450

Browse files
feat(cdk/table): Virtual scroll directive for tables
1 parent 30cfd7d commit e7b6450

29 files changed

+19570
-34
lines changed

package-lock.json

Lines changed: 18826 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
load(
2+
"//tools:defaults.bzl",
3+
"ng_module",
4+
"ng_test_library",
5+
"ng_web_test_suite",
6+
)
7+
8+
package(default_visibility = ["//visibility:public"])
9+
10+
ng_module(
11+
name = "table",
12+
srcs = glob(
13+
["**/*.ts"],
14+
exclude = ["**/*.spec.ts"],
15+
),
16+
module_name = "@angular/cdk-experimental/table",
17+
deps = [
18+
"//src/cdk/bidi",
19+
"//src/cdk/platform",
20+
"//src/cdk/table",
21+
"@npm//@angular/common",
22+
"@npm//@angular/core",
23+
"@npm//rxjs",
24+
],
25+
)
26+
27+
ng_test_library(
28+
name = "unit_test_sources",
29+
srcs = glob(
30+
["**/*.spec.ts"],
31+
exclude = ["**/*.e2e.spec.ts"],
32+
),
33+
deps = [
34+
":table",
35+
"//src/cdk/collections",
36+
"//src/cdk/platform",
37+
"//src/cdk/table",
38+
"@npm//rxjs",
39+
],
40+
)
41+
42+
ng_web_test_suite(
43+
name = "unit_tests",
44+
deps = [":unit_test_sources"],
45+
)

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

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() {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,8 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
403403
}
404404
}
405405

406+
readonly renderedContentOffsetChanged = new Subject<void>();
407+
406408
/** Run change detection. */
407409
private _doChangeDetection() {
408410
this._isChangeDetectionPending = false;
@@ -412,6 +414,11 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
412414
// string literals, a variable that can only be 'X' or 'Y', and user input that is run through
413415
// the `Number` function first to coerce it to a numeric value.
414416
this._contentWrapper.nativeElement.style.transform = this._renderedContentTransform;
417+
418+
// FIXME DO NOT SUBMIT: This is a hack to ensure the value returned from
419+
// `getOffsetToRenderedContentStart()` is always in sync with what is rendered in the viewport.
420+
this.renderedContentOffsetChanged.next();
421+
415422
// Apply changes to Angular bindings. Note: We must call `markForCheck` to run change detection
416423
// from the root, since the repeated items are content projected in. Calling `detectChanges`
417424
// instead does not properly check the projected content.

src/cdk/table/table-module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
CdkTable,
1414
CdkRecycleRows,
1515
FooterRowOutlet,
16-
NoDataRowOutlet,
16+
NoDataRowOutlet
1717
} from './table';
1818
import {
1919
CdkCellOutlet, CdkFooterRow, CdkFooterRowDef, CdkHeaderRow, CdkHeaderRowDef, CdkRow,

0 commit comments

Comments
 (0)