Skip to content

Commit 2bf17c3

Browse files
feat(cdk/table): Virtual scroll directive for tables
1 parent acb3f33 commit 2bf17c3

30 files changed

+769
-40
lines changed

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: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
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+
ElementRef,
11+
Inject,
12+
Input,
13+
OnDestroy,
14+
SkipSelf,
15+
} from '@angular/core';
16+
import {
17+
_RecycleViewRepeaterStrategy,
18+
_VIEW_REPEATER_STRATEGY,
19+
isDataSource,
20+
ListRange
21+
} from '@angular/cdk/collections';
22+
import {
23+
_CdkTableDataSourceInput,
24+
_TABLE_COLLECTION_VIEWER,
25+
CdkTable,
26+
RenderRow,
27+
RowContext,
28+
STICKY_POSITIONING_LISTENER,
29+
StickyPositioningListener,
30+
StickyUpdate
31+
} from '@angular/cdk/table';
32+
import {
33+
BehaviorSubject,
34+
combineLatest,
35+
isObservable,
36+
Observable,
37+
of as observableOf, OperatorFunction,
38+
ReplaySubject,
39+
Subject,
40+
} from 'rxjs';
41+
import {
42+
distinctUntilChanged,
43+
filter,
44+
map,
45+
shareReplay,
46+
switchMap,
47+
takeUntil
48+
} from 'rxjs/operators';
49+
import {
50+
CdkVirtualScrollRepeater,
51+
CdkVirtualScrollViewport,
52+
VIRTUAL_SCROLL_STRATEGY,
53+
VirtualScrollStrategy
54+
} from '@angular/cdk/scrolling';
55+
56+
57+
/**
58+
* An implementation of {@link StickyPositioningListener} that forwards sticky updates to another
59+
* listener.
60+
*
61+
* The {@link CdkTableVirtualScroll} directive cannot provide itself a {@link StickyPositioningListener}
62+
* because it introduces a circular dependency. The directive instead provides this class and
63+
* attaches itself as the receiving listener.
64+
*/
65+
class PositioningListenerProxy implements StickyPositioningListener {
66+
private _listener?: StickyPositioningListener;
67+
68+
setListener(listener: StickyPositioningListener) {
69+
this._listener = listener;
70+
}
71+
72+
stickyColumnsUpdated(update: StickyUpdate): void {
73+
this._listener?.stickyColumnsUpdated(update);
74+
}
75+
76+
stickyEndColumnsUpdated(update: StickyUpdate): void {
77+
this._listener?.stickyEndColumnsUpdated(update);
78+
}
79+
80+
stickyFooterRowsUpdated(update: StickyUpdate): void {
81+
this._listener?.stickyFooterRowsUpdated(update);
82+
}
83+
84+
stickyHeaderRowsUpdated(update: StickyUpdate): void {
85+
this._listener?.stickyHeaderRowsUpdated(update);
86+
}
87+
}
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_COLLECTION_VIEWER,
105+
useFactory: () => new BehaviorSubject<ListRange>({start: 0, end: 0}),
106+
},
107+
],
108+
host: {
109+
'class': 'cdk-table-virtual-scroll',
110+
},
111+
})
112+
export class CdkTableVirtualScroll<T> implements CdkVirtualScrollRepeater<T>, OnDestroy, StickyPositioningListener {
113+
/** Emits when the component is destroyed. **/
114+
private _destroyed = new ReplaySubject<void>(1);
115+
116+
/** The currently rendered range of indices. */
117+
private _renderedRange: ListRange;
118+
119+
/** Emits when the virtual scroll viewport's CSS `translateY()` value changes. */
120+
private _renderedContentOffsetChanged = this._viewport.renderedContentOffsetChanged.pipe(
121+
map(() => this._viewport.getOffsetToRenderedContentStart()),
122+
filter(offset => offset !== null) as OperatorFunction<number|null, number>,
123+
distinctUntilChanged());
124+
125+
/** Emits when the header rows sticky state changes. */
126+
private readonly _headerRowStickyUpdates = new Subject<StickyUpdate>();
127+
128+
/** Emits when the footer rows sticky state changes. */
129+
private readonly _footerRowStickyUpdates = new Subject<StickyUpdate>();
130+
131+
/**
132+
* Observable that emits the data source's complete data set. This exists to implement
133+
* {@link CdkVirtualScrollRepeater}.
134+
*/
135+
get dataStream(): Observable<T[] | ReadonlyArray<T>> {
136+
return this._table._dataSourceChanges.pipe(
137+
switchMap(dataSource => this._getDataStream(dataSource)),
138+
shareReplay(1));
139+
}
140+
141+
/**
142+
* The size of the cache used to store unused views. Setting the cache size to `0` will disable
143+
* caching.
144+
*/
145+
@Input()
146+
get viewCacheSize(): number {
147+
return this._viewRepeater.viewCacheSize;
148+
}
149+
set viewCacheSize(size: number) {
150+
this._viewRepeater.viewCacheSize = size;
151+
}
152+
153+
constructor(
154+
private readonly _table: CdkTable<T>,
155+
private readonly _tableEl: ElementRef<CdkTable<T>>,
156+
@Inject(STICKY_POSITIONING_LISTENER) positioningListener: PositioningListenerProxy,
157+
@Inject(_VIEW_REPEATER_STRATEGY) protected readonly _viewRepeater: _RecycleViewRepeaterStrategy<T, RenderRow<T>, RowContext<T>>,
158+
@Inject(VIRTUAL_SCROLL_STRATEGY) protected readonly _scrollStrategy: VirtualScrollStrategy,
159+
@SkipSelf() private readonly _viewport: CdkVirtualScrollViewport) {
160+
positioningListener.setListener(this);
161+
162+
// Force the table to enable `fixedLayout` to prevent column widths from changing as the user
163+
// scrolls. This also enables caching in the table's sticky styler which reduces calls to
164+
// expensive DOM APIs, such as `getBoundingClientRect()`, and improves overall performance.
165+
if (!this._table.fixedLayout && (typeof ngDevMode === 'undefined' || ngDevMode)) {
166+
throw Error('[virtualScroll] expects input `fixedLayout` to be set on the table.');
167+
}
168+
169+
// Update sticky styles for header rows when either the render range or sticky state change.
170+
combineLatest([this._renderedContentOffsetChanged, this._headerRowStickyUpdates])
171+
.pipe(takeUntil(this._destroyed))
172+
.subscribe(([offset, update]) => {
173+
this._stickHeaderRows(offset, update);
174+
});
175+
176+
// Update sticky styles for footer rows when either the render range or sticky state change.
177+
combineLatest([this._renderedContentOffsetChanged, this._footerRowStickyUpdates])
178+
.pipe(takeUntil(this._destroyed))
179+
.subscribe(([offset, update]) => {
180+
this._stickFooterRows(offset, update);
181+
});
182+
183+
// Update viewChange subscribers when the virtual scroll viewport's rendered range changes.
184+
this._viewport.renderedRangeStream.pipe(takeUntil(this._destroyed)).subscribe(range => {
185+
this._renderedRange = range;
186+
this._table.viewChange.next(range);
187+
});
188+
189+
this._viewport.attach(this);
190+
}
191+
192+
ngOnDestroy() {
193+
this._destroyed.next();
194+
this._destroyed.complete();
195+
}
196+
197+
measureRangeSize(range: ListRange, orientation: 'horizontal' | 'vertical'): number {
198+
return 0;
199+
}
200+
201+
stickyColumnsUpdated(update: StickyUpdate): void {
202+
// no-op
203+
}
204+
205+
stickyEndColumnsUpdated(update: StickyUpdate): void {
206+
// no-op
207+
}
208+
209+
stickyHeaderRowsUpdated(update: StickyUpdate): void {
210+
this._headerRowStickyUpdates.next(update);
211+
}
212+
213+
stickyFooterRowsUpdated(update: StickyUpdate): void {
214+
this._footerRowStickyUpdates.next(update);
215+
}
216+
217+
private _getDataStream(dataSource: _CdkTableDataSourceInput<T>): Observable<T[] | ReadonlyArray<T>> {
218+
if (isDataSource(dataSource)) {
219+
return dataSource.connect(this._table);
220+
} else if (isObservable(dataSource)) {
221+
return dataSource;
222+
}
223+
return observableOf(dataSource);
224+
}
225+
226+
private _stickHeaderRows(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+
const offset = offsetFromTop !== 0
236+
? Math.max(offsetFromTop - update.offsets[i]!, update.offsets[i]!)
237+
: -update.offsets[i]!;
238+
this._stickCells(update.elements[i]!, 'top', -offset);
239+
}
240+
}
241+
242+
private _stickFooterRows(offsetFromTop: number, update: StickyUpdate) {
243+
if (!update.sizes || !update.offsets || !update.elements) {
244+
return;
245+
}
246+
247+
for (let i = 0; i < update.elements.length; i++) {
248+
if (!update.elements[i]) {
249+
continue;
250+
}
251+
this._stickCells(update.elements[i]!, 'bottom', offsetFromTop + update.offsets[i]!);
252+
}
253+
}
254+
255+
private _stickCells(cells: HTMLElement[], position: 'bottom'|'top', offset: number) {
256+
for (let j = 0; j < cells.length; j++) {
257+
cells[j].style[position] = `${offset}px`;
258+
}
259+
}
260+
}

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)