Skip to content

Commit 0cb50f0

Browse files
committed
feat(virtual-scroll): fixed size virtual scroll
1 parent 303e004 commit 0cb50f0

17 files changed

+690
-7
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
/src/demo-app/toolbar/** @devversion
128128
/src/demo-app/tooltip/** @andrewseguin
129129
/src/demo-app/typography/** @crisbeto
130+
/src/demo-app/virtual-scroll/** @mmalerba
130131

131132
# E2E app
132133
/e2e/* @jelbourn

src/cdk/collections/collection-viewer.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88

99
import {Observable} from 'rxjs/Observable';
1010

11+
12+
/** Represents a range of numbers with a specified start and end. */
13+
export type Range = {start: number, end: number};
14+
15+
1116
/**
1217
* Interface for any component that provides a view of some data collection and wants to provide
1318
* information regarding the view and any changes made.
@@ -17,5 +22,5 @@ export interface CollectionViewer {
1722
* A stream that emits whenever the `CollectionViewer` starts looking at a new portion of the
1823
* data. The `start` index is inclusive, while the `end` is exclusive.
1924
*/
20-
viewChange: Observable<{start: number, end: number}>;
25+
viewChange: Observable<Range>;
2126
}

src/cdk/collections/data-source.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
*/
88

99
import {Observable} from 'rxjs/Observable';
10+
import {of} from 'rxjs/observable/of';
1011
import {CollectionViewer} from './collection-viewer';
1112

13+
1214
export abstract class DataSource<T> {
1315
/**
1416
* Connects a collection viewer (such as a data-table) to this data source. Note that
@@ -29,3 +31,15 @@ export abstract class DataSource<T> {
2931
*/
3032
abstract disconnect(collectionViewer: CollectionViewer): void;
3133
}
34+
35+
36+
/** DataSource wrapper for a native array. */
37+
export class ArrayDataSource<T> implements DataSource<T> {
38+
constructor(private _data: T[]) {}
39+
40+
connect(): Observable<T[]> {
41+
return of(this._data);
42+
}
43+
44+
disconnect() {}
45+
}

src/cdk/scrolling/for-of.ts

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
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 {ArrayDataSource, CollectionViewer, DataSource, Range} from '@angular/cdk/collections';
10+
import {
11+
Directive,
12+
DoCheck,
13+
EmbeddedViewRef,
14+
Host,
15+
Input,
16+
IterableChangeRecord,
17+
IterableChanges,
18+
IterableDiffer,
19+
IterableDiffers,
20+
NgIterable,
21+
OnDestroy,
22+
TemplateRef,
23+
TrackByFunction,
24+
ViewContainerRef,
25+
} from '@angular/core';
26+
import {Observable} from 'rxjs/Observable';
27+
import {pairwise} from 'rxjs/operators/pairwise';
28+
import {shareReplay} from 'rxjs/operators/shareReplay';
29+
import {startWith} from 'rxjs/operators/startWith';
30+
import {switchMap} from 'rxjs/operators/switchMap';
31+
import {Subject} from 'rxjs/Subject';
32+
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
33+
34+
35+
/** The context for an item rendered by `CdkForOf` */
36+
export class CdkForOfContext<T> {
37+
constructor(public $implicit: T, public cdkForOf: NgIterable<T> | DataSource<T>,
38+
public index: number, public count: number) {}
39+
40+
get first(): boolean { return this.index === 0; }
41+
42+
get last(): boolean { return this.index === this.count - 1; }
43+
44+
get even(): boolean { return this.index % 2 === 0; }
45+
46+
get odd(): boolean { return !this.even; }
47+
}
48+
49+
50+
type RecordViewTuple<T> = {
51+
record: IterableChangeRecord<T> | null,
52+
view?: EmbeddedViewRef<CdkForOfContext<T>>
53+
};
54+
55+
56+
/**
57+
* A directive similar to `ngForOf` to be used for rendering data inside a virtual scrolling
58+
* container.
59+
*/
60+
@Directive({
61+
selector: '[cdkFor][cdkForOf]',
62+
})
63+
export class CdkForOf<T> implements CollectionViewer, DoCheck, OnDestroy {
64+
/** Emits when the rendered view of the data changes. */
65+
viewChange = new Subject<Range>();
66+
67+
/** Emits when the data source changes. */
68+
private _dataSourceSubject = new Subject<DataSource<T>>();
69+
70+
/** The DataSource to display. */
71+
@Input()
72+
get cdkForOf(): NgIterable<T> | DataSource<T> { return this._cdkForOf; }
73+
set cdkForOf(value: NgIterable<T> | DataSource<T>) {
74+
this._cdkForOf = value;
75+
let ds = value instanceof DataSource ? value :
76+
new ArrayDataSource<T>(Array.prototype.slice.call(value));
77+
this._dataSourceSubject.next(ds);
78+
}
79+
_cdkForOf: NgIterable<T> | DataSource<T>;
80+
81+
/** The trackBy function to use for tracking elements. */
82+
@Input()
83+
get cdkForTrackBy(): TrackByFunction<T> {
84+
return this._cdkForOfTrackBy;
85+
}
86+
set cdkForTrackBy(fn: TrackByFunction<T>) {
87+
this._needsUpdate = true;
88+
this._cdkForOfTrackBy =
89+
(index, item) => fn(index + (this._renderedRange ? this._renderedRange.start : 0), item);
90+
}
91+
private _cdkForOfTrackBy: TrackByFunction<T>;
92+
93+
/** The template used to stamp out new elements. */
94+
@Input()
95+
set cdkForTemplate(value: TemplateRef<CdkForOfContext<T>>) {
96+
if (value) {
97+
this._needsUpdate = true;
98+
this._template = value;
99+
}
100+
}
101+
102+
/** Emits whenever the data in the current DataSource changes. */
103+
dataStream: Observable<T[]> = this._dataSourceSubject
104+
.pipe(
105+
startWith(null!),
106+
pairwise(),
107+
switchMap(([prev, cur]) => this._changeDataSource(prev, cur)),
108+
shareReplay(1));
109+
110+
private _differ: IterableDiffer<T> | null = null;
111+
112+
private _data: T[];
113+
114+
private _renderedItems: T[];
115+
116+
private _renderedRange: Range;
117+
118+
private _templateCache: EmbeddedViewRef<CdkForOfContext<T>>[] = [];
119+
120+
private _needsUpdate = false;
121+
122+
constructor(
123+
private _viewContainerRef: ViewContainerRef,
124+
private _template: TemplateRef<CdkForOfContext<T>>,
125+
private _differs: IterableDiffers,
126+
@Host() private _viewport: CdkVirtualScrollViewport) {
127+
this.dataStream.subscribe(data => this._data = data);
128+
this._viewport.renderedRangeStream.subscribe(range => this._onRenderedRangeChange(range));
129+
this._viewport.connect(this);
130+
}
131+
132+
/**
133+
* Get the client rect for the given index.
134+
* @param index The index of the data element whose client rect we want to measure.
135+
* @return The combined client rect for all DOM elements rendered as part of the given index.
136+
* Or null if no DOM elements are rendered for the given index.
137+
* @throws If the given index is not in the rendered range.
138+
*/
139+
measureClientRect(index: number): ClientRect | null {
140+
if (index < this._renderedRange.start || index >= this._renderedRange.end) {
141+
throw Error('Error: attempted to measure an element that isn\'t rendered.');
142+
}
143+
index -= this._renderedRange.start;
144+
let view = this._viewContainerRef.get(index) as EmbeddedViewRef<CdkForOfContext<T>> | null;
145+
if (view && view.rootNodes.length) {
146+
let minTop = Infinity;
147+
let minLeft = Infinity;
148+
let maxBottom = -Infinity;
149+
let maxRight = -Infinity;
150+
151+
// There may be multiple root DOM elements for a single data element, so we merge their rects.
152+
for (let i = 0, ilen = view.rootNodes.length; i < ilen; i++) {
153+
let rect = (view.rootNodes[i] as Element).getBoundingClientRect();
154+
minTop = Math.min(minTop, rect.top);
155+
minLeft = Math.min(minLeft, rect.left);
156+
maxBottom = Math.max(maxBottom, rect.bottom);
157+
maxRight = Math.max(maxRight, rect.right);
158+
}
159+
160+
return {
161+
top: minTop,
162+
left: minLeft,
163+
bottom: maxBottom,
164+
right: maxRight,
165+
height: maxBottom - minTop,
166+
width: maxRight - minLeft
167+
};
168+
}
169+
return null;
170+
}
171+
172+
ngDoCheck() {
173+
if (this._differ && this._needsUpdate) {
174+
const changes = this._differ.diff(this._renderedItems);
175+
this._applyChanges(changes);
176+
this._needsUpdate = false;
177+
}
178+
}
179+
180+
ngOnDestroy() {
181+
this._viewport.disconnect();
182+
183+
this._dataSourceSubject.complete();
184+
this.viewChange.complete();
185+
186+
for (let view of this._templateCache) {
187+
view.destroy();
188+
}
189+
}
190+
191+
/** React to scroll state changes in the viewport. */
192+
private _onRenderedRangeChange(renderedRange: Range) {
193+
this._renderedRange = renderedRange;
194+
this.viewChange.next(this._renderedRange);
195+
this._renderedItems = this._data.slice(this._renderedRange.start, this._renderedRange.end);
196+
if (!this._differ) {
197+
this._differ = this._differs.find(this._renderedItems).create(this.cdkForTrackBy);
198+
}
199+
this._needsUpdate = true;
200+
}
201+
202+
/** Swap out one `DataSource` for another. */
203+
private _changeDataSource(oldDs: DataSource<T> | null, newDs: DataSource<T>): Observable<T[]> {
204+
if (oldDs) {
205+
oldDs.disconnect(this);
206+
}
207+
this._needsUpdate = true;
208+
return newDs.connect(this);
209+
}
210+
211+
/** Apply changes to the DOM. */
212+
private _applyChanges(changes: IterableChanges<T> | null) {
213+
// If there are no changes, just update the index and count on the view context and be done.
214+
if (!changes) {
215+
for (let i = 0, len = this._viewContainerRef.length; i < len; i++) {
216+
let view = this._viewContainerRef.get(i) as EmbeddedViewRef<CdkForOfContext<T>>;
217+
view.context.index = this._renderedRange.start + i;
218+
view.context.count = this._data.length;
219+
view.detectChanges();
220+
}
221+
return;
222+
}
223+
224+
// Detach all of the views and add them into an array to preserve their original order.
225+
const previousViews: EmbeddedViewRef<CdkForOfContext<T>>[] = [];
226+
for (let i = 0, len = this._viewContainerRef.length; i < len; i++) {
227+
previousViews.unshift(
228+
this._viewContainerRef.detach()! as EmbeddedViewRef<CdkForOfContext<T>>);
229+
}
230+
231+
// Mark the removed indices so we can recycle their views.
232+
changes.forEachRemovedItem(record => {
233+
this._templateCache.push(previousViews[record.previousIndex!]);
234+
delete previousViews[record.previousIndex!];
235+
});
236+
237+
// Queue up the newly added items to be inserted, recycling views from the cache if possible.
238+
const insertTuples: RecordViewTuple<T>[] = [];
239+
changes.forEachAddedItem(record => {
240+
insertTuples[record.currentIndex!] = {record, view: this._templateCache.pop()};
241+
});
242+
243+
// Queue up moved items to be re-inserted.
244+
changes.forEachMovedItem(record => {
245+
insertTuples[record.currentIndex!] = {record, view: previousViews[record.previousIndex!]};
246+
delete previousViews[record.previousIndex!];
247+
});
248+
249+
// We have deleted all of the views that were removed or moved from previousViews. What is left
250+
// is the unchanged items that we queue up to be re-inserted.
251+
for (let i = 0, len = previousViews.length; i < len; i++) {
252+
if (previousViews[i]) {
253+
insertTuples[i] = {record: null, view: previousViews[i]};
254+
}
255+
}
256+
257+
// We now have a full list of everything to be inserted, so go ahead and insert them.
258+
for (let i = 0, len = insertTuples.length; i < len; i++) {
259+
let {view, record} = insertTuples[i];
260+
if (view) {
261+
this._viewContainerRef.insert(view);
262+
} else {
263+
view = this._viewContainerRef.createEmbeddedView(this._template,
264+
new CdkForOfContext<T>(null!, this._cdkForOf, -1, -1));
265+
}
266+
267+
if (record) {
268+
view.context.$implicit = record.item as T;
269+
}
270+
view.context.index = this._renderedRange.start + i;
271+
view.context.count = this._data.length;
272+
view.detectChanges();
273+
}
274+
}
275+
}

