Skip to content

virtual-scroll: add logic to correct the scroll position as user move… #11137

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 7, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 51 additions & 12 deletions src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,28 +122,35 @@ 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.
this._setScrollOffset();
}
}

/** 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).
Expand All @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
9 changes: 6 additions & 3 deletions src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 7 additions & 2 deletions src/cdk-experimental/scrolling/virtual-for-of.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


Expand Down Expand Up @@ -138,6 +138,8 @@ export class CdkVirtualForOf<T> implements CollectionViewer, DoCheck, OnDestroy
/** Whether the rendered data should be updated during the next ngDoCheck cycle. */
private _needsUpdate = false;

private _destroyed = new Subject<void>();

constructor(
/** The view container to add items to. */
private _viewContainerRef: ViewContainerRef,
Expand All @@ -151,7 +153,7 @@ export class CdkVirtualForOf<T> 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();
Expand Down Expand Up @@ -214,6 +216,9 @@ export class CdkVirtualForOf<T> implements CollectionViewer, DoCheck, OnDestroy
this._dataSourceChanges.complete();
this.viewChange.complete();

this._destroyed.next();
this._destroyed.complete();

for (let view of this._templateCache) {
view.destroy();
}
Expand Down
3 changes: 3 additions & 0 deletions src/cdk-experimental/scrolling/virtual-scroll-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
26 changes: 17 additions & 9 deletions src/cdk-experimental/scrolling/virtual-scroll-viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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();
});
}));
Expand Down Expand Up @@ -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();
}
});
});
}
}
Expand Down
1 change: 1 addition & 0 deletions src/demo-app/virtual-scroll/virtual-scroll-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ <h2>Fixed size</h2>

<h2>Observable data</h2>

<button (click)="emitData()">Add item</button>
<cdk-virtual-scroll-viewport class="demo-viewport" [itemSize]="50">
<div *cdkVirtualFor="let size of observableData | async; let i = index" class="demo-item"
[style.height.px]="size">
Expand Down
10 changes: 6 additions & 4 deletions src/demo-app/virtual-scroll/virtual-scroll-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}));
}
}