From 250eaed6f5b1cd74167d4b8268a12a2a729a2f5c Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Mon, 27 Nov 2017 16:07:55 -0800 Subject: [PATCH 1/5] feat(virtual-scroll): fixed size virtual scroll --- .github/CODEOWNERS | 9 +- src/cdk/collections/collection-viewer.ts | 7 +- src/cdk/collections/data-source.ts | 14 + src/cdk/scrolling/for-of.ts | 275 ++++++++++++++++++ src/cdk/scrolling/public-api.ts | 4 +- src/cdk/scrolling/scrolling-module.ts | 21 +- .../scrolling/virtual-scroll-fixed-size.ts | 83 ++++++ src/cdk/scrolling/virtual-scroll-strategy.ts | 20 ++ .../scrolling/virtual-scroll-viewport.html | 8 + .../scrolling/virtual-scroll-viewport.scss | 20 ++ src/cdk/scrolling/virtual-scroll-viewport.ts | 189 ++++++++++++ src/demo-app/demo-app/demo-app.ts | 3 +- src/demo-app/demo-app/demo-module.ts | 18 +- src/demo-app/demo-app/routes.ts | 2 + .../virtual-scroll/virtual-scroll-demo.html | 12 + .../virtual-scroll/virtual-scroll-demo.scss | 23 ++ .../virtual-scroll/virtual-scroll-demo.ts | 13 + 17 files changed, 704 insertions(+), 17 deletions(-) create mode 100644 src/cdk/scrolling/for-of.ts create mode 100644 src/cdk/scrolling/virtual-scroll-fixed-size.ts create mode 100644 src/cdk/scrolling/virtual-scroll-strategy.ts create mode 100644 src/cdk/scrolling/virtual-scroll-viewport.html create mode 100644 src/cdk/scrolling/virtual-scroll-viewport.scss create mode 100644 src/cdk/scrolling/virtual-scroll-viewport.ts create mode 100644 src/demo-app/virtual-scroll/virtual-scroll-demo.html create mode 100644 src/demo-app/virtual-scroll/virtual-scroll-demo.scss create mode 100644 src/demo-app/virtual-scroll/virtual-scroll-demo.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e526353343ec..f5d63ea99e6e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,7 @@ # Angular Material components /src/lib/* @jelbourn /src/lib/autocomplete/** @kara @crisbeto +/src/lib/badge/** @amcdnl /src/lib/bottom-sheet/** @jelbourn @crisbeto /src/lib/button-toggle/** @tinayuangao /src/lib/button/** @tinayuangao @@ -32,7 +33,6 @@ /src/lib/tabs/** @andrewseguin /src/lib/toolbar/** @devversion /src/lib/tooltip/** @andrewseguin -/src/lib/badge/** @amcdnl # Angular Material core /src/lib/core/* @jelbourn @@ -41,10 +41,10 @@ /src/lib/core/datetime/** @mmalerba /src/lib/core/error/** @crisbeto @mmalerba /src/lib/core/gestures/** @jelbourn +/src/lib/core/label/** @kara @mmalerba /src/lib/core/line/** @jelbourn /src/lib/core/option/** @kara @crisbeto /src/lib/core/placeholder/** @kara @mmalerba -/src/lib/core/label/** @kara @mmalerba /src/lib/core/ripple/** @devversion /src/lib/core/selection/** @tinayuangao @jelbourn /src/lib/core/selection/pseudo*/** @crisbeto @jelbourn @@ -90,8 +90,9 @@ /src/demo-app/* @jelbourn /src/demo-app/a11y/** @tinayuangao /src/demo-app/autocomplete/** @kara @crisbeto -/src/demo-app/bottom-sheet/** @jelbourn @crisbeto +/src/demo-app/badge/** @amcdnl /src/demo-app/baseline/** @mmalerba +/src/demo-app/bottom-sheet/** @jelbourn @crisbeto /src/demo-app/button-toggle/** @tinayuangao /src/demo-app/button/** @tinayuangao /src/demo-app/card/** @jelbourn @@ -130,7 +131,7 @@ /src/demo-app/toolbar/** @devversion /src/demo-app/tooltip/** @andrewseguin /src/demo-app/typography/** @crisbeto -/src/demo-app/badge/** @amcdnl +/src/demo-app/virtual-scroll/** @mmalerba # E2E app /e2e/* @jelbourn diff --git a/src/cdk/collections/collection-viewer.ts b/src/cdk/collections/collection-viewer.ts index 63543da18a45..e89a1eac0db8 100644 --- a/src/cdk/collections/collection-viewer.ts +++ b/src/cdk/collections/collection-viewer.ts @@ -8,6 +8,11 @@ import {Observable} from 'rxjs/Observable'; + +/** Represents a range of numbers with a specified start and end. */ +export type Range = {start: number, end: number}; + + /** * Interface for any component that provides a view of some data collection and wants to provide * information regarding the view and any changes made. @@ -17,5 +22,5 @@ export interface CollectionViewer { * A stream that emits whenever the `CollectionViewer` starts looking at a new portion of the * data. The `start` index is inclusive, while the `end` is exclusive. */ - viewChange: Observable<{start: number, end: number}>; + viewChange: Observable; } diff --git a/src/cdk/collections/data-source.ts b/src/cdk/collections/data-source.ts index b872d1c49cec..17129c125585 100644 --- a/src/cdk/collections/data-source.ts +++ b/src/cdk/collections/data-source.ts @@ -7,8 +7,10 @@ */ import {Observable} from 'rxjs/Observable'; +import {of} from 'rxjs/observable/of'; import {CollectionViewer} from './collection-viewer'; + export abstract class DataSource { /** * Connects a collection viewer (such as a data-table) to this data source. Note that @@ -29,3 +31,15 @@ export abstract class DataSource { */ abstract disconnect(collectionViewer: CollectionViewer): void; } + + +/** DataSource wrapper for a native array. */ +export class ArrayDataSource implements DataSource { + constructor(private _data: T[]) {} + + connect(): Observable { + return of(this._data); + } + + disconnect() {} +} diff --git a/src/cdk/scrolling/for-of.ts b/src/cdk/scrolling/for-of.ts new file mode 100644 index 000000000000..3a55be972ebe --- /dev/null +++ b/src/cdk/scrolling/for-of.ts @@ -0,0 +1,275 @@ +/** + * @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, Range} from '@angular/cdk/collections'; +import { + Directive, + DoCheck, + EmbeddedViewRef, + Host, + Input, + IterableChangeRecord, + IterableChanges, + IterableDiffer, + IterableDiffers, + NgIterable, + OnDestroy, + TemplateRef, + TrackByFunction, + ViewContainerRef, +} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import {pairwise} from 'rxjs/operators/pairwise'; +import {shareReplay} from 'rxjs/operators/shareReplay'; +import {startWith} from 'rxjs/operators/startWith'; +import {switchMap} from 'rxjs/operators/switchMap'; +import {Subject} from 'rxjs/Subject'; +import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; + + +/** The context for an item rendered by `CdkForOf` */ +export class CdkForOfContext { + constructor(public $implicit: T, public cdkForOf: NgIterable | DataSource, + public index: number, public count: number) {} + + get first(): boolean { return this.index === 0; } + + get last(): boolean { return this.index === this.count - 1; } + + get even(): boolean { return this.index % 2 === 0; } + + get odd(): boolean { return !this.even; } +} + + +type RecordViewTuple = { + record: IterableChangeRecord | null, + view?: EmbeddedViewRef> +}; + + +/** + * A directive similar to `ngForOf` to be used for rendering data inside a virtual scrolling + * container. + */ +@Directive({ + selector: '[cdkFor][cdkForOf]', +}) +export class CdkForOf implements CollectionViewer, DoCheck, OnDestroy { + /** Emits when the rendered view of the data changes. */ + viewChange = new Subject(); + + /** Emits when the data source changes. */ + private _dataSourceSubject = new Subject>(); + + /** The DataSource to display. */ + @Input() + get cdkForOf(): NgIterable | DataSource { return this._cdkForOf; } + set cdkForOf(value: NgIterable | DataSource) { + this._cdkForOf = value; + let ds = value instanceof DataSource ? value : + new ArrayDataSource(Array.prototype.slice.call(value)); + this._dataSourceSubject.next(ds); + } + _cdkForOf: NgIterable | DataSource; + + /** The trackBy function to use for tracking elements. */ + @Input() + get cdkForTrackBy(): TrackByFunction { + return this._cdkForOfTrackBy; + } + set cdkForTrackBy(fn: TrackByFunction) { + this._needsUpdate = true; + this._cdkForOfTrackBy = + (index, item) => fn(index + (this._renderedRange ? this._renderedRange.start : 0), item); + } + private _cdkForOfTrackBy: TrackByFunction; + + /** The template used to stamp out new elements. */ + @Input() + set cdkForTemplate(value: TemplateRef>) { + if (value) { + this._needsUpdate = true; + this._template = value; + } + } + + /** Emits whenever the data in the current DataSource changes. */ + dataStream: Observable = this._dataSourceSubject + .pipe( + startWith(null!), + pairwise(), + switchMap(([prev, cur]) => this._changeDataSource(prev, cur)), + shareReplay(1)); + + private _differ: IterableDiffer | null = null; + + private _data: T[]; + + private _renderedItems: T[]; + + private _renderedRange: Range; + + private _templateCache: EmbeddedViewRef>[] = []; + + private _needsUpdate = false; + + constructor( + private _viewContainerRef: ViewContainerRef, + private _template: TemplateRef>, + private _differs: IterableDiffers, + @Host() private _viewport: CdkVirtualScrollViewport) { + this.dataStream.subscribe(data => this._data = data); + this._viewport.renderedRangeStream.subscribe(range => this._onRenderedRangeChange(range)); + this._viewport.connect(this); + } + + /** + * Get the client rect for the given index. + * @param index The index of the data element whose client rect we want to measure. + * @return The combined client rect for all DOM elements rendered as part of the given index. + * Or null if no DOM elements are rendered for the given index. + * @throws If the given index is not in the rendered range. + */ + measureClientRect(index: number): ClientRect | null { + if (index < this._renderedRange.start || index >= this._renderedRange.end) { + throw Error('Error: attempted to measure an element that isn\'t rendered.'); + } + index -= this._renderedRange.start; + let view = this._viewContainerRef.get(index) as EmbeddedViewRef> | null; + if (view && view.rootNodes.length) { + let minTop = Infinity; + let minLeft = Infinity; + let maxBottom = -Infinity; + let maxRight = -Infinity; + + // There may be multiple root DOM elements for a single data element, so we merge their rects. + for (let i = 0, ilen = view.rootNodes.length; i < ilen; i++) { + let rect = (view.rootNodes[i] as Element).getBoundingClientRect(); + minTop = Math.min(minTop, rect.top); + minLeft = Math.min(minLeft, rect.left); + maxBottom = Math.max(maxBottom, rect.bottom); + maxRight = Math.max(maxRight, rect.right); + } + + return { + top: minTop, + left: minLeft, + bottom: maxBottom, + right: maxRight, + height: maxBottom - minTop, + width: maxRight - minLeft + }; + } + return null; + } + + ngDoCheck() { + if (this._differ && this._needsUpdate) { + const changes = this._differ.diff(this._renderedItems); + this._applyChanges(changes); + this._needsUpdate = false; + } + } + + ngOnDestroy() { + this._viewport.disconnect(); + + this._dataSourceSubject.complete(); + this.viewChange.complete(); + + for (let view of this._templateCache) { + view.destroy(); + } + } + + /** React to scroll state changes in the viewport. */ + private _onRenderedRangeChange(renderedRange: Range) { + 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._renderedItems).create(this.cdkForTrackBy); + } + this._needsUpdate = true; + } + + /** Swap out one `DataSource` for another. */ + private _changeDataSource(oldDs: DataSource | null, newDs: DataSource): Observable { + if (oldDs) { + oldDs.disconnect(this); + } + this._needsUpdate = true; + return newDs.connect(this); + } + + /** Apply changes to the DOM. */ + private _applyChanges(changes: IterableChanges | null) { + // If there are no changes, just update the index and count on the view context and be done. + if (!changes) { + for (let i = 0, len = this._viewContainerRef.length; i < len; i++) { + let view = this._viewContainerRef.get(i) as EmbeddedViewRef>; + view.context.index = this._renderedRange.start + i; + view.context.count = this._data.length; + view.detectChanges(); + } + return; + } + + // Detach all of the views and add them into an array to preserve their original order. + const previousViews: EmbeddedViewRef>[] = []; + for (let i = 0, len = this._viewContainerRef.length; i < len; i++) { + previousViews.unshift( + this._viewContainerRef.detach()! as EmbeddedViewRef>); + } + + // Mark the removed indices so we can recycle their views. + changes.forEachRemovedItem(record => { + this._templateCache.push(previousViews[record.previousIndex!]); + delete previousViews[record.previousIndex!]; + }); + + // Queue up the newly added items to be inserted, recycling views from the cache if possible. + const insertTuples: RecordViewTuple[] = []; + changes.forEachAddedItem(record => { + insertTuples[record.currentIndex!] = {record, view: this._templateCache.pop()}; + }); + + // Queue up moved items to be re-inserted. + changes.forEachMovedItem(record => { + insertTuples[record.currentIndex!] = {record, view: previousViews[record.previousIndex!]}; + delete previousViews[record.previousIndex!]; + }); + + // We have deleted all of the views that were removed or moved from previousViews. What is left + // is the unchanged items that we queue up to be re-inserted. + for (let i = 0, len = previousViews.length; i < len; i++) { + if (previousViews[i]) { + insertTuples[i] = {record: null, view: previousViews[i]}; + } + } + + // We now have a full list of everything to be inserted, so go ahead and insert them. + for (let i = 0, len = insertTuples.length; i < len; i++) { + let {view, record} = insertTuples[i]; + if (view) { + this._viewContainerRef.insert(view); + } else { + view = this._viewContainerRef.createEmbeddedView(this._template, + new CdkForOfContext(null!, this._cdkForOf, -1, -1)); + } + + if (record) { + view.context.$implicit = record.item as T; + } + view.context.index = this._renderedRange.start + i; + view.context.count = this._data.length; + view.detectChanges(); + } + } +} diff --git a/src/cdk/scrolling/public-api.ts b/src/cdk/scrolling/public-api.ts index ffbd65d14e06..32d52364ffd6 100644 --- a/src/cdk/scrolling/public-api.ts +++ b/src/cdk/scrolling/public-api.ts @@ -6,7 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ +export * from './for-of'; export * from './scroll-dispatcher'; export * from './scrollable'; -export * from './viewport-ruler'; export * from './scrolling-module'; +export * from './viewport-ruler'; +export * from './virtual-scroll-viewport'; diff --git a/src/cdk/scrolling/scrolling-module.ts b/src/cdk/scrolling/scrolling-module.ts index 616f29d07581..0ff639749c8f 100644 --- a/src/cdk/scrolling/scrolling-module.ts +++ b/src/cdk/scrolling/scrolling-module.ts @@ -6,15 +6,28 @@ * found in the LICENSE file at https://angular.io/license */ +import {PlatformModule} from '@angular/cdk/platform'; import {NgModule} from '@angular/core'; +import {CdkForOf} from './for-of'; import {SCROLL_DISPATCHER_PROVIDER} from './scroll-dispatcher'; -import {CdkScrollable} from './scrollable'; -import {PlatformModule} from '@angular/cdk/platform'; +import {CdkScrollable} from './scrollable'; +import {CdkVirtualScrollFixedSize} from './virtual-scroll-fixed-size'; +import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; @NgModule({ imports: [PlatformModule], - exports: [CdkScrollable], - declarations: [CdkScrollable], + exports: [ + CdkForOf, + CdkScrollable, + CdkVirtualScrollFixedSize, + CdkVirtualScrollViewport, + ], + declarations: [ + CdkForOf, + CdkScrollable, + CdkVirtualScrollFixedSize, + CdkVirtualScrollViewport, + ], providers: [SCROLL_DISPATCHER_PROVIDER], }) export class ScrollDispatchModule {} diff --git a/src/cdk/scrolling/virtual-scroll-fixed-size.ts b/src/cdk/scrolling/virtual-scroll-fixed-size.ts new file mode 100644 index 000000000000..9a897a6067aa --- /dev/null +++ b/src/cdk/scrolling/virtual-scroll-fixed-size.ts @@ -0,0 +1,83 @@ +import {Range} from '@angular/cdk/collections'; +import {Directive, forwardRef, Input, OnChanges} from '@angular/core'; +import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy'; +import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; + + +/** Virtual scrolling strategy for lists with items of known fixed size. */ +export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { + private _viewport: CdkVirtualScrollViewport; + + constructor(public itemSize: number, public bufferSize: number) {} + + /** Initialize the strategy and specify the viewport it will be working with. */ + init(viewport: CdkVirtualScrollViewport) { + this._viewport = viewport; + this._viewport.totalContentSize = this._viewport.dataLength * this.itemSize; + this._updateRenderedRange(); + } + + /** Re-initialize the strategy with the same viewport. */ + reinit() { + if (this._viewport) { + this.init(this._viewport); + } + } + + onContentScrolled() { + this._updateRenderedRange(); + } + + onDataLengthChanged() { + this._viewport.totalContentSize = this._viewport.dataLength * this.itemSize; + this._updateRenderedRange(); + } + + private _updateRenderedRange() { + const scrollOffset = this._viewport.measureScrollOffset(); + const firstVisibleIndex = Math.floor(scrollOffset / this.itemSize); + const range = this._expandRange( + {start: firstVisibleIndex, end: firstVisibleIndex}, + this.bufferSize, + Math.ceil(this._viewport.viewportSize / this.itemSize) + this.bufferSize); + this._viewport.renderedRange = range; + this._viewport.renderedContentOffset = this.itemSize * range.start; + } + + private _expandRange(range: Range, expandStart: number, expandEnd: number): Range { + const start = Math.max(0, range.start - expandStart); + const end = Math.min(this._viewport.dataLength, range.end + expandEnd); + return {start, end}; + } +} + + +export function _virtualScrollFixedSizeStrategyFactory(fixedSizeDir: CdkVirtualScrollFixedSize) { + return fixedSizeDir._scrollStrategy; +} + + +/** A virtual scroll strategy that supports fixed-size items. */ +@Directive({ + selector: 'cdk-virtual-scroll-viewport[itemSize]', + providers: [{ + provide: VIRTUAL_SCROLL_STRATEGY, + useFactory: _virtualScrollFixedSizeStrategyFactory, + deps: [forwardRef(() => CdkVirtualScrollFixedSize)], + }], +}) +export class CdkVirtualScrollFixedSize implements OnChanges { + /** The size of the items in the list. */ + @Input() itemSize = 20; + + /** The number of extra elements to render on either side of the viewport. */ + @Input() bufferSize = 5; + + _scrollStrategy = new VirtualScrollFixedSizeStrategy(this.itemSize, this.bufferSize); + + ngOnChanges() { + this._scrollStrategy.itemSize = this.itemSize; + this._scrollStrategy.bufferSize = this.bufferSize; + this._scrollStrategy.reinit(); + } +} diff --git a/src/cdk/scrolling/virtual-scroll-strategy.ts b/src/cdk/scrolling/virtual-scroll-strategy.ts new file mode 100644 index 000000000000..2a61769646c9 --- /dev/null +++ b/src/cdk/scrolling/virtual-scroll-strategy.ts @@ -0,0 +1,20 @@ +import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; +import {InjectionToken} from '@angular/core'; + + +/** The injection token used to specify the virtual scrolling strategy. */ +export const VIRTUAL_SCROLL_STRATEGY = + new InjectionToken('VIRTUAL_SCROLL_STRATEGY'); + + +/** A strategy that dictates which items should be rendered in the viewport. */ +export interface VirtualScrollStrategy { + /** Called after the viewport is initialized. */ + init(viewport: CdkVirtualScrollViewport): void; + + /** Called when the viewport is scrolled (debounced using requestAnimationFrame). */ + onContentScrolled(); + + /** Called when the length of the data changes. */ + onDataLengthChanged(); +} diff --git a/src/cdk/scrolling/virtual-scroll-viewport.html b/src/cdk/scrolling/virtual-scroll-viewport.html new file mode 100644 index 000000000000..966e1df6f442 --- /dev/null +++ b/src/cdk/scrolling/virtual-scroll-viewport.html @@ -0,0 +1,8 @@ +
+ +
+
+
diff --git a/src/cdk/scrolling/virtual-scroll-viewport.scss b/src/cdk/scrolling/virtual-scroll-viewport.scss new file mode 100644 index 000000000000..b580afe2b115 --- /dev/null +++ b/src/cdk/scrolling/virtual-scroll-viewport.scss @@ -0,0 +1,20 @@ +cdk-virtual-scroll-viewport { + display: block; + position: relative; + overflow: auto; +} + +cdk-virtual-scroll-sentinel, cdk-virtual-scroll-probe { + position: absolute; +} + +.cdk-virtual-scroll-content-wrapper { + position: absolute; + top: 0; + left: 0; + will-change: contents, transform; +} + +.cdk-virtual-scroll-spacer { + will-change: height, width; +} diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts new file mode 100644 index 000000000000..ca23c0428ff2 --- /dev/null +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -0,0 +1,189 @@ +/** + * @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 {Range} from '@angular/cdk/collections'; +import {CdkForOf} from '@angular/cdk/scrolling/for-of'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DoCheck, + ElementRef, + Inject, + NgZone, + OnDestroy, + OnInit, + ViewChild, + ViewEncapsulation, + Input, +} from '@angular/core'; +import {fromEvent} from 'rxjs/observable/fromEvent'; +import {takeUntil} from 'rxjs/operators/takeUntil'; +import {Subject} from 'rxjs/Subject'; +import {Observable} from 'rxjs/Observable'; +import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy'; + + +/** A viewport that virtualizes it's scrolling with the help of `CdkForOf`. */ +@Component({ + moduleId: module.id, + selector: 'cdk-virtual-scroll-viewport', + templateUrl: 'virtual-scroll-viewport.html', + styleUrls: ['virtual-scroll-viewport.css'], + host: { + 'class': 'cdk-virtual-scroll-viewport', + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + preserveWhitespaces: false, +}) +export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { + private _disconnectSubject = new Subject(); + + private _renderedRangeSubject = new Subject(); + + /** The direction the viewport scrolls. */ + @Input() orientation: 'horizontal' | 'vertical' = 'vertical'; + + /** The element that wraps the rendered content. */ + @ViewChild('contentWrapper') _contentWrapper: ElementRef; + + /** The total size of all content, including content that is not currently rendered. */ + get totalContentSize() { return this._totalContentSize; } + set totalContentSize(size: number) { + if (this._totalContentSize != size) { + this._ngZone.run(() => { + this._totalContentSize = size; + this._changeDetectorRef.markForCheck(); + }); + } + } + private _totalContentSize = 0; + + /** The currently rendered range of indices. */ + get renderedRange() { return this._renderedRange; } + set renderedRange(range: Range) { + if (!this._rangesEqual(this._renderedRange, range)) { + this._ngZone.run(() => { + this._renderedRangeSubject.next(this._renderedRange = range); + this._changeDetectorRef.markForCheck(); + }); + } + } + private _renderedRange: Range = {start: 0, end: 0}; + + /** The offset of the rendered portion of the data from the start. */ + get renderedContentOffset() { return this._renderedContentOffset; } + set renderedContentOffset(offset) { + if (this._renderedContentOffset != offset) { + this._ngZone.run(() => { + this._renderedContentOffset = offset; + this._renderedContentTransform = this.orientation === 'horizontal' ? + `translateX(${offset}px)`: `translateY(${offset}px)`; + this._changeDetectorRef.markForCheck(); + }); + } + } + private _renderedContentOffset = 0; + + /** The length of the data connected to this viewport. */ + get dataLength() { return this._dataLength; } + private _dataLength = 0; + + /** The size of the viewport. */ + get viewportSize() { return this._viewportSize; } + private _viewportSize = 0; + + /** A stream that emits whenever the rendered range changes. */ + renderedRangeStream: Observable = this._renderedRangeSubject.asObservable(); + + _renderedContentTransform: string; + + private _connected = false; + + private _scrollHandledStatus: 'needed' | 'pending' | 'done' = 'done'; + + private _scrollStrategyInited = false; + + constructor(public elementRef: ElementRef, private _changeDetectorRef: ChangeDetectorRef, + private _ngZone: NgZone, + @Inject(VIRTUAL_SCROLL_STRATEGY) private _scrollStrategy: VirtualScrollStrategy) {} + + /** Connect a `CdkForOf` to this viewport. */ + connect(forOf: CdkForOf) { + if (this._connected) { + throw Error('CdkVirtualScrollViewport is already connected.'); + } + + this._connected = true; + forOf.dataStream.pipe(takeUntil(this._disconnectSubject)).subscribe(data => { + const len = data.length; + if (len != this._dataLength) { + this._dataLength = len; + if (this._scrollStrategyInited) { + this._scrollStrategy.onDataLengthChanged(); + } + } + }); + } + + /** Disconnect the current `CdkForOf`. */ + disconnect() { + this._connected = false; + this._disconnectSubject.next(); + } + + /** Gets the current scroll offset of the viewport. */ + measureScrollOffset() { + return this.orientation === 'horizontal' ? + this.elementRef.nativeElement.scrollLeft : this.elementRef.nativeElement.scrollTop; + } + + ngOnInit() { + Promise.resolve().then(() => { + this._viewportSize = this.orientation === 'horizontal' ? + this.elementRef.nativeElement.clientWidth : this.elementRef.nativeElement.clientHeight; + fromEvent(this.elementRef.nativeElement, 'scroll').subscribe(() => { + this._markScrolled(); + }); + this._scrollStrategy.init(this); + this._scrollStrategyInited = true; + }); + } + + ngDoCheck() { + if (this._scrollHandledStatus === 'needed') { + this._scrollHandledStatus = 'pending'; + this._ngZone.runOutsideAngular(() => requestAnimationFrame(() => { + this._scrollHandledStatus = 'done'; + this._scrollStrategy.onContentScrolled(); + })); + } + } + + ngOnDestroy() { + this.disconnect(); + + // Complete all subjects + this._disconnectSubject.complete(); + this._renderedRangeSubject.complete(); + } + + private _markScrolled() { + if (this._scrollHandledStatus === 'done') { + this._ngZone.run(() => { + this._scrollHandledStatus = 'needed'; + this._changeDetectorRef.markForCheck(); + }); + } + } + + private _rangesEqual(r1: Range, r2: Range): boolean { + return r1.start == r2.start && r1.end == r2.end; + } +} diff --git a/src/demo-app/demo-app/demo-app.ts b/src/demo-app/demo-app/demo-app.ts index 3eb21c9ec0ce..f1952f5a81ae 100644 --- a/src/demo-app/demo-app/demo-app.ts +++ b/src/demo-app/demo-app/demo-app.ts @@ -88,7 +88,8 @@ export class DemoApp { {name: 'Tabs', route: '/tabs'}, {name: 'Toolbar', route: '/toolbar'}, {name: 'Tooltip', route: '/tooltip'}, - {name: 'Typography', route: '/typography'} + {name: 'Typography', route: '/typography'}, + {name: 'Virtual Scrolling', route: '/virtual-scroll'}, ]; constructor( diff --git a/src/demo-app/demo-app/demo-module.ts b/src/demo-app/demo-app/demo-module.ts index 4b61a3c8589d..1f797ab00d1b 100644 --- a/src/demo-app/demo-app/demo-module.ts +++ b/src/demo-app/demo-app/demo-module.ts @@ -6,14 +6,16 @@ * found in the LICENSE file at https://angular.io/license */ +import {LayoutModule} from '@angular/cdk/layout'; import {FullscreenOverlayContainer, OverlayContainer} from '@angular/cdk/overlay'; import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {RouterModule} from '@angular/router'; import {AutocompleteDemo} from '../autocomplete/autocomplete-demo'; -import {BottomSheetDemo, ExampleBottomSheet} from '../bottom-sheet/bottom-sheet-demo'; +import {BadgeDemo} from '../badge/badge-demo'; import {BaselineDemo} from '../baseline/baseline-demo'; +import {BottomSheetDemo, ExampleBottomSheet} from '../bottom-sheet/bottom-sheet-demo'; import {ButtonToggleDemo} from '../button-toggle/button-toggle-demo'; import {ButtonDemo} from '../button/button-demo'; import {CardDemo} from '../card/card-demo'; @@ -44,24 +46,27 @@ import {ProgressBarDemo} from '../progress-bar/progress-bar-demo'; import {ProgressSpinnerDemo} from '../progress-spinner/progress-spinner-demo'; import {RadioDemo} from '../radio/radio-demo'; import {RippleDemo} from '../ripple/ripple-demo'; +import {ScreenTypeDemo} from '../screen-type/screen-type-demo'; import {SelectDemo} from '../select/select-demo'; import {SidenavDemo} from '../sidenav/sidenav-demo'; import {SlideToggleDemo} from '../slide-toggle/slide-toggle-demo'; import {SliderDemo} from '../slider/slider-demo'; import {SnackBarDemo} from '../snack-bar/snack-bar-demo'; import {StepperDemo} from '../stepper/stepper-demo'; -import {ScreenTypeDemo} from '../screen-type/screen-type-demo'; -import {LayoutModule} from '@angular/cdk/layout'; +import {TableDemoModule} from '../table/table-demo-module'; import { - FoggyTabContent, RainyTabContent, SunnyTabContent, TabsDemo, Counter + Counter, + FoggyTabContent, + RainyTabContent, + SunnyTabContent, + TabsDemo } from '../tabs/tabs-demo'; import {ToolbarDemo} from '../toolbar/toolbar-demo'; import {TooltipDemo} from '../tooltip/tooltip-demo'; import {TypographyDemo} from '../typography/typography-demo'; +import {VirtualScrollDemo} from '../virtual-scroll/virtual-scroll-demo'; import {DemoApp, Home} from './demo-app'; import {DEMO_APP_ROUTES} from './routes'; -import {TableDemoModule} from '../table/table-demo-module'; -import {BadgeDemo} from '../badge/badge-demo'; @NgModule({ imports: [ @@ -127,6 +132,7 @@ import {BadgeDemo} from '../badge/badge-demo'; ToolbarDemo, TooltipDemo, TypographyDemo, + VirtualScrollDemo, ExampleBottomSheet, ], providers: [ diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index b7c80e5ae2a1..e980e235fcb8 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -48,6 +48,7 @@ import {TabsDemo} from '../tabs/tabs-demo'; import {ToolbarDemo} from '../toolbar/toolbar-demo'; import {TooltipDemo} from '../tooltip/tooltip-demo'; import {TypographyDemo} from '../typography/typography-demo'; +import {VirtualScrollDemo} from '../virtual-scroll/virtual-scroll-demo'; import {DemoApp, Home} from './demo-app'; import {TableDemoPage} from '../table/table-demo-page'; import {TABLE_DEMO_ROUTES} from '../table/routes'; @@ -98,6 +99,7 @@ export const DEMO_APP_ROUTES: Routes = [ {path: 'expansion', component: ExpansionDemo}, {path: 'stepper', component: StepperDemo}, {path: 'screen-type', component: ScreenTypeDemo}, + {path: 'virtual-scroll', component: VirtualScrollDemo}, ]} ]; diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.html b/src/demo-app/virtual-scroll/virtual-scroll-demo.html new file mode 100644 index 000000000000..5cab664a513a --- /dev/null +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.html @@ -0,0 +1,12 @@ + +
+ Item #{{i}} - ({{size}}px) +
+
+ + +
+ Item #{{i}} - ({{size}}px) +
+
diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.scss b/src/demo-app/virtual-scroll/virtual-scroll-demo.scss new file mode 100644 index 000000000000..5cab28d45edf --- /dev/null +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.scss @@ -0,0 +1,23 @@ +.demo-viewport { + height: 500px; + width: 500px; + border: 1px solid black; + + .cdk-virtual-scroll-content-wrapper { + display: flex; + flex-direction: column; + width: 100%; + } +} + +.demo-horizontal { + .cdk-virtual-scroll-content-wrapper { + flex-direction: row; + width: auto; + height: 100%; + } + + .demo-item { + writing-mode: vertical-lr; + } +} diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts new file mode 100644 index 000000000000..17445fb67dcb --- /dev/null +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts @@ -0,0 +1,13 @@ +import {Component, ViewEncapsulation} from '@angular/core'; + + +@Component({ + moduleId: module.id, + selector: 'virtual-scroll-demo', + templateUrl: 'virtual-scroll-demo.html', + styleUrls: ['virtual-scroll-demo.css'], + encapsulation: ViewEncapsulation.None, +}) +export class VirtualScrollDemo { + data = Array(10000).fill(20); +} From eb1db06eac5d2c633487690110794a5a991a96ca Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 16 Jan 2018 14:18:34 -0800 Subject: [PATCH 2/5] address some comments --- src/cdk/collections/data-source.ts | 13 -- src/cdk/collections/public-api.ts | 1 + .../collections/static-array-data-source.ts | 23 +++ src/cdk/scrolling/public-api.ts | 2 +- src/cdk/scrolling/scrolling-module.ts | 6 +- .../{for-of.ts => virtual-for-of.ts} | 174 ++++++++++++------ .../scrolling/virtual-scroll-viewport.scss | 4 - src/cdk/scrolling/virtual-scroll-viewport.ts | 20 +- .../virtual-scroll/virtual-scroll-demo.html | 4 +- 9 files changed, 154 insertions(+), 93 deletions(-) create mode 100644 src/cdk/collections/static-array-data-source.ts rename src/cdk/scrolling/{for-of.ts => virtual-for-of.ts} (54%) diff --git a/src/cdk/collections/data-source.ts b/src/cdk/collections/data-source.ts index 17129c125585..d945a137ba92 100644 --- a/src/cdk/collections/data-source.ts +++ b/src/cdk/collections/data-source.ts @@ -7,7 +7,6 @@ */ import {Observable} from 'rxjs/Observable'; -import {of} from 'rxjs/observable/of'; import {CollectionViewer} from './collection-viewer'; @@ -31,15 +30,3 @@ export abstract class DataSource { */ abstract disconnect(collectionViewer: CollectionViewer): void; } - - -/** DataSource wrapper for a native array. */ -export class ArrayDataSource implements DataSource { - constructor(private _data: T[]) {} - - connect(): Observable { - return of(this._data); - } - - disconnect() {} -} diff --git a/src/cdk/collections/public-api.ts b/src/cdk/collections/public-api.ts index f18860e3756c..dce328ee73ec 100644 --- a/src/cdk/collections/public-api.ts +++ b/src/cdk/collections/public-api.ts @@ -9,6 +9,7 @@ export * from './collection-viewer'; export * from './data-source'; export * from './selection'; +export * from './static-array-data-source'; export { UniqueSelectionDispatcher, UniqueSelectionDispatcherListener, diff --git a/src/cdk/collections/static-array-data-source.ts b/src/cdk/collections/static-array-data-source.ts new file mode 100644 index 000000000000..cd458033e877 --- /dev/null +++ b/src/cdk/collections/static-array-data-source.ts @@ -0,0 +1,23 @@ +/** + * @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 {Observable} from 'rxjs/Observable'; +import {of as observableOf} from 'rxjs/observable/of'; +import {DataSource} from './data-source'; + + +/** DataSource wrapper for a native array. */ +export class StaticArrayDataSource implements DataSource { + constructor(private _data: T[]) {} + + connect(): Observable { + return observableOf(this._data); + } + + disconnect() {} +} diff --git a/src/cdk/scrolling/public-api.ts b/src/cdk/scrolling/public-api.ts index 32d52364ffd6..646c2bb47427 100644 --- a/src/cdk/scrolling/public-api.ts +++ b/src/cdk/scrolling/public-api.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -export * from './for-of'; +export * from './virtual-for-of'; export * from './scroll-dispatcher'; export * from './scrollable'; export * from './scrolling-module'; diff --git a/src/cdk/scrolling/scrolling-module.ts b/src/cdk/scrolling/scrolling-module.ts index 0ff639749c8f..67f4c588c85a 100644 --- a/src/cdk/scrolling/scrolling-module.ts +++ b/src/cdk/scrolling/scrolling-module.ts @@ -8,7 +8,7 @@ import {PlatformModule} from '@angular/cdk/platform'; import {NgModule} from '@angular/core'; -import {CdkForOf} from './for-of'; +import {CdkVirtualForOf} from './virtual-for-of'; import {SCROLL_DISPATCHER_PROVIDER} from './scroll-dispatcher'; import {CdkScrollable} from './scrollable'; import {CdkVirtualScrollFixedSize} from './virtual-scroll-fixed-size'; @@ -17,13 +17,13 @@ import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; @NgModule({ imports: [PlatformModule], exports: [ - CdkForOf, + CdkVirtualForOf, CdkScrollable, CdkVirtualScrollFixedSize, CdkVirtualScrollViewport, ], declarations: [ - CdkForOf, + CdkVirtualForOf, CdkScrollable, CdkVirtualScrollFixedSize, CdkVirtualScrollViewport, diff --git a/src/cdk/scrolling/for-of.ts b/src/cdk/scrolling/virtual-for-of.ts similarity index 54% rename from src/cdk/scrolling/for-of.ts rename to src/cdk/scrolling/virtual-for-of.ts index 3a55be972ebe..3a451cb14595 100644 --- a/src/cdk/scrolling/for-of.ts +++ b/src/cdk/scrolling/virtual-for-of.ts @@ -6,12 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ -import {ArrayDataSource, CollectionViewer, DataSource, Range} from '@angular/cdk/collections'; +import {CollectionViewer, DataSource, Range, StaticArrayDataSource} from '@angular/cdk/collections'; import { Directive, DoCheck, EmbeddedViewRef, - Host, Input, IterableChangeRecord, IterableChanges, @@ -19,6 +18,7 @@ import { IterableDiffers, NgIterable, OnDestroy, + SkipSelf, TemplateRef, TrackByFunction, ViewContainerRef, @@ -32,24 +32,22 @@ import {Subject} from 'rxjs/Subject'; import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; -/** The context for an item rendered by `CdkForOf` */ -export class CdkForOfContext { - constructor(public $implicit: T, public cdkForOf: NgIterable | DataSource, - public index: number, public count: number) {} - - get first(): boolean { return this.index === 0; } - - get last(): boolean { return this.index === this.count - 1; } - - get even(): boolean { return this.index % 2 === 0; } - - get odd(): boolean { return !this.even; } -} +/** The context for an item rendered by `CdkVirtualForOf` */ +export type CdkVirtualForOfContext = { + $implicit: T; + cdkVirtualForOf: NgIterable | DataSource; + index: number; + count: number; + first: boolean; + last: boolean; + even: boolean; + odd: boolean; +}; type RecordViewTuple = { record: IterableChangeRecord | null, - view?: EmbeddedViewRef> + view?: EmbeddedViewRef> }; @@ -58,41 +56,45 @@ type RecordViewTuple = { * container. */ @Directive({ - selector: '[cdkFor][cdkForOf]', + selector: '[cdkVirtualFor][cdkVirtualForOf]', }) -export class CdkForOf implements CollectionViewer, DoCheck, OnDestroy { +export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy { /** Emits when the rendered view of the data changes. */ viewChange = new Subject(); - /** Emits when the data source changes. */ - private _dataSourceSubject = new Subject>(); + /** Subject that emits when a new DataSource instance is given. */ + private _dataSourceChanges = new Subject>(); /** The DataSource to display. */ @Input() - get cdkForOf(): NgIterable | DataSource { return this._cdkForOf; } - set cdkForOf(value: NgIterable | DataSource) { - this._cdkForOf = value; - let ds = value instanceof DataSource ? value : - new ArrayDataSource(Array.prototype.slice.call(value)); - this._dataSourceSubject.next(ds); + get cdkVirtualForOf(): NgIterable | DataSource { return this._cdkVirtualForOf; } + set cdkVirtualForOf(value: NgIterable | DataSource) { + this._cdkVirtualForOf = value; + const ds = value instanceof DataSource ? value : + // Slice the value since NgIterable may be array-like rather than an array. + new StaticArrayDataSource(Array.prototype.slice.call(value)); + this._dataSourceChanges.next(ds); } - _cdkForOf: NgIterable | DataSource; + _cdkVirtualForOf: NgIterable | DataSource; - /** The trackBy function to use for tracking elements. */ + /** + * 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 cdkForTrackBy(): TrackByFunction { - return this._cdkForOfTrackBy; + get cdkVirtualForTrackBy(): TrackByFunction { + return this._cdkVirtualForTrackBy; } - set cdkForTrackBy(fn: TrackByFunction) { + set cdkVirtualForTrackBy(fn: TrackByFunction) { this._needsUpdate = true; - this._cdkForOfTrackBy = + this._cdkVirtualForTrackBy = (index, item) => fn(index + (this._renderedRange ? this._renderedRange.start : 0), item); } - private _cdkForOfTrackBy: TrackByFunction; + private _cdkVirtualForTrackBy: TrackByFunction; /** The template used to stamp out new elements. */ @Input() - set cdkForTemplate(value: TemplateRef>) { + set cdkVirtualForTemplate(value: TemplateRef>) { if (value) { this._needsUpdate = true; this._template = value; @@ -100,30 +102,50 @@ export class CdkForOf implements CollectionViewer, DoCheck, OnDestroy { } /** Emits whenever the data in the current DataSource changes. */ - dataStream: Observable = this._dataSourceSubject + dataStream: Observable = 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 | 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: Range; - private _templateCache: EmbeddedViewRef>[] = []; + /** + * 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>[] = []; + /** 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, - private _template: TemplateRef>, + /** The template to use when stamping out new items. */ + private _template: TemplateRef>, + /** The set of available differs. */ private _differs: IterableDiffers, - @Host() private _viewport: CdkVirtualScrollViewport) { + /** 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.connect(this); @@ -138,18 +160,22 @@ export class CdkForOf implements CollectionViewer, DoCheck, OnDestroy { */ measureClientRect(index: number): ClientRect | null { if (index < this._renderedRange.start || index >= this._renderedRange.end) { - throw Error('Error: attempted to measure an element that isn\'t rendered.'); + throw Error(`Error: attempted to measure an element that isn't rendered.`); } - index -= this._renderedRange.start; - let view = this._viewContainerRef.get(index) as EmbeddedViewRef> | null; + const renderedIndex = index - this._renderedRange.start; + let view = this._viewContainerRef.get(renderedIndex) as + EmbeddedViewRef> | null; if (view && view.rootNodes.length) { + // There may be multiple root DOM elements for a single data element, so we merge their rects. + // These variables keep track of the minimum top and left as well as maximum bottom and right + // that we have encoutnered on any rectangle, so that we can merge the results into the + // smallest possible rect that contains all of the root rects. let minTop = Infinity; let minLeft = Infinity; let maxBottom = -Infinity; let maxRight = -Infinity; - // There may be multiple root DOM elements for a single data element, so we merge their rects. - for (let i = 0, ilen = view.rootNodes.length; i < ilen; i++) { + for (let i = view.rootNodes.length - 1; i >= 0 ; i--) { let rect = (view.rootNodes[i] as Element).getBoundingClientRect(); minTop = Math.min(minTop, rect.top); minLeft = Math.min(minLeft, rect.left); @@ -172,7 +198,11 @@ export class CdkForOf implements CollectionViewer, DoCheck, OnDestroy { ngDoCheck() { if (this._differ && this._needsUpdate) { const changes = this._differ.diff(this._renderedItems); - this._applyChanges(changes); + if (!changes) { + this._updateContext(); + } else { + this._applyChanges(changes); + } this._needsUpdate = false; } } @@ -180,7 +210,7 @@ export class CdkForOf implements CollectionViewer, DoCheck, OnDestroy { ngOnDestroy() { this._viewport.disconnect(); - this._dataSourceSubject.complete(); + this._dataSourceChanges.complete(); this.viewChange.complete(); for (let view of this._templateCache) { @@ -194,7 +224,7 @@ export class CdkForOf implements CollectionViewer, DoCheck, OnDestroy { 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._renderedItems).create(this.cdkForTrackBy); + this._differ = this._differs.find(this._renderedItems).create(this.cdkVirtualForTrackBy); } this._needsUpdate = true; } @@ -208,24 +238,24 @@ export class CdkForOf implements CollectionViewer, DoCheck, OnDestroy { return newDs.connect(this); } - /** Apply changes to the DOM. */ - private _applyChanges(changes: IterableChanges | null) { - // If there are no changes, just update the index and count on the view context and be done. - if (!changes) { - for (let i = 0, len = this._viewContainerRef.length; i < len; i++) { - let view = this._viewContainerRef.get(i) as EmbeddedViewRef>; - view.context.index = this._renderedRange.start + i; - view.context.count = this._data.length; - view.detectChanges(); - } - return; + /** Update the `CdkVirtualForOfContext` for all views. */ + private _updateContext() { + for (let i = 0, len = this._viewContainerRef.length; i < len; i++) { + let view = this._viewContainerRef.get(i) as EmbeddedViewRef>; + view.context.index = this._renderedRange.start + i; + view.context.count = this._data.length; + this._updateComputedContextProperties(view.context); + view.detectChanges(); } + } + /** Apply changes to the DOM. */ + private _applyChanges(changes: IterableChanges) { // Detach all of the views and add them into an array to preserve their original order. - const previousViews: EmbeddedViewRef>[] = []; + const previousViews: EmbeddedViewRef>[] = []; for (let i = 0, len = this._viewContainerRef.length; i < len; i++) { previousViews.unshift( - this._viewContainerRef.detach()! as EmbeddedViewRef>); + this._viewContainerRef.detach()! as EmbeddedViewRef>); } // Mark the removed indices so we can recycle their views. @@ -255,13 +285,26 @@ export class CdkForOf implements CollectionViewer, DoCheck, OnDestroy { } // We now have a full list of everything to be inserted, so go ahead and insert them. + this._insertViews(insertTuples); + } + + /** Insert the RecordViewTuples into the container element. */ + private _insertViews(insertTuples: RecordViewTuple[]) { for (let i = 0, len = insertTuples.length; i < len; i++) { let {view, record} = insertTuples[i]; if (view) { this._viewContainerRef.insert(view); } else { - view = this._viewContainerRef.createEmbeddedView(this._template, - new CdkForOfContext(null!, this._cdkForOf, -1, -1)); + view = this._viewContainerRef.createEmbeddedView(this._template, { + $implicit: null!, + cdkVirtualForOf: this._cdkVirtualForOf, + index: -1, + count: -1, + first: false, + last: false, + odd: false, + even: false + }); } if (record) { @@ -269,7 +312,16 @@ export class CdkForOf implements CollectionViewer, DoCheck, OnDestroy { } view.context.index = this._renderedRange.start + i; view.context.count = this._data.length; + this._updateComputedContextProperties(view.context); view.detectChanges(); } } + + /** Update the computed properties on the `CdkVirtualForOfContext`. */ + private _updateComputedContextProperties(context: CdkVirtualForOfContext) { + context.first = context.index === 0; + context.last = context.index === context.count - 1; + context.even = context.index % 2 === 0; + context.odd = !context.even; + } } diff --git a/src/cdk/scrolling/virtual-scroll-viewport.scss b/src/cdk/scrolling/virtual-scroll-viewport.scss index b580afe2b115..da7a53264c98 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.scss +++ b/src/cdk/scrolling/virtual-scroll-viewport.scss @@ -4,10 +4,6 @@ cdk-virtual-scroll-viewport { overflow: auto; } -cdk-virtual-scroll-sentinel, cdk-virtual-scroll-probe { - position: absolute; -} - .cdk-virtual-scroll-content-wrapper { position: absolute; top: 0; diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts index ca23c0428ff2..dabf46ea48fc 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -7,7 +7,7 @@ */ import {Range} from '@angular/cdk/collections'; -import {CdkForOf} from '@angular/cdk/scrolling/for-of'; +import {CdkVirtualForOf} from '@angular/cdk/scrolling/virtual-for-of'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -29,7 +29,7 @@ import {Observable} from 'rxjs/Observable'; import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy'; -/** A viewport that virtualizes it's scrolling with the help of `CdkForOf`. */ +/** A viewport that virtualizes it's scrolling with the help of `CdkVirtualForOf`. */ @Component({ moduleId: module.id, selector: 'cdk-virtual-scroll-viewport', @@ -78,8 +78,8 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { private _renderedRange: Range = {start: 0, end: 0}; /** The offset of the rendered portion of the data from the start. */ - get renderedContentOffset() { return this._renderedContentOffset; } - set renderedContentOffset(offset) { + get renderedContentOffset(): number { return this._renderedContentOffset; } + set renderedContentOffset(offset: number) { if (this._renderedContentOffset != offset) { this._ngZone.run(() => { this._renderedContentOffset = offset; @@ -114,8 +114,8 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { private _ngZone: NgZone, @Inject(VIRTUAL_SCROLL_STRATEGY) private _scrollStrategy: VirtualScrollStrategy) {} - /** Connect a `CdkForOf` to this viewport. */ - connect(forOf: CdkForOf) { + /** Connect a `CdkVirtualForOf` to this viewport. */ + connect(forOf: CdkVirtualForOf) { if (this._connected) { throw Error('CdkVirtualScrollViewport is already connected.'); } @@ -132,7 +132,7 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { }); } - /** Disconnect the current `CdkForOf`. */ + /** Disconnect the current `CdkVirtualForOf`. */ disconnect() { this._connected = false; this._disconnectSubject.next(); @@ -148,8 +148,10 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { Promise.resolve().then(() => { this._viewportSize = this.orientation === 'horizontal' ? this.elementRef.nativeElement.clientWidth : this.elementRef.nativeElement.clientHeight; - fromEvent(this.elementRef.nativeElement, 'scroll').subscribe(() => { - this._markScrolled(); + this._ngZone.runOutsideAngular(() => { + fromEvent(this.elementRef.nativeElement, 'scroll').subscribe(() => { + this._markScrolled(); + }); }); this._scrollStrategy.init(this); this._scrollStrategyInited = true; diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.html b/src/demo-app/virtual-scroll/virtual-scroll-demo.html index 5cab664a513a..afae552a0678 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.html +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.html @@ -1,12 +1,12 @@ -
+
Item #{{i}} - ({{size}}px)
-
+
Item #{{i}} - ({{size}}px)
From 6277388528a6eea145db36844c2cb2c540e064c0 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Mon, 22 Jan 2018 17:03:47 -0800 Subject: [PATCH 3/5] change VirtualScrollStrategy interface a bit --- src/cdk/scrolling/virtual-for-of.ts | 16 +-- .../scrolling/virtual-scroll-fixed-size.ts | 97 +++++++++++++++---- src/cdk/scrolling/virtual-scroll-strategy.ts | 10 +- src/cdk/scrolling/virtual-scroll-viewport.ts | 10 +- 4 files changed, 97 insertions(+), 36 deletions(-) diff --git a/src/cdk/scrolling/virtual-for-of.ts b/src/cdk/scrolling/virtual-for-of.ts index 3a451cb14595..1c567f60d9d9 100644 --- a/src/cdk/scrolling/virtual-for-of.ts +++ b/src/cdk/scrolling/virtual-for-of.ts @@ -252,7 +252,7 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy /** Apply changes to the DOM. */ private _applyChanges(changes: IterableChanges) { // Detach all of the views and add them into an array to preserve their original order. - const previousViews: EmbeddedViewRef>[] = []; + const previousViews: (EmbeddedViewRef> | null)[] = []; for (let i = 0, len = this._viewContainerRef.length; i < len; i++) { previousViews.unshift( this._viewContainerRef.detach()! as EmbeddedViewRef>); @@ -260,8 +260,8 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy // Mark the removed indices so we can recycle their views. changes.forEachRemovedItem(record => { - this._templateCache.push(previousViews[record.previousIndex!]); - delete previousViews[record.previousIndex!]; + this._templateCache.push(previousViews[record.previousIndex!]!); + previousViews[record.previousIndex!] = null; }); // Queue up the newly added items to be inserted, recycling views from the cache if possible. @@ -272,15 +272,15 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy // Queue up moved items to be re-inserted. changes.forEachMovedItem(record => { - insertTuples[record.currentIndex!] = {record, view: previousViews[record.previousIndex!]}; - delete previousViews[record.previousIndex!]; + insertTuples[record.currentIndex!] = {record, view: previousViews[record.previousIndex!]!}; + previousViews[record.previousIndex!] = null; }); - // We have deleted all of the views that were removed or moved from previousViews. What is left - // is the unchanged items that we queue up to be re-inserted. + // We have nulled-out all of the views that were removed or moved from previousViews. What is + // left is the unchanged items that we queue up to be re-inserted. for (let i = 0, len = previousViews.length; i < len; i++) { if (previousViews[i]) { - insertTuples[i] = {record: null, view: previousViews[i]}; + insertTuples[i] = {record: null, view: previousViews[i]!}; } } diff --git a/src/cdk/scrolling/virtual-scroll-fixed-size.ts b/src/cdk/scrolling/virtual-scroll-fixed-size.ts index 9a897a6067aa..30124d9f9479 100644 --- a/src/cdk/scrolling/virtual-scroll-fixed-size.ts +++ b/src/cdk/scrolling/virtual-scroll-fixed-size.ts @@ -6,45 +6,99 @@ import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; /** Virtual scrolling strategy for lists with items of known fixed size. */ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { - private _viewport: CdkVirtualScrollViewport; - - constructor(public itemSize: number, public bufferSize: number) {} + /** The attached viewport. */ + private _viewport: CdkVirtualScrollViewport | null = null; + + /** The size of the items in the virtually scrolling list. */ + private _itemSize: number; + + /** The number of buffer items to render beyond the edge of the viewport. */ + private _bufferSize: number; + + /** + * @param itemSize The size of the items in the virtually scrolling list. + * @param bufferSize he number of buffer items to render beyond the edge of the viewport. + */ + constructor(itemSize: number, bufferSize: number) { + this._itemSize = itemSize; + this._bufferSize = bufferSize; + } - /** Initialize the strategy and specify the viewport it will be working with. */ - init(viewport: CdkVirtualScrollViewport) { + /** + * Attaches this scroll strategy to a viewport. + * @param viewport The viewport to attach this strategy to. + */ + attach(viewport: CdkVirtualScrollViewport) { this._viewport = viewport; - this._viewport.totalContentSize = this._viewport.dataLength * this.itemSize; + this._updateTotalContentSize(); this._updateRenderedRange(); } - /** Re-initialize the strategy with the same viewport. */ - reinit() { - if (this._viewport) { - this.init(this._viewport); - } + /** Detaches this scroll strategy from the currently attached viewport. */ + detach() { + this._viewport = null; } + /** + * Update the item size and buffer size. + * @param itemSize The size of the items in the virtually scrolling list. + * @param bufferSize he number of buffer items to render beyond the edge of the viewport. + */ + updateItemAndBufferSize(itemSize: number, bufferSize: number) { + this._itemSize = itemSize; + this._bufferSize = bufferSize; + this._updateTotalContentSize(); + this._updateRenderedRange(); + } + + /** Called when the viewport is scrolled (debounced using requestAnimationFrame). */ onContentScrolled() { this._updateRenderedRange(); } + /** Called when the length of the data changes. */ onDataLengthChanged() { - this._viewport.totalContentSize = this._viewport.dataLength * this.itemSize; + this._updateTotalContentSize(); this._updateRenderedRange(); } + /** Update the viewport's total content size. */ + private _updateTotalContentSize() { + if (!this._viewport) { + return; + } + + this._viewport.totalContentSize = this._viewport.dataLength * this._itemSize; + }; + + /** Update the viewport's rendered range. */ private _updateRenderedRange() { + if (!this._viewport) { + return; + } + const scrollOffset = this._viewport.measureScrollOffset(); - const firstVisibleIndex = Math.floor(scrollOffset / this.itemSize); + const firstVisibleIndex = Math.floor(scrollOffset / this._itemSize); const range = this._expandRange( {start: firstVisibleIndex, end: firstVisibleIndex}, - this.bufferSize, - Math.ceil(this._viewport.viewportSize / this.itemSize) + this.bufferSize); + this._bufferSize, + Math.ceil(this._viewport.viewportSize / this._itemSize) + this._bufferSize); this._viewport.renderedRange = range; - this._viewport.renderedContentOffset = this.itemSize * range.start; + this._viewport.renderedContentOffset = this._itemSize * range.start; } + /** + * Expand the given range by the given amount in either direction. + * @param range The range to expand + * @param expandStart The number of items to expand the start of the range by. + * @param expandEnd The number of items to expand the end of the range by. + * @return The expanded range. + */ private _expandRange(range: Range, expandStart: number, expandEnd: number): Range { + if (!this._viewport) { + return {...range}; + } + const start = Math.max(0, range.start - expandStart); const end = Math.min(this._viewport.dataLength, range.end + expandEnd); return {start, end}; @@ -52,6 +106,12 @@ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { } +/** + * Provider factory for `VirtualScrollFixedSizeStrategy` that simply extracts the already created + * `VirtualScrollFixedSizeStrategy` from the given directive. + * @param fixedSizeDir The instance of `CdkVirtualScrollFixedSize` to extract the + * `VirtualScrollFixedSizeStrategy` from. + */ export function _virtualScrollFixedSizeStrategyFactory(fixedSizeDir: CdkVirtualScrollFixedSize) { return fixedSizeDir._scrollStrategy; } @@ -73,11 +133,10 @@ export class CdkVirtualScrollFixedSize implements OnChanges { /** The number of extra elements to render on either side of the viewport. */ @Input() bufferSize = 5; + /** The scroll strategy used by this directive. */ _scrollStrategy = new VirtualScrollFixedSizeStrategy(this.itemSize, this.bufferSize); ngOnChanges() { - this._scrollStrategy.itemSize = this.itemSize; - this._scrollStrategy.bufferSize = this.bufferSize; - this._scrollStrategy.reinit(); + this._scrollStrategy.updateItemAndBufferSize(this.itemSize, this.bufferSize); } } diff --git a/src/cdk/scrolling/virtual-scroll-strategy.ts b/src/cdk/scrolling/virtual-scroll-strategy.ts index 2a61769646c9..274481a799c5 100644 --- a/src/cdk/scrolling/virtual-scroll-strategy.ts +++ b/src/cdk/scrolling/virtual-scroll-strategy.ts @@ -9,8 +9,14 @@ export const VIRTUAL_SCROLL_STRATEGY = /** A strategy that dictates which items should be rendered in the viewport. */ export interface VirtualScrollStrategy { - /** Called after the viewport is initialized. */ - init(viewport: CdkVirtualScrollViewport): void; + /** + * Attaches this scroll strategy to a viewport. + * @param viewport The viewport to attach this strategy to. + */ + attach(viewport: CdkVirtualScrollViewport): void; + + /** Detaches this scroll strategy from the currently attached viewport. */ + detach(): void; /** Called when the viewport is scrolled (debounced using requestAnimationFrame). */ onContentScrolled(); diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts index dabf46ea48fc..d5ccaa1d9bb5 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -108,8 +108,6 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { private _scrollHandledStatus: 'needed' | 'pending' | 'done' = 'done'; - private _scrollStrategyInited = false; - constructor(public elementRef: ElementRef, private _changeDetectorRef: ChangeDetectorRef, private _ngZone: NgZone, @Inject(VIRTUAL_SCROLL_STRATEGY) private _scrollStrategy: VirtualScrollStrategy) {} @@ -125,9 +123,7 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { const len = data.length; if (len != this._dataLength) { this._dataLength = len; - if (this._scrollStrategyInited) { - this._scrollStrategy.onDataLengthChanged(); - } + this._scrollStrategy.onDataLengthChanged(); } }); } @@ -153,8 +149,7 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { this._markScrolled(); }); }); - this._scrollStrategy.init(this); - this._scrollStrategyInited = true; + this._scrollStrategy.attach(this); }); } @@ -170,6 +165,7 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { ngOnDestroy() { this.disconnect(); + this._scrollStrategy.detach(); // Complete all subjects this._disconnectSubject.complete(); From d3aa78034609697d989a0c8f0e71342713d2a707 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 23 Jan 2018 09:20:56 -0800 Subject: [PATCH 4/5] address some more comments --- src/cdk/scrolling/virtual-for-of.ts | 35 +++-- .../scrolling/virtual-scroll-fixed-size.ts | 14 +- .../scrolling/virtual-scroll-viewport.html | 12 +- .../scrolling/virtual-scroll-viewport.scss | 6 + src/cdk/scrolling/virtual-scroll-viewport.ts | 135 +++++++++++------- 5 files changed, 132 insertions(+), 70 deletions(-) diff --git a/src/cdk/scrolling/virtual-for-of.ts b/src/cdk/scrolling/virtual-for-of.ts index 1c567f60d9d9..3c7f2ad949e7 100644 --- a/src/cdk/scrolling/virtual-for-of.ts +++ b/src/cdk/scrolling/virtual-for-of.ts @@ -148,7 +148,7 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy @SkipSelf() private _viewport: CdkVirtualScrollViewport) { this.dataStream.subscribe(data => this._data = data); this._viewport.renderedRangeStream.subscribe(range => this._onRenderedRangeChange(range)); - this._viewport.connect(this); + this._viewport.attach(this); } /** @@ -197,6 +197,9 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy 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._renderedItems); if (!changes) { this._updateContext(); @@ -208,7 +211,7 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy } ngOnDestroy() { - this._viewport.disconnect(); + this._viewport.detach(); this._dataSourceChanges.complete(); this.viewChange.complete(); @@ -240,10 +243,12 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy /** Update the `CdkVirtualForOfContext` for all views. */ private _updateContext() { - for (let i = 0, len = this._viewContainerRef.length; i < len; i++) { + const count = this._data.length; + let i = this._viewContainerRef.length; + while(i--) { let view = this._viewContainerRef.get(i) as EmbeddedViewRef>; view.context.index = this._renderedRange.start + i; - view.context.count = this._data.length; + view.context.count = count; this._updateComputedContextProperties(view.context); view.detectChanges(); } @@ -251,9 +256,14 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy /** Apply changes to the DOM. */ private _applyChanges(changes: IterableChanges) { + // TODO(mmalerba): Currently we remove every view and then re-insert it in the correct place. + // It would be better to generate the minimal set of remove & inserts to get to the new list + // instead. + // Detach all of the views and add them into an array to preserve their original order. const previousViews: (EmbeddedViewRef> | null)[] = []; - for (let i = 0, len = this._viewContainerRef.length; i < len; i++) { + let i = this._viewContainerRef.length; + while (i--) { previousViews.unshift( this._viewContainerRef.detach()! as EmbeddedViewRef>); } @@ -278,7 +288,8 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy // We have nulled-out all of the views that were removed or moved from previousViews. What is // left is the unchanged items that we queue up to be re-inserted. - for (let i = 0, len = previousViews.length; i < len; i++) { + i = previousViews.length; + while(i--) { if (previousViews[i]) { insertTuples[i] = {record: null, view: previousViews[i]!}; } @@ -290,8 +301,12 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy /** Insert the RecordViewTuples into the container element. */ private _insertViews(insertTuples: RecordViewTuple[]) { - for (let i = 0, len = insertTuples.length; i < len; i++) { - let {view, record} = insertTuples[i]; + const count = this._data.length; + let i = insertTuples.length; + const lastIndex = i - 1; + while (i--) { + const index = lastIndex - i; + let {view, record} = insertTuples[index]; if (view) { this._viewContainerRef.insert(view); } else { @@ -310,8 +325,8 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy if (record) { view.context.$implicit = record.item as T; } - view.context.index = this._renderedRange.start + i; - view.context.count = this._data.length; + view.context.index = this._renderedRange.start + index; + view.context.count = count; this._updateComputedContextProperties(view.context); view.detectChanges(); } diff --git a/src/cdk/scrolling/virtual-scroll-fixed-size.ts b/src/cdk/scrolling/virtual-scroll-fixed-size.ts index 30124d9f9479..68fb8b229978 100644 --- a/src/cdk/scrolling/virtual-scroll-fixed-size.ts +++ b/src/cdk/scrolling/virtual-scroll-fixed-size.ts @@ -68,7 +68,7 @@ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { return; } - this._viewport.totalContentSize = this._viewport.dataLength * this._itemSize; + this._viewport.setTotalContentSize(this._viewport.getDataLength() * this._itemSize); }; /** Update the viewport's rendered range. */ @@ -82,9 +82,9 @@ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { const range = this._expandRange( {start: firstVisibleIndex, end: firstVisibleIndex}, this._bufferSize, - Math.ceil(this._viewport.viewportSize / this._itemSize) + this._bufferSize); - this._viewport.renderedRange = range; - this._viewport.renderedContentOffset = this._itemSize * range.start; + Math.ceil(this._viewport.getViewportSize() / this._itemSize) + this._bufferSize); + this._viewport.setRenderedRange(range); + this._viewport.setRenderedContentOffset(this._itemSize * range.start); } /** @@ -100,7 +100,7 @@ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { } const start = Math.max(0, range.start - expandStart); - const end = Math.min(this._viewport.dataLength, range.end + expandEnd); + const end = Math.min(this._viewport.getDataLength(), range.end + expandEnd); return {start, end}; } } @@ -127,10 +127,10 @@ export function _virtualScrollFixedSizeStrategyFactory(fixedSizeDir: CdkVirtualS }], }) export class CdkVirtualScrollFixedSize implements OnChanges { - /** The size of the items in the list. */ + /** The size of the items in the list (in pixels). */ @Input() itemSize = 20; - /** The number of extra elements to render on either side of the viewport. */ + /** The number of extra elements to render on either side of the scrolling viewport. */ @Input() bufferSize = 5; /** The scroll strategy used by this directive. */ diff --git a/src/cdk/scrolling/virtual-scroll-viewport.html b/src/cdk/scrolling/virtual-scroll-viewport.html index 966e1df6f442..5fc18943645a 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.html +++ b/src/cdk/scrolling/virtual-scroll-viewport.html @@ -1,8 +1,16 @@ +
+
+ [style.height.px]="orientation === 'horizontal' ? 1 : _totalContentSize" + [style.width.px]="orientation === 'horizontal' ? _totalContentSize : 1">
diff --git a/src/cdk/scrolling/virtual-scroll-viewport.scss b/src/cdk/scrolling/virtual-scroll-viewport.scss index da7a53264c98..bb9e62f4825f 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.scss +++ b/src/cdk/scrolling/virtual-scroll-viewport.scss @@ -1,9 +1,12 @@ +// Scrolling container. cdk-virtual-scroll-viewport { display: block; position: relative; overflow: auto; } +// Wrapper element for the rendered content. This element will be transformed to push the rendered +// content to its correct offset in the data set as a whole. .cdk-virtual-scroll-content-wrapper { position: absolute; top: 0; @@ -11,6 +14,9 @@ cdk-virtual-scroll-viewport { will-change: contents, transform; } +// Spacer element that whose width or height will be adjusted to match the size of the entire data +// set if it were rendered all at once. This ensures that the scrollable content region is the +// correct size. .cdk-virtual-scroll-spacer { will-change: height, width; } diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts index d5ccaa1d9bb5..9569bfa7e3bd 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -43,8 +43,10 @@ import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-s preserveWhitespaces: false, }) export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { - private _disconnectSubject = new Subject(); + /** Emits when the viewport is detached from a CdkVirtualForOf. */ + private _detachedSubject = new Subject(); + /** Emits when the rendered range changes. */ private _renderedRangeSubject = new Subject(); /** The direction the viewport scrolls. */ @@ -53,73 +55,101 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { /** The element that wraps the rendered content. */ @ViewChild('contentWrapper') _contentWrapper: ElementRef; - /** The total size of all content, including content that is not currently rendered. */ - get totalContentSize() { return this._totalContentSize; } - set totalContentSize(size: number) { + /** A stream that emits whenever the rendered range changes. */ + renderedRangeStream: Observable = this._renderedRangeSubject.asObservable(); + + /** + * The total size of all content (in pixels), including content that is not currently rendered. + */ + _totalContentSize = 0; + + /** The transform used to offset the rendered content wrapper element. */ + _renderedContentTransform: string; + + /** The currently rendered range of indices. */ + private _renderedRange: Range = {start: 0, end: 0}; + + /** The length of the data bound to this viewport (in number of items). */ + private _dataLength = 0; + + /** The size of the viewport (in pixels). */ + private _viewportSize = 0; + + /** Whether this viewport is attached to a CdkVirtualForOf. */ + private _isAttached = false; + + /** + * The scroll handling status. + * needed - The scroll state needs to be updated, but a check hasn't yet been scheduled. + * pending - The scroll state needs to be updated, and an update has already been scheduled. + * done - The scroll state does not need to be updated. + */ + private _scrollHandledStatus: 'needed' | 'pending' | 'done' = 'done'; + + constructor(public elementRef: ElementRef, private _changeDetectorRef: ChangeDetectorRef, + private _ngZone: NgZone, + @Inject(VIRTUAL_SCROLL_STRATEGY) private _scrollStrategy: VirtualScrollStrategy) {} + + /** Gets the length of the data bound to this viewport (in number of items). */ + getDataLength(): number { + return this._dataLength; + } + + /** Gets the size of the viewport (in pixels). */ + getViewportSize(): number { + return this._viewportSize; + } + + // TODO(mmalebra): Consider calling `detectChanges()` directly rather than the methods below. + + /** + * Sets the total size of all content (in pixels), including content that is not currently + * rendered. + */ + setTotalContentSize(size: number) { if (this._totalContentSize != size) { + // Re-enter the Angular zone so we can mark for change detection. this._ngZone.run(() => { this._totalContentSize = size; this._changeDetectorRef.markForCheck(); }); } } - private _totalContentSize = 0; - /** The currently rendered range of indices. */ - get renderedRange() { return this._renderedRange; } - set renderedRange(range: Range) { + /** Sets the currently rendered range of indices. */ + setRenderedRange(range: Range) { if (!this._rangesEqual(this._renderedRange, range)) { + // Re-enter the Angular zone so we can mark for change detection. this._ngZone.run(() => { this._renderedRangeSubject.next(this._renderedRange = range); this._changeDetectorRef.markForCheck(); }); } } - private _renderedRange: Range = {start: 0, end: 0}; - /** The offset of the rendered portion of the data from the start. */ - get renderedContentOffset(): number { return this._renderedContentOffset; } - set renderedContentOffset(offset: number) { - if (this._renderedContentOffset != offset) { + /** Sets the offset of the rendered portion of the data from the start (in pixels). */ + setRenderedContentOffset(offset: number) { + const transform = + this.orientation === 'horizontal' ? `translateX(${offset}px)`: `translateY(${offset}px)`; + if (this._renderedContentTransform != transform) { + // Re-enter the Angular zone so we can mark for change detection. this._ngZone.run(() => { - this._renderedContentOffset = offset; - this._renderedContentTransform = this.orientation === 'horizontal' ? - `translateX(${offset}px)`: `translateY(${offset}px)`; + this._renderedContentTransform = transform; this._changeDetectorRef.markForCheck(); }); } } - private _renderedContentOffset = 0; - - /** The length of the data connected to this viewport. */ - get dataLength() { return this._dataLength; } - private _dataLength = 0; - - /** The size of the viewport. */ - get viewportSize() { return this._viewportSize; } - private _viewportSize = 0; - - /** A stream that emits whenever the rendered range changes. */ - renderedRangeStream: Observable = this._renderedRangeSubject.asObservable(); - - _renderedContentTransform: string; - - private _connected = false; - - private _scrollHandledStatus: 'needed' | 'pending' | 'done' = 'done'; - - constructor(public elementRef: ElementRef, private _changeDetectorRef: ChangeDetectorRef, - private _ngZone: NgZone, - @Inject(VIRTUAL_SCROLL_STRATEGY) private _scrollStrategy: VirtualScrollStrategy) {} - /** Connect a `CdkVirtualForOf` to this viewport. */ - connect(forOf: CdkVirtualForOf) { - if (this._connected) { - throw Error('CdkVirtualScrollViewport is already connected.'); + /** Attaches a `CdkVirtualForOf` to this viewport. */ + attach(forOf: CdkVirtualForOf) { + if (this._isAttached) { + throw Error('CdkVirtualScrollViewport is already attached.'); } - this._connected = true; - forOf.dataStream.pipe(takeUntil(this._disconnectSubject)).subscribe(data => { + this._isAttached = true; + // Subscribe to the data stream of the CdkVirtualForOf to keep track of when the data length + // changes. + forOf.dataStream.pipe(takeUntil(this._detachedSubject)).subscribe(data => { const len = data.length; if (len != this._dataLength) { this._dataLength = len; @@ -128,13 +158,13 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { }); } - /** Disconnect the current `CdkVirtualForOf`. */ - disconnect() { - this._connected = false; - this._disconnectSubject.next(); + /** Detaches the current `CdkVirtualForOf`. */ + detach() { + this._isAttached = false; + this._detachedSubject.next(); } - /** Gets the current scroll offset of the viewport. */ + /** Gets the current scroll offset of the viewport (in pixels). */ measureScrollOffset() { return this.orientation === 'horizontal' ? this.elementRef.nativeElement.scrollLeft : this.elementRef.nativeElement.scrollTop; @@ -164,16 +194,18 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { } ngOnDestroy() { - this.disconnect(); + this.detach(); this._scrollStrategy.detach(); // Complete all subjects - this._disconnectSubject.complete(); + this._detachedSubject.complete(); this._renderedRangeSubject.complete(); } + /** Marks that a scroll event happened and that the scroll state should be checked. */ private _markScrolled() { if (this._scrollHandledStatus === 'done') { + // Re-enter the Angular zone so we can mark for change detection. this._ngZone.run(() => { this._scrollHandledStatus = 'needed'; this._changeDetectorRef.markForCheck(); @@ -181,6 +213,7 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { } } + /** Checks if the given ranges are equal. */ private _rangesEqual(r1: Range, r2: Range): boolean { return r1.start == r2.start && r1.end == r2.end; } From 33cb26f9a29768d41ce3ccff1279d950fe12bd52 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Thu, 15 Feb 2018 14:00:37 -0800 Subject: [PATCH 5/5] fix lint & build --- src/cdk/scrolling/BUILD.bazel | 24 +++++++-- src/cdk/scrolling/tsconfig-build.json | 3 +- src/cdk/scrolling/typings.d.ts | 1 + src/cdk/scrolling/virtual-for-of.ts | 4 +- .../scrolling/virtual-scroll-fixed-size.ts | 10 +++- src/cdk/scrolling/virtual-scroll-strategy.ts | 8 +++ src/cdk/scrolling/virtual-scroll-viewport.ts | 8 +-- .../virtual-scroll/virtual-scroll-demo.scss | 3 ++ .../virtual-scroll/virtual-scroll-demo.ts | 9 +++- tools/package-tools/rollup-globals.ts | 54 ++++++++++--------- 10 files changed, 86 insertions(+), 38 deletions(-) create mode 100644 src/cdk/scrolling/typings.d.ts diff --git a/src/cdk/scrolling/BUILD.bazel b/src/cdk/scrolling/BUILD.bazel index 9fd2db96de2b..42cd67bb3dde 100644 --- a/src/cdk/scrolling/BUILD.bazel +++ b/src/cdk/scrolling/BUILD.bazel @@ -1,13 +1,18 @@ package(default_visibility=["//visibility:public"]) load("@angular//:index.bzl", "ng_module") load("@build_bazel_rules_typescript//:defs.bzl", "ts_library", "ts_web_test") +load("@io_bazel_rules_sass//sass:sass.bzl", "sass_binary") ng_module( name = "scrolling", srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]), module_name = "@angular/cdk/scrolling", + assets = [ + ":virtual_scroll_viewport_css", + ], deps = [ + "//src/cdk/collections", "//src/cdk/platform", "@rxjs", ], @@ -28,9 +33,7 @@ ts_library( ts_web_test( name = "unit_tests", - bootstrap = [ - "//:web_test_bootstrap_scripts", - ], + bootstrap = ["//:web_test_bootstrap_scripts"], # Do not sort deps = [ @@ -41,3 +44,18 @@ ts_web_test( ":scrolling_test_sources", ], ) + +sass_binary( + name = "virtual_scroll_viewport_scss", + src = "virtual-scroll-viewport.scss", +) + +# TODO(jelbourn): remove this when sass_binary supports specifying an output filename and dir. +# Copy the output of the sass_binary such that the filename and path match what we expect. +genrule( + name = "virtual_scroll_viewport_css", + srcs = [":virtual_scroll_viewport_scss"], + outs = ["virtual-scroll-viewport.css"], + cmd = "cat $(locations :virtual_scroll_viewport_scss) > $@", +) + diff --git a/src/cdk/scrolling/tsconfig-build.json b/src/cdk/scrolling/tsconfig-build.json index 40c5aba7f877..9ca664bceac1 100644 --- a/src/cdk/scrolling/tsconfig-build.json +++ b/src/cdk/scrolling/tsconfig-build.json @@ -1,7 +1,8 @@ { "extends": "../tsconfig-build", "files": [ - "public-api.ts" + "public-api.ts", + "../typings.d.ts" ], "angularCompilerOptions": { "annotateForClosureCompiler": true, diff --git a/src/cdk/scrolling/typings.d.ts b/src/cdk/scrolling/typings.d.ts new file mode 100644 index 000000000000..ce4ae9b66cf0 --- /dev/null +++ b/src/cdk/scrolling/typings.d.ts @@ -0,0 +1 @@ +declare var module: {id: string}; diff --git a/src/cdk/scrolling/virtual-for-of.ts b/src/cdk/scrolling/virtual-for-of.ts index 3c7f2ad949e7..848fc024667a 100644 --- a/src/cdk/scrolling/virtual-for-of.ts +++ b/src/cdk/scrolling/virtual-for-of.ts @@ -245,7 +245,7 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy private _updateContext() { const count = this._data.length; let i = this._viewContainerRef.length; - while(i--) { + while (i--) { let view = this._viewContainerRef.get(i) as EmbeddedViewRef>; view.context.index = this._renderedRange.start + i; view.context.count = count; @@ -289,7 +289,7 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy // We have nulled-out all of the views that were removed or moved from previousViews. What is // left is the unchanged items that we queue up to be re-inserted. i = previousViews.length; - while(i--) { + while (i--) { if (previousViews[i]) { insertTuples[i] = {record: null, view: previousViews[i]!}; } diff --git a/src/cdk/scrolling/virtual-scroll-fixed-size.ts b/src/cdk/scrolling/virtual-scroll-fixed-size.ts index 68fb8b229978..49abbfecf42d 100644 --- a/src/cdk/scrolling/virtual-scroll-fixed-size.ts +++ b/src/cdk/scrolling/virtual-scroll-fixed-size.ts @@ -1,3 +1,11 @@ +/** + * @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 {Range} from '@angular/cdk/collections'; import {Directive, forwardRef, Input, OnChanges} from '@angular/core'; import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy'; @@ -69,7 +77,7 @@ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { } this._viewport.setTotalContentSize(this._viewport.getDataLength() * this._itemSize); - }; + } /** Update the viewport's rendered range. */ private _updateRenderedRange() { diff --git a/src/cdk/scrolling/virtual-scroll-strategy.ts b/src/cdk/scrolling/virtual-scroll-strategy.ts index 274481a799c5..4fbf35b419af 100644 --- a/src/cdk/scrolling/virtual-scroll-strategy.ts +++ b/src/cdk/scrolling/virtual-scroll-strategy.ts @@ -1,3 +1,11 @@ +/** + * @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 {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; import {InjectionToken} from '@angular/core'; diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts index 9569bfa7e3bd..9ea89f5e00d6 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -7,7 +7,6 @@ */ import {Range} from '@angular/cdk/collections'; -import {CdkVirtualForOf} from '@angular/cdk/scrolling/virtual-for-of'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -15,17 +14,18 @@ import { DoCheck, ElementRef, Inject, + Input, NgZone, OnDestroy, OnInit, ViewChild, ViewEncapsulation, - Input, } from '@angular/core'; +import {Observable} from 'rxjs/Observable'; import {fromEvent} from 'rxjs/observable/fromEvent'; import {takeUntil} from 'rxjs/operators/takeUntil'; import {Subject} from 'rxjs/Subject'; -import {Observable} from 'rxjs/Observable'; +import {CdkVirtualForOf} from './virtual-for-of'; import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy'; @@ -130,7 +130,7 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { /** Sets the offset of the rendered portion of the data from the start (in pixels). */ setRenderedContentOffset(offset: number) { const transform = - this.orientation === 'horizontal' ? `translateX(${offset}px)`: `translateY(${offset}px)`; + this.orientation === 'horizontal' ? `translateX(${offset}px)` : `translateY(${offset}px)`; if (this._renderedContentTransform != transform) { // Re-enter the Angular zone so we can mark for change detection. this._ngZone.run(() => { diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.scss b/src/demo-app/virtual-scroll/virtual-scroll-demo.scss index 5cab28d45edf..3ce419e652ad 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.scss +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.scss @@ -18,6 +18,9 @@ } .demo-item { + -ms-writing-mode: tb-lr; + -webkit-writing-mode: vertical-lr; + /* stylelint-disable-next-line material/no-prefixes */ writing-mode: vertical-lr; } } diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts index 17445fb67dcb..9df25f7ef301 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts @@ -1,5 +1,12 @@ -import {Component, ViewEncapsulation} from '@angular/core'; +/** + * @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 {Component, ViewEncapsulation} from '@angular/core'; @Component({ moduleId: module.id, diff --git a/tools/package-tools/rollup-globals.ts b/tools/package-tools/rollup-globals.ts index 0707dfac8d40..0893410efdc1 100644 --- a/tools/package-tools/rollup-globals.ts +++ b/tools/package-tools/rollup-globals.ts @@ -26,28 +26,28 @@ const rollupMatEntryPoints = matSecondaryEntryPoints.reduce((globals: any, entry /** Map of globals that are used inside of the different packages. */ export const rollupGlobals = { - 'tslib': 'tslib', 'moment': 'moment', + 'tslib': 'tslib', '@angular/animations': 'ng.animations', - '@angular/core': 'ng.core', '@angular/common': 'ng.common', - '@angular/forms': 'ng.forms', '@angular/common/http': 'ng.common.http', - '@angular/router': 'ng.router', + '@angular/common/http/testing': 'ng.common.http.testing', + '@angular/common/testing': 'ng.common.testing', + '@angular/core': 'ng.core', + '@angular/core/testing': 'ng.core.testing', + '@angular/forms': 'ng.forms', '@angular/platform-browser': 'ng.platformBrowser', - '@angular/platform-server': 'ng.platformServer', '@angular/platform-browser-dynamic': 'ng.platformBrowserDynamic', '@angular/platform-browser/animations': 'ng.platformBrowser.animations', - '@angular/core/testing': 'ng.core.testing', - '@angular/common/testing': 'ng.common.testing', - '@angular/common/http/testing': 'ng.common.http.testing', + '@angular/platform-server': 'ng.platformServer', + '@angular/router': 'ng.router', // Some packages are not really needed for the UMD bundles, but for the missingRollupGlobals rule. - '@angular/material-examples': 'ng.materialExamples', + '@angular/cdk': 'ng.cdk', '@angular/material': 'ng.material', + '@angular/material-examples': 'ng.materialExamples', '@angular/material-moment-adapter': 'ng.materialMomentAdapter', - '@angular/cdk': 'ng.cdk', // Include secondary entry-points of the cdk and material packages ...rollupCdkEntryPoints, @@ -55,35 +55,37 @@ export const rollupGlobals = { 'rxjs/BehaviorSubject': 'Rx', 'rxjs/Observable': 'Rx', - 'rxjs/Subject': 'Rx', - 'rxjs/Subscription': 'Rx', 'rxjs/Observer': 'Rx', - 'rxjs/Subscriber': 'Rx', 'rxjs/Scheduler': 'Rx', + 'rxjs/Subject': 'Rx', + 'rxjs/Subscriber': 'Rx', + 'rxjs/Subscription': 'Rx', 'rxjs/observable/combineLatest': 'Rx.Observable', + 'rxjs/observable/defer': 'Rx.Observable', + 'rxjs/observable/empty': 'Rx.Observable', 'rxjs/observable/forkJoin': 'Rx.Observable', 'rxjs/observable/fromEvent': 'Rx.Observable', + 'rxjs/observable/fromEventPattern': 'Rx.Observable', 'rxjs/observable/merge': 'Rx.Observable', 'rxjs/observable/of': 'Rx.Observable', 'rxjs/observable/throw': 'Rx.Observable', - 'rxjs/observable/defer': 'Rx.Observable', - 'rxjs/observable/fromEventPattern': 'Rx.Observable', - 'rxjs/observable/empty': 'Rx.Observable', + 'rxjs/operators/auditTime': 'Rx.operators', + 'rxjs/operators/catchError': 'Rx.operators', + 'rxjs/operators/combineLatest': 'Rx.operators', 'rxjs/operators/debounceTime': 'Rx.operators', - 'rxjs/operators/takeUntil': 'Rx.operators', - 'rxjs/operators/take': 'Rx.operators', - 'rxjs/operators/first': 'Rx.operators', + 'rxjs/operators/delay': 'Rx.operators', 'rxjs/operators/filter': 'Rx.operators', + 'rxjs/operators/finalize': 'Rx.operators', + 'rxjs/operators/first': 'Rx.operators', 'rxjs/operators/map': 'Rx.operators', - 'rxjs/operators/tap': 'Rx.operators', + 'rxjs/operators/pairwise': 'Rx.operators', + 'rxjs/operators/share': 'Rx.operators', + 'rxjs/operators/shareReplay': 'Rx.operators', 'rxjs/operators/startWith': 'Rx.operators', - 'rxjs/operators/auditTime': 'Rx.operators', 'rxjs/operators/switchMap': 'Rx.operators', - 'rxjs/operators/finalize': 'Rx.operators', - 'rxjs/operators/catchError': 'Rx.operators', - 'rxjs/operators/share': 'Rx.operators', - 'rxjs/operators/delay': 'Rx.operators', - 'rxjs/operators/combineLatest': 'Rx.operators', + 'rxjs/operators/take': 'Rx.operators', + 'rxjs/operators/takeUntil': 'Rx.operators', + 'rxjs/operators/tap': 'Rx.operators', };