src/cdk/scrolling/public-api.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
export * from './for-of';
910
export * from './scroll-dispatcher';
1011
export * from './scrollable';
11-
export * from './viewport-ruler';
1212
export * from './scrolling-module';
13+
export * from './viewport-ruler';
14+
export * from './virtual-scroll-viewport';

src/cdk/scrolling/scrolling-module.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,28 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {PlatformModule} from '@angular/cdk/platform';
910
import {NgModule} from '@angular/core';
11+
import {CdkForOf} from './for-of';
1012
import {SCROLL_DISPATCHER_PROVIDER} from './scroll-dispatcher';
11-
import {CdkScrollable} from './scrollable';
12-
import {PlatformModule} from '@angular/cdk/platform';
13+
import {CdkScrollable} from './scrollable';
14+
import {CdkVirtualScrollFixedSize} from './virtual-scroll-fixed-size';
15+
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
1316

1417
@NgModule({
1518
imports: [PlatformModule],
16-
exports: [CdkScrollable],
17-
declarations: [CdkScrollable],
19+
exports: [
20+
CdkForOf,
21+
CdkScrollable,
22+
CdkVirtualScrollFixedSize,
23+
CdkVirtualScrollViewport,
24+
],
25+
declarations: [
26+
CdkForOf,
27+
CdkScrollable,
28+
CdkVirtualScrollFixedSize,
29+
CdkVirtualScrollViewport,
30+
],
1831
providers: [SCROLL_DISPATCHER_PROVIDER],
1932
})
2033
export class ScrollDispatchModule {}

0 commit comments

Comments
 (0)