Skip to content

Instantly share code, notes, and snippets.

@amcdnl
Created April 30, 2018 19:04
Show Gist options
  • Save amcdnl/1fec845d65c2341611cbba9344d236d9 to your computer and use it in GitHub Desktop.
Save amcdnl/1fec845d65c2341611cbba9344d236d9 to your computer and use it in GitHub Desktop.
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ArrayDataSource, CollectionViewer, DataSource, ListRange} from './collections';
import {
Directive,
DoCheck,
EmbeddedViewRef,
Input,
IterableChangeRecord,
IterableChanges,
IterableDiffer,
IterableDiffers,
NgIterable,
OnDestroy,
SkipSelf,
TemplateRef,
TrackByFunction,
ViewContainerRef,
} from '@angular/core';
import {Observable, Subject} from 'rxjs';
import {pairwise, shareReplay, startWith, switchMap} from 'rxjs/operators';
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
/** The context for an item rendered by `CdkVirtualForOf` */
export type CdkVirtualForOfContext<T> = {
$implicit: T;
cdkVirtualForOf: DataSource<T> | Observable<T[]> | NgIterable<T>;
index: number;
count: number;
first: boolean;
last: boolean;
even: boolean;
odd: boolean;
};
/** Helper to extract size from a ClientRect. */
function getSize(orientation: 'horizontal' | 'vertical', rect: ClientRect): number {
return orientation == 'horizontal' ? rect.width : rect.height;
}
/**
* A directive similar to `ngForOf` to be used for rendering data inside a virtual scrolling
* container.
*/
@Directive({
selector: '[cdkVirtualFor][cdkVirtualForOf]',
})
export class CdkVirtualForOf<T> implements CollectionViewer, DoCheck, OnDestroy {
/** Emits when the rendered view of the data changes. */
viewChange = new Subject<ListRange>();
/** Subject that emits when a new DataSource instance is given. */
private _dataSourceChanges = new Subject<DataSource<T>>();
/** The DataSource to display. */
@Input()
get cdkVirtualForOf(): DataSource<T> | Observable<T[]> | NgIterable<T> {
return this._cdkVirtualForOf;
}
set cdkVirtualForOf(value: DataSource<T> | Observable<T[]> | NgIterable<T>) {
this._cdkVirtualForOf = value;
const ds = value instanceof DataSource ? value :
// Slice the value if its an NgIterable to ensure we're working with an array.
new ArrayDataSource<T>(
value instanceof Observable ? value : Array.prototype.slice.call(value || []));
this._dataSourceChanges.next(ds);
}
_cdkVirtualForOf: DataSource<T> | Observable<T[]> | NgIterable<T>;
/**
* The `TrackByFunction` to use for tracking changes. The `TrackByFunction` takes the index and
* the item and produces a value to be used as the item's identity when tracking changes.
*/
@Input()
get cdkVirtualForTrackBy(): TrackByFunction<T> {
return this._cdkVirtualForTrackBy;
}
set cdkVirtualForTrackBy(fn: TrackByFunction<T>) {
this._needsUpdate = true;
this._cdkVirtualForTrackBy =
(index, item) => fn(index + (this._renderedRange ? this._renderedRange.start : 0), item);
}
private _cdkVirtualForTrackBy: TrackByFunction<T>;
/** The template used to stamp out new elements. */
@Input()
set cdkVirtualForTemplate(value: TemplateRef<CdkVirtualForOfContext<T>>) {
if (value) {
this._needsUpdate = true;
this._template = value;
}
}
@Input() cdkVirtualForTemplateCacheSize: number = 20;
/** Emits whenever the data in the current DataSource changes. */
dataStream: Observable<T[]> = this._dataSourceChanges
.pipe(
// Start off with null `DataSource`.
startWith(null!),
// Bundle up the previous and current data sources so we can work with both.
pairwise(),
// Use `_changeDataSource` to disconnect from the previous data source and connect to the
// new one, passing back a stream of data changes which we run through `switchMap` to give
// us a data stream that emits the latest data from whatever the current `DataSource` is.
switchMap(([prev, cur]) => this._changeDataSource(prev, cur)),
// Replay the last emitted data when someone subscribes.
shareReplay(1));
/** The differ used to calculate changes to the data. */
private _differ: IterableDiffer<T> | null = null;
/** The most recent data emitted from the DataSource. */
private _data: T[];
/** The currently rendered items. */
private _renderedItems: T[];
/** The currently rendered range of indices. */
private _renderedRange: ListRange;
/**
* The template cache used to hold on ot template instancess that have been stamped out, but don't
* currently need to be rendered. These instances will be reused in the future rather than
* stamping out brand new ones.
*/
private _templateCache: EmbeddedViewRef<CdkVirtualForOfContext<T>>[] = [];
/** Whether the rendered data should be updated during the next ngDoCheck cycle. */
private _needsUpdate = false;
constructor(
/** The view container to add items to. */
private _viewContainerRef: ViewContainerRef,
/** The template to use when stamping out new items. */
private _template: TemplateRef<CdkVirtualForOfContext<T>>,
/** The set of available differs. */
private _differs: IterableDiffers,
/** The virtual scrolling viewport that these items are being rendered in. */
@SkipSelf() private _viewport: CdkVirtualScrollViewport) {
this.dataStream.subscribe(data => this._data = data);
this._viewport.renderedRangeStream.subscribe(range => this._onRenderedRangeChange(range));
this._viewport.attach(this);
}
/**
* Measures the combined size (width for horizontal orientation, height for vertical) of all items
* in the specified range. Throws an error if the range includes items that are not currently
* rendered.
*/
measureRangeSize(range: ListRange, orientation: 'horizontal' | 'vertical'): number {
if (range.start >= range.end) {
return 0;
}
if (range.start < this._renderedRange.start || range.end > this._renderedRange.end) {
throw Error(`Error: attempted to measure an item that isn't rendered.`);
}
// The index into the list of rendered views for the first item in the range.
const renderedStartIndex = range.start - this._renderedRange.start;
// The length of the range we're measuring.
const rangeLen = range.end - range.start;
// Loop over all root nodes for all items in the range and sum up their size.
// TODO(mmalerba): Make this work with non-element nodes.
let totalSize = 0;
let i = rangeLen;
while (i--) {
const view = this._viewContainerRef.get(i + renderedStartIndex) as
EmbeddedViewRef<CdkVirtualForOfContext<T>> | null;
let j = view ? view.rootNodes.length : 0;
while (j--) {
totalSize += getSize(orientation, (view!.rootNodes[j] as Element).getBoundingClientRect());
}
}
return totalSize;
}
ngDoCheck() {
if (this._differ && this._needsUpdate) {
// TODO(mmalerba): We should differentiate needs update due to scrolling and a new portion of
// this list being rendered (can use simpler algorithm) vs needs update due to data actually
// changing (need to do this diff).
const changes = this._differ.diff(this._data);
console.log('here', changes);
if (!changes) {
this._updateContext();
} else {
this._applyChanges(changes);
}
this._needsUpdate = false;
}
}
ngOnDestroy() {
this._viewport.detach();
this._dataSourceChanges.complete();
this.viewChange.complete();
for (let view of this._templateCache) {
view.destroy();
}
}
/** React to scroll state changes in the viewport. */
private _onRenderedRangeChange(renderedRange: ListRange) {
this._renderedRange = renderedRange;
this.viewChange.next(this._renderedRange);
this._renderedItems = this._data.slice(this._renderedRange.start, this._renderedRange.end);
if (!this._differ) {
this._differ = this._differs.find(this._data).create(this.cdkVirtualForTrackBy);
}
this._needsUpdate = true;
}
/** Swap out one `DataSource` for another. */
private _changeDataSource(oldDs: DataSource<T> | null, newDs: DataSource<T>): Observable<T[]> {
if (oldDs) {
oldDs.disconnect(this);
}
this._needsUpdate = true;
return newDs.connect(this);
}
/** Update the `CdkVirtualForOfContext` for all views. */
private _updateContext() {
const count = this._data.length;
let i = this._viewContainerRef.length;
while (i--) {
let view = this._viewContainerRef.get(i) as EmbeddedViewRef<CdkVirtualForOfContext<T>>;
view.context.index = this._renderedRange.start + i;
view.context.count = count;
this._updateComputedContextProperties(view.context);
view.detectChanges();
}
}
/** Apply changes to the DOM. */
private _applyChanges(changes: IterableChanges<T>) {
// Rearrange the views to put them in the right location.
changes.forEachOperation(
(record: IterableChangeRecord<T>, adjustedPreviousIndex: number, currentIndex: number) => {
if (record.previousIndex == null) { // Item added.
const view = this._getViewForNewItem();
this._viewContainerRef.insert(view, currentIndex);
view.context.$implicit = record.item;
} else if (currentIndex == null) { // Item removed.
this._cacheView(this._viewContainerRef.detach(adjustedPreviousIndex) as
EmbeddedViewRef<CdkVirtualForOfContext<T>>);
} else { // Item moved.
const view = this._viewContainerRef.get(adjustedPreviousIndex) as
EmbeddedViewRef<CdkVirtualForOfContext<T>>;
this._viewContainerRef.move(view, currentIndex);
view.context.$implicit = record.item;
}
});
// Update $implicit for any items that had an identity change.
changes.forEachIdentityChange((record: IterableChangeRecord<T>) => {
const view = this._viewContainerRef.get(record.currentIndex!) as
EmbeddedViewRef<CdkVirtualForOfContext<T>>;
view.context.$implicit = record.item;
});
// Update the context variables on all items.
const count = this._data.length;
let i = this._viewContainerRef.length;
while (i--) {
const view = this._viewContainerRef.get(i) as EmbeddedViewRef<CdkVirtualForOfContext<T>>;
view.context.index = this._renderedRange.start + i;
view.context.count = count;
this._updateComputedContextProperties(view.context);
}
}
/** Cache the given detached view. */
private _cacheView(view: EmbeddedViewRef<CdkVirtualForOfContext<T>>) {
if (this._templateCache.length < this.cdkVirtualForTemplateCacheSize) {
this._templateCache.push(view);
} else {
view.destroy();
}
}
/** Get a view for a new item, either from the cache or by creating a new one. */
private _getViewForNewItem(): EmbeddedViewRef<CdkVirtualForOfContext<T>> {
return this._templateCache.pop() || this._viewContainerRef.createEmbeddedView(this._template, {
$implicit: null!,
cdkVirtualForOf: this._cdkVirtualForOf,
index: -1,
count: -1,
first: false,
last: false,
odd: false,
even: false
});
}
/** Update the computed properties on the `CdkVirtualForOfContext`. */
private _updateComputedContextProperties(context: CdkVirtualForOfContext<any>) {
context.first = context.index === 0;
context.last = context.index === context.count - 1;
context.even = context.index % 2 === 0;
context.odd = !context.even;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment