diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f41937889641..7464d57455a9 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 000000000000..57084e95000e --- /dev/null +++ b/e2e/components/virtual-scroll-e2e.spec.ts @@ -0,0 +1,117 @@ +import {browser, by, element, ElementFinder} from 'protractor'; +import {ILocation, ISize} from 'selenium-webdriver'; + +declare var window: any; + + +describe('autosize cdk-virtual-scroll', () => { + let viewport: ElementFinder; + + describe('with uniform items', () => { + beforeEach(() => { + browser.get('/virtual-scroll'); + viewport = element(by.css('.demo-virtual-scroll-uniform-size cdk-virtual-scroll-viewport')); + }); + + it('should scroll down slowly', async () => { + await browser.executeAsyncScript(smoothScrollViewportTo, viewport, 2000); + const offScreen = element(by.css('.demo-virtual-scroll-uniform-size [data-index="39"]')); + const onScreen = element(by.css('.demo-virtual-scroll-uniform-size [data-index="40"]')); + expect(await isVisibleInViewport(offScreen, viewport)).toBe(false); + expect(await isVisibleInViewport(onScreen, viewport)).toBe(true); + }); + + it('should jump scroll position down and slowly scroll back up', async () => { + // The estimate of the total content size is exactly correct, so we wind up scrolled to the + // same place as if we slowly scrolled down. + await browser.executeAsyncScript(scrollViewportTo, viewport, 2000); + const offScreen = element(by.css('.demo-virtual-scroll-uniform-size [data-index="39"]')); + const onScreen = element(by.css('.demo-virtual-scroll-uniform-size [data-index="40"]')); + expect(await isVisibleInViewport(offScreen, viewport)).toBe(false); + expect(await isVisibleInViewport(onScreen, viewport)).toBe(true); + + // As we slowly scroll back up we should wind up back at the start of the content. + await browser.executeAsyncScript(smoothScrollViewportTo, viewport, 0); + const first = element(by.css('.demo-virtual-scroll-uniform-size [data-index="0"]')); + expect(await isVisibleInViewport(first, viewport)).toBe(true); + }); + }); + + describe('with variable size', () => { + beforeEach(() => { + browser.get('/virtual-scroll'); + viewport = element(by.css('.demo-virtual-scroll-variable-size cdk-virtual-scroll-viewport')); + }); + + it('should scroll down slowly', async () => { + await browser.executeAsyncScript(smoothScrollViewportTo, viewport, 2000); + const offScreen = element(by.css('.demo-virtual-scroll-variable-size [data-index="19"]')); + const onScreen = element(by.css('.demo-virtual-scroll-variable-size [data-index="20"]')); + expect(await isVisibleInViewport(offScreen, viewport)).toBe(false); + expect(await isVisibleInViewport(onScreen, viewport)).toBe(true); + }); + + it('should jump scroll position down and slowly scroll back up', async () => { + // The estimate of the total content size is slightly different than the actual, so we don't + // wind up in the same spot as if we scrolled slowly down. + await browser.executeAsyncScript(scrollViewportTo, viewport, 2000); + const offScreen = element(by.css('.demo-virtual-scroll-variable-size [data-index="18"]')); + const onScreen = element(by.css('.demo-virtual-scroll-variable-size [data-index="19"]')); + expect(await isVisibleInViewport(offScreen, viewport)).toBe(false); + expect(await isVisibleInViewport(onScreen, viewport)).toBe(true); + + // As we slowly scroll back up we should wind up back at the start of the content. As we + // scroll the error from when we jumped the scroll position should be slowly corrected. + await browser.executeAsyncScript(smoothScrollViewportTo, viewport, 0); + const first = element(by.css('.demo-virtual-scroll-variable-size [data-index="0"]')); + expect(await isVisibleInViewport(first, viewport)).toBe(true); + }); + }); +}); + + +/** Checks if the given element is visible in the given viewport. */ +async function isVisibleInViewport(el: ElementFinder, viewport: ElementFinder): Promise { + if (!await el.isPresent() || !await el.isDisplayed() || !await viewport.isPresent() || + !await viewport.isDisplayed()) { + return false; + } + const viewportRect = getRect(await viewport.getLocation(), await viewport.getSize()); + const elRect = getRect(await el.getLocation(), await el.getSize()); + return elRect.left < viewportRect.right && elRect.right > viewportRect.left && + elRect.top < viewportRect.bottom && elRect.bottom > viewportRect.top; +} + + +/** Gets the rect for an element given its location ans size. */ +function getRect(location: ILocation, size: ISize): + {top: number, left: number, bottom: number, right: number} { + return { + top: location.y, + left: location.x, + bottom: location.y + size.height, + right: location.x + size.width + }; +} + + +/** Immediately scrolls the viewport to the given offset. */ +function scrollViewportTo(viewportEl: any, offset: number, done: () => void) { + viewportEl.scrollTop = offset; + window.requestAnimationFrame(() => done()); +} + + +/** Smoothly scrolls the viewport to the given offset, 25px at a time. */ +function smoothScrollViewportTo(viewportEl: any, offset: number, done: () => void) { + let promise = Promise.resolve(); + let curOffset = viewportEl.scrollTop; + do { + const co = curOffset += Math.min(25, Math.max(-25, offset - curOffset)); + promise = promise.then(() => new Promise(resolve => { + viewportEl.scrollTop = co; + window.requestAnimationFrame(() => resolve()); + })); + } while (curOffset != offset); + promise.then(() => done()); +} diff --git a/packages.bzl b/packages.bzl index 7c67d05c9c2f..8b75678a4d0f 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 2ab857cf3eb9..c69c31f9bf16 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 4a4a2607869d..72e80667e29b 100644 --- a/src/cdk-experimental/package.json +++ b/src/cdk-experimental/package.json @@ -16,7 +16,7 @@ }, "homepage": "https://github.com/angular/material2#readme", "peerDependencies": { - "@angular/material": "0.0.0-PLACEHOLDER", + "@angular/cdk": "0.0.0-PLACEHOLDER", "@angular/core": "0.0.0-NG" }, "dependencies": { diff --git a/src/cdk-experimental/public-api.ts b/src/cdk-experimental/public-api.ts index 326c704b094c..8b70da4e86c8 100644 --- a/src/cdk-experimental/public-api.ts +++ b/src/cdk-experimental/public-api.ts @@ -7,3 +7,4 @@ */ export * from './version'; +export * from '@angular/cdk-experimental/scrolling'; diff --git a/src/cdk-experimental/scrolling/BUILD.bazel b/src/cdk-experimental/scrolling/BUILD.bazel new file mode 100644 index 000000000000..2e7fcb97bd56 --- /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 000000000000..027e703cf01c --- /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 000000000000..2e0537b246eb --- /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 000000000000..676ca90f1ffa --- /dev/null +++ b/src/cdk-experimental/scrolling/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; diff --git a/src/cdk-experimental/scrolling/public-api.ts b/src/cdk-experimental/scrolling/public-api.ts new file mode 100644 index 000000000000..176164b1ed6c --- /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 000000000000..cccaaacb3eb4 --- /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 000000000000..21ad182a0358 --- /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 000000000000..ce4ae9b66cf0 --- /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 000000000000..f2b1b57bcd42 --- /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 000000000000..cbb667201709 --- /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 000000000000..5fc18943645a --- /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 000000000000..b68799167b1e --- /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 000000000000..01af96e49834 --- /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 000000000000..c409f8c137ae --- /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 8e6e38c7a4b3..392dbe3c9290 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 dfd4fb7afce3..c66fa9da2ffe 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 7e813547746f..58cf626924cf 100644 --- a/src/cdk-experimental/tsconfig.json +++ b/src/cdk-experimental/tsconfig.json @@ -6,9 +6,7 @@ "baseUrl": ".", "paths": { "@angular/cdk/*": ["../cdk/*"], - "@angular/cdk": ["../cdk"], - "@angular/material/*": ["../lib/*"], - "@angular/material": ["../lib/public-api.ts"] + "@angular/cdk-experimental/*": ["../cdk-experimental/*"] } }, "include": ["./**/*.ts"] diff --git a/src/cdk/collections/array-data-source.ts b/src/cdk/collections/array-data-source.ts new file mode 100644 index 000000000000..50114292a6bb --- /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 c982971666ca..01e8d3c5e846 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 d79237c0414f..16cda8897a7b 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 d852391902f5..5c873d400dfd 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 6ad672132e7a..789a2b24dbff 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 5625aa8d2c57..a6aa928a98ed 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 60ecc6a90f95..f561ecc47674 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 a05cc0496902..7e5b2048ff9b 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 000000000000..7c8b754eee04 --- /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 000000000000..58e9e1741b3c --- /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 000000000000..8b797703b3b6 --- /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 ecb85b2763da..3f2aa9776358 100644 --- a/src/e2e-app/e2e-app-module.ts +++ b/src/e2e-app/e2e-app-module.ts @@ -1,24 +1,7 @@ +import {ScrollingModule} from '@angular/cdk-experimental'; +import {FullscreenOverlayContainer, OverlayContainer} from '@angular/cdk/overlay'; import {NgModule} from '@angular/core'; -import {BrowserModule} from '@angular/platform-browser'; -import {NoopAnimationsModule} from '@angular/platform-browser/animations'; -import {RouterModule} from '@angular/router'; -import {SimpleCheckboxes} from './checkbox/checkbox-e2e'; -import {E2EApp, Home} from './e2e-app/e2e-app'; -import {IconE2E} from './icon/icon-e2e'; -import {ButtonE2E} from './button/button-e2e'; -import {MenuE2E} from './menu/menu-e2e'; -import {SimpleRadioButtons} from './radio/radio-e2e'; -import {BasicTabs} from './tabs/tabs-e2e'; -import {DialogE2E, TestDialog} from './dialog/dialog-e2e'; -import {GridListE2E} from './grid-list/grid-list-e2e'; -import {ProgressBarE2E} from './progress-bar/progress-bar-e2e'; -import {ProgressSpinnerE2E} from './progress-spinner/progress-spinner-e2e'; -import {FullscreenE2E, TestDialogFullScreen} from './fullscreen/fullscreen-e2e'; -import {E2E_APP_ROUTES} from './e2e-app/routes'; -import {SlideToggleE2E} from './slide-toggle/slide-toggle-e2e'; -import {InputE2E} from './input/input-e2e'; -import {SidenavE2E} from './sidenav/sidenav-e2e'; -import {BlockScrollStrategyE2E} from './block-scroll-strategy/block-scroll-strategy-e2e'; +import {ReactiveFormsModule} from '@angular/forms'; import { MatButtonModule, MatCheckboxModule, @@ -39,9 +22,28 @@ import { MatStepperModule, MatTabsModule, } from '@angular/material'; -import {FullscreenOverlayContainer, OverlayContainer} from '@angular/cdk/overlay'; import {ExampleModule} from '@angular/material-examples'; -import {ReactiveFormsModule} from '@angular/forms'; +import {BrowserModule} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {RouterModule} from '@angular/router'; +import {BlockScrollStrategyE2E} from './block-scroll-strategy/block-scroll-strategy-e2e'; +import {ButtonE2E} from './button/button-e2e'; +import {SimpleCheckboxes} from './checkbox/checkbox-e2e'; +import {DialogE2E, TestDialog} from './dialog/dialog-e2e'; +import {E2EApp, Home} from './e2e-app/e2e-app'; +import {E2E_APP_ROUTES} from './e2e-app/routes'; +import {FullscreenE2E, TestDialogFullScreen} from './fullscreen/fullscreen-e2e'; +import {GridListE2E} from './grid-list/grid-list-e2e'; +import {IconE2E} from './icon/icon-e2e'; +import {InputE2E} from './input/input-e2e'; +import {MenuE2E} from './menu/menu-e2e'; +import {ProgressBarE2E} from './progress-bar/progress-bar-e2e'; +import {ProgressSpinnerE2E} from './progress-spinner/progress-spinner-e2e'; +import {SimpleRadioButtons} from './radio/radio-e2e'; +import {SidenavE2E} from './sidenav/sidenav-e2e'; +import {SlideToggleE2E} from './slide-toggle/slide-toggle-e2e'; +import {BasicTabs} from './tabs/tabs-e2e'; +import {VirtualScrollE2E} from './virtual-scroll/virtual-scroll-e2e'; /** * NgModule that contains all Material modules that are required to serve the e2e-app. @@ -66,6 +68,7 @@ import {ReactiveFormsModule} from '@angular/forms'; MatStepperModule, MatTabsModule, MatNativeDateModule, + ScrollingModule, ] }) export class E2eMaterialModule {} @@ -98,7 +101,8 @@ export class E2eMaterialModule {} SlideToggleE2E, TestDialog, TestDialogFullScreen, - BlockScrollStrategyE2E + BlockScrollStrategyE2E, + VirtualScrollE2E, ], bootstrap: [E2EApp], providers: [ diff --git a/src/e2e-app/e2e-app/e2e-app.html b/src/e2e-app/e2e-app/e2e-app.html index b1e16277e1eb..a28e711eaf86 100644 --- a/src/e2e-app/e2e-app/e2e-app.html +++ b/src/e2e-app/e2e-app/e2e-app.html @@ -22,6 +22,7 @@ Tabs Cards Toolbar + Virtual Scroll
diff --git a/src/e2e-app/e2e-app/routes.ts b/src/e2e-app/e2e-app/routes.ts index db20eb234048..58a2e48e7b7b 100644 --- a/src/e2e-app/e2e-app/routes.ts +++ b/src/e2e-app/e2e-app/routes.ts @@ -1,4 +1,5 @@ import {Routes} from '@angular/router'; +import {VirtualScrollE2E} from '../virtual-scroll/virtual-scroll-e2e'; import {Home} from './e2e-app'; import {ButtonE2E} from '../button/button-e2e'; import {BasicTabs} from '../tabs/tabs-e2e'; @@ -47,4 +48,5 @@ export const E2E_APP_ROUTES: Routes = [ {path: 'tabs', component: BasicTabs}, {path: 'cards', component: CardFancyExample}, {path: 'toolbar', component: ToolbarMultirowExample}, + {path: 'virtual-scroll', component: VirtualScrollE2E}, ]; diff --git a/src/e2e-app/system-config.ts b/src/e2e-app/system-config.ts index 65ea15b0ade8..cdd27d78a6df 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 e369b61a4a58..af370e60fdef 100644 --- a/src/e2e-app/tsconfig-build.json +++ b/src/e2e-app/tsconfig-build.json @@ -26,6 +26,10 @@ "@angular/cdk/*": ["./cdk/*"], "@angular/material": ["./material"], "@angular/material/*": ["./material/*"], + "@angular/material-experimental/*": ["./material-experimental/*"], + "@angular/material-experimental": ["./material-experimental/"], + "@angular/cdk-experimental/*": ["./cdk-experimental/*"], + "@angular/cdk-experimental": ["./cdk-experimental/"], "@angular/material-moment-adapter": ["./material-moment-adapter"], "@angular/material-examples": ["./material-examples"] } diff --git a/src/e2e-app/virtual-scroll/virtual-scroll-e2e.css b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.css new file mode 100644 index 000000000000..adf74dad8e06 --- /dev/null +++ b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.css @@ -0,0 +1,13 @@ +.demo-viewport { + height: 300px; + width: 300px; + box-shadow: 0 0 0 1px black; +} + +.demo-item { + background: magenta; +} + +.demo-odd { + background: cyan; +} diff --git a/src/e2e-app/virtual-scroll/virtual-scroll-e2e.html b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.html new file mode 100644 index 000000000000..182542cbe186 --- /dev/null +++ b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.html @@ -0,0 +1,19 @@ +
+

Uniform size

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

Random size

+ +
+ Variable Item #{{i}} - ({{size}}px) +
+
+
diff --git a/src/e2e-app/virtual-scroll/virtual-scroll-e2e.ts b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.ts new file mode 100644 index 000000000000..991d1caf8a68 --- /dev/null +++ b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.ts @@ -0,0 +1,16 @@ +import {Component} from '@angular/core'; + + +const itemSizeSample = [100, 25, 50, 50, 100, 200, 75, 100, 50, 250]; + + +@Component({ + moduleId: module.id, + selector: 'virtual-scroll-e2e', + templateUrl: 'virtual-scroll-e2e.html', + styleUrls: ['virtual-scroll-e2e.css'], +}) +export class VirtualScrollE2E { + uniformItems = Array(1000).fill(50); + variableItems = Array(100).fill(0).reduce(acc => acc.concat(itemSizeSample), []); +} diff --git a/src/material-examples/stepper-vertical/stepper-vertical-example.ts b/src/material-examples/stepper-vertical/stepper-vertical-example.ts index ba958383b4f5..13229872f1fd 100644 --- a/src/material-examples/stepper-vertical/stepper-vertical-example.ts +++ b/src/material-examples/stepper-vertical/stepper-vertical-example.ts @@ -1,4 +1,4 @@ -import {Component} from '@angular/core'; +import {Component, OnInit} from '@angular/core'; import {FormBuilder, FormGroup, Validators} from '@angular/forms'; /** @@ -9,7 +9,7 @@ import {FormBuilder, FormGroup, Validators} from '@angular/forms'; templateUrl: 'stepper-vertical-example.html', styleUrls: ['stepper-vertical-example.css'] }) -export class StepperVerticalExample { +export class StepperVerticalExample implements OnInit { isLinear = false; firstFormGroup: FormGroup; secondFormGroup: FormGroup; diff --git a/test/karma-test-shim.js b/test/karma-test-shim.js index 78e115b9d2c2..59d26de72981 100644 --- a/test/karma-test-shim.js +++ b/test/karma-test-shim.js @@ -72,6 +72,8 @@ System.config({ '@angular/cdk/text-field': 'dist/packages/cdk/text-field/index.js', '@angular/cdk/tree': 'dist/packages/cdk/tree/index.js', + '@angular/cdk-experimental/scrolling': 'dist/packages/cdk-experimental/scrolling/index.js', + '@angular/material/autocomplete': 'dist/packages/material/autocomplete/index.js', '@angular/material/badge': 'dist/packages/material/badge/index.js', '@angular/material/bottom-sheet': 'dist/packages/material/bottom-sheet/index.js', diff --git a/tools/gulp/tasks/e2e.ts b/tools/gulp/tasks/e2e.ts index e49b2ed29ba6..242e10866b13 100644 --- a/tools/gulp/tasks/e2e.ts +++ b/tools/gulp/tasks/e2e.ts @@ -2,7 +2,7 @@ import {task} from 'gulp'; import {join} from 'path'; import {ngcBuildTask, copyTask, execNodeTask, serverTask} from '../util/task_helpers'; import {copySync} from 'fs-extra'; -import {buildConfig, sequenceTask, watchFiles} from 'material2-build-tools'; +import {buildConfig, sequenceTask, triggerLivereload, watchFiles} from 'material2-build-tools'; // There are no type definitions available for these imports. const gulpConnect = require('gulp-connect'); @@ -13,6 +13,7 @@ const {outputDir, packagesDir, projectDir} = buildConfig; const releasesDir = join(outputDir, 'releases'); const appDir = join(packagesDir, 'e2e-app'); +const e2eTestDir = join(projectDir, 'e2e'); const outDir = join(outputDir, 'packages', 'e2e-app'); const PROTRACTOR_CONFIG_PATH = join(projectDir, 'test/protractor.conf.js'); @@ -21,9 +22,7 @@ const tsconfigPath = join(outDir, 'tsconfig-build.json'); /** Glob that matches all files that need to be copied to the output folder. */ const assetsGlob = join(appDir, '**/*.+(html|css|json|ts)'); -/** - * Builds and serves the e2e-app and runs protractor once the e2e-app is ready. - */ +/** Builds and serves the e2e-app and runs protractor once the e2e-app is ready. */ task('e2e', sequenceTask( [':test:protractor:setup', 'serve:e2eapp'], ':test:protractor', @@ -31,6 +30,34 @@ task('e2e', sequenceTask( 'screenshots', )); +/** + * Builds and serves the e2e-app and runs protractor when the app is ready. Re-runs protractor when + * the app or tests change. + */ +task('e2e:watch', sequenceTask( + [':test:protractor:setup', 'serve:e2eapp'], + [':test:protractor', 'material:watch', ':e2e:watch'], +)); + +/** Watches the e2e app and tests for changes and triggers a test rerun on change. */ +task(':e2e:watch', () => { + watchFiles([join(appDir, '**/*.+(html|ts|css)'), join(e2eTestDir, '**/*.+(html|ts)')], + [':e2e:rerun'], false); +}); + +/** Updates the e2e app and runs the protractor tests. */ +task(':e2e:rerun', sequenceTask( + 'e2e-app:copy-assets', + 'e2e-app:build-ts', + ':e2e:reload', + ':test:protractor' +)); + +/** Triggers a reload of the e2e app. */ +task(':e2e:reload', () => { + return triggerLivereload(); +}); + /** Task that builds the e2e-app in AOT mode. */ task('e2e-app:build', sequenceTask( 'clean', diff --git a/tools/package-tools/rollup-globals.ts b/tools/package-tools/rollup-globals.ts index e84d381ba705..fe36cf5dbd0b 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/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', - '@angular/cdk': 'ng.cdk', // Include secondary entry-points of the cdk and material packages ...rollupCdkEntryPoints, ...rollupMatEntryPoints, + ...rollupCdkExperimentalEntryPoints, 'rxjs': 'Rx', 'rxjs/operators': 'Rx.operators',