From f59658d848b7915198ac7070960b370aa9864a23 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Tue, 22 May 2018 09:19:56 -0700 Subject: [PATCH] virtual-scroll: merge into master under cdk-experimental (#11354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * chore: move virtual-scroll to cdk-experimental (#9974) * virtual-scroll: simplify scroll listener logic (#10102) * virtual-scroll: only move views that need to be moved (#10099) * virtual-scroll: only move views that need to be moved * address comments * virtual-scroll: switch `throttleTime` to `sampleTime` (#10179) * virtual-scroll: switch throttleTime to sampleTime * add comment * virtual-scroll: allow user to pass `Observable` (#10158) * virtual-scroll: rename `Range` to `ListRange` to avoid confusion with native `Range` (#10220) * 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 * virtual-scroll: add `onContentRendered` hook to `VirtualScrollStrategy` (#10290) * virtual-scroll: add `onContentRendered` hook to `VirtualScrollStrategy` * address comemnts * 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 * fix bazel * fix devapp * fix lint * cleanup * 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 * 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 * virtual-scroll: fix updating when data changes and add trackBy demos (#11085) * virtual-scroll: add logic to correct the scroll position as user move… (#11137) * virtual-scroll: add logic to correct the scroll position as user moves toward the top * address comments * fix(scrolling): adds right to fix pushed content (#11192) * fix(scrolling): adds right to fix pushed content * chore: address feedback * chore: address pr feedback * 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 * 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 * add some basic tests (#11295) * fix lint * 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 * address comments * fix lint and tests * fix bad rebase * 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 --- .github/CODEOWNERS | 4 + e2e/components/virtual-scroll-e2e.spec.ts | 117 ++++ packages.bzl | 15 + src/cdk-experimental/BUILD.bazel | 11 +- src/cdk-experimental/package.json | 2 +- src/cdk-experimental/public-api.ts | 1 + src/cdk-experimental/scrolling/BUILD.bazel | 62 ++ .../scrolling/auto-size-virtual-scroll.ts | 450 +++++++++++++ .../scrolling/fixed-size-virtual-scroll.ts | 165 +++++ src/cdk-experimental/scrolling/index.ts | 9 + src/cdk-experimental/scrolling/public-api.ts | 14 + .../scrolling/scrolling-module.ts | 30 + .../scrolling/tsconfig-build.json | 15 + src/cdk-experimental/scrolling/typings.d.ts | 1 + .../scrolling/virtual-for-of.ts | 338 ++++++++++ .../scrolling/virtual-scroll-strategy.ts | 40 ++ .../scrolling/virtual-scroll-viewport.html | 16 + .../scrolling/virtual-scroll-viewport.scss | 30 + .../scrolling/virtual-scroll-viewport.spec.ts | 611 ++++++++++++++++++ .../scrolling/virtual-scroll-viewport.ts | 310 +++++++++ src/cdk-experimental/tsconfig-build.json | 13 +- src/cdk-experimental/tsconfig-tests.json | 6 +- src/cdk-experimental/tsconfig.json | 4 +- src/cdk/collections/array-data-source.ts | 24 + src/cdk/collections/collection-viewer.ts | 7 +- src/cdk/collections/public-api.ts | 1 + src/demo-app/demo-app/demo-app.ts | 3 +- src/demo-app/demo-app/demo-module.ts | 19 +- src/demo-app/demo-app/routes.ts | 4 +- src/demo-app/demo-material-module.ts | 2 + src/demo-app/system-config.ts | 2 + .../virtual-scroll/virtual-scroll-demo.html | 96 +++ .../virtual-scroll/virtual-scroll-demo.scss | 39 ++ .../virtual-scroll/virtual-scroll-demo.ts | 109 ++++ 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/system-config.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 + test/karma-test-shim.js | 2 + tools/gulp/tasks/e2e.ts | 35 +- tools/package-tools/rollup-globals.ts | 53 +- 45 files changed, 2696 insertions(+), 71 deletions(-) create mode 100644 e2e/components/virtual-scroll-e2e.spec.ts create mode 100644 src/cdk-experimental/scrolling/BUILD.bazel create mode 100644 src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts create mode 100644 src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts 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 create mode 100644 src/cdk-experimental/scrolling/typings.d.ts create mode 100644 src/cdk-experimental/scrolling/virtual-for-of.ts create mode 100644 src/cdk-experimental/scrolling/virtual-scroll-strategy.ts create mode 100644 src/cdk-experimental/scrolling/virtual-scroll-viewport.html create mode 100644 src/cdk-experimental/scrolling/virtual-scroll-viewport.scss create mode 100644 src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts create mode 100644 src/cdk-experimental/scrolling/virtual-scroll-viewport.ts create mode 100644 src/cdk/collections/array-data-source.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 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 f4193788964..7464d57455a 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 @@ -141,6 +142,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 @@ -165,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 @@ -183,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 00000000000..57084e95000 --- /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/packages.bzl b/packages.bzl index 7c67d05c9c2..8b75678a4d0 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", @@ -84,6 +93,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 2ab857cf3eb..c69c31f9bf1 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", "VERSION_PLACEHOLDER_REPLACEMENTS") +load("//:packages.bzl", "CDK_EXPERIMENTAL_PACKAGES", "CDK_EXPERIMENTAL_TARGETS", "CDK_TARGETS", "ROLLUP_GLOBALS", "VERSION_PLACEHOLDER_REPLACEMENTS") +# 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", ) @@ -18,6 +19,6 @@ ng_package( entry_point = "src/cdk-experimental/public_api.js", globals = ROLLUP_GLOBALS, replacements = VERSION_PLACEHOLDER_REPLACEMENTS, - deps = [":cdk-experimental"], + deps = CDK_EXPERIMENTAL_TARGETS, tags = ["publish"], ) diff --git a/src/cdk-experimental/package.json b/src/cdk-experimental/package.json index 4a4a2607869..72e80667e29 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/public-api.ts b/src/cdk-experimental/public-api.ts index 326c704b094..8b70da4e86c 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-experimental/scrolling/BUILD.bazel b/src/cdk-experimental/scrolling/BUILD.bazel new file mode 100644 index 00000000000..2e7fcb97bd5 --- /dev/null +++ b/src/cdk-experimental/scrolling/BUILD.bazel @@ -0,0 +1,62 @@ +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-experimental/scrolling", + assets = [":virtual_scroll_viewport_css"] + glob(["**/*.html"]), + deps = [ + "//src/cdk/coercion", + "//src/cdk/collections", + "@rxjs", + ], + tsconfig = "//src/cdk-experimental: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.css"], + outs = ["virtual-scroll-viewport.css"], + cmd = "cp $< $@", +) + +ts_library( + name = "scrolling_test_sources", + testonly = 1, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":scrolling", + "//src/cdk/collections", + "//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/auto-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts new file mode 100644 index 00000000000..027e703cf01 --- /dev/null +++ b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts @@ -0,0 +1,450 @@ +/** + * @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 {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'; +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; + + /** 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; + } + + /** 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 newTotalWeight = this._totalWeight + range.end - range.start; + if (newTotalWeight) { + const newAverageItemSize = + (size + this._averageItemSize * this._totalWeight) / newTotalWeight; + if (newAverageItemSize) { + this._averageItemSize = newAverageItemSize; + this._totalWeight = newTotalWeight; + } + } + } + + /** Resets the averager. */ + reset() { + this._averageItemSize = this._defaultItemSize; + this._totalWeight = 0; + } +} + + +/** 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; + + /** 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; + + /** + * 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. + * @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._averager.reset(); + this._viewport = viewport; + this._setScrollOffset(); + } + + /** Detaches this scroll strategy from the currently attached viewport. */ + detach() { + this._viewport = null; + } + + /** @docs-private Implemented as part of VirtualScrollStrategy. */ + onContentScrolled() { + if (this._viewport) { + this._updateRenderedContentAfterScroll(); + } + } + + /** @docs-private Implemented as part of VirtualScrollStrategy. */ + onDataLengthChanged() { + if (this._viewport) { + this._setScrollOffset(); + this._checkRenderedContentSize(); + } + } + + /** @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). + * @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; + } + + /** 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. + let scrollDelta = scrollOffset - this._lastScrollOffset; + // The magnitude of the scroll delta. + let scrollMagnitude = Math.abs(scrollDelta); + + // The currently rendered range. + const renderedRange = viewport.getRenderedRange(); + + // 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; + // 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. 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 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) { + let removedSize = viewport.measureRangeSize({ + start: range.end, + end: renderedRange.end, + }); + // 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, + }); + // 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'; + } + + // Set the range and offset we calculated above. + viewport.setRenderedRange(range); + 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. + 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!; + 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 + * changed, but the rendered content will be recalculated based on our estimate of what should + * be shown at the current scroll offset. + */ + private _setScrollOffset(scrollOffset?: number) { + const viewport = this._viewport!; + if (scrollOffset == null) { + scrollOffset = viewport.measureScrollOffset(); + } else { + viewport.setScrollOffset(scrollOffset); + } + this._lastScrollOffset = scrollOffset; + this._removalFailures = 0; + + 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(renderedContentSize: number) { + const viewport = this._viewport!; + const renderedRange = viewport.getRenderedRange(); + const totalSize = renderedContentSize + + (viewport.getDataLength() - (renderedRange.end - renderedRange.start)) * + this._averager.getAverageItemSize(); + viewport.setTotalContentSize(totalSize); + } +} + +/** + * 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() + 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() + 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); + + ngOnChanges() { + this._scrollStrategy.updateBufferSize(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 new file mode 100644 index 00000000000..2e0537b246e --- /dev/null +++ b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts @@ -0,0 +1,165 @@ +/** + * @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 {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'; +import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; + + +/** Virtual scrolling strategy for lists with items of known fixed size. */ +export class FixedSizeVirtualScrollStrategy 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 The 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(); + } + + /** @docs-private Implemented as part of VirtualScrollStrategy. */ + onContentScrolled() { + this._updateRenderedRange(); + } + + /** @docs-private Implemented as part of VirtualScrollStrategy. */ + onDataLengthChanged() { + this._updateTotalContentSize(); + this._updateRenderedRange(); + } + + /** @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) { + 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 firstItemRemainder = scrollOffset % this._itemSize; + const range = this._expandRange( + {start: firstVisibleIndex, end: firstVisibleIndex}, + this._bufferSize, + Math.ceil((this._viewport.getViewportSize() + firstItemRemainder) / 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: ListRange, expandStart: number, expandEnd: number): ListRange { + 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 `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 _fixedSizeVirtualScrollStrategyFactory(fixedSizeDir: CdkFixedSizeVirtualScroll) { + 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: _fixedSizeVirtualScrollStrategyFactory, + deps: [forwardRef(() => CdkFixedSizeVirtualScroll)], + }], +}) +export class CdkFixedSizeVirtualScroll implements OnChanges { + /** The size of the items in the list (in pixels). */ + @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() + 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); + + ngOnChanges() { + this._scrollStrategy.updateItemAndBufferSize(this.itemSize, this.bufferSize); + } +} diff --git a/src/cdk-experimental/scrolling/index.ts b/src/cdk-experimental/scrolling/index.ts new file mode 100644 index 00000000000..676ca90f1ff --- /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 00000000000..176164b1ed6 --- /dev/null +++ b/src/cdk-experimental/scrolling/public-api.ts @@ -0,0 +1,14 @@ +/** + * @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 './auto-size-virtual-scroll'; +export * from './fixed-size-virtual-scroll'; +export * from './scrolling-module'; +export * from './virtual-for-of'; +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 00000000000..cccaaacb3eb --- /dev/null +++ b/src/cdk-experimental/scrolling/scrolling-module.ts @@ -0,0 +1,30 @@ +/** + * @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 {CdkAutoSizeVirtualScroll} from './auto-size-virtual-scroll'; +import {CdkFixedSizeVirtualScroll} from './fixed-size-virtual-scroll'; +import {CdkVirtualForOf} from './virtual-for-of'; +import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; + + +@NgModule({ + exports: [ + CdkAutoSizeVirtualScroll, + CdkFixedSizeVirtualScroll, + CdkVirtualForOf, + CdkVirtualScrollViewport, + ], + declarations: [ + CdkAutoSizeVirtualScroll, + CdkFixedSizeVirtualScroll, + CdkVirtualForOf, + 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 00000000000..21ad182a035 --- /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": false, // Workaround for Angular #22210 + "flatModuleOutFile": "index.js", + "flatModuleId": "@angular/cdk-experimental/scrolling", + "skipTemplateCodegen": true, + "fullTemplateTypeCheck": true + } +} diff --git a/src/cdk-experimental/scrolling/typings.d.ts b/src/cdk-experimental/scrolling/typings.d.ts new file mode 100644 index 00000000000..ce4ae9b66cf --- /dev/null +++ b/src/cdk-experimental/scrolling/typings.d.ts @@ -0,0 +1 @@ +declare var module: {id: string}; diff --git a/src/cdk-experimental/scrolling/virtual-for-of.ts b/src/cdk-experimental/scrolling/virtual-for-of.ts new file mode 100644 index 00000000000..f2b1b57bcd4 --- /dev/null +++ b/src/cdk-experimental/scrolling/virtual-for-of.ts @@ -0,0 +1,338 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ArrayDataSource, CollectionViewer, DataSource, ListRange} from '@angular/cdk/collections'; +import { + Directive, + DoCheck, + EmbeddedViewRef, + Input, + IterableChangeRecord, + IterableChanges, + IterableDiffer, + IterableDiffers, + NgIterable, + OnDestroy, + SkipSelf, + TemplateRef, + TrackByFunction, + ViewContainerRef, +} from '@angular/core'; +import {Observable, Subject} from 'rxjs'; +import {pairwise, shareReplay, startWith, switchMap, takeUntil} from 'rxjs/operators'; +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; +}; + + +/** Helper to extract size from a ClientRect. */ +function getSize(orientation: 'horizontal' | 'vertical', rect: ClientRect): number { + return orientation == 'horizontal' ? rect.width : rect.height; +} + + +/** + * A directive similar to `ngForOf` to be used for rendering data inside a virtual scrolling + * container. + */ +@Directive({ + selector: '[cdkVirtualFor][cdkVirtualForOf]', +}) +export class CdkVirtualForOf 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(): DataSource | Observable | NgIterable { + return this._cdkVirtualForOf; + } + set cdkVirtualForOf(value: DataSource | Observable | NgIterable) { + this._cdkVirtualForOf = value; + const ds = value instanceof DataSource ? value : + // Slice the value if its an NgIterable to ensure we're working with an array. + new ArrayDataSource( + value instanceof Observable ? value : Array.prototype.slice.call(value || [])); + this._dataSourceChanges.next(ds); + } + _cdkVirtualForOf: DataSource | Observable | NgIterable; + + /** + * 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 | undefined { + return this._cdkVirtualForTrackBy; + } + set cdkVirtualForTrackBy(fn: TrackByFunction | undefined) { + this._needsUpdate = true; + this._cdkVirtualForTrackBy = fn ? + (index, item) => fn(index + (this._renderedRange ? this._renderedRange.start : 0), item) : + undefined; + } + private _cdkVirtualForTrackBy: TrackByFunction | undefined; + + /** The template used to stamp out new elements. */ + @Input() + set cdkVirtualForTemplate(value: TemplateRef>) { + if (value) { + this._needsUpdate = true; + this._template = value; + } + } + + @Input() cdkVirtualForTemplateCacheSize: number = 20; + + /** 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: ListRange; + + /** + * The template cache used to hold on ot template instancess that have been stamped out, but don't + * currently need to be rendered. These instances will be reused in the future rather than + * stamping out brand new ones. + */ + private _templateCache: EmbeddedViewRef>[] = []; + + /** 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, + /** 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._onRenderedDataChange(); + }); + this._viewport.renderedRangeStream.pipe(takeUntil(this._destroyed)).subscribe(range => { + this._renderedRange = range; + this.viewChange.next(this._renderedRange); + this._onRenderedDataChange(); + }); + this._viewport.attach(this); + } + + /** + * Measures the combined size (width for horizontal orientation, height for vertical) of all items + * in the specified range. Throws an error if the range includes items that are not currently + * rendered. + */ + measureRangeSize(range: ListRange, orientation: 'horizontal' | 'vertical'): number { + if (range.start >= range.end) { + return 0; + } + if (range.start < this._renderedRange.start || range.end > this._renderedRange.end) { + throw Error(`Error: attempted to measure an item that isn't rendered.`); + } + + // The index into the list of rendered views for the first item in the range. + const renderedStartIndex = range.start - this._renderedRange.start; + // The length of the range we're measuring. + const rangeLen = range.end - range.start; + + // Loop over all root nodes for all items in the range and sum up their size. + // TODO(mmalerba): Make this work with non-element nodes. + let totalSize = 0; + let i = rangeLen; + while (i--) { + const view = this._viewContainerRef.get(i + renderedStartIndex) as + EmbeddedViewRef> | null; + let j = view ? view.rootNodes.length : 0; + while (j--) { + totalSize += getSize(orientation, (view!.rootNodes[j] as Element).getBoundingClientRect()); + } + } + + return totalSize; + } + + ngDoCheck() { + if (this._differ && this._needsUpdate) { + // TODO(mmalerba): We should differentiate needs update due to scrolling and a new portion of + // this list being rendered (can use simpler algorithm) vs needs update due to data actually + // changing (need to do this diff). + const changes = this._differ.diff(this._renderedItems); + if (!changes) { + this._updateContext(); + } else { + this._applyChanges(changes); + } + this._needsUpdate = false; + } + } + + ngOnDestroy() { + this._viewport.detach(); + + this._dataSourceChanges.complete(); + this.viewChange.complete(); + + this._destroyed.next(); + this._destroyed.complete(); + + for (let view of this._templateCache) { + view.destroy(); + } + } + + /** React to scroll state changes in the viewport. */ + 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); + } + 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) { + // 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; + }); + + // Update the context variables on all items. + const count = this._data.length; + let i = this._viewContainerRef.length; + while (i--) { + const view = this._viewContainerRef.get(i) as EmbeddedViewRef>; + view.context.index = this._renderedRange.start + i; + view.context.count = count; + this._updateComputedContextProperties(view.context); + } + } + + /** Cache the given detached view. */ + private _cacheView(view: EmbeddedViewRef>) { + if (this._templateCache.length < this.cdkVirtualForTemplateCacheSize) { + this._templateCache.push(view); + } else { + view.destroy(); + } + } + + /** Get a view for a new item, either from the cache or by creating a new one. */ + private _getViewForNewItem(): EmbeddedViewRef> { + return this._templateCache.pop() || this._viewContainerRef.createEmbeddedView(this._template, { + $implicit: null!, + cdkVirtualForOf: this._cdkVirtualForOf, + index: -1, + count: -1, + first: false, + last: false, + odd: false, + even: false + }); + } + + /** Update the computed properties on the `CdkVirtualForOfContext`. */ + private _updateComputedContextProperties(context: CdkVirtualForOfContext) { + 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-experimental/scrolling/virtual-scroll-strategy.ts b/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts new file mode 100644 index 00000000000..cbb66720170 --- /dev/null +++ b/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts @@ -0,0 +1,40 @@ +/** + * @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(); + + /** 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.html b/src/cdk-experimental/scrolling/virtual-scroll-viewport.html new file mode 100644 index 00000000000..5fc18943645 --- /dev/null +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.html @@ -0,0 +1,16 @@ + +
+ +
+ +
+
diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss b/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss new file mode 100644 index 00000000000..b68799167b1 --- /dev/null +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss @@ -0,0 +1,30 @@ +// 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; +} + +.cdk-virtual-scroll-orientation-horizontal .cdk-virtual-scroll-content-wrapper { + bottom: 0; +} + +.cdk-virtual-scroll-orientation-vertical .cdk-virtual-scroll-content-wrapper { + 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. +.cdk-virtual-scroll-spacer { + will-change: height, width; +} 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 00000000000..01af96e4983 --- /dev/null +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts @@ -0,0 +1,611 @@ +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, Subject} from 'rxjs'; +import {ScrollingModule} from './scrolling-module'; +import {CdkVirtualForOf} from './virtual-for-of'; +import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; + + +describe('CdkVirtualScrollViewport', () => { + describe ('with FixedSizeVirtualScrollStrategy', () => { + let fixture: ComponentFixture; + let testComponent: FixedSizeVirtualScroll; + let viewport: CdkVirtualScrollViewport; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ScrollingModule], + declarations: [FixedSizeVirtualScroll], + }).compileComponents(); + + fixture = TestBed.createComponent(FixedSizeVirtualScroll); + 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.trim()).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 update viewport as user scrolls right in horizontal mode', fakeAsync(() => { + testComponent.orientation = 'horizontal'; + 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 left in horizontal mode', fakeAsync(() => { + testComponent.orientation = 'horizontal'; + 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 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); + })); + }); + + 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. + }); +}); + + +/** 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; + } + + .cdk-virtual-scroll-orientation-horizontal .cdk-virtual-scroll-content-wrapper { + flex-direction: row; + } + `], + encapsulation: ViewEncapsulation.None, +}) +class FixedSizeVirtualScroll { + @ViewChild(CdkVirtualScrollViewport) viewport: CdkVirtualScrollViewport; + @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; + + get viewportWidth() { + return this.orientation == 'horizontal' ? this.viewportSize : this.viewportCrossSize; + } + + get viewportHeight() { + 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 new file mode 100644 index 00000000000..c409f8c137a --- /dev/null +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -0,0 +1,310 @@ +/** + * @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 { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DoCheck, + ElementRef, + Inject, + Input, + NgZone, + OnDestroy, + OnInit, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import {DomSanitizer, SafeStyle} from '@angular/platform-browser'; +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'; + + +/** Checks if the given ranges are equal. */ +function rangesEqual(r1: ListRange, r2: ListRange): 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, + selector: 'cdk-virtual-scroll-viewport', + templateUrl: 'virtual-scroll-viewport.html', + styleUrls: ['virtual-scroll-viewport.css'], + host: { + 'class': 'cdk-virtual-scroll-viewport', + '[class.cdk-virtual-scroll-orientation-horizontal]': 'orientation === "horizontal"', + '[class.cdk-virtual-scroll-orientation-vertical]': 'orientation === "vertical"', + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkVirtualScrollViewport implements DoCheck, OnInit, 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: 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}; + + /** 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; + + /** 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; + + /** 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; + + /** 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) {} + + 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; + 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, animationFrameScheduler), takeUntil(this._destroyed)) + .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(); + this._destroyed.next(); + + // Complete all subjects + this._renderedRangeSubject.complete(); + this._detachedSubject.complete(); + this._destroyed.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; + } + + /** Gets the size of the viewport (in pixels). */ + getViewportSize(): number { + 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; + } + + // 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: ListRange) { + 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); + this._changeDetectorRef.markForCheck(); + }); + // 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()))); + } + } + + /** + * 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._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(); + } + }); + }); + } + } + + /** 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(); + }); + } + + /** Gets the current scroll offset of the viewport (in pixels). */ + 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(): number { + const contentEl = this._contentWrapper.nativeElement; + return this.orientation === 'horizontal' ? contentEl.offsetWidth : contentEl.offsetHeight; + } + + /** + * 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/cdk-experimental/tsconfig-build.json b/src/cdk-experimental/tsconfig-build.json index 8e6e38c7a4b..392dbe3c929 100644 --- a/src/cdk-experimental/tsconfig-build.json +++ b/src/cdk-experimental/tsconfig-build.json @@ -13,17 +13,19 @@ "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"], "@angular/cdk/*": ["../../dist/packages/cdk/*"], - "@angular/cdk": ["../../dist/packages/cdk"] + "@angular/cdk-experimental/*": ["../../dist/packages/cdk-experimental/*"] } }, "files": [ @@ -35,7 +37,8 @@ "strictMetadataEmit": true, "flatModuleOutFile": "index.js", "flatModuleId": "@angular/cdk-experimental", - "skipTemplateCodegen": true + "skipTemplateCodegen": true, + "fullTemplateTypeCheck": true }, "bazelOptions": { "suppressTsconfigOverrideWarnings": true diff --git a/src/cdk-experimental/tsconfig-tests.json b/src/cdk-experimental/tsconfig-tests.json index dfd4fb7afce..c66fa9da2ff 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": true, diff --git a/src/cdk-experimental/tsconfig.json b/src/cdk-experimental/tsconfig.json index 7e813547746..58cf626924c 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/cdk/collections/array-data-source.ts b/src/cdk/collections/array-data-source.ts new file mode 100644 index 00000000000..50114292a6b --- /dev/null +++ b/src/cdk/collections/array-data-source.ts @@ -0,0 +1,24 @@ +/** + * @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, of as observableOf} from 'rxjs'; +import {DataSource} from './data-source'; + + +/** DataSource wrapper for a native array. */ +export class ArrayDataSource extends DataSource { + constructor(private _data: T[] | Observable) { + super(); + } + + connect(): Observable { + return this._data instanceof Observable ? this._data : observableOf(this._data); + } + + disconnect() {} +} diff --git a/src/cdk/collections/collection-viewer.ts b/src/cdk/collections/collection-viewer.ts index c982971666c..01e8d3c5e84 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 ListRange = {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/public-api.ts b/src/cdk/collections/public-api.ts index d79237c0414..16cda8897a7 100644 --- a/src/cdk/collections/public-api.ts +++ b/src/cdk/collections/public-api.ts @@ -6,6 +6,7 @@ * 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'; diff --git a/src/demo-app/demo-app/demo-app.ts b/src/demo-app/demo-app/demo-app.ts index d852391902f..5c873d400df 100644 --- a/src/demo-app/demo-app/demo-app.ts +++ b/src/demo-app/demo-app/demo-app.ts @@ -90,7 +90,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 6ad672132e7..789a2b24dbf 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,17 +56,19 @@ 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'; 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 {PaginatorDemo} from '../paginator/paginator-demo'; -import {ExamplesPage} from '../examples-page/examples-page'; -import {MaterialExampleModule} from '../example/example-module'; @NgModule({ imports: [ @@ -99,7 +104,6 @@ import {MaterialExampleModule} from '../example/example-module'; DrawerDemo, ExampleBottomSheet, ExpansionDemo, - ExpansionDemo, FocusOriginDemo, FoggyTabContent, GesturesDemo, @@ -134,6 +138,7 @@ import {MaterialExampleModule} from '../example/example-module'; ToolbarDemo, TooltipDemo, TypographyDemo, + VirtualScrollDemo, ], providers: [ {provide: OverlayContainer, useClass: FullscreenOverlayContainer}, diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index 5625aa8d2c5..a6aa928a98e 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -48,6 +48,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 {BadgeDemo} from '../badge/badge-demo'; import {ConnectedOverlayDemo} from '../connected-overlay/connected-overlay-demo'; @@ -103,7 +104,8 @@ export const DEMO_APP_ROUTES: Routes = [ {path: 'stepper', component: StepperDemo}, {path: 'screen-type', component: ScreenTypeDemo}, {path: 'connected-overlay', component: ConnectedOverlayDemo}, - {path: 'examples', component: ExamplesPage} + {path: 'virtual-scroll', component: VirtualScrollDemo}, + {path: 'examples', component: ExamplesPage}, ]} ]; diff --git a/src/demo-app/demo-material-module.ts b/src/demo-app/demo-material-module.ts index 60ecc6a90f9..f561ecc4767 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'; @@ -108,6 +109,7 @@ import { OverlayModule, PlatformModule, PortalModule, + ScrollingModule, ] }) export class DemoMaterialModule {} diff --git a/src/demo-app/system-config.ts b/src/demo-app/system-config.ts index a05cc049690..7e5b2048ff9 100644 --- a/src/demo-app/system-config.ts +++ b/src/demo-app/system-config.ts @@ -62,6 +62,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/demo-app/virtual-scroll/virtual-scroll-demo.html b/src/demo-app/virtual-scroll/virtual-scroll-demo.html new file mode 100644 index 00000000000..7c8b754eee0 --- /dev/null +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.html @@ -0,0 +1,96 @@ +

Autosize

+ +

Uniform size

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

Increasing size

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

Decreasing size

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

Random size

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

Fixed size

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

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 new file mode 100644 index 00000000000..58e9e1741b3 --- /dev/null +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.scss @@ -0,0 +1,39 @@ +.demo-viewport { + height: 500px; + width: 500px; + border: 1px solid black; + + .cdk-virtual-scroll-content-wrapper { + display: flex; + flex-direction: column; + } +} + +.demo-horizontal { + .cdk-virtual-scroll-content-wrapper { + flex-direction: row; + } + + .demo-item { + -ms-writing-mode: tb-lr; + -webkit-writing-mode: vertical-lr; + /* stylelint-disable-next-line material/no-prefixes */ + 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 new file mode 100644 index 00000000000..8b797703b3b --- /dev/null +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts @@ -0,0 +1,109 @@ +/** + * @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'; +import {BehaviorSubject} from 'rxjs/index'; + + +type State = { + name: string, + capital: string +}; + + +@Component({ + moduleId: module.id, + selector: 'virtual-scroll-demo', + templateUrl: 'virtual-scroll-demo.html', + styleUrls: ['virtual-scroll-demo.css'], + encapsulation: ViewEncapsulation.None, +}) +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)); + 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(); + } + + emitData() { + let data = this.observableData.value.concat([50]); + this.observableData.next(data); + } + + sortBy(prop: 'name' | 'capital') { + this.statesObservable.next(this.states.map(s => ({...s})).sort((a, b) => { + const aProp = a[prop], bProp = b[prop]; + if (aProp < bProp) { + return -1; + } else if (aProp > bProp) { + return 1; + } + return 0; + })); + } +} diff --git a/src/e2e-app/e2e-app-module.ts b/src/e2e-app/e2e-app-module.ts index ecb85b2763d..3f2aa977635 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 b1e16277e1e..a28e711eaf8 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 db20eb23404..58a2e48e7b7 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/system-config.ts b/src/e2e-app/system-config.ts index 65ea15b0ade..cdd27d78a6d 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/src/e2e-app/tsconfig-build.json b/src/e2e-app/tsconfig-build.json index e369b61a4a5..af370e60fde 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 00000000000..adf74dad8e0 --- /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 00000000000..182542cbe18 --- /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 00000000000..991d1caf8a6 --- /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/test/karma-test-shim.js b/test/karma-test-shim.js index 78e115b9d2c..59d26de7298 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', diff --git a/tools/gulp/tasks/e2e.ts b/tools/gulp/tasks/e2e.ts index e49b2ed29ba..242e10866b1 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 e84d381ba70..fe36cf5dbd0 100644 --- a/tools/package-tools/rollup-globals.ts +++ b/tools/package-tools/rollup-globals.ts @@ -6,53 +6,68 @@ 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 = { - '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-dynamic/testing': 'ng.platformBrowserDynamic.testing', '@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/material': 'ng.material', - '@angular/material-moment-adapter': 'ng.materialMomentAdapter', '@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 ...rollupCdkEntryPoints, ...rollupMatEntryPoints, + ...rollupCdkExperimentalEntryPoints, 'rxjs': 'Rx', 'rxjs/operators': 'Rx.operators',