diff --git a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts index 66dbcf157676..1279317c546f 100644 --- a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts +++ b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts @@ -122,14 +122,14 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { this._viewport = null; } - /** Implemented as part of VirtualScrollStrategy. */ + /** @docs-private Implemented as part of VirtualScrollStrategy. */ onContentScrolled() { if (this._viewport) { this._updateRenderedContentAfterScroll(); } } - /** Implemented as part of VirtualScrollStrategy. */ + /** @docs-private Implemented as part of VirtualScrollStrategy. */ onDataLengthChanged() { if (this._viewport) { // TODO(mmalebra): Do something smarter here. @@ -137,13 +137,20 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { } } - /** Implemented as part of VirtualScrollStrategy. */ + /** @docs-private Implemented as part of VirtualScrollStrategy. */ onContentRendered() { if (this._viewport) { this._checkRenderedContentSize(); } } + /** @docs-private Implemented as part of VirtualScrollStrategy. */ + onRenderedOffsetChanged() { + if (this._viewport) { + this._checkRenderedContentOffset(); + } + } + /** * Update the buffer parameters. * @param minBufferPx The minimum amount of buffer rendered beyond the viewport (in pixels). @@ -162,13 +169,38 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { // The current scroll offset. const scrollOffset = viewport.measureScrollOffset(); // The delta between the current scroll offset and the previously recorded scroll offset. - const scrollDelta = scrollOffset - this._lastScrollOffset; + let scrollDelta = scrollOffset - this._lastScrollOffset; // The magnitude of the scroll delta. - const scrollMagnitude = Math.abs(scrollDelta); + let scrollMagnitude = Math.abs(scrollDelta); + + // The currently rendered range. + const renderedRange = viewport.getRenderedRange(); - // TODO(mmalerba): Record error between actual scroll offset and predicted scroll offset given - // the index of the first rendered element. Fudge the scroll delta to slowly eliminate the error - // as the user scrolls. + // If we're scrolling toward the top, we need to account for the fact that the predicted amount + // of content and the actual amount of scrollable space may differ. We address this by slowly + // correcting the difference on each scroll event. + let offsetCorrection = 0; + if (scrollDelta < 0) { + // The content offset we would expect based on the average item size. + const predictedOffset = renderedRange.start * this._averager.getAverageItemSize(); + // The difference between the predicted size of the unrendered content at the beginning and + // the actual available space to scroll over. We need to reduce this to zero by the time the + // user scrolls to the top. + // - 0 indicates that the predicted size and available space are the same. + // - A negative number that the predicted size is smaller than the available space. + // - A positive number indicates the predicted size is larger than the available space + const offsetDifference = predictedOffset - this._lastRenderedContentOffset; + // The amount of difference to correct during this scroll event. We calculate this as a + // percentage of the total difference based on the percentage of the distance toward the top + // that the user scrolled. + offsetCorrection = Math.round(offsetDifference * + Math.max(0, Math.min(1, scrollMagnitude / (scrollOffset + scrollMagnitude)))); + + // Based on the offset correction above, we pretend that the scroll delta was bigger or + // smaller than it actually was, this way we can start to eliminate the difference. + scrollDelta = scrollDelta - offsetCorrection; + scrollMagnitude = Math.abs(scrollDelta); + } // The current amount of buffer past the start of the viewport. const startBuffer = this._lastScrollOffset - this._lastRenderedContentOffset; @@ -190,8 +222,6 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { if (scrollMagnitude >= viewport.getViewportSize()) { this._setScrollOffset(); } else { - // The currently rendered range. - const renderedRange = viewport.getRenderedRange(); // The number of new items to render on the side the user is scrolling towards. Rather than // just filling the underscan space, we actually fill enough to have a buffer size of // `addBufferPx`. This gives us a little wiggle room in case our item size estimate is off. @@ -265,8 +295,12 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { // Set the range and offset we calculated above. viewport.setRenderedRange(range); - viewport.setRenderedContentOffset(contentOffset, contentOffsetTo); + viewport.setRenderedContentOffset(contentOffset + offsetCorrection, contentOffsetTo); } + } else if (offsetCorrection) { + // Even if the rendered range didn't change, we may still need to adjust the content offset to + // simulate scrolling slightly slower or faster than the user actually scrolled. + viewport.setRenderedContentOffset(this._lastRenderedContentOffset + offsetCorrection); } // Save the scroll offset to be compared to the new value on the next scroll event. @@ -279,12 +313,17 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { */ private _checkRenderedContentSize() { const viewport = this._viewport!; - this._lastRenderedContentOffset = viewport.getOffsetToRenderedContentStart()!; this._lastRenderedContentSize = viewport.measureRenderedContentSize(); this._averager.addSample(viewport.getRenderedRange(), this._lastRenderedContentSize); this._updateTotalContentSize(this._lastRenderedContentSize); } + /** Checks the currently rendered content offset and saves the value for later use. */ + private _checkRenderedContentOffset() { + const viewport = this._viewport!; + this._lastRenderedContentOffset = viewport.getOffsetToRenderedContentStart()!; + } + /** * Sets the scroll offset and renders the content we estimate should be shown at that point. * @param scrollOffset The offset to jump to. If not specified the scroll offset will not be diff --git a/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts index fce2c9ce7fcb..97cf910c3f48 100644 --- a/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts +++ b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts @@ -60,20 +60,23 @@ export class FixedSizeVirtualScrollStrategy implements VirtualScrollStrategy { this._updateRenderedRange(); } - /** Called when the viewport is scrolled. */ + /** @docs-private Implemented as part of VirtualScrollStrategy. */ onContentScrolled() { this._updateRenderedRange(); } - /** Called when the length of the data changes. */ + /** @docs-private Implemented as part of VirtualScrollStrategy. */ onDataLengthChanged() { this._updateTotalContentSize(); this._updateRenderedRange(); } - /** Called when the range of items rendered in the DOM has changed. */ + /** @docs-private Implemented as part of VirtualScrollStrategy. */ onContentRendered() { /* no-op */ } + /** @docs-private Implemented as part of VirtualScrollStrategy. */ + onRenderedOffsetChanged() { /* no-op */ } + /** Update the viewport's total content size. */ private _updateTotalContentSize() { if (!this._viewport) { diff --git a/src/cdk-experimental/scrolling/virtual-for-of.ts b/src/cdk-experimental/scrolling/virtual-for-of.ts index 50cc3b6aec37..78524c225082 100644 --- a/src/cdk-experimental/scrolling/virtual-for-of.ts +++ b/src/cdk-experimental/scrolling/virtual-for-of.ts @@ -24,7 +24,7 @@ import { ViewContainerRef, } from '@angular/core'; import {Observable, Subject} from 'rxjs'; -import {pairwise, shareReplay, startWith, switchMap} from 'rxjs/operators'; +import {pairwise, shareReplay, startWith, switchMap, takeUntil} from 'rxjs/operators'; import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; @@ -138,6 +138,8 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy /** Whether the rendered data should be updated during the next ngDoCheck cycle. */ private _needsUpdate = false; + private _destroyed = new Subject(); + constructor( /** The view container to add items to. */ private _viewContainerRef: ViewContainerRef, @@ -151,7 +153,7 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy this._data = data; this._onRenderedDataChange(); }); - this._viewport.renderedRangeStream.subscribe(range => { + this._viewport.renderedRangeStream.pipe(takeUntil(this._destroyed)).subscribe(range => { this._renderedRange = range; this.viewChange.next(this._renderedRange); this._onRenderedDataChange(); @@ -214,6 +216,9 @@ export class CdkVirtualForOf implements CollectionViewer, DoCheck, OnDestroy this._dataSourceChanges.complete(); this.viewChange.complete(); + this._destroyed.next(); + this._destroyed.complete(); + for (let view of this._templateCache) { view.destroy(); } diff --git a/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts b/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts index 1bd56fbb99de..cbb667201709 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-strategy.ts @@ -34,4 +34,7 @@ export interface VirtualScrollStrategy { /** Called when the range of items rendered in the DOM has changed. */ onContentRendered(); + + /** Called when the offset of the rendered items changed. */ + onRenderedOffsetChanged(); } diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts index f01f82ca6ba4..0a127d18b6a3 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.ts @@ -71,6 +71,9 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { /** The transform used to offset the rendered content wrapper element. */ _renderedContentTransform: SafeStyle; + /** The raw string version of the rendered content transform. */ + private _rawRenderedContentTransform: string; + /** The currently rendered range of indices. */ private _renderedRange: ListRange = {start: 0, end: 0}; @@ -214,14 +217,6 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { // // The call to `onContentRendered` will happen after all of the updates have been applied. Promise.resolve().then(() => { - // If the rendered content offset was specified as an offset to the end of the content, - // rewrite it as an offset to the start of the content. - if (this._renderedContentOffsetNeedsRewrite) { - this._renderedContentOffset -= this.measureRenderedContentSize(); - this._renderedContentOffsetNeedsRewrite = false; - this.setRenderedContentOffset(this._renderedContentOffset); - } - this._scrollStrategy.onContentRendered(); }); })); @@ -251,13 +246,26 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy { transform += ` translate${axis}(-100%)`; this._renderedContentOffsetNeedsRewrite = true; } - if (this._renderedContentTransform != transform) { + if (this._rawRenderedContentTransform != transform) { // Re-enter the Angular zone so we can mark for change detection. this._ngZone.run(() => { // We know this value is safe because we parse `offset` with `Number()` before passing it // into the string. + this._rawRenderedContentTransform = transform; this._renderedContentTransform = this._sanitizer.bypassSecurityTrustStyle(transform); this._changeDetectorRef.markForCheck(); + + // If the rendered content offset was specified as an offset to the end of the content, + // rewrite it as an offset to the start of the content. + this._ngZone.onStable.pipe(take(1)).subscribe(() => { + if (this._renderedContentOffsetNeedsRewrite) { + this._renderedContentOffset -= this.measureRenderedContentSize(); + this._renderedContentOffsetNeedsRewrite = false; + this.setRenderedContentOffset(this._renderedContentOffset); + } else { + this._scrollStrategy.onRenderedOffsetChanged(); + } + }); }); } } diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.html b/src/demo-app/virtual-scroll/virtual-scroll-demo.html index 289b8e54a37d..7c8b754eee04 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.html +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.html @@ -51,6 +51,7 @@

Fixed size

Observable data

+
diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts index 7e80c092d9cf..8b797703b3b6 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts @@ -93,15 +93,17 @@ export class VirtualScrollDemo { emitData() { let data = this.observableData.value.concat([50]); this.observableData.next(data); - if (data.length < 1000) { - setTimeout(() => this.emitData(), 1000); - } } sortBy(prop: 'name' | 'capital') { this.statesObservable.next(this.states.map(s => ({...s})).sort((a, b) => { const aProp = a[prop], bProp = b[prop]; - return aProp < bProp ? -1 : (aProp > bProp ? 1 : 0); + if (aProp < bProp) { + return -1; + } else if (aProp > bProp) { + return 1; + } + return 0; })); } }