From 3967a64ac73f6d52d448c08385b76e8a7238f4b5 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Thu, 15 Feb 2018 14:02:48 -0800 Subject: [PATCH 01/28] feat(virtual-scroll): fixed size virtual scroll (#9316) * feat(virtual-scroll): fixed size virtual scroll * address some comments * change VirtualScrollStrategy interface a bit * address some more comments * fix lint & build --- .github/CODEOWNERS | 1 + src/cdk/collections/collection-viewer.ts | 7 +- src/cdk/collections/data-source.ts | 1 + src/cdk/collections/public-api.ts | 1 + .../collections/static-array-data-source.ts | 23 ++ src/cdk/scrolling/BUILD.bazel | 20 + src/cdk/scrolling/public-api.ts | 4 +- src/cdk/scrolling/scrolling-module.ts | 17 +- src/cdk/scrolling/tsconfig-build.json | 3 +- src/cdk/scrolling/typings.d.ts | 1 + src/cdk/scrolling/virtual-for-of.ts | 342 ++++++++++++++++++ .../scrolling/virtual-scroll-fixed-size.ts | 150 ++++++++ src/cdk/scrolling/virtual-scroll-strategy.ts | 34 ++ .../scrolling/virtual-scroll-viewport.html | 16 + .../scrolling/virtual-scroll-viewport.scss | 22 ++ src/cdk/scrolling/virtual-scroll-viewport.ts | 220 +++++++++++ src/demo-app/demo-app-module.ts | 8 +- src/demo-app/demo-app/demo-app.ts | 3 +- src/demo-app/demo-app/demo-module.ts | 17 +- src/demo-app/demo-app/routes.ts | 2 + .../virtual-scroll/virtual-scroll-demo.html | 12 + .../virtual-scroll/virtual-scroll-demo.scss | 26 ++ .../virtual-scroll/virtual-scroll-demo.ts | 20 + tools/package-tools/rollup-globals.ts | 20 +- 24 files changed, 945 insertions(+), 25 deletions(-) create mode 100644 src/cdk/collections/static-array-data-source.ts create mode 100644 src/cdk/scrolling/typings.d.ts create mode 100644 src/cdk/scrolling/virtual-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 5132bfcbefc2..0824ad4252ee 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -138,6 +138,7 @@ /src/demo-app/tooltip/** @andrewseguin /src/demo-app/tree/** @tinayuangao /src/demo-app/typography/** @crisbeto +/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 c982971666ca..aceb58b142c9 100644 --- a/src/cdk/collections/collection-viewer.ts +++ b/src/cdk/collections/collection-viewer.ts @@ -8,6 +8,11 @@ import {Observable} from 'rxjs'; + +/** 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 2dfe47fb214c..ffddbf1af473 100644 --- a/src/cdk/collections/data-source.ts +++ b/src/cdk/collections/data-source.ts @@ -9,6 +9,7 @@ import {Observable} from 'rxjs'; import {CollectionViewer} from './collection-viewer'; + export abstract class DataSource { /** * Connects a collection viewer (such as a data-table) to this data source. Note that diff --git a/src/cdk/collections/public-api.ts b/src/cdk/collections/public-api.ts index d79237c0414f..a1c7f4fbf236 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/BUILD.bazel b/src/cdk/scrolling/BUILD.bazel index 3a249aeaccfd..2f0ba3af351b 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", ], @@ -42,3 +47,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/public-api.ts b/src/cdk/scrolling/public-api.ts index ffbd65d14e06..646c2bb47427 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 './virtual-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 955e1751e562..ae5d9dddc42e 100644 --- a/src/cdk/scrolling/scrolling-module.ts +++ b/src/cdk/scrolling/scrolling-module.ts @@ -9,10 +9,23 @@ import {PlatformModule} from '@angular/cdk/platform'; import {NgModule} from '@angular/core'; import {CdkScrollable} from './scrollable'; +import {CdkVirtualForOf} from './virtual-for-of'; +import {CdkVirtualScrollFixedSize} from './virtual-scroll-fixed-size'; +import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; @NgModule({ imports: [PlatformModule], - exports: [CdkScrollable], - declarations: [CdkScrollable], + exports: [ + CdkVirtualForOf, + CdkScrollable, + CdkVirtualScrollFixedSize, + CdkVirtualScrollViewport, + ], + declarations: [ + CdkVirtualForOf, + CdkScrollable, + CdkVirtualScrollFixedSize, + CdkVirtualScrollViewport, + ], }) export class ScrollDispatchModule {} diff --git a/src/cdk/scrolling/tsconfig-build.json b/src/cdk/scrolling/tsconfig-build.json index c76f5c02d8d1..3a7903793216 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 new file mode 100644 index 000000000000..848fc024667a --- /dev/null +++ b/src/cdk/scrolling/virtual-for-of.ts @@ -0,0 +1,342 @@ +/** + * @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 {CollectionViewer, DataSource, Range, StaticArrayDataSource} from '@angular/cdk/collections'; +import { + Directive, + DoCheck, + EmbeddedViewRef, + Input, + IterableChangeRecord, + IterableChanges, + IterableDiffer, + IterableDiffers, + NgIterable, + OnDestroy, + SkipSelf, + 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 `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> +}; + + +/** + * A directive similar to `ngForOf` to be used for rendering data inside a virtual scrolling + * container. + */ +@Directive({ + selector: '[cdkVirtualFor][cdkVirtualForOf]', +}) +export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy { + /** Emits when the rendered view of the data changes. */ + viewChange = new Subject(); + + /** Subject that emits when a new DataSource instance is given. */ + private _dataSourceChanges = new Subject>(); + + /** The DataSource to display. */ + @Input() + 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); + } + _cdkVirtualForOf: NgIterable | DataSource; + + /** + * 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 { + return this._cdkVirtualForTrackBy; + } + set cdkVirtualForTrackBy(fn: TrackByFunction) { + this._needsUpdate = true; + this._cdkVirtualForTrackBy = + (index, item) => fn(index + (this._renderedRange ? this._renderedRange.start : 0), item); + } + private _cdkVirtualForTrackBy: TrackByFunction; + + /** The template used to stamp out new elements. */ + @Input() + set cdkVirtualForTemplate(value: TemplateRef>) { + if (value) { + this._needsUpdate = true; + this._template = value; + } + } + + /** Emits whenever the data in the current DataSource changes. */ + 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; + + /** + * 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, + /** The template to use when stamping out new items. */ + private _template: TemplateRef>, + /** 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); + } + + /** + * 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.`); + } + 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; + + 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); + 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) { + // 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(); + } 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: 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.cdkVirtualForTrackBy); + } + 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); + } + + /** 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>; + 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) { + // 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)[] = []; + let i = this._viewContainerRef.length; + while (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!]!); + previousViews[record.previousIndex!] = null; + }); + + // 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!]!}; + previousViews[record.previousIndex!] = null; + }); + + // 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--) { + 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. + this._insertViews(insertTuples); + } + + /** Insert the RecordViewTuples into the container element. */ + private _insertViews(insertTuples: RecordViewTuple[]) { + 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 { + 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) { + view.context.$implicit = record.item as T; + } + view.context.index = this._renderedRange.start + index; + view.context.count = count; + 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-fixed-size.ts b/src/cdk/scrolling/virtual-scroll-fixed-size.ts new file mode 100644 index 000000000000..49abbfecf42d --- /dev/null +++ b/src/cdk/scrolling/virtual-scroll-fixed-size.ts @@ -0,0 +1,150 @@ +/** + * @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'; +import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; + + +/** Virtual scrolling strategy for lists with items of known fixed size. */ +export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { + /** 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; + } + + /** + * Attaches this scroll strategy to a viewport. + * @param viewport The viewport to attach this strategy to. + */ + attach(viewport: CdkVirtualScrollViewport) { + this._viewport = viewport; + this._updateTotalContentSize(); + this._updateRenderedRange(); + } + + /** 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._updateTotalContentSize(); + this._updateRenderedRange(); + } + + /** Update the viewport's total content size. */ + private _updateTotalContentSize() { + if (!this._viewport) { + return; + } + + this._viewport.setTotalContentSize(this._viewport.getDataLength() * 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 range = this._expandRange( + {start: firstVisibleIndex, end: firstVisibleIndex}, + this._bufferSize, + Math.ceil(this._viewport.getViewportSize() / this._itemSize) + this._bufferSize); + this._viewport.setRenderedRange(range); + this._viewport.setRenderedContentOffset(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.getDataLength(), range.end + expandEnd); + return {start, end}; + } +} + + +/** + * 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; +} + + +/** 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 (in pixels). */ + @Input() itemSize = 20; + + /** The number of extra elements to render on either side of the scrolling viewport. */ + @Input() bufferSize = 5; + + /** The scroll strategy used by this directive. */ + _scrollStrategy = new VirtualScrollFixedSizeStrategy(this.itemSize, this.bufferSize); + + ngOnChanges() { + 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 new file mode 100644 index 000000000000..4fbf35b419af --- /dev/null +++ b/src/cdk/scrolling/virtual-scroll-strategy.ts @@ -0,0 +1,34 @@ +/** + * @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'; + + +/** 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 { + /** + * 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(); + + /** 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..5fc18943645a --- /dev/null +++ b/src/cdk/scrolling/virtual-scroll-viewport.html @@ -0,0 +1,16 @@ + +
+ +
+ +
+
diff --git a/src/cdk/scrolling/virtual-scroll-viewport.scss b/src/cdk/scrolling/virtual-scroll-viewport.scss new file mode 100644 index 000000000000..bb9e62f4825f --- /dev/null +++ b/src/cdk/scrolling/virtual-scroll-viewport.scss @@ -0,0 +1,22 @@ +// 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; + left: 0; + 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 new file mode 100644 index 000000000000..9ea89f5e00d6 --- /dev/null +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -0,0 +1,220 @@ +/** + * @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 { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DoCheck, + ElementRef, + Inject, + Input, + NgZone, + OnDestroy, + OnInit, + ViewChild, + ViewEncapsulation, +} 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 {CdkVirtualForOf} from './virtual-for-of'; +import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy'; + + +/** A viewport that virtualizes it's scrolling with the help of `CdkVirtualForOf`. */ +@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 { + /** 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. */ + @Input() orientation: 'horizontal' | 'vertical' = 'vertical'; + + /** The element that wraps the rendered content. */ + @ViewChild('contentWrapper') _contentWrapper: ElementRef; + + /** 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(); + }); + } + } + + /** 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(); + }); + } + } + + /** 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._renderedContentTransform = transform; + this._changeDetectorRef.markForCheck(); + }); + } + } + + /** Attaches a `CdkVirtualForOf` to this viewport. */ + attach(forOf: CdkVirtualForOf) { + if (this._isAttached) { + throw Error('CdkVirtualScrollViewport is already attached.'); + } + + 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; + this._scrollStrategy.onDataLengthChanged(); + } + }); + } + + /** Detaches the current `CdkVirtualForOf`. */ + detach() { + this._isAttached = false; + this._detachedSubject.next(); + } + + /** Gets the current scroll offset of the viewport (in pixels). */ + 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; + this._ngZone.runOutsideAngular(() => { + fromEvent(this.elementRef.nativeElement, 'scroll').subscribe(() => { + this._markScrolled(); + }); + }); + this._scrollStrategy.attach(this); + }); + } + + ngDoCheck() { + if (this._scrollHandledStatus === 'needed') { + this._scrollHandledStatus = 'pending'; + this._ngZone.runOutsideAngular(() => requestAnimationFrame(() => { + this._scrollHandledStatus = 'done'; + this._scrollStrategy.onContentScrolled(); + })); + } + } + + ngOnDestroy() { + this.detach(); + this._scrollStrategy.detach(); + + // Complete all subjects + 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(); + }); + } + } + + /** Checks if the given ranges are equal. */ + private _rangesEqual(r1: Range, r2: Range): boolean { + return r1.start == r2.start && r1.end == r2.end; + } +} diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 5e42bbf01d24..f954aac852a5 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -6,15 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ +import {HttpClientModule} from '@angular/common/http'; import {ApplicationRef, NgModule} from '@angular/core'; import {BrowserModule} from '@angular/platform-browser'; -import {HttpClientModule} from '@angular/common/http'; -import {RouterModule} from '@angular/router'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; -import {ALL_ROUTES} from './demo-app/routes'; +import {RouterModule} from '@angular/router'; +import {AccessibilityDemoModule} from './a11y/a11y-module'; import {EntryApp} from './demo-app/demo-app'; import {DemoModule} from './demo-app/demo-module'; -import {AccessibilityDemoModule} from './a11y/a11y-module'; +import {ALL_ROUTES} from './demo-app/routes'; @NgModule({ diff --git a/src/demo-app/demo-app/demo-app.ts b/src/demo-app/demo-app/demo-app.ts index 310b4f30b294..f7e0fced4284 100644 --- a/src/demo-app/demo-app/demo-app.ts +++ b/src/demo-app/demo-app/demo-app.ts @@ -89,7 +89,8 @@ export class DemoApp { {name: 'Toolbar', route: '/toolbar'}, {name: 'Tooltip', route: '/tooltip'}, {name: 'Tree', route: '/tree'}, - {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 919b4e1e2629..a989b92451e5 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,28 @@ 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'; import {TreeDemoModule} from '../tree/tree-demo-module'; import {ConnectedOverlayDemo, DemoOverlay} from '../connected-overlay/connected-overlay-demo'; @@ -131,6 +137,7 @@ import {ConnectedOverlayDemo, DemoOverlay} from '../connected-overlay/connected- ToolbarDemo, TooltipDemo, TypographyDemo, + VirtualScrollDemo, ExampleBottomSheet, ExpansionDemo, ConnectedOverlayDemo, diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index 34c4d65f05d5..bcec2ebe9054 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -49,6 +49,7 @@ import {ToolbarDemo} from '../toolbar/toolbar-demo'; import {TooltipDemo} from '../tooltip/tooltip-demo'; import {TreeDemo} from '../tree/tree-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'; @@ -102,6 +103,7 @@ export const DEMO_APP_ROUTES: Routes = [ {path: 'stepper', component: StepperDemo}, {path: 'screen-type', component: ScreenTypeDemo}, {path: 'connected-overlay', component: ConnectedOverlayDemo}, + {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..afae552a0678 --- /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..3ce419e652ad --- /dev/null +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.scss @@ -0,0 +1,26 @@ +.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 { + -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 new file mode 100644 index 000000000000..9df25f7ef301 --- /dev/null +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts @@ -0,0 +1,20 @@ +/** + * @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, + 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); +} diff --git a/tools/package-tools/rollup-globals.ts b/tools/package-tools/rollup-globals.ts index e0cd4c19df02..63327d885d77 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, From 1c19ca411beb5fa2302d73c84a5cffe516245657 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Tue, 20 Feb 2018 07:35:21 -0800 Subject: [PATCH 02/28] chore: move virtual-scroll to cdk-experimental (#9974) --- .github/CODEOWNERS | 1 + src/cdk-experimental/index.ts | 1 + src/cdk-experimental/scrolling/BUILD.bazel | 33 +++++++++++++++++++ src/cdk-experimental/scrolling/index.ts | 9 +++++ src/cdk-experimental/scrolling/public-api.ts | 13 ++++++++ .../scrolling/scrolling-module.ts | 27 +++++++++++++++ .../scrolling/tsconfig-build.json | 15 +++++++++ .../scrolling/typings.d.ts | 0 .../scrolling/virtual-for-of.ts | 0 .../scrolling/virtual-scroll-fixed-size.ts | 0 .../scrolling/virtual-scroll-strategy.ts | 0 .../scrolling/virtual-scroll-viewport.html | 0 .../scrolling/virtual-scroll-viewport.scss | 0 .../scrolling/virtual-scroll-viewport.ts | 0 src/cdk/scrolling/BUILD.bazel | 19 ----------- src/cdk/scrolling/public-api.ts | 2 -- src/cdk/scrolling/scrolling-module.ts | 17 ++-------- src/cdk/scrolling/tsconfig-build.json | 3 +- src/demo-app/demo-material-module.ts | 2 ++ 19 files changed, 104 insertions(+), 38 deletions(-) create mode 100644 src/cdk-experimental/scrolling/BUILD.bazel create mode 100644 src/cdk-experimental/scrolling/index.ts create mode 100644 src/cdk-experimental/scrolling/public-api.ts create mode 100644 src/cdk-experimental/scrolling/scrolling-module.ts create mode 100644 src/cdk-experimental/scrolling/tsconfig-build.json rename src/{cdk => cdk-experimental}/scrolling/typings.d.ts (100%) rename src/{cdk => cdk-experimental}/scrolling/virtual-for-of.ts (100%) rename src/{cdk => cdk-experimental}/scrolling/virtual-scroll-fixed-size.ts (100%) rename src/{cdk => cdk-experimental}/scrolling/virtual-scroll-strategy.ts (100%) rename src/{cdk => cdk-experimental}/scrolling/virtual-scroll-viewport.html (100%) rename src/{cdk => cdk-experimental}/scrolling/virtual-scroll-viewport.scss (100%) rename src/{cdk => cdk-experimental}/scrolling/virtual-scroll-viewport.ts (100%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0824ad4252ee..a08d8e3f5a89 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -86,6 +86,7 @@ # CDK experimental package /src/cdk-experimental/** @jelbourn /src/cdk-experimental/dialog/** @jelbourn @josephperrott @crisbeto +/src/cdk-experimental/scrolling/** @mmalerba # Docs examples & guides /guides/** @amcdnl @jelbourn diff --git a/src/cdk-experimental/index.ts b/src/cdk-experimental/index.ts index 676ca90f1ffa..0f411c889e3f 100644 --- a/src/cdk-experimental/index.ts +++ b/src/cdk-experimental/index.ts @@ -7,3 +7,4 @@ */ export * from './public-api'; +export * from './scrolling/index'; diff --git a/src/cdk-experimental/scrolling/BUILD.bazel b/src/cdk-experimental/scrolling/BUILD.bazel new file mode 100644 index 000000000000..133985734a23 --- /dev/null +++ b/src/cdk-experimental/scrolling/BUILD.bazel @@ -0,0 +1,33 @@ +package(default_visibility=["//visibility:public"]) +load("@angular//:index.bzl", "ng_module") +load("@io_bazel_rules_sass//sass:sass.bzl", "sass_binary") + + +ng_module( + name = "scrolling", + srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]), + module_name = "@angular/cdk-experimental/scrolling", + assets = [ + ":virtual_scroll_viewport_css", + ], + deps = [ + "//src/cdk/collections", + "@rxjs", + ], + tsconfig = ":tsconfig-build.json", +) + +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-experimental/scrolling/index.ts b/src/cdk-experimental/scrolling/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/cdk-experimental/scrolling/index.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export * from './public-api'; diff --git a/src/cdk-experimental/scrolling/public-api.ts b/src/cdk-experimental/scrolling/public-api.ts new file mode 100644 index 000000000000..31a7b9aadb81 --- /dev/null +++ b/src/cdk-experimental/scrolling/public-api.ts @@ -0,0 +1,13 @@ +/** + * @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 + */ + +export * from './scrolling-module'; +export * from './virtual-for-of'; +export * from './virtual-scroll-fixed-size'; +export * from './virtual-scroll-strategy'; +export * from './virtual-scroll-viewport'; diff --git a/src/cdk-experimental/scrolling/scrolling-module.ts b/src/cdk-experimental/scrolling/scrolling-module.ts new file mode 100644 index 000000000000..e625b67a5ab9 --- /dev/null +++ b/src/cdk-experimental/scrolling/scrolling-module.ts @@ -0,0 +1,27 @@ +/** + * @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 {NgModule} from '@angular/core'; +import {CdkVirtualForOf} from './virtual-for-of'; +import {CdkVirtualScrollFixedSize} from './virtual-scroll-fixed-size'; +import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; + + +@NgModule({ + exports: [ + CdkVirtualForOf, + CdkVirtualScrollFixedSize, + CdkVirtualScrollViewport, + ], + declarations: [ + CdkVirtualForOf, + CdkVirtualScrollFixedSize, + CdkVirtualScrollViewport, + ], +}) +export class ScrollingModule {} diff --git a/src/cdk-experimental/scrolling/tsconfig-build.json b/src/cdk-experimental/scrolling/tsconfig-build.json new file mode 100644 index 000000000000..d94e5e159676 --- /dev/null +++ b/src/cdk-experimental/scrolling/tsconfig-build.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig-build", + "files": [ + "public-api.ts", + "../typings.d.ts" + ], + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@angular/cdk-experimental/scrolling", + "skipTemplateCodegen": true, + "fullTemplateTypeCheck": true + } +} diff --git a/src/cdk/scrolling/typings.d.ts b/src/cdk-experimental/scrolling/typings.d.ts similarity index 100% rename from src/cdk/scrolling/typings.d.ts rename to src/cdk-experimental/scrolling/typings.d.ts diff --git a/src/cdk/scrolling/virtual-for-of.ts b/src/cdk-experimental/scrolling/virtual-for-of.ts similarity index 100% rename from src/cdk/scrolling/virtual-for-of.ts rename to src/cdk-experimental/scrolling/virtual-for-of.ts diff --git a/src/cdk/scrolling/virtual-scroll-fixed-size.ts b/src/cdk-experimental/scrolling/virtual-scroll-fixed-size.ts similarity index 100% rename from src/cdk/scrolling/virtual-scroll-fixed-size.ts rename to src/cdk-experimental/scrolling/virtual-scroll-fixed-size.ts diff --git a/src/cdk/scrolling/virtual-scroll-strategy.ts b/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts similarity index 100% rename from src/cdk/scrolling/virtual-scroll-strategy.ts rename to src/cdk-experimental/scrolling/virtual-scroll-strategy.ts diff --git a/src/cdk/scrolling/virtual-scroll-viewport.html b/src/cdk-experimental/scrolling/virtual-scroll-viewport.html similarity index 100% rename from src/cdk/scrolling/virtual-scroll-viewport.html rename to src/cdk-experimental/scrolling/virtual-scroll-viewport.html diff --git a/src/cdk/scrolling/virtual-scroll-viewport.scss b/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss similarity index 100% rename from src/cdk/scrolling/virtual-scroll-viewport.scss rename to src/cdk-experimental/scrolling/virtual-scroll-viewport.scss diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts similarity index 100% rename from src/cdk/scrolling/virtual-scroll-viewport.ts rename to src/cdk-experimental/scrolling/virtual-scroll-viewport.ts diff --git a/src/cdk/scrolling/BUILD.bazel b/src/cdk/scrolling/BUILD.bazel index 2f0ba3af351b..9db694d1e903 100644 --- a/src/cdk/scrolling/BUILD.bazel +++ b/src/cdk/scrolling/BUILD.bazel @@ -1,16 +1,12 @@ 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", @@ -47,18 +43,3 @@ 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/public-api.ts b/src/cdk/scrolling/public-api.ts index 646c2bb47427..8cafb330c710 100644 --- a/src/cdk/scrolling/public-api.ts +++ b/src/cdk/scrolling/public-api.ts @@ -6,9 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -export * from './virtual-for-of'; export * from './scroll-dispatcher'; export * from './scrollable'; 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 ae5d9dddc42e..955e1751e562 100644 --- a/src/cdk/scrolling/scrolling-module.ts +++ b/src/cdk/scrolling/scrolling-module.ts @@ -9,23 +9,10 @@ import {PlatformModule} from '@angular/cdk/platform'; import {NgModule} from '@angular/core'; import {CdkScrollable} from './scrollable'; -import {CdkVirtualForOf} from './virtual-for-of'; -import {CdkVirtualScrollFixedSize} from './virtual-scroll-fixed-size'; -import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; @NgModule({ imports: [PlatformModule], - exports: [ - CdkVirtualForOf, - CdkScrollable, - CdkVirtualScrollFixedSize, - CdkVirtualScrollViewport, - ], - declarations: [ - CdkVirtualForOf, - CdkScrollable, - CdkVirtualScrollFixedSize, - CdkVirtualScrollViewport, - ], + exports: [CdkScrollable], + declarations: [CdkScrollable], }) export class ScrollDispatchModule {} diff --git a/src/cdk/scrolling/tsconfig-build.json b/src/cdk/scrolling/tsconfig-build.json index 3a7903793216..c76f5c02d8d1 100644 --- a/src/cdk/scrolling/tsconfig-build.json +++ b/src/cdk/scrolling/tsconfig-build.json @@ -1,8 +1,7 @@ { "extends": "../tsconfig-build", "files": [ - "public-api.ts", - "../typings.d.ts" + "public-api.ts" ], "angularCompilerOptions": { "annotateForClosureCompiler": true, diff --git a/src/demo-app/demo-material-module.ts b/src/demo-app/demo-material-module.ts index 59f87509b13f..8272787f12d8 100644 --- a/src/demo-app/demo-material-module.ts +++ b/src/demo-app/demo-material-module.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {ScrollingModule} from '@angular/cdk-experimental'; import {A11yModule} from '@angular/cdk/a11y'; import {CdkAccordionModule} from '@angular/cdk/accordion'; import {BidiModule} from '@angular/cdk/bidi'; @@ -107,6 +108,7 @@ import { OverlayModule, PlatformModule, PortalModule, + ScrollingModule, ] }) export class DemoMaterialModule {} From 7495ee7a84624c6ba037882033a92473530da47e Mon Sep 17 00:00:00 2001 From: mmalerba Date: Mon, 26 Feb 2018 16:24:17 -0800 Subject: [PATCH 03/28] virtual-scroll: simplify scroll listener logic (#10102) --- .../scrolling/virtual-scroll-viewport.ts | 56 +++++-------------- 1 file changed, 15 insertions(+), 41 deletions(-) diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts index 9ea89f5e00d6..3a532814525d 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -11,7 +11,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - DoCheck, ElementRef, Inject, Input, @@ -24,11 +23,19 @@ import { import {Observable} from 'rxjs/Observable'; import {fromEvent} from 'rxjs/observable/fromEvent'; import {takeUntil} from 'rxjs/operators/takeUntil'; +import {throttleTime} from 'rxjs/operators/throttleTime'; +import {animationFrame} from 'rxjs/scheduler/animationFrame'; import {Subject} from 'rxjs/Subject'; import {CdkVirtualForOf} from './virtual-for-of'; import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy'; +/** Checks if the given ranges are equal. */ +function rangesEqual(r1: Range, r2: Range): boolean { + return r1.start == r2.start && r1.end == r2.end; +} + + /** A viewport that virtualizes it's scrolling with the help of `CdkVirtualForOf`. */ @Component({ moduleId: module.id, @@ -42,7 +49,7 @@ import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-s changeDetection: ChangeDetectionStrategy.OnPush, preserveWhitespaces: false, }) -export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { +export class CdkVirtualScrollViewport implements OnInit, OnDestroy { /** Emits when the viewport is detached from a CdkVirtualForOf. */ private _detachedSubject = new Subject(); @@ -78,14 +85,6 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { /** 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) {} @@ -118,7 +117,7 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { /** Sets the currently rendered range of indices. */ setRenderedRange(range: Range) { - if (!this._rangesEqual(this._renderedRange, range)) { + if (!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); @@ -174,25 +173,16 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { Promise.resolve().then(() => { this._viewportSize = this.orientation === 'horizontal' ? this.elementRef.nativeElement.clientWidth : this.elementRef.nativeElement.clientHeight; + this._scrollStrategy.attach(this); + this._ngZone.runOutsideAngular(() => { - fromEvent(this.elementRef.nativeElement, 'scroll').subscribe(() => { - this._markScrolled(); - }); + fromEvent(this.elementRef.nativeElement, 'scroll') + .pipe(throttleTime(0, animationFrame)) + .subscribe(() => this._scrollStrategy.onContentScrolled()); }); - this._scrollStrategy.attach(this); }); } - ngDoCheck() { - if (this._scrollHandledStatus === 'needed') { - this._scrollHandledStatus = 'pending'; - this._ngZone.runOutsideAngular(() => requestAnimationFrame(() => { - this._scrollHandledStatus = 'done'; - this._scrollStrategy.onContentScrolled(); - })); - } - } - ngOnDestroy() { this.detach(); this._scrollStrategy.detach(); @@ -201,20 +191,4 @@ export class CdkVirtualScrollViewport implements OnInit, DoCheck, OnDestroy { 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(); - }); - } - } - - /** Checks if the given ranges are equal. */ - private _rangesEqual(r1: Range, r2: Range): boolean { - return r1.start == r2.start && r1.end == r2.end; - } } From e0591b308384158b6a017bebc4b67e0395b2cbd1 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Mon, 26 Feb 2018 16:26:33 -0800 Subject: [PATCH 04/28] virtual-scroll: only move views that need to be moved (#10099) * virtual-scroll: only move views that need to be moved * address comments --- .../scrolling/virtual-for-of.ts | 123 ++++++++---------- 1 file changed, 51 insertions(+), 72 deletions(-) diff --git a/src/cdk-experimental/scrolling/virtual-for-of.ts b/src/cdk-experimental/scrolling/virtual-for-of.ts index 848fc024667a..04caea9d65dd 100644 --- a/src/cdk-experimental/scrolling/virtual-for-of.ts +++ b/src/cdk-experimental/scrolling/virtual-for-of.ts @@ -45,12 +45,6 @@ export type CdkVirtualForOfContext = { }; -type RecordViewTuple = { - record: IterableChangeRecord | null, - view?: EmbeddedViewRef> -}; - - /** * A directive similar to `ngForOf` to be used for rendering data inside a virtual scrolling * container. @@ -101,6 +95,8 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy } } + @Input() cdkVirtualForTemplateCacheSize: number = 20; + /** Emits whenever the data in the current DataSource changes. */ dataStream: Observable = this._dataSourceChanges .pipe( @@ -256,80 +252,63 @@ 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. + // Rearrange the views to put them in the right location. + changes.forEachOperation( + (record: IterableChangeRecord, 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>); + } else { // Item moved. + const view = this._viewContainerRef.get(adjustedPreviousIndex) as + EmbeddedViewRef>; + this._viewContainerRef.move(view, currentIndex); + view.context.$implicit = record.item; + } + }); + + // Update $implicit for any items that had an identity change. + changes.forEachIdentityChange((record: IterableChangeRecord) => { + const view = this._viewContainerRef.get(record.currentIndex!) as + EmbeddedViewRef>; + view.context.$implicit = record.item; + }); - // Detach all of the views and add them into an array to preserve their original order. - const previousViews: (EmbeddedViewRef> | null)[] = []; + // Update the context variables on all items. + const count = this._data.length; let i = this._viewContainerRef.length; while (i--) { - previousViews.unshift( - this._viewContainerRef.detach()! as EmbeddedViewRef>); + const view = this._viewContainerRef.get(i) as EmbeddedViewRef>; + view.context.index = this._renderedRange.start + i; + view.context.count = count; + this._updateComputedContextProperties(view.context); } + } - // Mark the removed indices so we can recycle their views. - changes.forEachRemovedItem(record => { - 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. - 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!]!}; - previousViews[record.previousIndex!] = null; - }); - - // 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--) { - if (previousViews[i]) { - insertTuples[i] = {record: null, view: previousViews[i]!}; - } + /** Cache the given detached view. */ + private _cacheView(view: EmbeddedViewRef>) { + if (this._templateCache.length < this.cdkVirtualForTemplateCacheSize) { + this._templateCache.push(view); + } else { + view.destroy(); } - - // 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[]) { - 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 { - 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) { - view.context.$implicit = record.item as T; - } - view.context.index = this._renderedRange.start + index; - view.context.count = count; - this._updateComputedContextProperties(view.context); - view.detectChanges(); - } + /** Get a view for a new item, either from the cache or by creating a new one. */ + private _getViewForNewItem(): EmbeddedViewRef> { + 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`. */ From 55da6b295555713f1821b5aca5b3d4a799408cd1 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Wed, 28 Feb 2018 09:12:20 -0800 Subject: [PATCH 05/28] virtual-scroll: switch `throttleTime` to `sampleTime` (#10179) * virtual-scroll: switch throttleTime to sampleTime * add comment --- src/cdk-experimental/scrolling/virtual-scroll-viewport.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts index 3a532814525d..ce4f8dc3a11e 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -22,8 +22,8 @@ import { } from '@angular/core'; import {Observable} from 'rxjs/Observable'; import {fromEvent} from 'rxjs/observable/fromEvent'; +import {sampleTime} from 'rxjs/operators/sampleTime'; import {takeUntil} from 'rxjs/operators/takeUntil'; -import {throttleTime} from 'rxjs/operators/throttleTime'; import {animationFrame} from 'rxjs/scheduler/animationFrame'; import {Subject} from 'rxjs/Subject'; import {CdkVirtualForOf} from './virtual-for-of'; @@ -177,7 +177,9 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy { this._ngZone.runOutsideAngular(() => { fromEvent(this.elementRef.nativeElement, 'scroll') - .pipe(throttleTime(0, animationFrame)) + // Sample the scroll stream at every animation frame. This way if there are multiple + // scroll events in the same frame we only need to recheck our layout once + .pipe(sampleTime(0, animationFrame)) .subscribe(() => this._scrollStrategy.onContentScrolled()); }); }); From 1471964b4bbd23bc9f4e1b0003a429a5ec2c74af Mon Sep 17 00:00:00 2001 From: mmalerba Date: Wed, 28 Feb 2018 11:34:38 -0800 Subject: [PATCH 06/28] virtual-scroll: allow user to pass `Observable` (#10158) --- .../scrolling/virtual-for-of.ts | 17 ++++++++++------- ...rray-data-source.ts => array-data-source.ts} | 6 +++--- src/cdk/collections/public-api.ts | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) rename src/cdk/collections/{static-array-data-source.ts => array-data-source.ts} (69%) diff --git a/src/cdk-experimental/scrolling/virtual-for-of.ts b/src/cdk-experimental/scrolling/virtual-for-of.ts index 04caea9d65dd..f4889b4bb117 100644 --- a/src/cdk-experimental/scrolling/virtual-for-of.ts +++ b/src/cdk-experimental/scrolling/virtual-for-of.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {CollectionViewer, DataSource, Range, StaticArrayDataSource} from '@angular/cdk/collections'; +import {ArrayDataSource, CollectionViewer, DataSource, Range} from '@angular/cdk/collections'; import { Directive, DoCheck, @@ -35,7 +35,7 @@ import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; /** The context for an item rendered by `CdkVirtualForOf` */ export type CdkVirtualForOfContext = { $implicit: T; - cdkVirtualForOf: NgIterable | DataSource; + cdkVirtualForOf: DataSource | Observable | NgIterable; index: number; count: number; first: boolean; @@ -61,15 +61,18 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy /** The DataSource to display. */ @Input() - get cdkVirtualForOf(): NgIterable | DataSource { return this._cdkVirtualForOf; } - set cdkVirtualForOf(value: NgIterable | DataSource) { + get cdkVirtualForOf(): DataSource | Observable | NgIterable { + return this._cdkVirtualForOf; + } + set cdkVirtualForOf(value: DataSource | Observable | NgIterable) { 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)); + // Slice the value if its an NgIterable to ensure we're working with an array. + new ArrayDataSource( + value instanceof Observable ? value : Array.prototype.slice.call(value)); this._dataSourceChanges.next(ds); } - _cdkVirtualForOf: NgIterable | DataSource; + _cdkVirtualForOf: DataSource | Observable | NgIterable; /** * The `TrackByFunction` to use for tracking changes. The `TrackByFunction` takes the index and diff --git a/src/cdk/collections/static-array-data-source.ts b/src/cdk/collections/array-data-source.ts similarity index 69% rename from src/cdk/collections/static-array-data-source.ts rename to src/cdk/collections/array-data-source.ts index cd458033e877..91a9c1164f0e 100644 --- a/src/cdk/collections/static-array-data-source.ts +++ b/src/cdk/collections/array-data-source.ts @@ -12,11 +12,11 @@ import {DataSource} from './data-source'; /** DataSource wrapper for a native array. */ -export class StaticArrayDataSource implements DataSource { - constructor(private _data: T[]) {} +export class ArrayDataSource implements DataSource { + constructor(private _data: T[] | Observable) {} connect(): Observable { - return observableOf(this._data); + return this._data instanceof Observable ? this._data : observableOf(this._data); } disconnect() {} diff --git a/src/cdk/collections/public-api.ts b/src/cdk/collections/public-api.ts index a1c7f4fbf236..16cda8897a7b 100644 --- a/src/cdk/collections/public-api.ts +++ b/src/cdk/collections/public-api.ts @@ -6,10 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ +export * from './array-data-source'; export * from './collection-viewer'; export * from './data-source'; export * from './selection'; -export * from './static-array-data-source'; export { UniqueSelectionDispatcher, UniqueSelectionDispatcherListener, From ece59bae273ea45344032999d7e9ad588d9dd35c Mon Sep 17 00:00:00 2001 From: mmalerba Date: Mon, 5 Mar 2018 10:23:27 -0800 Subject: [PATCH 07/28] virtual-scroll: rename `Range` to `ListRange` to avoid confusion with native `Range` (#10220) --- src/cdk-experimental/scrolling/virtual-for-of.ts | 8 ++++---- .../scrolling/virtual-scroll-fixed-size.ts | 4 ++-- .../scrolling/virtual-scroll-viewport.ts | 12 ++++++------ src/cdk/collections/collection-viewer.ts | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/cdk-experimental/scrolling/virtual-for-of.ts b/src/cdk-experimental/scrolling/virtual-for-of.ts index f4889b4bb117..20ceb0b87c0a 100644 --- a/src/cdk-experimental/scrolling/virtual-for-of.ts +++ b/src/cdk-experimental/scrolling/virtual-for-of.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {ArrayDataSource, CollectionViewer, DataSource, Range} from '@angular/cdk/collections'; +import {ArrayDataSource, CollectionViewer, DataSource, ListRange} from '@angular/cdk/collections'; import { Directive, DoCheck, @@ -54,7 +54,7 @@ export type CdkVirtualForOfContext = { }) export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy { /** Emits when the rendered view of the data changes. */ - viewChange = new Subject(); + viewChange = new Subject(); /** Subject that emits when a new DataSource instance is given. */ private _dataSourceChanges = new Subject>(); @@ -124,7 +124,7 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy private _renderedItems: T[]; /** The currently rendered range of indices. */ - private _renderedRange: Range; + private _renderedRange: ListRange; /** * The template cache used to hold on ot template instancess that have been stamped out, but don't @@ -221,7 +221,7 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy } /** React to scroll state changes in the viewport. */ - private _onRenderedRangeChange(renderedRange: Range) { + private _onRenderedRangeChange(renderedRange: ListRange) { this._renderedRange = renderedRange; this.viewChange.next(this._renderedRange); this._renderedItems = this._data.slice(this._renderedRange.start, this._renderedRange.end); diff --git a/src/cdk-experimental/scrolling/virtual-scroll-fixed-size.ts b/src/cdk-experimental/scrolling/virtual-scroll-fixed-size.ts index 49abbfecf42d..08226613c397 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-fixed-size.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-fixed-size.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Range} from '@angular/cdk/collections'; +import {ListRange} 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'; @@ -102,7 +102,7 @@ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { * @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 { + private _expandRange(range: ListRange, expandStart: number, expandEnd: number): ListRange { if (!this._viewport) { return {...range}; } diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts index ce4f8dc3a11e..ce5eb56aadc6 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Range} from '@angular/cdk/collections'; +import {ListRange} from '@angular/cdk/collections'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -31,7 +31,7 @@ import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-s /** Checks if the given ranges are equal. */ -function rangesEqual(r1: Range, r2: Range): boolean { +function rangesEqual(r1: ListRange, r2: ListRange): boolean { return r1.start == r2.start && r1.end == r2.end; } @@ -54,7 +54,7 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy { private _detachedSubject = new Subject(); /** Emits when the rendered range changes. */ - private _renderedRangeSubject = new Subject(); + private _renderedRangeSubject = new Subject(); /** The direction the viewport scrolls. */ @Input() orientation: 'horizontal' | 'vertical' = 'vertical'; @@ -63,7 +63,7 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy { @ViewChild('contentWrapper') _contentWrapper: ElementRef; /** A stream that emits whenever the rendered range changes. */ - renderedRangeStream: Observable = this._renderedRangeSubject.asObservable(); + renderedRangeStream: Observable = this._renderedRangeSubject.asObservable(); /** * The total size of all content (in pixels), including content that is not currently rendered. @@ -74,7 +74,7 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy { _renderedContentTransform: string; /** The currently rendered range of indices. */ - private _renderedRange: Range = {start: 0, end: 0}; + private _renderedRange: ListRange = {start: 0, end: 0}; /** The length of the data bound to this viewport (in number of items). */ private _dataLength = 0; @@ -116,7 +116,7 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy { } /** Sets the currently rendered range of indices. */ - setRenderedRange(range: Range) { + setRenderedRange(range: ListRange) { if (!rangesEqual(this._renderedRange, range)) { // Re-enter the Angular zone so we can mark for change detection. this._ngZone.run(() => { diff --git a/src/cdk/collections/collection-viewer.ts b/src/cdk/collections/collection-viewer.ts index aceb58b142c9..01e8d3c5e846 100644 --- a/src/cdk/collections/collection-viewer.ts +++ b/src/cdk/collections/collection-viewer.ts @@ -10,7 +10,7 @@ import {Observable} from 'rxjs'; /** Represents a range of numbers with a specified start and end. */ -export type Range = {start: number, end: number}; +export type ListRange = {start: number, end: number}; /** @@ -22,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; + viewChange: Observable; } From b10591624489b981a3262dc4f548c610c7ceeb80 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Tue, 6 Mar 2018 09:12:39 -0800 Subject: [PATCH 08/28] virtual-scroll: add autosize scroll strategy (#10219) * rename fixed size virtual scroll directive * add autosize virtual scroll strategy * add item size estimator class * add logic for jumping rendered content based on scroll position * address comments --- .../scrolling/auto-size-virtual-scroll.ts | 228 ++++++++++++++++++ ...d-size.ts => fixed-size-virtual-scroll.ts} | 24 +- src/cdk-experimental/scrolling/public-api.ts | 3 +- .../scrolling/scrolling-module.ts | 9 +- .../virtual-scroll/virtual-scroll-demo.html | 28 ++- .../virtual-scroll/virtual-scroll-demo.ts | 3 +- 6 files changed, 274 insertions(+), 21 deletions(-) create mode 100644 src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts rename src/cdk-experimental/scrolling/{virtual-scroll-fixed-size.ts => fixed-size-virtual-scroll.ts} (83%) diff --git a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts new file mode 100644 index 000000000000..3d6d77830013 --- /dev/null +++ b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts @@ -0,0 +1,228 @@ +/** + * @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 {ListRange} 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'; + + +/** + * A class that tracks the size of items that have been seen and uses it to estimate the average + * item size. + */ +export class ItemSizeAverager { + /** The total amount of weight behind the current average. */ + private _totalWeight = 0; + + /** The current average item size. */ + private _averageItemSize: number; + + /** @param defaultItemSize The default size to use for items when no data is available. */ + constructor(defaultItemSize = 50) { + this._averageItemSize = defaultItemSize; + } + + /** Returns the average item size. */ + getAverageItemSize(): number { + return this._averageItemSize; + } + + /** + * Adds a measurement sample for the estimator to consider. + * @param range The measured range. + * @param size The measured size of the given range in pixels. + */ + addSample(range: ListRange, size: number) { + const weight = range.end - range.start; + const newTotalWeight = this._totalWeight + weight; + if (newTotalWeight) { + const newAverageItemSize = + (size * weight + this._averageItemSize * this._totalWeight) / newTotalWeight; + if (newAverageItemSize) { + this._averageItemSize = newAverageItemSize; + this._totalWeight = newTotalWeight; + } + } + } +} + + +/** Virtual scrolling strategy for lists with items of unknown or dynamic size. */ +export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { + /** The attached viewport. */ + private _viewport: CdkVirtualScrollViewport | null = null; + + /** The minimum amount of buffer rendered beyond the viewport (in pixels). */ + private _minBufferPx: number; + + /** The number of buffer items to render beyond the edge of the viewport (in pixels). */ + private _addBufferPx: number; + + /** The estimator used to estimate the size of unseen items. */ + private _averager: ItemSizeAverager; + + /** + * @param minBufferPx The minimum amount of buffer rendered beyond the viewport (in pixels). + * If the amount of buffer dips below this number, more items will be rendered. + * @param addBufferPx The number of pixels worth of buffer to shoot for when rendering new items. + * If the actual amount turns out to be less it will not necessarily trigger an additional + * rendering cycle (as long as the amount of buffer is still greater than `minBufferPx`). + * @param averager The averager used to estimate the size of unseen items. + */ + constructor(minBufferPx: number, addBufferPx: number, averager = new ItemSizeAverager()) { + this._minBufferPx = minBufferPx; + this._addBufferPx = addBufferPx; + this._averager = averager; + } + + /** + * Attaches this scroll strategy to a viewport. + * @param viewport The viewport to attach this strategy to. + */ + attach(viewport: CdkVirtualScrollViewport) { + this._viewport = viewport; + this._updateTotalContentSize(); + this._renderContentForOffset(this._viewport.measureScrollOffset()); + } + + /** Detaches this scroll strategy from the currently attached viewport. */ + detach() { + this._viewport = null; + } + + /** Called when the viewport is scrolled. */ + onContentScrolled() { + if (this._viewport) { + this._renderContentForOffset(this._viewport.measureScrollOffset()); + } + } + + /** Called when the length of the data changes. */ + onDataLengthChanged() { + if (this._viewport) { + this._updateTotalContentSize(); + this._renderContentForOffset(this._viewport.measureScrollOffset()); + } + } + + /** + * Update the buffer parameters. + * @param minBufferPx The minimum amount of buffer rendered beyond the viewport (in pixels). + * @param addBufferPx The number of buffer items to render beyond the edge of the viewport (in + * pixels). + */ + updateBufferSize(minBufferPx: number, addBufferPx: number) { + this._minBufferPx = minBufferPx; + this._addBufferPx = addBufferPx; + } + + /** + * Render the content that we estimate should be shown for the given scroll offset. + * Note: must not be called if `this._viewport` is null + */ + private _renderContentForOffset(scrollOffset: number) { + const viewport = this._viewport!; + const itemSize = this._averager.getAverageItemSize(); + const firstVisibleIndex = + Math.min(viewport.getDataLength() - 1, Math.floor(scrollOffset / itemSize)); + const bufferSize = Math.ceil(this._addBufferPx / itemSize); + const range = this._expandRange( + this._getVisibleRangeForIndex(firstVisibleIndex), bufferSize, bufferSize); + + viewport.setRenderedRange(range); + viewport.setRenderedContentOffset(itemSize * range.start); + } + + // TODO: maybe move to base class, can probably share with fixed size strategy. + /** + * Gets the visible range of data for the given start index. If the start index is too close to + * the end of the list it may be backed up to ensure the estimated size of the range is enough to + * fill the viewport. + * Note: must not be called if `this._viewport` is null + * @param startIndex The index to start the range at + * @return a range estimated to be large enough to fill the viewport when rendered. + */ + private _getVisibleRangeForIndex(startIndex: number): ListRange { + const viewport = this._viewport!; + const range: ListRange = { + start: startIndex, + end: startIndex + + Math.ceil(viewport.getViewportSize() / this._averager.getAverageItemSize()) + }; + const extra = range.end - viewport.getDataLength(); + if (extra > 0) { + range.start = Math.max(0, range.start - extra); + } + return range; + } + + // TODO: maybe move to base class, can probably share with fixed size strategy. + /** + * Expand the given range by the given amount in either direction. + * Note: must not be called if `this._viewport` is null + * @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: ListRange, expandStart: number, expandEnd: number): ListRange { + const viewport = this._viewport!; + const start = Math.max(0, range.start - expandStart); + const end = Math.min(viewport.getDataLength(), range.end + expandEnd); + return {start, end}; + } + + /** Update the viewport's total content size. */ + private _updateTotalContentSize() { + const viewport = this._viewport!; + viewport.setTotalContentSize(viewport.getDataLength() * this._averager.getAverageItemSize()); + } +} + +/** + * Provider factory for `AutoSizeVirtualScrollStrategy` that simply extracts the already created + * `AutoSizeVirtualScrollStrategy` from the given directive. + * @param autoSizeDir The instance of `CdkAutoSizeVirtualScroll` to extract the + * `AutoSizeVirtualScrollStrategy` from. + */ +export function _autoSizeVirtualScrollStrategyFactory(autoSizeDir: CdkAutoSizeVirtualScroll) { + return autoSizeDir._scrollStrategy; +} + + +/** A virtual scroll strategy that supports unknown or dynamic size items. */ +@Directive({ + selector: 'cdk-virtual-scroll-viewport[autosize]', + providers: [{ + provide: VIRTUAL_SCROLL_STRATEGY, + useFactory: _autoSizeVirtualScrollStrategyFactory, + deps: [forwardRef(() => CdkAutoSizeVirtualScroll)], + }], +}) +export class CdkAutoSizeVirtualScroll implements OnChanges { + /** + * The minimum amount of buffer rendered beyond the viewport (in pixels). + * If the amount of buffer dips below this number, more items will be rendered. + */ + @Input() minBufferPx: number = 100; + + /** + * The number of pixels worth of buffer to shoot for when rendering new items. + * If the actual amount turns out to be less it will not necessarily trigger an additional + * rendering cycle (as long as the amount of buffer is still greater than `minBufferPx`). + */ + @Input() addBufferPx: number = 200; + + /** The scroll strategy used by this directive. */ + _scrollStrategy = new AutoSizeVirtualScrollStrategy(this.minBufferPx, this.addBufferPx); + + ngOnChanges() { + this._scrollStrategy.updateBufferSize(this.minBufferPx, this.addBufferPx); + } +} diff --git a/src/cdk-experimental/scrolling/virtual-scroll-fixed-size.ts b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts similarity index 83% rename from src/cdk-experimental/scrolling/virtual-scroll-fixed-size.ts rename to src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts index 08226613c397..46d523b34484 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-fixed-size.ts +++ b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts @@ -13,7 +13,7 @@ import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; /** Virtual scrolling strategy for lists with items of known fixed size. */ -export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { +export class FixedSizeVirtualScrollStrategy implements VirtualScrollStrategy { /** The attached viewport. */ private _viewport: CdkVirtualScrollViewport | null = null; @@ -25,7 +25,7 @@ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { /** * @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. + * @param bufferSize The number of buffer items to render beyond the edge of the viewport. */ constructor(itemSize: number, bufferSize: number) { this._itemSize = itemSize; @@ -59,7 +59,7 @@ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { this._updateRenderedRange(); } - /** Called when the viewport is scrolled (debounced using requestAnimationFrame). */ + /** Called when the viewport is scrolled. */ onContentScrolled() { this._updateRenderedRange(); } @@ -115,12 +115,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. + * Provider factory for `FixedSizeVirtualScrollStrategy` that simply extracts the already created + * `FixedSizeVirtualScrollStrategy` from the given directive. + * @param fixedSizeDir The instance of `CdkFixedSizeVirtualScroll` to extract the + * `FixedSizeVirtualScrollStrategy` from. */ -export function _virtualScrollFixedSizeStrategyFactory(fixedSizeDir: CdkVirtualScrollFixedSize) { +export function _fixedSizeVirtualScrollStrategyFactory(fixedSizeDir: CdkFixedSizeVirtualScroll) { return fixedSizeDir._scrollStrategy; } @@ -130,11 +130,11 @@ export function _virtualScrollFixedSizeStrategyFactory(fixedSizeDir: CdkVirtualS selector: 'cdk-virtual-scroll-viewport[itemSize]', providers: [{ provide: VIRTUAL_SCROLL_STRATEGY, - useFactory: _virtualScrollFixedSizeStrategyFactory, - deps: [forwardRef(() => CdkVirtualScrollFixedSize)], + useFactory: _fixedSizeVirtualScrollStrategyFactory, + deps: [forwardRef(() => CdkFixedSizeVirtualScroll)], }], }) -export class CdkVirtualScrollFixedSize implements OnChanges { +export class CdkFixedSizeVirtualScroll implements OnChanges { /** The size of the items in the list (in pixels). */ @Input() itemSize = 20; @@ -142,7 +142,7 @@ export class CdkVirtualScrollFixedSize implements OnChanges { @Input() bufferSize = 5; /** The scroll strategy used by this directive. */ - _scrollStrategy = new VirtualScrollFixedSizeStrategy(this.itemSize, this.bufferSize); + _scrollStrategy = new FixedSizeVirtualScrollStrategy(this.itemSize, this.bufferSize); ngOnChanges() { this._scrollStrategy.updateItemAndBufferSize(this.itemSize, this.bufferSize); diff --git a/src/cdk-experimental/scrolling/public-api.ts b/src/cdk-experimental/scrolling/public-api.ts index 31a7b9aadb81..176164b1ed6c 100644 --- a/src/cdk-experimental/scrolling/public-api.ts +++ b/src/cdk-experimental/scrolling/public-api.ts @@ -6,8 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ +export * from './auto-size-virtual-scroll'; +export * from './fixed-size-virtual-scroll'; export * from './scrolling-module'; export * from './virtual-for-of'; -export * from './virtual-scroll-fixed-size'; export * from './virtual-scroll-strategy'; export * from './virtual-scroll-viewport'; diff --git a/src/cdk-experimental/scrolling/scrolling-module.ts b/src/cdk-experimental/scrolling/scrolling-module.ts index e625b67a5ab9..cccaaacb3eb4 100644 --- a/src/cdk-experimental/scrolling/scrolling-module.ts +++ b/src/cdk-experimental/scrolling/scrolling-module.ts @@ -7,20 +7,23 @@ */ import {NgModule} from '@angular/core'; +import {CdkAutoSizeVirtualScroll} from './auto-size-virtual-scroll'; +import {CdkFixedSizeVirtualScroll} from './fixed-size-virtual-scroll'; import {CdkVirtualForOf} from './virtual-for-of'; -import {CdkVirtualScrollFixedSize} from './virtual-scroll-fixed-size'; import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; @NgModule({ exports: [ + CdkAutoSizeVirtualScroll, + CdkFixedSizeVirtualScroll, CdkVirtualForOf, - CdkVirtualScrollFixedSize, CdkVirtualScrollViewport, ], declarations: [ + CdkAutoSizeVirtualScroll, + CdkFixedSizeVirtualScroll, CdkVirtualForOf, - CdkVirtualScrollFixedSize, CdkVirtualScrollViewport, ], }) diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.html b/src/demo-app/virtual-scroll/virtual-scroll-demo.html index afae552a0678..c77bbd2b5cfb 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.html +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.html @@ -1,12 +1,32 @@ - -
+

Autosize

+ + +
+ Item #{{i}} - ({{size}}px) +
+
+ + +
+ Item #{{i}} - ({{size}}px) +
+
+ +

Fixed size

+ + +
Item #{{i}} - ({{size}}px)
- -
+
Item #{{i}} - ({{size}}px)
diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts index 9df25f7ef301..2f09db639df6 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts @@ -16,5 +16,6 @@ import {Component, ViewEncapsulation} from '@angular/core'; encapsulation: ViewEncapsulation.None, }) export class VirtualScrollDemo { - data = Array(10000).fill(20); + fixedSizeData = Array(10000).fill(50); + randomData = Array(10000).fill(0).map(() => Math.round(Math.random() * 100)); } From f04b470848953d22a15bfc4be80a737be4ac4344 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Wed, 7 Mar 2018 14:17:35 -0800 Subject: [PATCH 09/28] virtual-scroll: add `onContentRendered` hook to `VirtualScrollStrategy` (#10290) * virtual-scroll: add `onContentRendered` hook to `VirtualScrollStrategy` * address comemnts --- .../scrolling/auto-size-virtual-scroll.ts | 37 ++++++++++++++----- .../scrolling/fixed-size-virtual-scroll.ts | 3 ++ .../scrolling/virtual-scroll-strategy.ts | 3 ++ .../scrolling/virtual-scroll-viewport.ts | 15 ++++++++ 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts index 3d6d77830013..b1f8694e710a 100644 --- a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts +++ b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts @@ -39,11 +39,10 @@ export class ItemSizeAverager { * @param size The measured size of the given range in pixels. */ addSample(range: ListRange, size: number) { - const weight = range.end - range.start; - const newTotalWeight = this._totalWeight + weight; + const newTotalWeight = this._totalWeight + range.end - range.start; if (newTotalWeight) { const newAverageItemSize = - (size * weight + this._averageItemSize * this._totalWeight) / newTotalWeight; + (size + this._averageItemSize * this._totalWeight) / newTotalWeight; if (newAverageItemSize) { this._averageItemSize = newAverageItemSize; this._totalWeight = newTotalWeight; @@ -87,7 +86,6 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { */ attach(viewport: CdkVirtualScrollViewport) { this._viewport = viewport; - this._updateTotalContentSize(); this._renderContentForOffset(this._viewport.measureScrollOffset()); } @@ -96,21 +94,27 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { this._viewport = null; } - /** Called when the viewport is scrolled. */ + /** Implemented as part of VirtualScrollStrategy. */ onContentScrolled() { if (this._viewport) { this._renderContentForOffset(this._viewport.measureScrollOffset()); } } - /** Called when the length of the data changes. */ + /** Implemented as part of VirtualScrollStrategy. */ onDataLengthChanged() { if (this._viewport) { - this._updateTotalContentSize(); this._renderContentForOffset(this._viewport.measureScrollOffset()); } } + /** Implemented as part of VirtualScrollStrategy. */ + onContentRendered() { + if (this._viewport) { + this._checkRenderedContentSize(); + } + } + /** * Update the buffer parameters. * @param minBufferPx The minimum amount of buffer rendered beyond the viewport (in pixels). @@ -122,6 +126,17 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { this._addBufferPx = addBufferPx; } + /** + * Checks the size of the currently rendered content and uses it to update the estimated item size + * and estimated total content size. + */ + private _checkRenderedContentSize() { + const viewport = this._viewport!; + const renderedContentSize = viewport.measureRenderedContentSize(); + this._averager.addSample(viewport.getRenderedRange(), renderedContentSize); + this._updateTotalContentSize(renderedContentSize); + } + /** * Render the content that we estimate should be shown for the given scroll offset. * Note: must not be called if `this._viewport` is null @@ -179,9 +194,13 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { } /** Update the viewport's total content size. */ - private _updateTotalContentSize() { + private _updateTotalContentSize(renderedContentSize: number) { const viewport = this._viewport!; - viewport.setTotalContentSize(viewport.getDataLength() * this._averager.getAverageItemSize()); + const renderedRange = viewport.getRenderedRange(); + const totalSize = renderedContentSize + + (viewport.getDataLength() - (renderedRange.end - renderedRange.start)) * + this._averager.getAverageItemSize(); + viewport.setTotalContentSize(totalSize); } } diff --git a/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts index 46d523b34484..9a7bfa2b38f1 100644 --- a/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts +++ b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts @@ -70,6 +70,9 @@ export class FixedSizeVirtualScrollStrategy implements VirtualScrollStrategy { this._updateRenderedRange(); } + /** Called when the range of items rendered in the DOM has changed. */ + onContentRendered() { /* no-op */ } + /** Update the viewport's total content size. */ private _updateTotalContentSize() { if (!this._viewport) { diff --git a/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts b/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts index 4fbf35b419af..1bd56fbb99de 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts @@ -31,4 +31,7 @@ export interface VirtualScrollStrategy { /** Called when the length of the data changes. */ onDataLengthChanged(); + + /** Called when the range of items rendered in the DOM has changed. */ + onContentRendered(); } diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts index ce5eb56aadc6..fdabd276ec88 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -23,6 +23,7 @@ import { import {Observable} from 'rxjs/Observable'; import {fromEvent} from 'rxjs/observable/fromEvent'; import {sampleTime} from 'rxjs/operators/sampleTime'; +import {take} from 'rxjs/operators/take'; import {takeUntil} from 'rxjs/operators/takeUntil'; import {animationFrame} from 'rxjs/scheduler/animationFrame'; import {Subject} from 'rxjs/Subject'; @@ -99,6 +100,11 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy { return this._viewportSize; } + /** Get the current rendered range of items. */ + getRenderedRange(): ListRange { + return this._renderedRange; + } + // TODO(mmalebra): Consider calling `detectChanges()` directly rather than the methods below. /** @@ -122,6 +128,9 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy { this._ngZone.run(() => { this._renderedRangeSubject.next(this._renderedRange = range); this._changeDetectorRef.markForCheck(); + this._ngZone.runOutsideAngular(() => this._ngZone.onStable.pipe(take(1)).subscribe(() => { + this._scrollStrategy.onContentRendered(); + })); }); } } @@ -169,6 +178,12 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy { this.elementRef.nativeElement.scrollLeft : this.elementRef.nativeElement.scrollTop; } + /** Measure the combined size of all of the rendered items. */ + measureRenderedContentSize() { + const contentEl = this._contentWrapper.nativeElement; + return this.orientation === 'horizontal' ? contentEl.offsetWidth : contentEl.offsetHeight; + } + ngOnInit() { Promise.resolve().then(() => { this._viewportSize = this.orientation === 'horizontal' ? From 4b8a2c5e1168fde687d0fcd38cf2ba579afd2742 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Thu, 5 Apr 2018 12:20:01 -0700 Subject: [PATCH 10/28] virtual-scroll: add incremental scroll logic in `AutosizeVirtualScrollStrategy` (#10504) * virtual-scroll: add incremental scroll logic in `AutosizeVirtualScrollStrategy`. This still has a couple issues that need to be ironed out and it doesn't have the code for correcting the error between the predicted and actual scroll position. (See various TODOs for additional things that need work). * fix lint * address comments * address comments --- .../scrolling/auto-size-virtual-scroll.ts | 143 +++++++++++++- .../scrolling/virtual-for-of.ts | 70 ++++--- .../scrolling/virtual-scroll-viewport.spec.ts | 46 +++++ .../scrolling/virtual-scroll-viewport.ts | 183 ++++++++++++------ src/demo-app/demo-app/demo-module.ts | 5 +- 5 files changed, 344 insertions(+), 103 deletions(-) create mode 100644 src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts diff --git a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts index b1f8694e710a..47b49604780b 100644 --- a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts +++ b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts @@ -23,8 +23,12 @@ export class ItemSizeAverager { /** The current average item size. */ private _averageItemSize: number; + /** The default size to use for items when no data is available. */ + private _defaultItemSize: number; + /** @param defaultItemSize The default size to use for items when no data is available. */ constructor(defaultItemSize = 50) { + this._defaultItemSize = defaultItemSize; this._averageItemSize = defaultItemSize; } @@ -49,6 +53,12 @@ export class ItemSizeAverager { } } } + + /** Resets the averager. */ + reset() { + this._averageItemSize = this._defaultItemSize; + this._totalWeight = 0; + } } @@ -66,6 +76,15 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { /** The estimator used to estimate the size of unseen items. */ private _averager: ItemSizeAverager; + /** The last measured scroll offset of the viewport. */ + private _lastScrollOffset: number; + + /** The last measured size of the rendered content in the viewport. */ + private _lastRenderedContentSize: number; + + /** The last measured size of the rendered content in the viewport. */ + private _lastRenderedContentOffset: number; + /** * @param minBufferPx The minimum amount of buffer rendered beyond the viewport (in pixels). * If the amount of buffer dips below this number, more items will be rendered. @@ -85,8 +104,9 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { * @param viewport The viewport to attach this strategy to. */ attach(viewport: CdkVirtualScrollViewport) { + this._averager.reset(); this._viewport = viewport; - this._renderContentForOffset(this._viewport.measureScrollOffset()); + this._setScrollOffset(); } /** Detaches this scroll strategy from the currently attached viewport. */ @@ -97,14 +117,15 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { /** Implemented as part of VirtualScrollStrategy. */ onContentScrolled() { if (this._viewport) { - this._renderContentForOffset(this._viewport.measureScrollOffset()); + this._updateRenderedContentAfterScroll(); } } /** Implemented as part of VirtualScrollStrategy. */ onDataLengthChanged() { if (this._viewport) { - this._renderContentForOffset(this._viewport.measureScrollOffset()); + // TODO(mmalebra): Do something smarter here. + this._setScrollOffset(); } } @@ -126,23 +147,127 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { this._addBufferPx = addBufferPx; } + /** Update the rendered content after the user scrolls. */ + private _updateRenderedContentAfterScroll() { + const viewport = this._viewport!; + + // The current scroll offset. + const scrollOffset = viewport.measureScrollOffset(); + // The delta between the current scroll offset and the previously recorded scroll offset. + const scrollDelta = scrollOffset - this._lastScrollOffset; + // The magnitude of the scroll delta. + const scrollMagnitude = Math.abs(scrollDelta); + + // TODO(mmalerba): Record error between actual scroll offset and predicted scroll offset given + // the index of the first rendered element. Fudge the scroll delta to slowly eliminate the error + // as the user scrolls. + + // The current amount of buffer past the start of the viewport. + const startBuffer = this._lastScrollOffset - this._lastRenderedContentOffset; + // The current amount of buffer past the end of the viewport. + const endBuffer = (this._lastRenderedContentOffset + this._lastRenderedContentSize) - + (this._lastScrollOffset + viewport.getViewportSize()); + // The amount of unfilled space that should be filled on the side the user is scrolling toward + // in order to safely absorb the scroll delta. + const underscan = scrollMagnitude + this._minBufferPx - + (scrollDelta < 0 ? startBuffer : endBuffer); + + // Check if there's unfilled space that we need to render new elements to fill. + if (underscan > 0) { + // Check if the scroll magnitude was larger than the viewport size. In this case the user + // won't notice a discontinuity if we just jump to the new estimated position in the list. + // However, if the scroll magnitude is smaller than the viewport the user might notice some + // jitteriness if we just jump to the estimated position. Instead we make sure to scroll by + // the same number of pixels as the scroll magnitude. + if (scrollMagnitude >= viewport.getViewportSize()) { + this._setScrollOffset(); + } else { + // The number of new items to render on the side the user is scrolling towards. Rather than + // just filling the underscan space, we actually fill enough to have a buffer size of + // `addBufferPx`. This gives us a little wiggle room in case our item size estimate is off. + const addItems = Math.max(0, Math.ceil((underscan - this._minBufferPx + this._addBufferPx) / + this._averager.getAverageItemSize())); + // The amount of filled space beyond what is necessary on the side the user is scrolling + // away from. + const overscan = (scrollDelta < 0 ? endBuffer : startBuffer) - this._minBufferPx + + scrollMagnitude; + // The number of currently rendered items to remove on the side the user is scrolling away + // from. + const removeItems = Math.max(0, Math.floor(overscan / this._averager.getAverageItemSize())); + + // The currently rendered range. + const renderedRange = viewport.getRenderedRange(); + // The new range we will tell the viewport to render. We first expand it to include the new + // items we want rendered, we then contract the opposite side to remove items we no longer + // want rendered. + const range = this._expandRange( + renderedRange, scrollDelta < 0 ? addItems : 0, scrollDelta > 0 ? addItems : 0); + if (scrollDelta < 0) { + range.end = Math.max(range.start + 1, range.end - removeItems); + } else { + range.start = Math.min(range.end - 1, range.start + removeItems); + } + + // The new offset we want to set on the rendered content. To determine this we measure the + // number of pixels we removed and then adjust the offset to the start of the rendered + // content or to the end of the rendered content accordingly (whichever one doesn't require + // that the newly added items to be rendered to calculate.) + let contentOffset: number; + let contentOffsetTo: 'to-start' | 'to-end'; + if (scrollDelta < 0) { + const removedSize = viewport.measureRangeSize({ + start: range.end, + end: renderedRange.end, + }); + contentOffset = + this._lastRenderedContentOffset + this._lastRenderedContentSize - removedSize; + contentOffsetTo = 'to-end'; + } else { + const removedSize = viewport.measureRangeSize({ + start: renderedRange.start, + end: range.start, + }); + contentOffset = this._lastRenderedContentOffset + removedSize; + contentOffsetTo = 'to-start'; + } + + // Set the range and offset we calculated above. + viewport.setRenderedRange(range); + viewport.setRenderedContentOffset(contentOffset, contentOffsetTo); + } + } + + // Save the scroll offset to be compared to the new value on the next scroll event. + this._lastScrollOffset = scrollOffset; + } + /** * Checks the size of the currently rendered content and uses it to update the estimated item size * and estimated total content size. */ private _checkRenderedContentSize() { const viewport = this._viewport!; - const renderedContentSize = viewport.measureRenderedContentSize(); - this._averager.addSample(viewport.getRenderedRange(), renderedContentSize); - this._updateTotalContentSize(renderedContentSize); + this._lastRenderedContentOffset = viewport.measureRenderedContentOffset(); + this._lastRenderedContentSize = viewport.measureRenderedContentSize(); + this._averager.addSample(viewport.getRenderedRange(), this._lastRenderedContentSize); + this._updateTotalContentSize(this._lastRenderedContentSize); } /** - * Render the content that we estimate should be shown for the given scroll offset. - * Note: must not be called if `this._viewport` is null + * Sets the scroll offset and renders the content we estimate should be shown at that point. + * @param scrollOffset The offset to jump to. If not specified the scroll offset will not be + * changed, but the rendered content will be recalculated based on our estimate of what should + * be shown at the current scroll offset. */ - private _renderContentForOffset(scrollOffset: number) { + private _setScrollOffset(scrollOffset?: number) { const viewport = this._viewport!; + if (scrollOffset == null) { + scrollOffset = viewport.measureScrollOffset(); + } else { + viewport.setScrollOffset(scrollOffset); + } + this._lastScrollOffset = scrollOffset; + const itemSize = this._averager.getAverageItemSize(); const firstVisibleIndex = Math.min(viewport.getDataLength() - 1, Math.floor(scrollOffset / itemSize)); diff --git a/src/cdk-experimental/scrolling/virtual-for-of.ts b/src/cdk-experimental/scrolling/virtual-for-of.ts index 20ceb0b87c0a..1820ac5f9cce 100644 --- a/src/cdk-experimental/scrolling/virtual-for-of.ts +++ b/src/cdk-experimental/scrolling/virtual-for-of.ts @@ -45,6 +45,12 @@ export type CdkVirtualForOfContext = { }; +/** 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. @@ -151,47 +157,37 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy } /** - * 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. + * 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. */ - 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.`); + 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.`); } - 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; - - 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); - 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 - }; + // 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> | null; + let j = view ? view.rootNodes.length : 0; + while (j--) { + totalSize += getSize(orientation, (view!.rootNodes[j] as Element).getBoundingClientRect()); + } } - return null; + + return totalSize; } ngDoCheck() { diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts new file mode 100644 index 000000000000..a84330d870a4 --- /dev/null +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts @@ -0,0 +1,46 @@ +import {Component, ViewChild} from '@angular/core'; +import {ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing'; +import {ScrollingModule} from './scrolling-module'; +import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; + +describe('Basic CdkVirtualScrollViewport', () => { + let fixture: ComponentFixture; + let viewport: CdkVirtualScrollViewport; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ScrollingModule], + declarations: [BasicViewport], + }).compileComponents(); + + fixture = TestBed.createComponent(BasicViewport); + viewport = fixture.componentInstance.viewport; + }); + + it('should sanitize transform inputs', fakeAsync(() => { + fixture.detectChanges(); + flush(); + + viewport.orientation = 'arbitrary string as orientation' as any; + viewport.setRenderedContentOffset( + 'arbitrary string as offset' as any, 'arbitrary string as to' as any); + fixture.detectChanges(); + flush(); + + expect((viewport._renderedContentTransform as any).changingThisBreaksApplicationSecurity) + .toEqual('translateY(NaNpx)'); + })); +}); + +@Component({ + template: ` + + {{item}} + + ` +}) +class BasicViewport { + @ViewChild(CdkVirtualScrollViewport) viewport; + + items = Array(10).fill(0); +} diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts index fdabd276ec88..20cc5c48db75 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -11,6 +11,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + DoCheck, ElementRef, Inject, Input, @@ -20,6 +21,7 @@ import { ViewChild, ViewEncapsulation, } from '@angular/core'; +import {DomSanitizer, SafeStyle} from '@angular/platform-browser'; import {Observable} from 'rxjs/Observable'; import {fromEvent} from 'rxjs/observable/fromEvent'; import {sampleTime} from 'rxjs/operators/sampleTime'; @@ -50,7 +52,7 @@ function rangesEqual(r1: ListRange, r2: ListRange): boolean { changeDetection: ChangeDetectionStrategy.OnPush, preserveWhitespaces: false, }) -export class CdkVirtualScrollViewport implements OnInit, OnDestroy { +export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { /** Emits when the viewport is detached from a CdkVirtualForOf. */ private _detachedSubject = new Subject(); @@ -72,7 +74,7 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy { _totalContentSize = 0; /** The transform used to offset the rendered content wrapper element. */ - _renderedContentTransform: string; + _renderedContentTransform: SafeStyle; /** The currently rendered range of indices. */ private _renderedRange: ListRange = {start: 0, end: 0}; @@ -83,13 +85,78 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy { /** The size of the viewport (in pixels). */ private _viewportSize = 0; - /** Whether this viewport is attached to a CdkVirtualForOf. */ - private _isAttached = false; + /** The pending scroll offset to be applied during the next change detection cycle. */ + private _pendingScrollOffset: number | null; + + /** the currently attached CdkVirtualForOf. */ + private _forOf: CdkVirtualForOf | null; constructor(public elementRef: ElementRef, private _changeDetectorRef: ChangeDetectorRef, - private _ngZone: NgZone, + private _ngZone: NgZone, private _sanitizer: DomSanitizer, @Inject(VIRTUAL_SCROLL_STRATEGY) private _scrollStrategy: VirtualScrollStrategy) {} + ngOnInit() { + const viewportEl = this.elementRef.nativeElement; + Promise.resolve().then(() => { + this._viewportSize = this.orientation === 'horizontal' ? + viewportEl.clientWidth : viewportEl.clientHeight; + this._scrollStrategy.attach(this); + + this._ngZone.runOutsideAngular(() => { + fromEvent(viewportEl, 'scroll') + // Sample the scroll stream at every animation frame. This way if there are multiple + // scroll events in the same frame we only need to recheck our layout once. + .pipe(sampleTime(0, animationFrame)) + .subscribe(() => this._scrollStrategy.onContentScrolled()); + }); + }); + } + + ngDoCheck() { + // In order to batch setting the scroll offset together with other DOM writes, we wait until a + // change detection cycle to actually apply it. + if (this._pendingScrollOffset != null) { + if (this.orientation === 'horizontal') { + this.elementRef.nativeElement.scrollLeft = this._pendingScrollOffset; + } else { + this.elementRef.nativeElement.scrollTop = this._pendingScrollOffset; + } + } + } + + ngOnDestroy() { + this.detach(); + this._scrollStrategy.detach(); + + // Complete all subjects + this._renderedRangeSubject.complete(); + this._detachedSubject.complete(); + } + + /** Attaches a `CdkVirtualForOf` to this viewport. */ + attach(forOf: CdkVirtualForOf) { + if (this._forOf) { + throw Error('CdkVirtualScrollViewport is already attached.'); + } + this._forOf = forOf; + + // Subscribe to the data stream of the CdkVirtualForOf to keep track of when the data length + // changes. + this._forOf.dataStream.pipe(takeUntil(this._detachedSubject)).subscribe(data => { + const len = data.length; + if (len != this._dataLength) { + this._dataLength = len; + this._scrollStrategy.onDataLengthChanged(); + } + }); + } + + /** Detaches the current `CdkVirtualForOf`. */ + detach() { + this._forOf = null; + this._detachedSubject.next(); + } + /** Gets the length of the data bound to this viewport (in number of items). */ getDataLength(): number { return this._dataLength; @@ -100,6 +167,11 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy { return this._viewportSize; } + // TODO(mmalerba): This is technically out of sync with what's really rendered until a render + // cycle happens. I'm being careful to only call it after the render cycle is complete and before + // setting it to something else, but its error prone and should probably be split into + // `pendingRange` and `renderedRange`, the latter reflecting whats actually in the DOM. + /** Get the current rendered range of items. */ getRenderedRange(): ListRange { return this._renderedRange; @@ -129,83 +201,86 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy { this._renderedRangeSubject.next(this._renderedRange = range); this._changeDetectorRef.markForCheck(); this._ngZone.runOutsideAngular(() => this._ngZone.onStable.pipe(take(1)).subscribe(() => { - this._scrollStrategy.onContentRendered(); + // Queue this up in a `Promise.resolve()` so that if the user makes a series of calls + // like: + // + // viewport.setRenderedRange(...); + // viewport.setTotalContentSize(...); + // viewport.setRenderedContentOffset(...); + // + // The call to `onContentRendered` will happen after all of the updates have been applied. + Promise.resolve().then(() => this._scrollStrategy.onContentRendered()); })); }); } } /** 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)`; + setRenderedContentOffset(offset: number, to: 'to-start' | 'to-end' = 'to-start') { + const axis = this.orientation === 'horizontal' ? 'X' : 'Y'; + let transform = `translate${axis}(${Number(offset)}px)`; + if (to === 'to-end') { + // TODO(mmalerba): The viewport should rewrite this as a `to-start` offset on the next render + // cycle. Otherwise elements will appear to expand in the wrong direction (e.g. + // `mat-expansion-panel` would expand upward). + transform += ` translate${axis}(-100%)`; + } if (this._renderedContentTransform != transform) { // Re-enter the Angular zone so we can mark for change detection. this._ngZone.run(() => { - this._renderedContentTransform = transform; + // We know this value is safe because we parse `offset` with `Number()` before passing it + // into the string. + this._renderedContentTransform = this._sanitizer.bypassSecurityTrustStyle(transform); this._changeDetectorRef.markForCheck(); }); } } - /** Attaches a `CdkVirtualForOf` to this viewport. */ - attach(forOf: CdkVirtualForOf) { - if (this._isAttached) { - throw Error('CdkVirtualScrollViewport is already attached.'); - } - - 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; - this._scrollStrategy.onDataLengthChanged(); - } + /** Sets the scroll offset on the viewport. */ + setScrollOffset(offset: number) { + // Rather than setting the offset immediately, we batch it up to be applied along with other DOM + // writes during the next change detection cycle. + this._ngZone.run(() => { + this._pendingScrollOffset = offset; + this._changeDetectorRef.markForCheck(); }); } - /** Detaches the current `CdkVirtualForOf`. */ - detach() { - this._isAttached = false; - this._detachedSubject.next(); - } - /** Gets the current scroll offset of the viewport (in pixels). */ - measureScrollOffset() { + measureScrollOffset(): number { return this.orientation === 'horizontal' ? this.elementRef.nativeElement.scrollLeft : this.elementRef.nativeElement.scrollTop; } /** Measure the combined size of all of the rendered items. */ - measureRenderedContentSize() { + measureRenderedContentSize(): number { const contentEl = this._contentWrapper.nativeElement; return this.orientation === 'horizontal' ? contentEl.offsetWidth : contentEl.offsetHeight; } - ngOnInit() { - Promise.resolve().then(() => { - this._viewportSize = this.orientation === 'horizontal' ? - this.elementRef.nativeElement.clientWidth : this.elementRef.nativeElement.clientHeight; - this._scrollStrategy.attach(this); - - this._ngZone.runOutsideAngular(() => { - fromEvent(this.elementRef.nativeElement, 'scroll') - // Sample the scroll stream at every animation frame. This way if there are multiple - // scroll events in the same frame we only need to recheck our layout once - .pipe(sampleTime(0, animationFrame)) - .subscribe(() => this._scrollStrategy.onContentScrolled()); - }); - }); + // TODO(mmalerba): Try to do this in a way that's less bad for performance. (The bad part here is + // that we have to measure the viewport which is not absolutely positioned.) + /** Measure the offset from the start of the viewport to the start of the rendered content. */ + measureRenderedContentOffset(): number { + const viewportEl = this.elementRef.nativeElement; + const contentEl = this._contentWrapper.nativeElement; + if (this.orientation === 'horizontal') { + return contentEl.getBoundingClientRect().left + viewportEl.scrollLeft - + viewportEl.getBoundingClientRect().left - viewportEl.clientLeft; + } else { + return contentEl.getBoundingClientRect().top + viewportEl.scrollTop - + viewportEl.getBoundingClientRect().top - viewportEl.clientTop; + } } - ngOnDestroy() { - this.detach(); - this._scrollStrategy.detach(); - - // Complete all subjects - this._detachedSubject.complete(); - this._renderedRangeSubject.complete(); + /** + * Measure the total combined size of the given range. Throws if the range includes items that are + * not rendered. + */ + measureRangeSize(range: ListRange): number { + if (!this._forOf) { + return 0; + } + return this._forOf.measureRangeSize(range, this.orientation); } } diff --git a/src/demo-app/demo-app/demo-module.ts b/src/demo-app/demo-app/demo-module.ts index a989b92451e5..58e28527dd98 100644 --- a/src/demo-app/demo-app/demo-module.ts +++ b/src/demo-app/demo-app/demo-module.ts @@ -21,6 +21,7 @@ import {ButtonDemo} from '../button/button-demo'; import {CardDemo} from '../card/card-demo'; import {CheckboxDemo, MatCheckboxDemoNestedChecklist} from '../checkbox/checkbox-demo'; import {ChipsDemo} from '../chips/chips-demo'; +import {ConnectedOverlayDemo, DemoOverlay} from '../connected-overlay/connected-overlay-demo'; import {CustomHeader, DatepickerDemo} from '../datepicker/datepicker-demo'; import {DemoMaterialModule} from '../demo-material-module'; import {ContentElementDialog, DialogDemo, IFrameDialog, JazzDialog} from '../dialog/dialog-demo'; @@ -63,13 +64,11 @@ import { } from '../tabs/tabs-demo'; import {ToolbarDemo} from '../toolbar/toolbar-demo'; import {TooltipDemo} from '../tooltip/tooltip-demo'; +import {TreeDemoModule} from '../tree/tree-demo-module'; 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 {TreeDemoModule} from '../tree/tree-demo-module'; -import {ConnectedOverlayDemo, DemoOverlay} from '../connected-overlay/connected-overlay-demo'; @NgModule({ imports: [ From 3acaddabe861e10a9ae596870ad2a32e3972c6d0 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Thu, 5 Apr 2018 14:58:06 -0700 Subject: [PATCH 11/28] fix bazel --- packages.bzl | 15 +++++++ src/cdk-experimental/BUILD.bazel | 11 +++--- src/cdk-experimental/index.ts | 2 +- src/cdk-experimental/package.json | 2 +- src/cdk-experimental/scrolling/BUILD.bazel | 39 ++++++++++++++++--- .../scrolling/tsconfig-build.json | 2 +- .../scrolling/virtual-for-of.ts | 8 +--- .../scrolling/virtual-scroll-viewport.ts | 11 ++---- src/cdk-experimental/tsconfig-build.json | 9 ++++- src/cdk/collections/array-data-source.ts | 3 +- tools/package-tools/rollup-globals.ts | 2 + 11 files changed, 72 insertions(+), 32 deletions(-) diff --git a/packages.bzl b/packages.bzl index e5dd37245230..571e66bc47de 100644 --- a/packages.bzl +++ b/packages.bzl @@ -21,6 +21,15 @@ CDK_PACKAGES = [ CDK_TARGETS = ["//src/cdk"] + ["//src/cdk/%s" % p for p in CDK_PACKAGES] +CDK_EXPERIMENTAL_PACKAGES = [ + # "dialog", # Disabled because BUILD.bazel doesn't exist yet + "scrolling", +] + +CDK_EXPERIMENTAL_TARGETS = ["//src/cdk-experimental"] + [ + "//src/cdk-experimental/%s" % p for p in CDK_EXPERIMENTAL_PACKAGES +] + MATERIAL_PACKAGES = [ "autocomplete", "badge", @@ -76,6 +85,12 @@ ROLLUP_GLOBALS.update({ "@angular/cdk/%s" % p: "ng.cdk.%s" % p for p in CDK_PACKAGES }) +# Rollup globals for cdk subpackages in the form of, e.g., +# {"@angular/cdk-experimental/scrolling": "ng.cdkExperimental.scrolling"} +ROLLUP_GLOBALS.update({ + "@angular/cdk-experimental/%s" % p: "ng.cdkExperimental.%s" % p for p in CDK_EXPERIMENTAL_PACKAGES +}) + # Rollup globals for material subpackages, e.g., {"@angular/material/list": "ng.material.list"} ROLLUP_GLOBALS.update({ "@angular/material/%s" % p: "ng.material.%s" % p for p in MATERIAL_PACKAGES diff --git a/src/cdk-experimental/BUILD.bazel b/src/cdk-experimental/BUILD.bazel index 6703ee2f9407..dc43fbbf1710 100644 --- a/src/cdk-experimental/BUILD.bazel +++ b/src/cdk-experimental/BUILD.bazel @@ -1,14 +1,15 @@ package(default_visibility=["//visibility:public"]) load("@angular//:index.bzl", "ng_module", "ng_package") -load("//:packages.bzl", "CDK_TARGETS", "ROLLUP_GLOBALS") +load("//:packages.bzl", "CDK_EXPERIMENTAL_PACKAGES", "CDK_EXPERIMENTAL_TARGETS", "CDK_TARGETS", "ROLLUP_GLOBALS") +# Export the CDK tsconfig so that subpackages can reference it directly. +exports_files(["tsconfig-build.json"]) ng_module( name = "cdk-experimental", - srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]), + srcs = glob(["*.ts"], exclude=["**/*.spec.ts"]), module_name = "@angular/cdk-experimental", - deps = CDK_TARGETS, - assets = glob(["**/*.css", "**/*.html"]), + deps = ["//src/cdk-experimental/%s" % p for p in CDK_EXPERIMENTAL_PACKAGES], tsconfig = "//src/lib:tsconfig-build.json", ) @@ -17,5 +18,5 @@ ng_package( srcs = ["package.json"], entry_point = "src/cdk-experimental/public_api.js", globals = ROLLUP_GLOBALS, - deps = [":cdk-experimental"], + deps = CDK_EXPERIMENTAL_TARGETS, ) diff --git a/src/cdk-experimental/index.ts b/src/cdk-experimental/index.ts index 0f411c889e3f..e9931cbedf1a 100644 --- a/src/cdk-experimental/index.ts +++ b/src/cdk-experimental/index.ts @@ -7,4 +7,4 @@ */ export * from './public-api'; -export * from './scrolling/index'; +export * from '@angular/cdk-experimental/scrolling'; diff --git a/src/cdk-experimental/package.json b/src/cdk-experimental/package.json index 4a4a2607869d..72e80667e29b 100644 --- a/src/cdk-experimental/package.json +++ b/src/cdk-experimental/package.json @@ -16,7 +16,7 @@ }, "homepage": "https://github.com/angular/material2#readme", "peerDependencies": { - "@angular/material": "0.0.0-PLACEHOLDER", + "@angular/cdk": "0.0.0-PLACEHOLDER", "@angular/core": "0.0.0-NG" }, "dependencies": { diff --git a/src/cdk-experimental/scrolling/BUILD.bazel b/src/cdk-experimental/scrolling/BUILD.bazel index 133985734a23..dc0e10f9e87f 100644 --- a/src/cdk-experimental/scrolling/BUILD.bazel +++ b/src/cdk-experimental/scrolling/BUILD.bazel @@ -1,5 +1,6 @@ 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") @@ -7,14 +8,12 @@ ng_module( name = "scrolling", srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]), module_name = "@angular/cdk-experimental/scrolling", - assets = [ - ":virtual_scroll_viewport_css", - ], + assets = [":virtual_scroll_viewport_css"] + glob(["**/*.html"]), deps = [ "//src/cdk/collections", "@rxjs", ], - tsconfig = ":tsconfig-build.json", + tsconfig = "//src/cdk-experimental:tsconfig-build.json", ) sass_binary( @@ -26,8 +25,36 @@ sass_binary( # 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"], + srcs = [":virtual_scroll_viewport_scss.css"], outs = ["virtual-scroll-viewport.css"], - cmd = "cat $(locations :virtual_scroll_viewport_scss) > $@", + cmd = "cp $< $@", ) +ts_library( + name = "scrolling_test_sources", + testonly = 1, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":scrolling", + "//src/cdk/testing", + "@rxjs", + ], + tsconfig = "//src/cdk-experimental:tsconfig-build.json", +) + +ts_web_test( + name = "unit_tests", + bootstrap = [ + "//:web_test_bootstrap_scripts", + ], + tags = ["manual"], + + # Do not sort + deps = [ + "//:tslib_bundle", + "//:angular_bundles", + "//:angular_test_bundles", + "//test:angular_test_init", + ":scrolling_test_sources", + ], +) diff --git a/src/cdk-experimental/scrolling/tsconfig-build.json b/src/cdk-experimental/scrolling/tsconfig-build.json index d94e5e159676..21ad182a0358 100644 --- a/src/cdk-experimental/scrolling/tsconfig-build.json +++ b/src/cdk-experimental/scrolling/tsconfig-build.json @@ -6,7 +6,7 @@ ], "angularCompilerOptions": { "annotateForClosureCompiler": true, - "strictMetadataEmit": true, + "strictMetadataEmit": false, // Workaround for Angular #22210 "flatModuleOutFile": "index.js", "flatModuleId": "@angular/cdk-experimental/scrolling", "skipTemplateCodegen": true, diff --git a/src/cdk-experimental/scrolling/virtual-for-of.ts b/src/cdk-experimental/scrolling/virtual-for-of.ts index 1820ac5f9cce..648cc8ae611c 100644 --- a/src/cdk-experimental/scrolling/virtual-for-of.ts +++ b/src/cdk-experimental/scrolling/virtual-for-of.ts @@ -23,12 +23,8 @@ import { 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 {Observable, Subject} from 'rxjs'; +import {pairwise, shareReplay, startWith, switchMap} from 'rxjs/operators'; import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts index 20cc5c48db75..f31679ca9c47 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -22,13 +22,8 @@ import { ViewEncapsulation, } from '@angular/core'; import {DomSanitizer, SafeStyle} from '@angular/platform-browser'; -import {Observable} from 'rxjs/Observable'; -import {fromEvent} from 'rxjs/observable/fromEvent'; -import {sampleTime} from 'rxjs/operators/sampleTime'; -import {take} from 'rxjs/operators/take'; -import {takeUntil} from 'rxjs/operators/takeUntil'; -import {animationFrame} from 'rxjs/scheduler/animationFrame'; -import {Subject} from 'rxjs/Subject'; +import {animationFrameScheduler, fromEvent, Observable, Subject} from 'rxjs'; +import {sampleTime, take, takeUntil} from 'rxjs/operators'; import {CdkVirtualForOf} from './virtual-for-of'; import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy'; @@ -106,7 +101,7 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { fromEvent(viewportEl, 'scroll') // Sample the scroll stream at every animation frame. This way if there are multiple // scroll events in the same frame we only need to recheck our layout once. - .pipe(sampleTime(0, animationFrame)) + .pipe(sampleTime(0, animationFrameScheduler)) .subscribe(() => this._scrollStrategy.onContentScrolled()); }); }); diff --git a/src/cdk-experimental/tsconfig-build.json b/src/cdk-experimental/tsconfig-build.json index ce793c1773b6..7b838247458e 100644 --- a/src/cdk-experimental/tsconfig-build.json +++ b/src/cdk-experimental/tsconfig-build.json @@ -13,12 +13,16 @@ "moduleResolution": "node", "outDir": "../../dist/packages/cdk-experimental", "rootDir": ".", + "rootDirs": [ + ".", + "../../dist/packages/cdk-experimental" + ], "sourceMap": true, "inlineSources": true, "target": "es2015", "lib": ["es2015", "dom"], "skipLibCheck": true, - "types": [], + "types": ["jasmine", "tslib"], "paths": { "@angular/material/*": ["../../dist/packages/material/*"], "@angular/material": ["../../dist/packages/material/public-api"], @@ -35,6 +39,7 @@ "strictMetadataEmit": false, // Workaround for Angular #22210 "flatModuleOutFile": "index.js", "flatModuleId": "@angular/cdk-experimental", - "skipTemplateCodegen": true + "skipTemplateCodegen": true, + "fullTemplateTypeCheck": true } } diff --git a/src/cdk/collections/array-data-source.ts b/src/cdk/collections/array-data-source.ts index 91a9c1164f0e..5d950fbb2c76 100644 --- a/src/cdk/collections/array-data-source.ts +++ b/src/cdk/collections/array-data-source.ts @@ -6,8 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Observable} from 'rxjs/Observable'; -import {of as observableOf} from 'rxjs/observable/of'; +import {Observable, of as observableOf} from 'rxjs'; import {DataSource} from './data-source'; diff --git a/tools/package-tools/rollup-globals.ts b/tools/package-tools/rollup-globals.ts index 63327d885d77..2a3faa90c9f9 100644 --- a/tools/package-tools/rollup-globals.ts +++ b/tools/package-tools/rollup-globals.ts @@ -45,8 +45,10 @@ export const rollupGlobals = { // Some packages are not really needed for the UMD bundles, but for the missingRollupGlobals rule. '@angular/cdk': 'ng.cdk', + '@angular/cdk-experimental': 'ng.cdkExperimental', '@angular/material': 'ng.material', '@angular/material-examples': 'ng.materialExamples', + '@angular/material-experimental': 'ng.materialExperimental', '@angular/material-moment-adapter': 'ng.materialMomentAdapter', // Include secondary entry-points of the cdk and material packages From 2538942adb1e7372f35e08a563aedb513c4371f1 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Thu, 5 Apr 2018 15:20:58 -0700 Subject: [PATCH 12/28] fix devapp --- src/cdk-experimental/tsconfig-build.json | 4 +--- src/cdk-experimental/tsconfig-tests.json | 6 +++++- src/cdk-experimental/tsconfig.json | 4 +--- src/demo-app/system-config.ts | 2 ++ src/e2e-app/system-config.ts | 2 ++ test/karma-test-shim.js | 2 ++ 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/cdk-experimental/tsconfig-build.json b/src/cdk-experimental/tsconfig-build.json index 7b838247458e..9760823cafcc 100644 --- a/src/cdk-experimental/tsconfig-build.json +++ b/src/cdk-experimental/tsconfig-build.json @@ -24,10 +24,8 @@ "skipLibCheck": true, "types": ["jasmine", "tslib"], "paths": { - "@angular/material/*": ["../../dist/packages/material/*"], - "@angular/material": ["../../dist/packages/material/public-api"], "@angular/cdk/*": ["../../dist/packages/cdk/*"], - "@angular/cdk": ["../../dist/packages/cdk"] + "@angular/cdk-experimental/*": ["../../dist/packages/cdk-experimental/*"] } }, "files": [ diff --git a/src/cdk-experimental/tsconfig-tests.json b/src/cdk-experimental/tsconfig-tests.json index a2db344c56d5..66c7b191742a 100644 --- a/src/cdk-experimental/tsconfig-tests.json +++ b/src/cdk-experimental/tsconfig-tests.json @@ -7,7 +7,11 @@ "importHelpers": false, "module": "commonjs", "target": "es5", - "types": ["jasmine"] + "types": ["jasmine"], + "paths": { + "@angular/cdk/*": ["../../dist/packages/cdk/*/public-api"], + "@angular/cdk-experimental/*": ["./*"] + } }, "angularCompilerOptions": { "strictMetadataEmit": false, // Workaround for Angular #22210 diff --git a/src/cdk-experimental/tsconfig.json b/src/cdk-experimental/tsconfig.json index 7e813547746f..58cf626924cf 100644 --- a/src/cdk-experimental/tsconfig.json +++ b/src/cdk-experimental/tsconfig.json @@ -6,9 +6,7 @@ "baseUrl": ".", "paths": { "@angular/cdk/*": ["../cdk/*"], - "@angular/cdk": ["../cdk"], - "@angular/material/*": ["../lib/*"], - "@angular/material": ["../lib/public-api.ts"] + "@angular/cdk-experimental/*": ["../cdk-experimental/*"] } }, "include": ["./**/*.ts"] diff --git a/src/demo-app/system-config.ts b/src/demo-app/system-config.ts index d92d5c3683e5..abe20110c503 100644 --- a/src/demo-app/system-config.ts +++ b/src/demo-app/system-config.ts @@ -60,6 +60,8 @@ System.config({ '@angular/cdk/text-field': 'dist/packages/cdk/text-field/index.js', '@angular/cdk/tree': 'dist/packages/cdk/tree/index.js', + '@angular/cdk-experimental/scrolling': 'dist/packages/cdk-experimental/scrolling/index.js', + '@angular/material/autocomplete': 'dist/packages/material/autocomplete/index.js', '@angular/material/bottom-sheet': 'dist/packages/material/bottom-sheet/index.js', '@angular/material/button': 'dist/packages/material/button/index.js', diff --git a/src/e2e-app/system-config.ts b/src/e2e-app/system-config.ts index 2b0687889a8a..b56861f79c4a 100644 --- a/src/e2e-app/system-config.ts +++ b/src/e2e-app/system-config.ts @@ -52,6 +52,8 @@ System.config({ '@angular/material-examples': 'dist/bundles/material-examples.umd.js', '@angular/cdk/text-field': 'dist/bundles/cdk-text-field.umd.js', + '@angular/cdk-experimental/scrolling': 'dist/bundles/cdk-experimental-scrolling.umd.js', + '@angular/material/autocomplete': 'dist/bundles/material-autocomplete.umd.js', '@angular/material/bottom-sheet': 'dist/bundles/material-bottom-sheet.umd.js', '@angular/material/button': 'dist/bundles/material-button.umd.js', diff --git a/test/karma-test-shim.js b/test/karma-test-shim.js index 78e115b9d2c2..59d26de72981 100644 --- a/test/karma-test-shim.js +++ b/test/karma-test-shim.js @@ -72,6 +72,8 @@ System.config({ '@angular/cdk/text-field': 'dist/packages/cdk/text-field/index.js', '@angular/cdk/tree': 'dist/packages/cdk/tree/index.js', + '@angular/cdk-experimental/scrolling': 'dist/packages/cdk-experimental/scrolling/index.js', + '@angular/material/autocomplete': 'dist/packages/material/autocomplete/index.js', '@angular/material/badge': 'dist/packages/material/badge/index.js', '@angular/material/bottom-sheet': 'dist/packages/material/bottom-sheet/index.js', From 8565c60e8cf3876dd3e3ffd6c79018c9ef7acc0e Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Thu, 5 Apr 2018 15:27:19 -0700 Subject: [PATCH 13/28] fix lint --- src/cdk-experimental/scrolling/virtual-for-of.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cdk-experimental/scrolling/virtual-for-of.ts b/src/cdk-experimental/scrolling/virtual-for-of.ts index 648cc8ae611c..e9ed75b6f614 100644 --- a/src/cdk-experimental/scrolling/virtual-for-of.ts +++ b/src/cdk-experimental/scrolling/virtual-for-of.ts @@ -41,7 +41,7 @@ export type CdkVirtualForOfContext = { }; -/** Helper to extract size from a ClientRect. **/ +/** Helper to extract size from a ClientRect. */ function getSize(orientation: 'horizontal' | 'vertical', rect: ClientRect): number { return orientation == 'horizontal' ? rect.width : rect.height; } From 97d623be2a24c653c1f5d994425fa90489861c26 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Thu, 12 Apr 2018 10:10:04 -0700 Subject: [PATCH 14/28] cleanup --- src/cdk-experimental/index.ts | 1 - src/cdk-experimental/public-api.ts | 1 + src/cdk/collections/data-source.ts | 1 - src/cdk/scrolling/BUILD.bazel | 1 - src/cdk/scrolling/public-api.ts | 2 +- src/demo-app/demo-app-module.ts | 8 ++++---- 6 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/cdk-experimental/index.ts b/src/cdk-experimental/index.ts index e9931cbedf1a..676ca90f1ffa 100644 --- a/src/cdk-experimental/index.ts +++ b/src/cdk-experimental/index.ts @@ -7,4 +7,3 @@ */ export * from './public-api'; -export * from '@angular/cdk-experimental/scrolling'; diff --git a/src/cdk-experimental/public-api.ts b/src/cdk-experimental/public-api.ts index 326c704b094c..8b70da4e86c8 100644 --- a/src/cdk-experimental/public-api.ts +++ b/src/cdk-experimental/public-api.ts @@ -7,3 +7,4 @@ */ export * from './version'; +export * from '@angular/cdk-experimental/scrolling'; diff --git a/src/cdk/collections/data-source.ts b/src/cdk/collections/data-source.ts index ffddbf1af473..2dfe47fb214c 100644 --- a/src/cdk/collections/data-source.ts +++ b/src/cdk/collections/data-source.ts @@ -9,7 +9,6 @@ import {Observable} from 'rxjs'; import {CollectionViewer} from './collection-viewer'; - export abstract class DataSource { /** * Connects a collection viewer (such as a data-table) to this data source. Note that diff --git a/src/cdk/scrolling/BUILD.bazel b/src/cdk/scrolling/BUILD.bazel index 9db694d1e903..3a249aeaccfd 100644 --- a/src/cdk/scrolling/BUILD.bazel +++ b/src/cdk/scrolling/BUILD.bazel @@ -8,7 +8,6 @@ ng_module( srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]), module_name = "@angular/cdk/scrolling", deps = [ - "//src/cdk/collections", "//src/cdk/platform", "@rxjs", ], diff --git a/src/cdk/scrolling/public-api.ts b/src/cdk/scrolling/public-api.ts index 8cafb330c710..ffbd65d14e06 100644 --- a/src/cdk/scrolling/public-api.ts +++ b/src/cdk/scrolling/public-api.ts @@ -8,5 +8,5 @@ export * from './scroll-dispatcher'; export * from './scrollable'; -export * from './scrolling-module'; export * from './viewport-ruler'; +export * from './scrolling-module'; diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index f954aac852a5..5e42bbf01d24 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -6,15 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ -import {HttpClientModule} from '@angular/common/http'; import {ApplicationRef, NgModule} from '@angular/core'; import {BrowserModule} from '@angular/platform-browser'; -import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; +import {HttpClientModule} from '@angular/common/http'; import {RouterModule} from '@angular/router'; -import {AccessibilityDemoModule} from './a11y/a11y-module'; +import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; +import {ALL_ROUTES} from './demo-app/routes'; import {EntryApp} from './demo-app/demo-app'; import {DemoModule} from './demo-app/demo-module'; -import {ALL_ROUTES} from './demo-app/routes'; +import {AccessibilityDemoModule} from './a11y/a11y-module'; @NgModule({ From ed6ab9a9040a402af352f1defb501738e81e4951 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Fri, 27 Apr 2018 09:58:38 -0700 Subject: [PATCH 15/28] virtual-scroll: rewrite offset in terms of "to-top" and fix a bug where items were removed too soon (#10986) * rewrite offsets to the end of the rendered content as offsets to the start * add some more autosize demos for testing * make sure not to remove too many items * address comments --- .../scrolling/auto-size-virtual-scroll.ts | 50 +++++++++++++++---- .../scrolling/virtual-scroll-viewport.ts | 50 ++++++++++++------- .../virtual-scroll/virtual-scroll-demo.html | 18 +++++++ .../virtual-scroll/virtual-scroll-demo.ts | 3 ++ 4 files changed, 95 insertions(+), 26 deletions(-) diff --git a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts index 47b49604780b..a26d6d1af704 100644 --- a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts +++ b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts @@ -85,6 +85,13 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { /** The last measured size of the rendered content in the viewport. */ private _lastRenderedContentOffset: number; + /** + * The number of consecutive cycles where removing extra items has failed. Failure here means that + * we estimated how many items we could safely remove, but our estimate turned out to be too much + * and it wasn't safe to remove that many elements. + */ + private _removalFailures = 0; + /** * @param minBufferPx The minimum amount of buffer rendered beyond the viewport (in pixels). * If the amount of buffer dips below this number, more items will be rendered. @@ -182,6 +189,8 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { if (scrollMagnitude >= viewport.getViewportSize()) { this._setScrollOffset(); } else { + // The currently rendered range. + const renderedRange = viewport.getRenderedRange(); // The number of new items to render on the side the user is scrolling towards. Rather than // just filling the underscan space, we actually fill enough to have a buffer size of // `addBufferPx`. This gives us a little wiggle room in case our item size estimate is off. @@ -192,11 +201,13 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { const overscan = (scrollDelta < 0 ? endBuffer : startBuffer) - this._minBufferPx + scrollMagnitude; // The number of currently rendered items to remove on the side the user is scrolling away - // from. - const removeItems = Math.max(0, Math.floor(overscan / this._averager.getAverageItemSize())); + // from. If removal has failed in recent cycles we are less aggressive in how much we try to + // remove. + const unboundedRemoveItems = Math.floor( + overscan / this._averager.getAverageItemSize() / (this._removalFailures + 1)); + const removeItems = + Math.min(renderedRange.end - renderedRange.start, Math.max(0, unboundedRemoveItems)); - // The currently rendered range. - const renderedRange = viewport.getRenderedRange(); // The new range we will tell the viewport to render. We first expand it to include the new // items we want rendered, we then contract the opposite side to remove items we no longer // want rendered. @@ -215,19 +226,39 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { let contentOffset: number; let contentOffsetTo: 'to-start' | 'to-end'; if (scrollDelta < 0) { - const removedSize = viewport.measureRangeSize({ + let removedSize = viewport.measureRangeSize({ start: range.end, end: renderedRange.end, }); - contentOffset = - this._lastRenderedContentOffset + this._lastRenderedContentSize - removedSize; + // Check that we're not removing too much. + if (removedSize <= overscan) { + contentOffset = + this._lastRenderedContentOffset + this._lastRenderedContentSize - removedSize; + this._removalFailures = 0; + } else { + // If the removal is more than the overscan can absorb just undo it and record the fact + // that the removal failed so we can be less aggressive next time. + range.end = renderedRange.end; + contentOffset = this._lastRenderedContentOffset + this._lastRenderedContentSize; + this._removalFailures++; + } contentOffsetTo = 'to-end'; } else { const removedSize = viewport.measureRangeSize({ start: renderedRange.start, end: range.start, }); - contentOffset = this._lastRenderedContentOffset + removedSize; + // Check that we're not removing too much. + if (removedSize <= overscan) { + contentOffset = this._lastRenderedContentOffset + removedSize; + this._removalFailures = 0; + } else { + // If the removal is more than the overscan can absorb just undo it and record the fact + // that the removal failed so we can be less aggressive next time. + range.start = renderedRange.start; + contentOffset = this._lastRenderedContentOffset; + this._removalFailures++; + } contentOffsetTo = 'to-start'; } @@ -247,7 +278,7 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { */ private _checkRenderedContentSize() { const viewport = this._viewport!; - this._lastRenderedContentOffset = viewport.measureRenderedContentOffset(); + this._lastRenderedContentOffset = viewport.getOffsetToRenderedContentStart()!; this._lastRenderedContentSize = viewport.measureRenderedContentSize(); this._averager.addSample(viewport.getRenderedRange(), this._lastRenderedContentSize); this._updateTotalContentSize(this._lastRenderedContentSize); @@ -267,6 +298,7 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { viewport.setScrollOffset(scrollOffset); } this._lastScrollOffset = scrollOffset; + this._removalFailures = 0; const itemSize = this._averager.getAverageItemSize(); const firstVisibleIndex = diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts index f31679ca9c47..f01f82ca6ba4 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -86,6 +86,15 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { /** the currently attached CdkVirtualForOf. */ private _forOf: CdkVirtualForOf | null; + /** The last rendered content offset that was set. */ + private _renderedContentOffset = 0; + + /** + * Whether the last rendered content offset was to the end of the content (and therefore needs to + * be rewritten as an offset to the start of the content). + */ + private _renderedContentOffsetNeedsRewrite = false; + constructor(public elementRef: ElementRef, private _changeDetectorRef: ChangeDetectorRef, private _ngZone: NgZone, private _sanitizer: DomSanitizer, @Inject(VIRTUAL_SCROLL_STRATEGY) private _scrollStrategy: VirtualScrollStrategy) {} @@ -204,21 +213,43 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { // viewport.setRenderedContentOffset(...); // // The call to `onContentRendered` will happen after all of the updates have been applied. - Promise.resolve().then(() => this._scrollStrategy.onContentRendered()); + Promise.resolve().then(() => { + // If the rendered content offset was specified as an offset to the end of the content, + // rewrite it as an offset to the start of the content. + if (this._renderedContentOffsetNeedsRewrite) { + this._renderedContentOffset -= this.measureRenderedContentSize(); + this._renderedContentOffsetNeedsRewrite = false; + this.setRenderedContentOffset(this._renderedContentOffset); + } + + this._scrollStrategy.onContentRendered(); + }); })); }); } } - /** Sets the offset of the rendered portion of the data from the start (in pixels). */ + /** + * Gets the offset from the start of the viewport to the start of the rendered data (in pixels). + */ + getOffsetToRenderedContentStart(): number | null { + return this._renderedContentOffsetNeedsRewrite ? null: this._renderedContentOffset; + } + + /** + * Sets the offset from the start of the viewport to either the start or end of the rendered data + * (in pixels). + */ setRenderedContentOffset(offset: number, to: 'to-start' | 'to-end' = 'to-start') { const axis = this.orientation === 'horizontal' ? 'X' : 'Y'; let transform = `translate${axis}(${Number(offset)}px)`; + this._renderedContentOffset = offset; if (to === 'to-end') { // TODO(mmalerba): The viewport should rewrite this as a `to-start` offset on the next render // cycle. Otherwise elements will appear to expand in the wrong direction (e.g. // `mat-expansion-panel` would expand upward). transform += ` translate${axis}(-100%)`; + this._renderedContentOffsetNeedsRewrite = true; } if (this._renderedContentTransform != transform) { // Re-enter the Angular zone so we can mark for change detection. @@ -253,21 +284,6 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { return this.orientation === 'horizontal' ? contentEl.offsetWidth : contentEl.offsetHeight; } - // TODO(mmalerba): Try to do this in a way that's less bad for performance. (The bad part here is - // that we have to measure the viewport which is not absolutely positioned.) - /** Measure the offset from the start of the viewport to the start of the rendered content. */ - measureRenderedContentOffset(): number { - const viewportEl = this.elementRef.nativeElement; - const contentEl = this._contentWrapper.nativeElement; - if (this.orientation === 'horizontal') { - return contentEl.getBoundingClientRect().left + viewportEl.scrollLeft - - viewportEl.getBoundingClientRect().left - viewportEl.clientLeft; - } else { - return contentEl.getBoundingClientRect().top + viewportEl.scrollTop - - viewportEl.getBoundingClientRect().top - viewportEl.clientTop; - } - } - /** * Measure the total combined size of the given range. Throws if the range includes items that are * not rendered. diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.html b/src/demo-app/virtual-scroll/virtual-scroll-demo.html index c77bbd2b5cfb..b42439967b1c 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.html +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.html @@ -1,5 +1,6 @@

Autosize

+

Uniform size

@@ -7,6 +8,23 @@

Autosize

+

Increasing size

+ +
+ Item #{{i}} - ({{size}}px) +
+
+ +

Decreasing size

+ +
+ Item #{{i}} - ({{size}}px) +
+
+ +

Random size

diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts index 2f09db639df6..5132f0f29d12 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts @@ -17,5 +17,8 @@ import {Component, ViewEncapsulation} from '@angular/core'; }) export class VirtualScrollDemo { fixedSizeData = Array(10000).fill(50); + increasingSizeData = Array(10000).fill(0).map((_, i) => (1 + Math.floor(i / 1000)) * 20); + decreasingSizeData = Array(10000).fill(0) + .map((_, i) => (1 + Math.floor((10000 - i) / 1000)) * 20); randomData = Array(10000).fill(0).map(() => Math.round(Math.random() * 100)); } From 285fe6c338660973a30132a82babcd734cd4653c Mon Sep 17 00:00:00 2001 From: mmalerba Date: Fri, 27 Apr 2018 10:17:41 -0700 Subject: [PATCH 16/28] virtual-scroll: address amcdnl's feedback (#10988) * rewrite offsets to the end of the rendered content as offsets to the start * add some more autosize demos for testing * make sure not to remove too many items * virtual-scroll: address amcdnl's feedback --- .../scrolling/auto-size-virtual-scroll.ts | 11 +++++++++-- .../scrolling/fixed-size-virtual-scroll.ts | 11 +++++++++-- src/cdk-experimental/scrolling/virtual-for-of.ts | 2 +- .../virtual-scroll/virtual-scroll-demo.html | 9 +++++++++ src/demo-app/virtual-scroll/virtual-scroll-demo.ts | 14 ++++++++++++++ 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts index a26d6d1af704..66dbcf157676 100644 --- a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts +++ b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {coerceNumberProperty} from '@angular/cdk/coercion'; import {ListRange} from '@angular/cdk/collections'; import {Directive, forwardRef, Input, OnChanges} from '@angular/core'; import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy'; @@ -386,14 +387,20 @@ export class CdkAutoSizeVirtualScroll implements OnChanges { * The minimum amount of buffer rendered beyond the viewport (in pixels). * If the amount of buffer dips below this number, more items will be rendered. */ - @Input() minBufferPx: number = 100; + @Input() + get minBufferPx(): number { return this._minBufferPx; } + set minBufferPx(value: number) { this._minBufferPx = coerceNumberProperty(value); } + _minBufferPx = 100; /** * The number of pixels worth of buffer to shoot for when rendering new items. * If the actual amount turns out to be less it will not necessarily trigger an additional * rendering cycle (as long as the amount of buffer is still greater than `minBufferPx`). */ - @Input() addBufferPx: number = 200; + @Input() + get addBufferPx(): number { return this._addBufferPx; } + set addBufferPx(value: number) { this._addBufferPx = coerceNumberProperty(value); } + _addBufferPx = 200; /** The scroll strategy used by this directive. */ _scrollStrategy = new AutoSizeVirtualScrollStrategy(this.minBufferPx, this.addBufferPx); diff --git a/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts index 9a7bfa2b38f1..fce2c9ce7fcb 100644 --- a/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts +++ b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {coerceNumberProperty} from '@angular/cdk/coercion'; import {ListRange} from '@angular/cdk/collections'; import {Directive, forwardRef, Input, OnChanges} from '@angular/core'; import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy'; @@ -139,10 +140,16 @@ export function _fixedSizeVirtualScrollStrategyFactory(fixedSizeDir: CdkFixedSiz }) export class CdkFixedSizeVirtualScroll implements OnChanges { /** The size of the items in the list (in pixels). */ - @Input() itemSize = 20; + @Input() + get itemSize(): number { return this._itemSize; } + set itemSize(value: number) { this._itemSize = coerceNumberProperty(value); } + _itemSize = 20; /** The number of extra elements to render on either side of the scrolling viewport. */ - @Input() bufferSize = 5; + @Input() + get bufferSize(): number { return this._bufferSize; } + set bufferSize(value: number) { this._bufferSize = coerceNumberProperty(value); } + _bufferSize = 5; /** The scroll strategy used by this directive. */ _scrollStrategy = new FixedSizeVirtualScrollStrategy(this.itemSize, this.bufferSize); diff --git a/src/cdk-experimental/scrolling/virtual-for-of.ts b/src/cdk-experimental/scrolling/virtual-for-of.ts index e9ed75b6f614..a68d67062c76 100644 --- a/src/cdk-experimental/scrolling/virtual-for-of.ts +++ b/src/cdk-experimental/scrolling/virtual-for-of.ts @@ -71,7 +71,7 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy const ds = value instanceof DataSource ? value : // Slice the value if its an NgIterable to ensure we're working with an array. new ArrayDataSource( - value instanceof Observable ? value : Array.prototype.slice.call(value)); + value instanceof Observable ? value : Array.prototype.slice.call(value || [])); this._dataSourceChanges.next(ds); } _cdkVirtualForOf: DataSource | Observable | NgIterable; diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.html b/src/demo-app/virtual-scroll/virtual-scroll-demo.html index b42439967b1c..fa6e44a0d004 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.html +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.html @@ -48,3 +48,12 @@

Fixed size

Item #{{i}} - ({{size}}px)
+ +

Observable data

+ + +
+ Item #{{i}} - ({{size}}px) +
+
diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts index 5132f0f29d12..f1e1a6e9335c 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts @@ -7,6 +7,7 @@ */ import {Component, ViewEncapsulation} from '@angular/core'; +import {BehaviorSubject} from 'rxjs/index'; @Component({ moduleId: module.id, @@ -21,4 +22,17 @@ export class VirtualScrollDemo { decreasingSizeData = Array(10000).fill(0) .map((_, i) => (1 + Math.floor((10000 - i) / 1000)) * 20); randomData = Array(10000).fill(0).map(() => Math.round(Math.random() * 100)); + observableData = new BehaviorSubject([]); + + constructor() { + this.emitData(); + } + + emitData() { + let data = this.observableData.value.concat([50]); + this.observableData.next(data); + if (data.length < 1000) { + setTimeout(() => this.emitData(), 1000); + } + } } From 0e4a580af401bf1c5034775bc77c14387428e122 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Tue, 1 May 2018 10:44:09 -0700 Subject: [PATCH 17/28] virtual-scroll: fix updating when data changes and add trackBy demos (#11085) --- .../scrolling/virtual-for-of.ts | 18 +++-- .../virtual-scroll/virtual-scroll-demo.html | 38 +++++++++- .../virtual-scroll/virtual-scroll-demo.scss | 16 +++++ .../virtual-scroll/virtual-scroll-demo.ts | 69 +++++++++++++++++++ 4 files changed, 135 insertions(+), 6 deletions(-) diff --git a/src/cdk-experimental/scrolling/virtual-for-of.ts b/src/cdk-experimental/scrolling/virtual-for-of.ts index a68d67062c76..50cc3b6aec37 100644 --- a/src/cdk-experimental/scrolling/virtual-for-of.ts +++ b/src/cdk-experimental/scrolling/virtual-for-of.ts @@ -147,8 +147,15 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy 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.dataStream.subscribe(data => { + this._data = data; + this._onRenderedDataChange(); + }); + this._viewport.renderedRangeStream.subscribe(range => { + this._renderedRange = range; + this.viewChange.next(this._renderedRange); + this._onRenderedDataChange(); + }); this._viewport.attach(this); } @@ -213,9 +220,10 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy } /** React to scroll state changes in the viewport. */ - private _onRenderedRangeChange(renderedRange: ListRange) { - this._renderedRange = renderedRange; - this.viewChange.next(this._renderedRange); + private _onRenderedDataChange() { + if (!this._renderedRange) { + return; + } this._renderedItems = this._data.slice(this._renderedRange.start, this._renderedRange.end); if (!this._differ) { this._differ = this._differs.find(this._renderedItems).create(this.cdkVirtualForTrackBy); diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.html b/src/demo-app/virtual-scroll/virtual-scroll-demo.html index fa6e44a0d004..289b8e54a37d 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.html +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.html @@ -52,8 +52,44 @@

Fixed size

Observable data

-
Item #{{i}} - ({{size}}px)
+ +

No trackBy

+ + + + +
+
{{state.name}}
+
{{state.capital}}
+
+
+ +

trackBy index

+ + + + +
+
{{state.name}}
+
{{state.capital}}
+
+
+ +

trackBy state name

+ + + + +
+
{{state.name}}
+
{{state.capital}}
+
+
diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.scss b/src/demo-app/virtual-scroll/virtual-scroll-demo.scss index 3ce419e652ad..5afdc95ad00e 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.scss +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.scss @@ -24,3 +24,19 @@ writing-mode: vertical-lr; } } + +.demo-state-item { + height: 60px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.demo-state { + font-size: 20px; + font-weight: 500; +} + +.demo-capital { + font-size: 14px; +} diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts index f1e1a6e9335c..7e80c092d9cf 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts @@ -9,6 +9,13 @@ import {Component, ViewEncapsulation} from '@angular/core'; import {BehaviorSubject} from 'rxjs/index'; + +type State = { + name: string, + capital: string +}; + + @Component({ moduleId: module.id, selector: 'virtual-scroll-demo', @@ -23,6 +30,61 @@ export class VirtualScrollDemo { .map((_, i) => (1 + Math.floor((10000 - i) / 1000)) * 20); randomData = Array(10000).fill(0).map(() => Math.round(Math.random() * 100)); observableData = new BehaviorSubject([]); + states = [ + {name: 'Alabama', capital: 'Montgomery'}, + {name: 'Alaska', capital: 'Juneau'}, + {name: 'Arizona', capital: 'Phoenix'}, + {name: 'Arkansas', capital: 'Little Rock'}, + {name: 'California', capital: 'Sacramento'}, + {name: 'Colorado', capital: 'Denver'}, + {name: 'Connecticut', capital: 'Hartford'}, + {name: 'Delaware', capital: 'Dover'}, + {name: 'Florida', capital: 'Tallahassee'}, + {name: 'Georgia', capital: 'Atlanta'}, + {name: 'Hawaii', capital: 'Honolulu'}, + {name: 'Idaho', capital: 'Boise'}, + {name: 'Illinois', capital: 'Springfield'}, + {name: 'Indiana', capital: 'Indianapolis'}, + {name: 'Iowa', capital: 'Des Moines'}, + {name: 'Kansas', capital: 'Topeka'}, + {name: 'Kentucky', capital: 'Frankfort'}, + {name: 'Louisiana', capital: 'Baton Rouge'}, + {name: 'Maine', capital: 'Augusta'}, + {name: 'Maryland', capital: 'Annapolis'}, + {name: 'Massachusetts', capital: 'Boston'}, + {name: 'Michigan', capital: 'Lansing'}, + {name: 'Minnesota', capital: 'St. Paul'}, + {name: 'Mississippi', capital: 'Jackson'}, + {name: 'Missouri', capital: 'Jefferson City'}, + {name: 'Montana', capital: 'Helena'}, + {name: 'Nebraska', capital: 'Lincoln'}, + {name: 'Nevada', capital: 'Carson City'}, + {name: 'New Hampshire', capital: 'Concord'}, + {name: 'New Jersey', capital: 'Trenton'}, + {name: 'New Mexico', capital: 'Santa Fe'}, + {name: 'New York', capital: 'Albany'}, + {name: 'North Carolina', capital: 'Raleigh'}, + {name: 'North Dakota', capital: 'Bismarck'}, + {name: 'Ohio', capital: 'Columbus'}, + {name: 'Oklahoma', capital: 'Oklahoma City'}, + {name: 'Oregon', capital: 'Salem'}, + {name: 'Pennsylvania', capital: 'Harrisburg'}, + {name: 'Rhode Island', capital: 'Providence'}, + {name: 'South Carolina', capital: 'Columbia'}, + {name: 'South Dakota', capital: 'Pierre'}, + {name: 'Tennessee', capital: 'Nashville'}, + {name: 'Texas', capital: 'Austin'}, + {name: 'Utah', capital: 'Salt Lake City'}, + {name: 'Vermont', capital: 'Montpelier'}, + {name: 'Virginia', capital: 'Richmond'}, + {name: 'Washington', capital: 'Olympia'}, + {name: 'West Virginia', capital: 'Charleston'}, + {name: 'Wisconsin', capital: 'Madison'}, + {name: 'Wyoming', capital: 'Cheyenne'}, + ]; + statesObservable = new BehaviorSubject(this.states); + indexTrackFn = (index: number) => index; + nameTrackFn = (_: number, item: State) => item.name; constructor() { this.emitData(); @@ -35,4 +97,11 @@ export class VirtualScrollDemo { setTimeout(() => this.emitData(), 1000); } } + + sortBy(prop: 'name' | 'capital') { + this.statesObservable.next(this.states.map(s => ({...s})).sort((a, b) => { + const aProp = a[prop], bProp = b[prop]; + return aProp < bProp ? -1 : (aProp > bProp ? 1 : 0); + })); + } } From 604f7439393b96b4bf283cb54cf1aa75b8418273 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Mon, 7 May 2018 13:47:48 -0700 Subject: [PATCH 18/28] =?UTF-8?q?virtual-scroll:=20add=20logic=20to=20corr?= =?UTF-8?q?ect=20the=20scroll=20position=20as=20user=20move=E2=80=A6=20(#1?= =?UTF-8?q?1137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * virtual-scroll: add logic to correct the scroll position as user moves toward the top * address comments --- .../scrolling/auto-size-virtual-scroll.ts | 63 +++++++++++++++---- .../scrolling/fixed-size-virtual-scroll.ts | 9 ++- .../scrolling/virtual-for-of.ts | 9 ++- .../scrolling/virtual-scroll-strategy.ts | 3 + .../scrolling/virtual-scroll-viewport.ts | 26 +++++--- .../virtual-scroll/virtual-scroll-demo.html | 1 + .../virtual-scroll/virtual-scroll-demo.ts | 10 +-- 7 files changed, 91 insertions(+), 30 deletions(-) diff --git a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts index 66dbcf157676..1279317c546f 100644 --- a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts +++ b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts @@ -122,14 +122,14 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { this._viewport = null; } - /** Implemented as part of VirtualScrollStrategy. */ + /** @docs-private Implemented as part of VirtualScrollStrategy. */ onContentScrolled() { if (this._viewport) { this._updateRenderedContentAfterScroll(); } } - /** Implemented as part of VirtualScrollStrategy. */ + /** @docs-private Implemented as part of VirtualScrollStrategy. */ onDataLengthChanged() { if (this._viewport) { // TODO(mmalebra): Do something smarter here. @@ -137,13 +137,20 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { } } - /** Implemented as part of VirtualScrollStrategy. */ + /** @docs-private Implemented as part of VirtualScrollStrategy. */ onContentRendered() { if (this._viewport) { this._checkRenderedContentSize(); } } + /** @docs-private Implemented as part of VirtualScrollStrategy. */ + onRenderedOffsetChanged() { + if (this._viewport) { + this._checkRenderedContentOffset(); + } + } + /** * Update the buffer parameters. * @param minBufferPx The minimum amount of buffer rendered beyond the viewport (in pixels). @@ -162,13 +169,38 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { // The current scroll offset. const scrollOffset = viewport.measureScrollOffset(); // The delta between the current scroll offset and the previously recorded scroll offset. - const scrollDelta = scrollOffset - this._lastScrollOffset; + let scrollDelta = scrollOffset - this._lastScrollOffset; // The magnitude of the scroll delta. - const scrollMagnitude = Math.abs(scrollDelta); + let scrollMagnitude = Math.abs(scrollDelta); + + // The currently rendered range. + const renderedRange = viewport.getRenderedRange(); - // TODO(mmalerba): Record error between actual scroll offset and predicted scroll offset given - // the index of the first rendered element. Fudge the scroll delta to slowly eliminate the error - // as the user scrolls. + // If we're scrolling toward the top, we need to account for the fact that the predicted amount + // of content and the actual amount of scrollable space may differ. We address this by slowly + // correcting the difference on each scroll event. + let offsetCorrection = 0; + if (scrollDelta < 0) { + // The content offset we would expect based on the average item size. + const predictedOffset = renderedRange.start * this._averager.getAverageItemSize(); + // The difference between the predicted size of the unrendered content at the beginning and + // the actual available space to scroll over. We need to reduce this to zero by the time the + // user scrolls to the top. + // - 0 indicates that the predicted size and available space are the same. + // - A negative number that the predicted size is smaller than the available space. + // - A positive number indicates the predicted size is larger than the available space + const offsetDifference = predictedOffset - this._lastRenderedContentOffset; + // The amount of difference to correct during this scroll event. We calculate this as a + // percentage of the total difference based on the percentage of the distance toward the top + // that the user scrolled. + offsetCorrection = Math.round(offsetDifference * + Math.max(0, Math.min(1, scrollMagnitude / (scrollOffset + scrollMagnitude)))); + + // Based on the offset correction above, we pretend that the scroll delta was bigger or + // smaller than it actually was, this way we can start to eliminate the difference. + scrollDelta = scrollDelta - offsetCorrection; + scrollMagnitude = Math.abs(scrollDelta); + } // The current amount of buffer past the start of the viewport. const startBuffer = this._lastScrollOffset - this._lastRenderedContentOffset; @@ -190,8 +222,6 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { if (scrollMagnitude >= viewport.getViewportSize()) { this._setScrollOffset(); } else { - // The currently rendered range. - const renderedRange = viewport.getRenderedRange(); // The number of new items to render on the side the user is scrolling towards. Rather than // just filling the underscan space, we actually fill enough to have a buffer size of // `addBufferPx`. This gives us a little wiggle room in case our item size estimate is off. @@ -265,8 +295,12 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { // Set the range and offset we calculated above. viewport.setRenderedRange(range); - viewport.setRenderedContentOffset(contentOffset, contentOffsetTo); + viewport.setRenderedContentOffset(contentOffset + offsetCorrection, contentOffsetTo); } + } else if (offsetCorrection) { + // Even if the rendered range didn't change, we may still need to adjust the content offset to + // simulate scrolling slightly slower or faster than the user actually scrolled. + viewport.setRenderedContentOffset(this._lastRenderedContentOffset + offsetCorrection); } // Save the scroll offset to be compared to the new value on the next scroll event. @@ -279,12 +313,17 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { */ private _checkRenderedContentSize() { const viewport = this._viewport!; - this._lastRenderedContentOffset = viewport.getOffsetToRenderedContentStart()!; this._lastRenderedContentSize = viewport.measureRenderedContentSize(); this._averager.addSample(viewport.getRenderedRange(), this._lastRenderedContentSize); this._updateTotalContentSize(this._lastRenderedContentSize); } + /** Checks the currently rendered content offset and saves the value for later use. */ + private _checkRenderedContentOffset() { + const viewport = this._viewport!; + this._lastRenderedContentOffset = viewport.getOffsetToRenderedContentStart()!; + } + /** * Sets the scroll offset and renders the content we estimate should be shown at that point. * @param scrollOffset The offset to jump to. If not specified the scroll offset will not be diff --git a/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts index fce2c9ce7fcb..97cf910c3f48 100644 --- a/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts +++ b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts @@ -60,20 +60,23 @@ export class FixedSizeVirtualScrollStrategy implements VirtualScrollStrategy { this._updateRenderedRange(); } - /** Called when the viewport is scrolled. */ + /** @docs-private Implemented as part of VirtualScrollStrategy. */ onContentScrolled() { this._updateRenderedRange(); } - /** Called when the length of the data changes. */ + /** @docs-private Implemented as part of VirtualScrollStrategy. */ onDataLengthChanged() { this._updateTotalContentSize(); this._updateRenderedRange(); } - /** Called when the range of items rendered in the DOM has changed. */ + /** @docs-private Implemented as part of VirtualScrollStrategy. */ onContentRendered() { /* no-op */ } + /** @docs-private Implemented as part of VirtualScrollStrategy. */ + onRenderedOffsetChanged() { /* no-op */ } + /** Update the viewport's total content size. */ private _updateTotalContentSize() { if (!this._viewport) { diff --git a/src/cdk-experimental/scrolling/virtual-for-of.ts b/src/cdk-experimental/scrolling/virtual-for-of.ts index 50cc3b6aec37..78524c225082 100644 --- a/src/cdk-experimental/scrolling/virtual-for-of.ts +++ b/src/cdk-experimental/scrolling/virtual-for-of.ts @@ -24,7 +24,7 @@ import { ViewContainerRef, } from '@angular/core'; import {Observable, Subject} from 'rxjs'; -import {pairwise, shareReplay, startWith, switchMap} from 'rxjs/operators'; +import {pairwise, shareReplay, startWith, switchMap, takeUntil} from 'rxjs/operators'; import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; @@ -138,6 +138,8 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy /** Whether the rendered data should be updated during the next ngDoCheck cycle. */ private _needsUpdate = false; + private _destroyed = new Subject(); + constructor( /** The view container to add items to. */ private _viewContainerRef: ViewContainerRef, @@ -151,7 +153,7 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy this._data = data; this._onRenderedDataChange(); }); - this._viewport.renderedRangeStream.subscribe(range => { + this._viewport.renderedRangeStream.pipe(takeUntil(this._destroyed)).subscribe(range => { this._renderedRange = range; this.viewChange.next(this._renderedRange); this._onRenderedDataChange(); @@ -214,6 +216,9 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy this._dataSourceChanges.complete(); this.viewChange.complete(); + this._destroyed.next(); + this._destroyed.complete(); + for (let view of this._templateCache) { view.destroy(); } diff --git a/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts b/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts index 1bd56fbb99de..cbb667201709 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts @@ -34,4 +34,7 @@ export interface VirtualScrollStrategy { /** Called when the range of items rendered in the DOM has changed. */ onContentRendered(); + + /** Called when the offset of the rendered items changed. */ + onRenderedOffsetChanged(); } diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts index f01f82ca6ba4..0a127d18b6a3 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -71,6 +71,9 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { /** The transform used to offset the rendered content wrapper element. */ _renderedContentTransform: SafeStyle; + /** The raw string version of the rendered content transform. */ + private _rawRenderedContentTransform: string; + /** The currently rendered range of indices. */ private _renderedRange: ListRange = {start: 0, end: 0}; @@ -214,14 +217,6 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { // // The call to `onContentRendered` will happen after all of the updates have been applied. Promise.resolve().then(() => { - // If the rendered content offset was specified as an offset to the end of the content, - // rewrite it as an offset to the start of the content. - if (this._renderedContentOffsetNeedsRewrite) { - this._renderedContentOffset -= this.measureRenderedContentSize(); - this._renderedContentOffsetNeedsRewrite = false; - this.setRenderedContentOffset(this._renderedContentOffset); - } - this._scrollStrategy.onContentRendered(); }); })); @@ -251,13 +246,26 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { transform += ` translate${axis}(-100%)`; this._renderedContentOffsetNeedsRewrite = true; } - if (this._renderedContentTransform != transform) { + if (this._rawRenderedContentTransform != transform) { // Re-enter the Angular zone so we can mark for change detection. this._ngZone.run(() => { // We know this value is safe because we parse `offset` with `Number()` before passing it // into the string. + this._rawRenderedContentTransform = transform; this._renderedContentTransform = this._sanitizer.bypassSecurityTrustStyle(transform); this._changeDetectorRef.markForCheck(); + + // If the rendered content offset was specified as an offset to the end of the content, + // rewrite it as an offset to the start of the content. + this._ngZone.onStable.pipe(take(1)).subscribe(() => { + if (this._renderedContentOffsetNeedsRewrite) { + this._renderedContentOffset -= this.measureRenderedContentSize(); + this._renderedContentOffsetNeedsRewrite = false; + this.setRenderedContentOffset(this._renderedContentOffset); + } else { + this._scrollStrategy.onRenderedOffsetChanged(); + } + }); }); } } diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.html b/src/demo-app/virtual-scroll/virtual-scroll-demo.html index 289b8e54a37d..7c8b754eee04 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.html +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.html @@ -51,6 +51,7 @@

Fixed size

Observable data

+
diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts index 7e80c092d9cf..8b797703b3b6 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts @@ -93,15 +93,17 @@ export class VirtualScrollDemo { emitData() { let data = this.observableData.value.concat([50]); this.observableData.next(data); - if (data.length < 1000) { - setTimeout(() => this.emitData(), 1000); - } } sortBy(prop: 'name' | 'capital') { this.statesObservable.next(this.states.map(s => ({...s})).sort((a, b) => { const aProp = a[prop], bProp = b[prop]; - return aProp < bProp ? -1 : (aProp > bProp ? 1 : 0); + if (aProp < bProp) { + return -1; + } else if (aProp > bProp) { + return 1; + } + return 0; })); } } From 11783b7b70890d59d6ce2964876022de248756c2 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 7 May 2018 15:48:30 -0500 Subject: [PATCH 19/28] fix(scrolling): adds right to fix pushed content (#11192) * fix(scrolling): adds right to fix pushed content * chore: address feedback * chore: address pr feedback --- .../scrolling/virtual-scroll-viewport.scss | 8 ++++++++ src/cdk-experimental/scrolling/virtual-scroll-viewport.ts | 2 ++ 2 files changed, 10 insertions(+) diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss b/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss index bb9e62f4825f..eef90817bbce 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss @@ -14,6 +14,14 @@ cdk-virtual-scroll-viewport { will-change: contents, transform; } +.virtual-scroll-orientation-horizontal { + bottom: 0; +} + +.virtual-scroll-orientation-vertical { + right: 0; +} + // 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. diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts index 0a127d18b6a3..b0792418316f 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -42,6 +42,8 @@ function rangesEqual(r1: ListRange, r2: ListRange): boolean { styleUrls: ['virtual-scroll-viewport.css'], host: { 'class': 'cdk-virtual-scroll-viewport', + '[class.virtual-scroll-orientation-horizontal]': 'orientation === "horizontal"', + '[class.virtual-scroll-orientation-vertical]': 'orientation === "vertical"', }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, From 5ad55da1db4542b3e31069a199e4d43e4d5d11df Mon Sep 17 00:00:00 2001 From: mmalerba Date: Thu, 10 May 2018 10:02:20 -0700 Subject: [PATCH 20/28] virtual-scroll: add tests for fixed size items (#11255) * virtual-scroll: add tests for fixed size items * address comments * fix bug in fixed size strategy * fix bazel build --- src/cdk-experimental/scrolling/BUILD.bazel | 1 + .../scrolling/fixed-size-virtual-scroll.ts | 4 +- .../scrolling/virtual-scroll-viewport.spec.ts | 427 ++++++++++++++++-- .../scrolling/virtual-scroll-viewport.ts | 3 +- .../virtual-scroll/virtual-scroll-demo.scss | 3 - 5 files changed, 404 insertions(+), 34 deletions(-) diff --git a/src/cdk-experimental/scrolling/BUILD.bazel b/src/cdk-experimental/scrolling/BUILD.bazel index dc0e10f9e87f..f7dfdb24b012 100644 --- a/src/cdk-experimental/scrolling/BUILD.bazel +++ b/src/cdk-experimental/scrolling/BUILD.bazel @@ -10,6 +10,7 @@ ng_module( module_name = "@angular/cdk-experimental/scrolling", assets = [":virtual_scroll_viewport_css"] + glob(["**/*.html"]), deps = [ + "//src/cdk/coercion", "//src/cdk/collections", "@rxjs", ], diff --git a/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts index 97cf910c3f48..2e0537b246eb 100644 --- a/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts +++ b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts @@ -94,10 +94,12 @@ export class FixedSizeVirtualScrollStrategy implements VirtualScrollStrategy { const scrollOffset = this._viewport.measureScrollOffset(); const firstVisibleIndex = Math.floor(scrollOffset / this._itemSize); + const firstItemRemainder = scrollOffset % this._itemSize; const range = this._expandRange( {start: firstVisibleIndex, end: firstVisibleIndex}, this._bufferSize, - Math.ceil(this._viewport.getViewportSize() / this._itemSize) + this._bufferSize); + Math.ceil((this._viewport.getViewportSize() + firstItemRemainder) / this._itemSize) + + this._bufferSize); this._viewport.setRenderedRange(range); this._viewport.setRenderedContentOffset(this._itemSize * range.start); } diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts index a84330d870a4..27182f667b38 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts @@ -1,46 +1,417 @@ -import {Component, ViewChild} from '@angular/core'; +import {dispatchFakeEvent} from '@angular/cdk/testing'; +import {Component, Input, ViewChild, ViewContainerRef, ViewEncapsulation} from '@angular/core'; import {ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing'; +import {animationFrameScheduler} from 'rxjs'; import {ScrollingModule} from './scrolling-module'; +import {CdkVirtualForOf} from './virtual-for-of'; import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; -describe('Basic CdkVirtualScrollViewport', () => { - let fixture: ComponentFixture; - let viewport: CdkVirtualScrollViewport; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ScrollingModule], - declarations: [BasicViewport], - }).compileComponents(); +describe('CdkVirtualScrollViewport', () => { + describe ('with FixedSizeVirtualScrollStrategy', () => { + let fixture: ComponentFixture; + let testComponent: FixedVirtualScroll; + let viewport: CdkVirtualScrollViewport; - fixture = TestBed.createComponent(BasicViewport); - viewport = fixture.componentInstance.viewport; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ScrollingModule], + declarations: [FixedVirtualScroll], + }).compileComponents(); + + fixture = TestBed.createComponent(FixedVirtualScroll); + testComponent = fixture.componentInstance; + viewport = testComponent.viewport; + }); + + it('should sanitize transform inputs', fakeAsync(() => { + finishInit(fixture); + viewport.orientation = 'arbitrary string as orientation' as any; + viewport.setRenderedContentOffset( + 'arbitrary string as offset' as any, 'arbitrary string as to' as any); + fixture.detectChanges(); + + expect((viewport._renderedContentTransform as any).changingThisBreaksApplicationSecurity) + .toBe('translateY(NaNpx)'); + })); + + it('should render initial state', fakeAsync(() => { + finishInit(fixture); + + const contentWrapper = + viewport.elementRef.nativeElement.querySelector('.cdk-virtual-scroll-content-wrapper'); + expect(contentWrapper.children.length) + .toBe(4, 'should render 4 50px items to fill 200px space'); + })); + + it('should get the data length', fakeAsync(() => { + finishInit(fixture); + + expect(viewport.getDataLength()).toBe(testComponent.items.length); + })); + + it('should get the viewport size', fakeAsync(() => { + finishInit(fixture); + + expect(viewport.getViewportSize()).toBe(testComponent.viewportSize); + })); + + it('should get the rendered range', fakeAsync(() => { + finishInit(fixture); + + expect(viewport.getRenderedRange()) + .toEqual({start: 0, end: 4}, 'should render the first 4 50px items to fill 200px space'); + })); + + it('should get the rendered content offset', fakeAsync(() => { + finishInit(fixture); + triggerScroll(viewport, testComponent.itemSize + 5); + fixture.detectChanges(); + + expect(viewport.getOffsetToRenderedContentStart()).toBe(testComponent.itemSize, + 'should have 50px offset since first 50px item is not rendered'); + })); + + it('should get the scroll offset', fakeAsync(() => { + finishInit(fixture); + triggerScroll(viewport, testComponent.itemSize + 5); + fixture.detectChanges(); + + expect(viewport.measureScrollOffset()).toBe(testComponent.itemSize + 5); + })); + + it('should get the rendered content size', fakeAsync(() => { + finishInit(fixture); + + expect(viewport.measureRenderedContentSize()) + .toBe(testComponent.viewportSize, + 'should render 4 50px items with combined size of 200px to fill 200px space'); + })); + + it('should measure range size', fakeAsync(() => { + finishInit(fixture); + + expect(viewport.measureRangeSize({start: 1, end: 3})) + .toBe(testComponent.itemSize * 2, 'combined size of 2 50px items should be 100px'); + })); + + it('should set total content size', fakeAsync(() => { + finishInit(fixture); + viewport.setTotalContentSize(10000); + fixture.detectChanges(); + + expect(viewport.elementRef.nativeElement.scrollHeight).toBe(10000); + })); + + it('should set rendered range', fakeAsync(() => { + finishInit(fixture); + viewport.setRenderedRange({start: 2, end: 3}); + fixture.detectChanges(); + + const items = fixture.elementRef.nativeElement.querySelectorAll('.item'); + expect(items.length).toBe(1, 'Expected 1 item to be rendered'); + expect(items[0].innerText).toBe('2 - 2', 'Expected item with index 2 to be rendered'); + })); + + it('should set content offset to top of content', fakeAsync(() => { + finishInit(fixture); + viewport.setRenderedContentOffset(10, 'to-start'); + fixture.detectChanges(); + + expect(viewport.getOffsetToRenderedContentStart()).toBe(10); + })); + + it('should set content offset to bottom of content', fakeAsync(() => { + finishInit(fixture); + const contentSize = viewport.measureRenderedContentSize(); + + expect(contentSize).toBeGreaterThan(0); + + viewport.setRenderedContentOffset(contentSize + 10, 'to-end'); + fixture.detectChanges(); + + expect(viewport.getOffsetToRenderedContentStart()).toBe(10); + })); + + it('should set scroll offset', fakeAsync(() => { + finishInit(fixture); + viewport.setScrollOffset(testComponent.itemSize * 2); + fixture.detectChanges(); + triggerScroll(viewport); + fixture.detectChanges(); + + expect(viewport.elementRef.nativeElement.scrollTop).toBe(testComponent.itemSize * 2); + expect(viewport.getRenderedRange()).toEqual({start: 2, end: 6}); + })); + + it('should update viewport as user scrolls down', fakeAsync(() => { + finishInit(fixture); + + const maxOffset = + testComponent.itemSize * testComponent.items.length - testComponent.viewportSize; + for (let offset = 0; offset <= maxOffset; offset += 10) { + triggerScroll(viewport, offset); + fixture.detectChanges(); + + const expectedRange = { + start: Math.floor(offset / testComponent.itemSize), + end: Math.ceil((offset + testComponent.viewportSize) / testComponent.itemSize) + }; + expect(viewport.getRenderedRange()) + .toEqual(expectedRange, + `rendered range should match expected value at scroll offset ${offset}`); + expect(viewport.getOffsetToRenderedContentStart()) + .toBe(expectedRange.start * testComponent.itemSize, + `rendered content offset should match expected value at scroll offset ${offset}`); + expect(viewport.measureRenderedContentSize()) + .toBe((expectedRange.end - expectedRange.start) * testComponent.itemSize, + `rendered content size should match expected value at offset ${offset}`); + } + })); + + it('should update viewport as user scrolls up', fakeAsync(() => { + finishInit(fixture); + + const maxOffset = + testComponent.itemSize * testComponent.items.length - testComponent.viewportSize; + for (let offset = maxOffset; offset >= 0; offset -= 10) { + triggerScroll(viewport, offset); + fixture.detectChanges(); + + const expectedRange = { + start: Math.floor(offset / testComponent.itemSize), + end: Math.ceil((offset + testComponent.viewportSize) / testComponent.itemSize) + }; + expect(viewport.getRenderedRange()) + .toEqual(expectedRange, + `rendered range should match expected value at scroll offset ${offset}`); + expect(viewport.getOffsetToRenderedContentStart()) + .toBe(expectedRange.start * testComponent.itemSize, + `rendered content offset should match expected value at scroll offset ${offset}`); + expect(viewport.measureRenderedContentSize()) + .toBe((expectedRange.end - expectedRange.start) * testComponent.itemSize, + `rendered content size should match expected value at offset ${offset}`); + } + })); + + it('should render buffer element at the end when scrolled to the top', fakeAsync(() => { + testComponent.bufferSize = 1; + finishInit(fixture); + + expect(viewport.getRenderedRange()).toEqual({start: 0, end: 5}, + 'should render the first 5 50px items to fill 200px space, plus one buffer element at' + + ' the end'); + })); + + it('should render buffer element at the start and end when scrolled to the middle', + fakeAsync(() => { + testComponent.bufferSize = 1; + finishInit(fixture); + triggerScroll(viewport, testComponent.itemSize * 2); + fixture.detectChanges(); + + expect(viewport.getRenderedRange()).toEqual({start: 1, end: 7}, + 'should render 6 50px items to fill 200px space, plus one buffer element at the' + + ' start and end'); + })); + + it('should render buffer element at the start when scrolled to the bottom', fakeAsync(() => { + testComponent.bufferSize = 1; + finishInit(fixture); + triggerScroll(viewport, testComponent.itemSize * 6); + fixture.detectChanges(); + + expect(viewport.getRenderedRange()).toEqual({start: 5, end: 10}, + 'should render the last 5 50px items to fill 200px space, plus one buffer element at' + + ' the start'); + })); + + it('should handle dynamic item size', fakeAsync(() => { + finishInit(fixture); + triggerScroll(viewport, testComponent.itemSize * 2); + fixture.detectChanges(); + + expect(viewport.getRenderedRange()) + .toEqual({start: 2, end: 6}, 'should render 4 50px items to fill 200px space'); + + testComponent.itemSize *= 2; + fixture.detectChanges(); + + expect(viewport.getRenderedRange()) + .toEqual({start: 1, end: 3}, 'should render 2 100px items to fill 200px space'); + })); + + it('should handle dynamic buffer size', fakeAsync(() => { + finishInit(fixture); + triggerScroll(viewport, testComponent.itemSize * 2); + fixture.detectChanges(); + + expect(viewport.getRenderedRange()) + .toEqual({start: 2, end: 6}, 'should render 4 50px items to fill 200px space'); + + testComponent.bufferSize = 1; + fixture.detectChanges(); + + expect(viewport.getRenderedRange()) + .toEqual({start: 1, end: 7}, 'should expand to 1 buffer element on each side'); + })); + + it('should handle dynamic item array', fakeAsync(() => { + finishInit(fixture); + triggerScroll(viewport, testComponent.itemSize * 6); + fixture.detectChanges(); + + expect(viewport.getOffsetToRenderedContentStart()) + .toBe(testComponent.itemSize * 6, 'should be scrolled to bottom of 10 item list'); + + testComponent.items = Array(5).fill(0); + fixture.detectChanges(); + triggerScroll(viewport); + fixture.detectChanges(); + + expect(viewport.getOffsetToRenderedContentStart()) + .toBe(testComponent.itemSize, 'should be scrolled to bottom of 5 item list'); + })); }); - it('should sanitize transform inputs', fakeAsync(() => { - fixture.detectChanges(); - flush(); + describe('with FixedSizeVirtualScrollStrategy and horizontal orientation', () => { + let fixture: ComponentFixture; + let testComponent: FixedHorizontalVirtualScroll; + let viewport: CdkVirtualScrollViewport; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ScrollingModule], + declarations: [FixedHorizontalVirtualScroll], + }).compileComponents(); + + fixture = TestBed.createComponent(FixedHorizontalVirtualScroll); + testComponent = fixture.componentInstance; + viewport = testComponent.viewport; + }); - viewport.orientation = 'arbitrary string as orientation' as any; - viewport.setRenderedContentOffset( - 'arbitrary string as offset' as any, 'arbitrary string as to' as any); - fixture.detectChanges(); - flush(); + it('should update viewport as user scrolls right', fakeAsync(() => { + finishInit(fixture); - expect((viewport._renderedContentTransform as any).changingThisBreaksApplicationSecurity) - .toEqual('translateY(NaNpx)'); - })); + const maxOffset = + testComponent.itemSize * testComponent.items.length - testComponent.viewportSize; + for (let offset = 0; offset <= maxOffset; offset += 10) { + triggerScroll(viewport, offset); + fixture.detectChanges(); + + const expectedRange = { + start: Math.floor(offset / testComponent.itemSize), + end: Math.ceil((offset + testComponent.viewportSize) / testComponent.itemSize) + }; + expect(viewport.getRenderedRange()) + .toEqual(expectedRange, + `rendered range should match expected value at scroll offset ${offset}`); + expect(viewport.getOffsetToRenderedContentStart()) + .toBe(expectedRange.start * testComponent.itemSize, + `rendered content offset should match expected value at scroll offset ${offset}`); + expect(viewport.measureRenderedContentSize()) + .toBe((expectedRange.end - expectedRange.start) * testComponent.itemSize, + `rendered content size should match expected value at offset ${offset}`); + } + })); + + it('should update viewport as user scrolls left', fakeAsync(() => { + finishInit(fixture); + + const maxOffset = + testComponent.itemSize * testComponent.items.length - testComponent.viewportSize; + for (let offset = maxOffset; offset >= 0; offset -= 10) { + triggerScroll(viewport, offset); + fixture.detectChanges(); + + const expectedRange = { + start: Math.floor(offset / testComponent.itemSize), + end: Math.ceil((offset + testComponent.viewportSize) / testComponent.itemSize) + }; + expect(viewport.getRenderedRange()) + .toEqual(expectedRange, + `rendered range should match expected value at scroll offset ${offset}`); + expect(viewport.getOffsetToRenderedContentStart()) + .toBe(expectedRange.start * testComponent.itemSize, + `rendered content offset should match expected value at scroll offset ${offset}`); + expect(viewport.measureRenderedContentSize()) + .toBe((expectedRange.end - expectedRange.start) * testComponent.itemSize, + `rendered content size should match expected value at offset ${offset}`); + } + })); + }); }); + +/** Finish initializing the virtual scroll component at the beginning of a test. */ +function finishInit(fixture: ComponentFixture) { + // On the first cycle we render and measure the viewport. + fixture.detectChanges(); + flush(); + + // On the second cycle we render the items. + fixture.detectChanges(); +} + +/** Trigger a scroll event on the viewport (optionally setting a new scroll offset). */ +function triggerScroll(viewport: CdkVirtualScrollViewport, offset?: number) { + if (offset !== undefined) { + if (viewport.orientation == 'horizontal') { + viewport.elementRef.nativeElement.scrollLeft = offset; + } else { + viewport.elementRef.nativeElement.scrollTop = offset; + } + } + dispatchFakeEvent(viewport.elementRef.nativeElement, 'scroll'); + animationFrameScheduler.flush(); +} + + +@Component({ + template: ` + +
+ {{i}} - {{item}} +
+
+ `, + styles: [`.cdk-virtual-scroll-content-wrapper { display: flex; flex-direction: column; }`], + encapsulation: ViewEncapsulation.None, +}) +class FixedVirtualScroll { + @ViewChild(CdkVirtualScrollViewport) viewport: CdkVirtualScrollViewport; + @ViewChild(CdkVirtualForOf, {read: ViewContainerRef}) cdkForOfViewContainer: ViewContainerRef; + + @Input() viewportSize = 200; + @Input() viewportCrossSize = 100; + @Input() itemSize = 50; + @Input() bufferSize = 0; + @Input() items = Array(10).fill(0).map((_, i) => i); +} + @Component({ template: ` - - {{item}} + +
+ {{i}} - {{item}} +
- ` + `, + styles: [`.cdk-virtual-scroll-content-wrapper { display: flex; flex-direction: row; }`], + encapsulation: ViewEncapsulation.None, }) -class BasicViewport { - @ViewChild(CdkVirtualScrollViewport) viewport; +class FixedHorizontalVirtualScroll { + @ViewChild(CdkVirtualScrollViewport) viewport: CdkVirtualScrollViewport; - items = Array(10).fill(0); + @Input() viewportSize = 200; + @Input() viewportCrossSize = 100; + @Input() itemSize = 50; + @Input() bufferSize = 0; + @Input() items = Array(10).fill(0).map((_, i) => i); } diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts index b0792418316f..6a9e3e15ced9 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -47,7 +47,6 @@ function rangesEqual(r1: ListRange, r2: ListRange): boolean { }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, - preserveWhitespaces: false, }) export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { /** Emits when the viewport is detached from a CdkVirtualForOf. */ @@ -230,7 +229,7 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { * Gets the offset from the start of the viewport to the start of the rendered data (in pixels). */ getOffsetToRenderedContentStart(): number | null { - return this._renderedContentOffsetNeedsRewrite ? null: this._renderedContentOffset; + return this._renderedContentOffsetNeedsRewrite ? null : this._renderedContentOffset; } /** diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.scss b/src/demo-app/virtual-scroll/virtual-scroll-demo.scss index 5afdc95ad00e..58e9e1741b3c 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.scss +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.scss @@ -6,15 +6,12 @@ .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 { From c196eec97363ab0cbb4af70e28ad02c36c285ad1 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Fri, 11 May 2018 08:50:19 -0700 Subject: [PATCH 21/28] virtual-scroll: add tests for `cdkVirtualFor` logic (#11275) * merge fixed size test components into one * add tests for cdkVirtualFor logic * allow undefined to be explicitly passed as trackBy * fix bazel build * address comments --- src/cdk-experimental/scrolling/BUILD.bazel | 1 + .../scrolling/virtual-for-of.ts | 11 +- .../scrolling/virtual-scroll-viewport.scss | 4 +- .../scrolling/virtual-scroll-viewport.spec.ts | 208 ++++++++++++++---- .../scrolling/virtual-scroll-viewport.ts | 4 +- src/cdk/collections/array-data-source.ts | 6 +- 6 files changed, 175 insertions(+), 59 deletions(-) diff --git a/src/cdk-experimental/scrolling/BUILD.bazel b/src/cdk-experimental/scrolling/BUILD.bazel index f7dfdb24b012..2e7fcb97bd56 100644 --- a/src/cdk-experimental/scrolling/BUILD.bazel +++ b/src/cdk-experimental/scrolling/BUILD.bazel @@ -37,6 +37,7 @@ ts_library( srcs = glob(["**/*.spec.ts"]), deps = [ ":scrolling", + "//src/cdk/collections", "//src/cdk/testing", "@rxjs", ], diff --git a/src/cdk-experimental/scrolling/virtual-for-of.ts b/src/cdk-experimental/scrolling/virtual-for-of.ts index 78524c225082..a83f59464efd 100644 --- a/src/cdk-experimental/scrolling/virtual-for-of.ts +++ b/src/cdk-experimental/scrolling/virtual-for-of.ts @@ -81,15 +81,16 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy * the item and produces a value to be used as the item's identity when tracking changes. */ @Input() - get cdkVirtualForTrackBy(): TrackByFunction { + get cdkVirtualForTrackBy(): TrackByFunction | undefined { return this._cdkVirtualForTrackBy; } - set cdkVirtualForTrackBy(fn: TrackByFunction) { + set cdkVirtualForTrackBy(fn: TrackByFunction | undefined) { this._needsUpdate = true; - this._cdkVirtualForTrackBy = - (index, item) => fn(index + (this._renderedRange ? this._renderedRange.start : 0), item); + this._cdkVirtualForTrackBy = fn ? + (index, item) => fn(index + (this._renderedRange ? this._renderedRange.start : 0), item) : + undefined; } - private _cdkVirtualForTrackBy: TrackByFunction; + private _cdkVirtualForTrackBy: TrackByFunction | undefined; /** The template used to stamp out new elements. */ @Input() diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss b/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss index eef90817bbce..60f6c3b03edf 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss @@ -14,11 +14,11 @@ cdk-virtual-scroll-viewport { will-change: contents, transform; } -.virtual-scroll-orientation-horizontal { +.cdk-virtual-scroll-orientation-horizontal { bottom: 0; } -.virtual-scroll-orientation-vertical { +.cdk-virtual-scroll-orientation-vertical { right: 0; } diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts index 27182f667b38..2f1363d8e018 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts @@ -1,7 +1,8 @@ +import {ArrayDataSource} from '@angular/cdk/collections'; import {dispatchFakeEvent} from '@angular/cdk/testing'; import {Component, Input, ViewChild, ViewContainerRef, ViewEncapsulation} from '@angular/core'; import {ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing'; -import {animationFrameScheduler} from 'rxjs'; +import {animationFrameScheduler, Subject} from 'rxjs'; import {ScrollingModule} from './scrolling-module'; import {CdkVirtualForOf} from './virtual-for-of'; import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; @@ -272,25 +273,9 @@ describe('CdkVirtualScrollViewport', () => { expect(viewport.getOffsetToRenderedContentStart()) .toBe(testComponent.itemSize, 'should be scrolled to bottom of 5 item list'); })); - }); - - describe('with FixedSizeVirtualScrollStrategy and horizontal orientation', () => { - let fixture: ComponentFixture; - let testComponent: FixedHorizontalVirtualScroll; - let viewport: CdkVirtualScrollViewport; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ScrollingModule], - declarations: [FixedHorizontalVirtualScroll], - }).compileComponents(); - - fixture = TestBed.createComponent(FixedHorizontalVirtualScroll); - testComponent = fixture.componentInstance; - viewport = testComponent.viewport; - }); - - it('should update viewport as user scrolls right', fakeAsync(() => { + it('should update viewport as user scrolls right in horizontal mode', fakeAsync(() => { + testComponent.orientation = 'horizontal'; finishInit(fixture); const maxOffset = @@ -315,7 +300,8 @@ describe('CdkVirtualScrollViewport', () => { } })); - it('should update viewport as user scrolls left', fakeAsync(() => { + it('should update viewport as user scrolls left in horizontal mode', fakeAsync(() => { + testComponent.orientation = 'horizontal'; finishInit(fixture); const maxOffset = @@ -339,6 +325,134 @@ describe('CdkVirtualScrollViewport', () => { `rendered content size should match expected value at offset ${offset}`); } })); + + it('should work with an Observable', fakeAsync(() => { + const data = new Subject(); + testComponent.items = data as any; + finishInit(fixture); + + expect(viewport.getRenderedRange()) + .toEqual({start: 0, end: 0}, 'no items should be rendered'); + + data.next([1, 2, 3]); + fixture.detectChanges(); + + expect(viewport.getRenderedRange()) + .toEqual({start: 0, end: 3}, 'newly emitted items should be rendered'); + })); + + it('should work with a DataSource', fakeAsync(() => { + const data = new Subject(); + testComponent.items = new ArrayDataSource(data) as any; + finishInit(fixture); + flush(); + + expect(viewport.getRenderedRange()) + .toEqual({start: 0, end: 0}, 'no items should be rendered'); + + data.next([1, 2, 3]); + fixture.detectChanges(); + flush(); + + expect(viewport.getRenderedRange()) + .toEqual({start: 0, end: 3}, 'newly emitted items should be rendered'); + })); + + it('should trackBy value by default', fakeAsync(() => { + testComponent.items = []; + spyOn(testComponent.virtualForViewContainer, 'detach').and.callThrough(); + finishInit(fixture); + + testComponent.items = [0]; + fixture.detectChanges(); + + expect(testComponent.virtualForViewContainer.detach).not.toHaveBeenCalled(); + + testComponent.items = [1]; + fixture.detectChanges(); + + expect(testComponent.virtualForViewContainer.detach).toHaveBeenCalled(); + })); + + it('should trackBy index when specified', fakeAsync(() => { + testComponent.trackBy = i => i; + testComponent.items = []; + spyOn(testComponent.virtualForViewContainer, 'detach').and.callThrough(); + finishInit(fixture); + + testComponent.items = [0]; + fixture.detectChanges(); + + expect(testComponent.virtualForViewContainer.detach).not.toHaveBeenCalled(); + + testComponent.items = [1]; + fixture.detectChanges(); + + expect(testComponent.virtualForViewContainer.detach).not.toHaveBeenCalled(); + })); + + it('should recycle views when template cache is large enough to accommodate', fakeAsync(() => { + testComponent.trackBy = i => i; + const spy = + spyOn(testComponent.virtualForViewContainer, 'createEmbeddedView').and.callThrough(); + finishInit(fixture); + + // Should create views for the initial rendered items. + expect(testComponent.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(4); + + spy.calls.reset(); + triggerScroll(viewport, 10); + fixture.detectChanges(); + + // As we first start to scroll we need to create one more item. This is because the first item + // is still partially on screen and therefore can't be removed yet. At the same time a new + // item is now partially on the screen at the bottom and so a new view is needed. + expect(testComponent.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(1); + + spy.calls.reset(); + const maxOffset = + testComponent.itemSize * testComponent.items.length - testComponent.viewportSize; + for (let offset = 10; offset <= maxOffset; offset += 10) { + triggerScroll(viewport, offset); + fixture.detectChanges(); + } + + // As we scroll through the rest of the items, no new views should be created, our existing 5 + // can just be recycled as appropriate. + expect(testComponent.virtualForViewContainer.createEmbeddedView).not.toHaveBeenCalled(); + })); + + it('should not recycle views when template cache is full', fakeAsync(() => { + testComponent.trackBy = i => i; + testComponent.templateCacheSize = 0; + const spy = + spyOn(testComponent.virtualForViewContainer, 'createEmbeddedView').and.callThrough(); + finishInit(fixture); + + // Should create views for the initial rendered items. + expect(testComponent.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(4); + + spy.calls.reset(); + triggerScroll(viewport, 10); + fixture.detectChanges(); + + // As we first start to scroll we need to create one more item. This is because the first item + // is still partially on screen and therefore can't be removed yet. At the same time a new + // item is now partially on the screen at the bottom and so a new view is needed. + expect(testComponent.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(1); + + spy.calls.reset(); + const maxOffset = + testComponent.itemSize * testComponent.items.length - testComponent.viewportSize; + for (let offset = 10; offset <= maxOffset; offset += 10) { + triggerScroll(viewport, offset); + fixture.detectChanges(); + } + + // Since our template cache size is 0, as we scroll through the rest of the items, we need to + // create a new view for each one. + expect(testComponent.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(5); + })); }); }); @@ -370,48 +484,46 @@ function triggerScroll(viewport: CdkVirtualScrollViewport, offset?: number) { @Component({ template: ` -
+ [itemSize]="itemSize" [bufferSize]="bufferSize" [orientation]="orientation" + [style.height.px]="viewportHeight" [style.width.px]="viewportWidth"> +
{{i}} - {{item}}
`, - styles: [`.cdk-virtual-scroll-content-wrapper { display: flex; flex-direction: column; }`], + styles: [` + .cdk-virtual-scroll-content-wrapper { + display: flex; + flex-direction: column; + } + + .cdk-virtual-scroll-orientation-horizontal .cdk-virtual-scroll-content-wrapper { + flex-direction: row; + } + `], encapsulation: ViewEncapsulation.None, }) class FixedVirtualScroll { @ViewChild(CdkVirtualScrollViewport) viewport: CdkVirtualScrollViewport; - @ViewChild(CdkVirtualForOf, {read: ViewContainerRef}) cdkForOfViewContainer: ViewContainerRef; + @ViewChild(CdkVirtualForOf, {read: ViewContainerRef}) virtualForViewContainer: ViewContainerRef; + @Input() orientation = 'vertical'; @Input() viewportSize = 200; @Input() viewportCrossSize = 100; @Input() itemSize = 50; @Input() bufferSize = 0; @Input() items = Array(10).fill(0).map((_, i) => i); -} + @Input() trackBy; + @Input() templateCacheSize = 20; -@Component({ - template: ` - -
- {{i}} - {{item}} -
-
- `, - styles: [`.cdk-virtual-scroll-content-wrapper { display: flex; flex-direction: row; }`], - encapsulation: ViewEncapsulation.None, -}) -class FixedHorizontalVirtualScroll { - @ViewChild(CdkVirtualScrollViewport) viewport: CdkVirtualScrollViewport; + get viewportWidth() { + return this.orientation == 'horizontal' ? this.viewportSize : this.viewportCrossSize; + } - @Input() viewportSize = 200; - @Input() viewportCrossSize = 100; - @Input() itemSize = 50; - @Input() bufferSize = 0; - @Input() items = Array(10).fill(0).map((_, i) => i); + get viewportHeight() { + return this.orientation == 'horizontal' ? this.viewportCrossSize : this.viewportSize; + } } diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts index 6a9e3e15ced9..a0ad24085591 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -42,8 +42,8 @@ function rangesEqual(r1: ListRange, r2: ListRange): boolean { styleUrls: ['virtual-scroll-viewport.css'], host: { 'class': 'cdk-virtual-scroll-viewport', - '[class.virtual-scroll-orientation-horizontal]': 'orientation === "horizontal"', - '[class.virtual-scroll-orientation-vertical]': 'orientation === "vertical"', + '[class.cdk-virtual-scroll-orientation-horizontal]': 'orientation === "horizontal"', + '[class.cdk-virtual-scroll-orientation-vertical]': 'orientation === "vertical"', }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/cdk/collections/array-data-source.ts b/src/cdk/collections/array-data-source.ts index 5d950fbb2c76..50114292a6bb 100644 --- a/src/cdk/collections/array-data-source.ts +++ b/src/cdk/collections/array-data-source.ts @@ -11,8 +11,10 @@ import {DataSource} from './data-source'; /** DataSource wrapper for a native array. */ -export class ArrayDataSource implements DataSource { - constructor(private _data: T[] | Observable) {} +export class ArrayDataSource extends DataSource { + constructor(private _data: T[] | Observable) { + super(); + } connect(): Observable { return this._data instanceof Observable ? this._data : observableOf(this._data); From 4bef998991750d4dcdb96a2e7457ba2269a81666 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Fri, 11 May 2018 17:57:28 -0700 Subject: [PATCH 22/28] add some basic tests (#11295) --- .../scrolling/virtual-scroll-viewport.spec.ts | 92 ++++++++++++++++++- .../scrolling/virtual-scroll-viewport.ts | 30 +++--- 2 files changed, 103 insertions(+), 19 deletions(-) diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts index 2f1363d8e018..544f0f143c27 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts @@ -10,17 +10,17 @@ import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; describe('CdkVirtualScrollViewport', () => { describe ('with FixedSizeVirtualScrollStrategy', () => { - let fixture: ComponentFixture; - let testComponent: FixedVirtualScroll; + let fixture: ComponentFixture; + let testComponent: FixedSizeVirtualScroll; let viewport: CdkVirtualScrollViewport; beforeEach(() => { TestBed.configureTestingModule({ imports: [ScrollingModule], - declarations: [FixedVirtualScroll], + declarations: [FixedSizeVirtualScroll], }).compileComponents(); - fixture = TestBed.createComponent(FixedVirtualScroll); + fixture = TestBed.createComponent(FixedSizeVirtualScroll); testComponent = fixture.componentInstance; viewport = testComponent.viewport; }); @@ -454,6 +454,45 @@ describe('CdkVirtualScrollViewport', () => { expect(testComponent.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(5); })); }); + + describe ('with AutoSizeVirtualScrollStrategy', () => { + let fixture: ComponentFixture; + let testComponent: AutoSizeVirtualScroll; + let viewport: CdkVirtualScrollViewport; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ScrollingModule], + declarations: [AutoSizeVirtualScroll], + }).compileComponents(); + + fixture = TestBed.createComponent(AutoSizeVirtualScroll); + testComponent = fixture.componentInstance; + viewport = testComponent.viewport; + }); + + it('should render initial state for uniform items', fakeAsync(() => { + finishInit(fixture); + + const contentWrapper = + viewport.elementRef.nativeElement.querySelector('.cdk-virtual-scroll-content-wrapper'); + expect(contentWrapper.children.length) + .toBe(4, 'should render 4 50px items to fill 200px space'); + })); + + it('should render extra content if first item is smaller than average', fakeAsync(() => { + testComponent.items = [50, 200, 200, 200, 200, 200]; + finishInit(fixture); + + const contentWrapper = + viewport.elementRef.nativeElement.querySelector('.cdk-virtual-scroll-content-wrapper'); + expect(contentWrapper.children.length).toBe(4, + 'should render 4 items to fill 200px space based on 50px estimate from first item'); + })); + + // TODO(mmalerba): Add test that it corrects the initial render if it didn't render enough, + // once it actually does that. + }); }); @@ -506,7 +545,7 @@ function triggerScroll(viewport: CdkVirtualScrollViewport, offset?: number) { `], encapsulation: ViewEncapsulation.None, }) -class FixedVirtualScroll { +class FixedSizeVirtualScroll { @ViewChild(CdkVirtualScrollViewport) viewport: CdkVirtualScrollViewport; @ViewChild(CdkVirtualForOf, {read: ViewContainerRef}) virtualForViewContainer: ViewContainerRef; @@ -527,3 +566,46 @@ class FixedVirtualScroll { return this.orientation == 'horizontal' ? this.viewportCrossSize : this.viewportSize; } } + +@Component({ + template: ` + +
+ {{i}} - {{size}} +
+
+ `, + styles: [` + .cdk-virtual-scroll-content-wrapper { + display: flex; + flex-direction: column; + } + + .cdk-virtual-scroll-orientation-horizontal .cdk-virtual-scroll-content-wrapper { + flex-direction: row; + } + `], + encapsulation: ViewEncapsulation.None, +}) +class AutoSizeVirtualScroll { + @ViewChild(CdkVirtualScrollViewport) viewport: CdkVirtualScrollViewport; + + @Input() orientation = 'vertical'; + @Input() viewportSize = 200; + @Input() viewportCrossSize = 100; + @Input() minBufferSize = 0; + @Input() addBufferSize = 0; + @Input() items = Array(10).fill(50); + + get viewportWidth() { + return this.orientation == 'horizontal' ? this.viewportSize : this.viewportCrossSize; + } + + get viewportHeight() { + return this.orientation == 'horizontal' ? this.viewportCrossSize : this.viewportSize; + } +} diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts index a0ad24085591..33c95dbc7d97 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -99,6 +99,9 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { */ private _renderedContentOffsetNeedsRewrite = false; + /** Observable that emits when the viewport is destroyed. */ + private _destroyed = new Subject(); + constructor(public elementRef: ElementRef, private _changeDetectorRef: ChangeDetectorRef, private _ngZone: NgZone, private _sanitizer: DomSanitizer, @Inject(VIRTUAL_SCROLL_STRATEGY) private _scrollStrategy: VirtualScrollStrategy) {} @@ -114,7 +117,7 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { fromEvent(viewportEl, 'scroll') // Sample the scroll stream at every animation frame. This way if there are multiple // scroll events in the same frame we only need to recheck our layout once. - .pipe(sampleTime(0, animationFrameScheduler)) + .pipe(sampleTime(0, animationFrameScheduler), takeUntil(this._destroyed)) .subscribe(() => this._scrollStrategy.onContentScrolled()); }); }); @@ -135,10 +138,12 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { ngOnDestroy() { this.detach(); this._scrollStrategy.detach(); + this._destroyed.next(); // Complete all subjects this._renderedRangeSubject.complete(); this._detachedSubject.complete(); + this._destroyed.complete(); } /** Attaches a `CdkVirtualForOf` to this viewport. */ @@ -208,20 +213,17 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { this._ngZone.run(() => { this._renderedRangeSubject.next(this._renderedRange = range); this._changeDetectorRef.markForCheck(); - this._ngZone.runOutsideAngular(() => this._ngZone.onStable.pipe(take(1)).subscribe(() => { - // Queue this up in a `Promise.resolve()` so that if the user makes a series of calls - // like: - // - // viewport.setRenderedRange(...); - // viewport.setTotalContentSize(...); - // viewport.setRenderedContentOffset(...); - // - // The call to `onContentRendered` will happen after all of the updates have been applied. - Promise.resolve().then(() => { - this._scrollStrategy.onContentRendered(); - }); - })); }); + // Queue this up in a `Promise.resolve()` so that if the user makes a series of calls + // like: + // + // viewport.setRenderedRange(...); + // viewport.setTotalContentSize(...); + // viewport.setRenderedContentOffset(...); + // + // The call to `onContentRendered` will happen after all of the updates have been applied. + this._ngZone.runOutsideAngular(() => this._ngZone.onStable.pipe(take(1)).subscribe( + () => Promise.resolve().then(() => this._scrollStrategy.onContentRendered()))); } } From 2253b6d573aa9811d7eda14cc4dfc559962ab7bc Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Mon, 14 May 2018 12:05:07 -0700 Subject: [PATCH 23/28] fix lint --- .../scrolling/virtual-scroll-viewport.spec.ts | 6 +++--- src/demo-app/demo-app/demo-module.ts | 16 ++++++++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts index 544f0f143c27..b52612224cc5 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts @@ -525,7 +525,7 @@ function triggerScroll(viewport: CdkVirtualScrollViewport, offset?: number) { -
@@ -571,9 +571,9 @@ class FixedSizeVirtualScroll { template: ` -
{{i}} - {{size}}
diff --git a/src/demo-app/demo-app/demo-module.ts b/src/demo-app/demo-app/demo-module.ts index a0b12e5b954f..f4c813860909 100644 --- a/src/demo-app/demo-app/demo-module.ts +++ b/src/demo-app/demo-app/demo-module.ts @@ -10,10 +10,10 @@ import {LayoutModule} from '@angular/cdk/layout'; import {FullscreenOverlayContainer, OverlayContainer} from '@angular/cdk/overlay'; import {CommonModule} from '@angular/common'; import {Injector, NgModule} from '@angular/core'; -import {FormsModule, ReactiveFormsModule} from '@angular/forms'; -import {RouterModule} from '@angular/router'; import {createCustomElement} from '@angular/elements'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {EXAMPLE_COMPONENTS, ExampleModule} from '@angular/material-examples'; +import {RouterModule} from '@angular/router'; import {AutocompleteDemo} from '../autocomplete/autocomplete-demo'; import {BadgeDemo} from '../badge/badge-demo'; @@ -29,6 +29,8 @@ import {CustomHeader, DatepickerDemo} from '../datepicker/datepicker-demo'; import {DemoMaterialModule} from '../demo-material-module'; import {ContentElementDialog, DialogDemo, IFrameDialog, JazzDialog} from '../dialog/dialog-demo'; import {DrawerDemo} from '../drawer/drawer-demo'; +import {MaterialExampleModule} from '../example/example-module'; +import {ExamplesPage} from '../examples-page/examples-page'; import {ExpansionDemo} from '../expansion/expansion-demo'; import {FocusOriginDemo} from '../focus-origin/focus-origin-demo'; import {GesturesDemo} from '../gestures/gestures-demo'; @@ -38,6 +40,7 @@ import {InputDemo} from '../input/input-demo'; import {ListDemo} from '../list/list-demo'; import {LiveAnnouncerDemo} from '../live-announcer/live-announcer-demo'; import {MenuDemo} from '../menu/menu-demo'; +import {PaginatorDemo} from '../paginator/paginator-demo'; import {PlatformDemo} from '../platform/platform-demo'; import {PortalDemo, ScienceJoke} from '../portal/portal-demo'; import {ProgressBarDemo} from '../progress-bar/progress-bar-demo'; @@ -53,7 +56,11 @@ import {SnackBarDemo} from '../snack-bar/snack-bar-demo'; import {StepperDemo} from '../stepper/stepper-demo'; import {TableDemoModule} from '../table/table-demo-module'; import { - Counter,FoggyTabContent, RainyTabContent, SunnyTabContent, TabsDemo + Counter, + FoggyTabContent, + RainyTabContent, + SunnyTabContent, + TabsDemo } from '../tabs/tabs-demo'; import {ToolbarDemo} from '../toolbar/toolbar-demo'; import {TooltipDemo} from '../tooltip/tooltip-demo'; @@ -62,9 +69,6 @@ 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 {PaginatorDemo} from '../paginator/paginator-demo'; -import {ExamplesPage} from '../examples-page/examples-page'; -import {MaterialExampleModule} from '../example/example-module'; @NgModule({ imports: [ From 9497bad5ad7a8c1112f32368de5616e13e537a63 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Tue, 15 May 2018 15:40:23 -0700 Subject: [PATCH 24/28] virtual-scroll: add e2e tests for autosize scroll strategy (#11345) * set up virtual scroll page in e2e app * add gulp task for e2e:watch * add e2e tests for autosize * address comments --- .github/CODEOWNERS | 2 + e2e/components/virtual-scroll-e2e.spec.ts | 117 ++++++++++++++++++ .../scrolling/virtual-scroll-viewport.scss | 4 +- src/e2e-app/e2e-app-module.ts | 50 ++++---- src/e2e-app/e2e-app/e2e-app.html | 1 + src/e2e-app/e2e-app/routes.ts | 2 + src/e2e-app/tsconfig-build.json | 4 + .../virtual-scroll/virtual-scroll-e2e.css | 13 ++ .../virtual-scroll/virtual-scroll-e2e.html | 19 +++ .../virtual-scroll/virtual-scroll-e2e.ts | 16 +++ tools/gulp/tasks/e2e.ts | 35 +++++- tools/package-tools/rollup-globals.ts | 29 +++-- 12 files changed, 255 insertions(+), 37 deletions(-) create mode 100644 e2e/components/virtual-scroll-e2e.spec.ts create mode 100644 src/e2e-app/virtual-scroll/virtual-scroll-e2e.css create mode 100644 src/e2e-app/virtual-scroll/virtual-scroll-e2e.html create mode 100644 src/e2e-app/virtual-scroll/virtual-scroll-e2e.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 884a325f546e..7464d57455a9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -167,6 +167,7 @@ /e2e/components/stepper-e2e.spec.ts @mmalerba /e2e/components/tabs-e2e.spec.ts @andrewseguin /e2e/components/toolbar-e2e.spec.ts @devversion +/e2e/components/virtual-scroll-e2e.spec.ts @mmalerba /e2e/util/** @jelbourn /src/e2e-app/* @jelbourn /src/e2e-app/block-scroll-strategy/** @andrewseguin @crisbeto @@ -185,6 +186,7 @@ /src/e2e-app/sidenav/** @mmalerba /src/e2e-app/slide-toggle/** @devversion /src/e2e-app/tabs/** @andrewseguin +/src/e2e-app/virtual-scroll/** @mmalerba # Universal app /src/universal-app/** @jelbourn diff --git a/e2e/components/virtual-scroll-e2e.spec.ts b/e2e/components/virtual-scroll-e2e.spec.ts new file mode 100644 index 000000000000..57084e95000e --- /dev/null +++ b/e2e/components/virtual-scroll-e2e.spec.ts @@ -0,0 +1,117 @@ +import {browser, by, element, ElementFinder} from 'protractor'; +import {ILocation, ISize} from 'selenium-webdriver'; + +declare var window: any; + + +describe('autosize cdk-virtual-scroll', () => { + let viewport: ElementFinder; + + describe('with uniform items', () => { + beforeEach(() => { + browser.get('/virtual-scroll'); + viewport = element(by.css('.demo-virtual-scroll-uniform-size cdk-virtual-scroll-viewport')); + }); + + it('should scroll down slowly', async () => { + await browser.executeAsyncScript(smoothScrollViewportTo, viewport, 2000); + const offScreen = element(by.css('.demo-virtual-scroll-uniform-size [data-index="39"]')); + const onScreen = element(by.css('.demo-virtual-scroll-uniform-size [data-index="40"]')); + expect(await isVisibleInViewport(offScreen, viewport)).toBe(false); + expect(await isVisibleInViewport(onScreen, viewport)).toBe(true); + }); + + it('should jump scroll position down and slowly scroll back up', async () => { + // The estimate of the total content size is exactly correct, so we wind up scrolled to the + // same place as if we slowly scrolled down. + await browser.executeAsyncScript(scrollViewportTo, viewport, 2000); + const offScreen = element(by.css('.demo-virtual-scroll-uniform-size [data-index="39"]')); + const onScreen = element(by.css('.demo-virtual-scroll-uniform-size [data-index="40"]')); + expect(await isVisibleInViewport(offScreen, viewport)).toBe(false); + expect(await isVisibleInViewport(onScreen, viewport)).toBe(true); + + // As we slowly scroll back up we should wind up back at the start of the content. + await browser.executeAsyncScript(smoothScrollViewportTo, viewport, 0); + const first = element(by.css('.demo-virtual-scroll-uniform-size [data-index="0"]')); + expect(await isVisibleInViewport(first, viewport)).toBe(true); + }); + }); + + describe('with variable size', () => { + beforeEach(() => { + browser.get('/virtual-scroll'); + viewport = element(by.css('.demo-virtual-scroll-variable-size cdk-virtual-scroll-viewport')); + }); + + it('should scroll down slowly', async () => { + await browser.executeAsyncScript(smoothScrollViewportTo, viewport, 2000); + const offScreen = element(by.css('.demo-virtual-scroll-variable-size [data-index="19"]')); + const onScreen = element(by.css('.demo-virtual-scroll-variable-size [data-index="20"]')); + expect(await isVisibleInViewport(offScreen, viewport)).toBe(false); + expect(await isVisibleInViewport(onScreen, viewport)).toBe(true); + }); + + it('should jump scroll position down and slowly scroll back up', async () => { + // The estimate of the total content size is slightly different than the actual, so we don't + // wind up in the same spot as if we scrolled slowly down. + await browser.executeAsyncScript(scrollViewportTo, viewport, 2000); + const offScreen = element(by.css('.demo-virtual-scroll-variable-size [data-index="18"]')); + const onScreen = element(by.css('.demo-virtual-scroll-variable-size [data-index="19"]')); + expect(await isVisibleInViewport(offScreen, viewport)).toBe(false); + expect(await isVisibleInViewport(onScreen, viewport)).toBe(true); + + // As we slowly scroll back up we should wind up back at the start of the content. As we + // scroll the error from when we jumped the scroll position should be slowly corrected. + await browser.executeAsyncScript(smoothScrollViewportTo, viewport, 0); + const first = element(by.css('.demo-virtual-scroll-variable-size [data-index="0"]')); + expect(await isVisibleInViewport(first, viewport)).toBe(true); + }); + }); +}); + + +/** Checks if the given element is visible in the given viewport. */ +async function isVisibleInViewport(el: ElementFinder, viewport: ElementFinder): Promise { + if (!await el.isPresent() || !await el.isDisplayed() || !await viewport.isPresent() || + !await viewport.isDisplayed()) { + return false; + } + const viewportRect = getRect(await viewport.getLocation(), await viewport.getSize()); + const elRect = getRect(await el.getLocation(), await el.getSize()); + return elRect.left < viewportRect.right && elRect.right > viewportRect.left && + elRect.top < viewportRect.bottom && elRect.bottom > viewportRect.top; +} + + +/** Gets the rect for an element given its location ans size. */ +function getRect(location: ILocation, size: ISize): + {top: number, left: number, bottom: number, right: number} { + return { + top: location.y, + left: location.x, + bottom: location.y + size.height, + right: location.x + size.width + }; +} + + +/** Immediately scrolls the viewport to the given offset. */ +function scrollViewportTo(viewportEl: any, offset: number, done: () => void) { + viewportEl.scrollTop = offset; + window.requestAnimationFrame(() => done()); +} + + +/** Smoothly scrolls the viewport to the given offset, 25px at a time. */ +function smoothScrollViewportTo(viewportEl: any, offset: number, done: () => void) { + let promise = Promise.resolve(); + let curOffset = viewportEl.scrollTop; + do { + const co = curOffset += Math.min(25, Math.max(-25, offset - curOffset)); + promise = promise.then(() => new Promise(resolve => { + viewportEl.scrollTop = co; + window.requestAnimationFrame(() => resolve()); + })); + } while (curOffset != offset); + promise.then(() => done()); +} diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss b/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss index 60f6c3b03edf..b68799167b1e 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss @@ -14,11 +14,11 @@ cdk-virtual-scroll-viewport { will-change: contents, transform; } -.cdk-virtual-scroll-orientation-horizontal { +.cdk-virtual-scroll-orientation-horizontal .cdk-virtual-scroll-content-wrapper { bottom: 0; } -.cdk-virtual-scroll-orientation-vertical { +.cdk-virtual-scroll-orientation-vertical .cdk-virtual-scroll-content-wrapper { right: 0; } diff --git a/src/e2e-app/e2e-app-module.ts b/src/e2e-app/e2e-app-module.ts index ecb85b2763da..3f2aa9776358 100644 --- a/src/e2e-app/e2e-app-module.ts +++ b/src/e2e-app/e2e-app-module.ts @@ -1,24 +1,7 @@ +import {ScrollingModule} from '@angular/cdk-experimental'; +import {FullscreenOverlayContainer, OverlayContainer} from '@angular/cdk/overlay'; import {NgModule} from '@angular/core'; -import {BrowserModule} from '@angular/platform-browser'; -import {NoopAnimationsModule} from '@angular/platform-browser/animations'; -import {RouterModule} from '@angular/router'; -import {SimpleCheckboxes} from './checkbox/checkbox-e2e'; -import {E2EApp, Home} from './e2e-app/e2e-app'; -import {IconE2E} from './icon/icon-e2e'; -import {ButtonE2E} from './button/button-e2e'; -import {MenuE2E} from './menu/menu-e2e'; -import {SimpleRadioButtons} from './radio/radio-e2e'; -import {BasicTabs} from './tabs/tabs-e2e'; -import {DialogE2E, TestDialog} from './dialog/dialog-e2e'; -import {GridListE2E} from './grid-list/grid-list-e2e'; -import {ProgressBarE2E} from './progress-bar/progress-bar-e2e'; -import {ProgressSpinnerE2E} from './progress-spinner/progress-spinner-e2e'; -import {FullscreenE2E, TestDialogFullScreen} from './fullscreen/fullscreen-e2e'; -import {E2E_APP_ROUTES} from './e2e-app/routes'; -import {SlideToggleE2E} from './slide-toggle/slide-toggle-e2e'; -import {InputE2E} from './input/input-e2e'; -import {SidenavE2E} from './sidenav/sidenav-e2e'; -import {BlockScrollStrategyE2E} from './block-scroll-strategy/block-scroll-strategy-e2e'; +import {ReactiveFormsModule} from '@angular/forms'; import { MatButtonModule, MatCheckboxModule, @@ -39,9 +22,28 @@ import { MatStepperModule, MatTabsModule, } from '@angular/material'; -import {FullscreenOverlayContainer, OverlayContainer} from '@angular/cdk/overlay'; import {ExampleModule} from '@angular/material-examples'; -import {ReactiveFormsModule} from '@angular/forms'; +import {BrowserModule} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {RouterModule} from '@angular/router'; +import {BlockScrollStrategyE2E} from './block-scroll-strategy/block-scroll-strategy-e2e'; +import {ButtonE2E} from './button/button-e2e'; +import {SimpleCheckboxes} from './checkbox/checkbox-e2e'; +import {DialogE2E, TestDialog} from './dialog/dialog-e2e'; +import {E2EApp, Home} from './e2e-app/e2e-app'; +import {E2E_APP_ROUTES} from './e2e-app/routes'; +import {FullscreenE2E, TestDialogFullScreen} from './fullscreen/fullscreen-e2e'; +import {GridListE2E} from './grid-list/grid-list-e2e'; +import {IconE2E} from './icon/icon-e2e'; +import {InputE2E} from './input/input-e2e'; +import {MenuE2E} from './menu/menu-e2e'; +import {ProgressBarE2E} from './progress-bar/progress-bar-e2e'; +import {ProgressSpinnerE2E} from './progress-spinner/progress-spinner-e2e'; +import {SimpleRadioButtons} from './radio/radio-e2e'; +import {SidenavE2E} from './sidenav/sidenav-e2e'; +import {SlideToggleE2E} from './slide-toggle/slide-toggle-e2e'; +import {BasicTabs} from './tabs/tabs-e2e'; +import {VirtualScrollE2E} from './virtual-scroll/virtual-scroll-e2e'; /** * NgModule that contains all Material modules that are required to serve the e2e-app. @@ -66,6 +68,7 @@ import {ReactiveFormsModule} from '@angular/forms'; MatStepperModule, MatTabsModule, MatNativeDateModule, + ScrollingModule, ] }) export class E2eMaterialModule {} @@ -98,7 +101,8 @@ export class E2eMaterialModule {} SlideToggleE2E, TestDialog, TestDialogFullScreen, - BlockScrollStrategyE2E + BlockScrollStrategyE2E, + VirtualScrollE2E, ], bootstrap: [E2EApp], providers: [ diff --git a/src/e2e-app/e2e-app/e2e-app.html b/src/e2e-app/e2e-app/e2e-app.html index b1e16277e1eb..a28e711eaf86 100644 --- a/src/e2e-app/e2e-app/e2e-app.html +++ b/src/e2e-app/e2e-app/e2e-app.html @@ -22,6 +22,7 @@ Tabs Cards Toolbar + Virtual Scroll
diff --git a/src/e2e-app/e2e-app/routes.ts b/src/e2e-app/e2e-app/routes.ts index db20eb234048..58a2e48e7b7b 100644 --- a/src/e2e-app/e2e-app/routes.ts +++ b/src/e2e-app/e2e-app/routes.ts @@ -1,4 +1,5 @@ import {Routes} from '@angular/router'; +import {VirtualScrollE2E} from '../virtual-scroll/virtual-scroll-e2e'; import {Home} from './e2e-app'; import {ButtonE2E} from '../button/button-e2e'; import {BasicTabs} from '../tabs/tabs-e2e'; @@ -47,4 +48,5 @@ export const E2E_APP_ROUTES: Routes = [ {path: 'tabs', component: BasicTabs}, {path: 'cards', component: CardFancyExample}, {path: 'toolbar', component: ToolbarMultirowExample}, + {path: 'virtual-scroll', component: VirtualScrollE2E}, ]; diff --git a/src/e2e-app/tsconfig-build.json b/src/e2e-app/tsconfig-build.json index e369b61a4a58..af370e60fdef 100644 --- a/src/e2e-app/tsconfig-build.json +++ b/src/e2e-app/tsconfig-build.json @@ -26,6 +26,10 @@ "@angular/cdk/*": ["./cdk/*"], "@angular/material": ["./material"], "@angular/material/*": ["./material/*"], + "@angular/material-experimental/*": ["./material-experimental/*"], + "@angular/material-experimental": ["./material-experimental/"], + "@angular/cdk-experimental/*": ["./cdk-experimental/*"], + "@angular/cdk-experimental": ["./cdk-experimental/"], "@angular/material-moment-adapter": ["./material-moment-adapter"], "@angular/material-examples": ["./material-examples"] } diff --git a/src/e2e-app/virtual-scroll/virtual-scroll-e2e.css b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.css new file mode 100644 index 000000000000..adf74dad8e06 --- /dev/null +++ b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.css @@ -0,0 +1,13 @@ +.demo-viewport { + height: 300px; + width: 300px; + box-shadow: 0 0 0 1px black; +} + +.demo-item { + background: magenta; +} + +.demo-odd { + background: cyan; +} diff --git a/src/e2e-app/virtual-scroll/virtual-scroll-e2e.html b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.html new file mode 100644 index 000000000000..182542cbe186 --- /dev/null +++ b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.html @@ -0,0 +1,19 @@ +
+

Uniform size

+ +
+ Uniform Item #{{i}} - ({{size}}px) +
+
+
+ +
+

Random size

+ +
+ Variable Item #{{i}} - ({{size}}px) +
+
+
diff --git a/src/e2e-app/virtual-scroll/virtual-scroll-e2e.ts b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.ts new file mode 100644 index 000000000000..991d1caf8a68 --- /dev/null +++ b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.ts @@ -0,0 +1,16 @@ +import {Component} from '@angular/core'; + + +const itemSizeSample = [100, 25, 50, 50, 100, 200, 75, 100, 50, 250]; + + +@Component({ + moduleId: module.id, + selector: 'virtual-scroll-e2e', + templateUrl: 'virtual-scroll-e2e.html', + styleUrls: ['virtual-scroll-e2e.css'], +}) +export class VirtualScrollE2E { + uniformItems = Array(1000).fill(50); + variableItems = Array(100).fill(0).reduce(acc => acc.concat(itemSizeSample), []); +} diff --git a/tools/gulp/tasks/e2e.ts b/tools/gulp/tasks/e2e.ts index e49b2ed29ba6..242e10866b13 100644 --- a/tools/gulp/tasks/e2e.ts +++ b/tools/gulp/tasks/e2e.ts @@ -2,7 +2,7 @@ import {task} from 'gulp'; import {join} from 'path'; import {ngcBuildTask, copyTask, execNodeTask, serverTask} from '../util/task_helpers'; import {copySync} from 'fs-extra'; -import {buildConfig, sequenceTask, watchFiles} from 'material2-build-tools'; +import {buildConfig, sequenceTask, triggerLivereload, watchFiles} from 'material2-build-tools'; // There are no type definitions available for these imports. const gulpConnect = require('gulp-connect'); @@ -13,6 +13,7 @@ const {outputDir, packagesDir, projectDir} = buildConfig; const releasesDir = join(outputDir, 'releases'); const appDir = join(packagesDir, 'e2e-app'); +const e2eTestDir = join(projectDir, 'e2e'); const outDir = join(outputDir, 'packages', 'e2e-app'); const PROTRACTOR_CONFIG_PATH = join(projectDir, 'test/protractor.conf.js'); @@ -21,9 +22,7 @@ const tsconfigPath = join(outDir, 'tsconfig-build.json'); /** Glob that matches all files that need to be copied to the output folder. */ const assetsGlob = join(appDir, '**/*.+(html|css|json|ts)'); -/** - * Builds and serves the e2e-app and runs protractor once the e2e-app is ready. - */ +/** Builds and serves the e2e-app and runs protractor once the e2e-app is ready. */ task('e2e', sequenceTask( [':test:protractor:setup', 'serve:e2eapp'], ':test:protractor', @@ -31,6 +30,34 @@ task('e2e', sequenceTask( 'screenshots', )); +/** + * Builds and serves the e2e-app and runs protractor when the app is ready. Re-runs protractor when + * the app or tests change. + */ +task('e2e:watch', sequenceTask( + [':test:protractor:setup', 'serve:e2eapp'], + [':test:protractor', 'material:watch', ':e2e:watch'], +)); + +/** Watches the e2e app and tests for changes and triggers a test rerun on change. */ +task(':e2e:watch', () => { + watchFiles([join(appDir, '**/*.+(html|ts|css)'), join(e2eTestDir, '**/*.+(html|ts)')], + [':e2e:rerun'], false); +}); + +/** Updates the e2e app and runs the protractor tests. */ +task(':e2e:rerun', sequenceTask( + 'e2e-app:copy-assets', + 'e2e-app:build-ts', + ':e2e:reload', + ':test:protractor' +)); + +/** Triggers a reload of the e2e app. */ +task(':e2e:reload', () => { + return triggerLivereload(); +}); + /** Task that builds the e2e-app in AOT mode. */ task('e2e-app:build', sequenceTask( 'clean', diff --git a/tools/package-tools/rollup-globals.ts b/tools/package-tools/rollup-globals.ts index 19ae925047c8..fe36cf5dbd0b 100644 --- a/tools/package-tools/rollup-globals.ts +++ b/tools/package-tools/rollup-globals.ts @@ -6,23 +6,35 @@ import {buildConfig} from './build-config'; export const dashCaseToCamelCase = (str: string) => str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); +/** Generates rollup entry point mappings for the given package and entry points. */ +function generateRollupEntryPoints(packageName: string, entryPoints: string[]): + {[k: string]: string} { + return entryPoints.reduce((globals: {[k: string]: string}, entryPoint: string) => { + globals[`@angular/${packageName}/${entryPoint}`] = + `ng.${dashCaseToCamelCase(packageName)}.${dashCaseToCamelCase(entryPoint)}`; + return globals; + }, {}); +} + /** List of potential secondary entry-points for the cdk package. */ const cdkSecondaryEntryPoints = getSubdirectoryNames(join(buildConfig.packagesDir, 'cdk')); /** List of potential secondary entry-points for the material package. */ const matSecondaryEntryPoints = getSubdirectoryNames(join(buildConfig.packagesDir, 'lib')); +/** List of potential secondary entry-points for the cdk-experimental package. */ +const cdkExperimentalSecondaryEntryPoints = + getSubdirectoryNames(join(buildConfig.packagesDir, 'cdk-experimental')); + /** Object with all cdk entry points in the format of Rollup globals. */ -const rollupCdkEntryPoints = cdkSecondaryEntryPoints.reduce((globals: any, entryPoint: string) => { - globals[`@angular/cdk/${entryPoint}`] = `ng.cdk.${dashCaseToCamelCase(entryPoint)}`; - return globals; -}, {}); +const rollupCdkEntryPoints = generateRollupEntryPoints('cdk', cdkSecondaryEntryPoints); /** Object with all material entry points in the format of Rollup globals. */ -const rollupMatEntryPoints = matSecondaryEntryPoints.reduce((globals: any, entryPoint: string) => { - globals[`@angular/material/${entryPoint}`] = `ng.material.${dashCaseToCamelCase(entryPoint)}`; - return globals; -}, {}); +const rollupMatEntryPoints = generateRollupEntryPoints('material', matSecondaryEntryPoints); + +/** Object with all cdk-experimental entry points in the format of Rollup globals. */ +const rollupCdkExperimentalEntryPoints = + generateRollupEntryPoints('cdk-experimental', cdkExperimentalSecondaryEntryPoints); /** Map of globals that are used inside of the different packages. */ export const rollupGlobals = { @@ -55,6 +67,7 @@ export const rollupGlobals = { // Include secondary entry-points of the cdk and material packages ...rollupCdkEntryPoints, ...rollupMatEntryPoints, + ...rollupCdkExperimentalEntryPoints, 'rxjs': 'Rx', 'rxjs/operators': 'Rx.operators', From 341bb158d44949f3726bd29e2287c0fb83e52333 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Wed, 16 May 2018 10:04:21 -0700 Subject: [PATCH 25/28] address comments --- src/cdk-experimental/scrolling/virtual-for-of.ts | 8 ++++++++ src/cdk-experimental/scrolling/virtual-scroll-viewport.ts | 6 ++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/cdk-experimental/scrolling/virtual-for-of.ts b/src/cdk-experimental/scrolling/virtual-for-of.ts index a83f59464efd..f2b1b57bcd42 100644 --- a/src/cdk-experimental/scrolling/virtual-for-of.ts +++ b/src/cdk-experimental/scrolling/virtual-for-of.ts @@ -30,13 +30,21 @@ import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; /** The context for an item rendered by `CdkVirtualForOf` */ export type CdkVirtualForOfContext = { + /** The item value. */ $implicit: T; + /** The DataSource, Observable, or NgIterable that was passed to *cdkVirtualFor. */ cdkVirtualForOf: DataSource | Observable | NgIterable; + /** The index of the item in the DataSource. */ index: number; + /** The number of items in the DataSource. */ count: number; + /** Whether this is the first item in the DataSource. */ first: boolean; + /** Whether this is the last item in the DataSource. */ last: boolean; + /** Whether the index is even. */ even: boolean; + /** Whether the index is odd. */ odd: boolean; }; diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts index 33c95dbc7d97..c409f8c137ae 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -108,6 +108,8 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { ngOnInit() { const viewportEl = this.elementRef.nativeElement; + // It's still too early to measure the viewport at this point. Deferring with a promise allows + // the Viewport to be rendered with the correct size before we measure. Promise.resolve().then(() => { this._viewportSize = this.orientation === 'horizontal' ? viewportEl.clientWidth : viewportEl.clientHeight; @@ -157,7 +159,7 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { // changes. this._forOf.dataStream.pipe(takeUntil(this._detachedSubject)).subscribe(data => { const len = data.length; - if (len != this._dataLength) { + if (len !== this._dataLength) { this._dataLength = len; this._scrollStrategy.onDataLengthChanged(); } @@ -197,7 +199,7 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { * rendered. */ setTotalContentSize(size: number) { - if (this._totalContentSize != size) { + if (this._totalContentSize !== size) { // Re-enter the Angular zone so we can mark for change detection. this._ngZone.run(() => { this._totalContentSize = size; From 63e058eb93114efe9394902f6dc8cc4b1de23e47 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Wed, 16 May 2018 10:44:52 -0700 Subject: [PATCH 26/28] fix lint and tests --- .../scrolling/virtual-scroll-viewport.spec.ts | 2 +- .../stepper-vertical/stepper-vertical-example.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts index b52612224cc5..01af96e49834 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts @@ -111,7 +111,7 @@ describe('CdkVirtualScrollViewport', () => { const items = fixture.elementRef.nativeElement.querySelectorAll('.item'); expect(items.length).toBe(1, 'Expected 1 item to be rendered'); - expect(items[0].innerText).toBe('2 - 2', 'Expected item with index 2 to be rendered'); + expect(items[0].innerText.trim()).toBe('2 - 2', 'Expected item with index 2 to be rendered'); })); it('should set content offset to top of content', fakeAsync(() => { diff --git a/src/material-examples/stepper-vertical/stepper-vertical-example.ts b/src/material-examples/stepper-vertical/stepper-vertical-example.ts index ba958383b4f5..13229872f1fd 100644 --- a/src/material-examples/stepper-vertical/stepper-vertical-example.ts +++ b/src/material-examples/stepper-vertical/stepper-vertical-example.ts @@ -1,4 +1,4 @@ -import {Component} from '@angular/core'; +import {Component, OnInit} from '@angular/core'; import {FormBuilder, FormGroup, Validators} from '@angular/forms'; /** @@ -9,7 +9,7 @@ import {FormBuilder, FormGroup, Validators} from '@angular/forms'; templateUrl: 'stepper-vertical-example.html', styleUrls: ['stepper-vertical-example.css'] }) -export class StepperVerticalExample { +export class StepperVerticalExample implements OnInit { isLinear = false; firstFormGroup: FormGroup; secondFormGroup: FormGroup; From c4177ffb7b93bf703b04e9ddeb5e393f79892263 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Wed, 16 May 2018 10:48:29 -0700 Subject: [PATCH 27/28] fix bad rebase --- src/demo-app/demo-app/demo-module.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/demo-app/demo-app/demo-module.ts b/src/demo-app/demo-app/demo-module.ts index 08cff552c46b..789a2b24dbff 100644 --- a/src/demo-app/demo-app/demo-module.ts +++ b/src/demo-app/demo-app/demo-module.ts @@ -104,7 +104,6 @@ import {DEMO_APP_ROUTES} from './routes'; DrawerDemo, ExampleBottomSheet, ExpansionDemo, - ExpansionDemo, FocusOriginDemo, FoggyTabContent, GesturesDemo, @@ -140,10 +139,6 @@ import {DEMO_APP_ROUTES} from './routes'; TooltipDemo, TypographyDemo, VirtualScrollDemo, - ExampleBottomSheet, - ExpansionDemo, - ConnectedOverlayDemo, - DemoOverlay, ], providers: [ {provide: OverlayContainer, useClass: FullscreenOverlayContainer}, From fe54f73a7c2c977d1a1eb0fc8fef5a865b33e50c Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 16 May 2018 13:10:06 -0500 Subject: [PATCH 28/28] fix(scrolling): scrollbar jump when data change on scroll (#11193) * fix(scrolling): data change not updating size * chore: remove todo * chore: more performant fix for jump --- src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts index 1279317c546f..027e703cf01c 100644 --- a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts +++ b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts @@ -132,8 +132,8 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { /** @docs-private Implemented as part of VirtualScrollStrategy. */ onDataLengthChanged() { if (this._viewport) { - // TODO(mmalebra): Do something smarter here. this._setScrollOffset(); + this._checkRenderedContentSize(); } }