Skip to content

Commit 604f743

Browse files
authored
virtual-scroll: add logic to correct the scroll position as user move… (#11137)
* virtual-scroll: add logic to correct the scroll position as user moves toward the top * address comments
1 parent 0e4a580 commit 604f743

File tree

7 files changed

+91
-30
lines changed

7 files changed

+91
-30
lines changed

src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -122,28 +122,35 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
122122
this._viewport = null;
123123
}
124124

125-
/** Implemented as part of VirtualScrollStrategy. */
125+
/** @docs-private Implemented as part of VirtualScrollStrategy. */
126126
onContentScrolled() {
127127
if (this._viewport) {
128128
this._updateRenderedContentAfterScroll();
129129
}
130130
}
131131

132-
/** Implemented as part of VirtualScrollStrategy. */
132+
/** @docs-private Implemented as part of VirtualScrollStrategy. */
133133
onDataLengthChanged() {
134134
if (this._viewport) {
135135
// TODO(mmalebra): Do something smarter here.
136136
this._setScrollOffset();
137137
}
138138
}
139139

140-
/** Implemented as part of VirtualScrollStrategy. */
140+
/** @docs-private Implemented as part of VirtualScrollStrategy. */
141141
onContentRendered() {
142142
if (this._viewport) {
143143
this._checkRenderedContentSize();
144144
}
145145
}
146146

147+
/** @docs-private Implemented as part of VirtualScrollStrategy. */
148+
onRenderedOffsetChanged() {
149+
if (this._viewport) {
150+
this._checkRenderedContentOffset();
151+
}
152+
}
153+
147154
/**
148155
* Update the buffer parameters.
149156
* @param minBufferPx The minimum amount of buffer rendered beyond the viewport (in pixels).
@@ -162,13 +169,38 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
162169
// The current scroll offset.
163170
const scrollOffset = viewport.measureScrollOffset();
164171
// The delta between the current scroll offset and the previously recorded scroll offset.
165-
const scrollDelta = scrollOffset - this._lastScrollOffset;
172+
let scrollDelta = scrollOffset - this._lastScrollOffset;
166173
// The magnitude of the scroll delta.
167-
const scrollMagnitude = Math.abs(scrollDelta);
174+
let scrollMagnitude = Math.abs(scrollDelta);
175+
176+
// The currently rendered range.
177+
const renderedRange = viewport.getRenderedRange();
168178

169-
// TODO(mmalerba): Record error between actual scroll offset and predicted scroll offset given
170-
// the index of the first rendered element. Fudge the scroll delta to slowly eliminate the error
171-
// as the user scrolls.
179+
// If we're scrolling toward the top, we need to account for the fact that the predicted amount
180+
// of content and the actual amount of scrollable space may differ. We address this by slowly
181+
// correcting the difference on each scroll event.
182+
let offsetCorrection = 0;
183+
if (scrollDelta < 0) {
184+
// The content offset we would expect based on the average item size.
185+
const predictedOffset = renderedRange.start * this._averager.getAverageItemSize();
186+
// The difference between the predicted size of the unrendered content at the beginning and
187+
// the actual available space to scroll over. We need to reduce this to zero by the time the
188+
// user scrolls to the top.
189+
// - 0 indicates that the predicted size and available space are the same.
190+
// - A negative number that the predicted size is smaller than the available space.
191+
// - A positive number indicates the predicted size is larger than the available space
192+
const offsetDifference = predictedOffset - this._lastRenderedContentOffset;
193+
// The amount of difference to correct during this scroll event. We calculate this as a
194+
// percentage of the total difference based on the percentage of the distance toward the top
195+
// that the user scrolled.
196+
offsetCorrection = Math.round(offsetDifference *
197+
Math.max(0, Math.min(1, scrollMagnitude / (scrollOffset + scrollMagnitude))));
198+
199+
// Based on the offset correction above, we pretend that the scroll delta was bigger or
200+
// smaller than it actually was, this way we can start to eliminate the difference.
201+
scrollDelta = scrollDelta - offsetCorrection;
202+
scrollMagnitude = Math.abs(scrollDelta);
203+
}
172204

173205
// The current amount of buffer past the start of the viewport.
174206
const startBuffer = this._lastScrollOffset - this._lastRenderedContentOffset;
@@ -190,8 +222,6 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
190222
if (scrollMagnitude >= viewport.getViewportSize()) {
191223
this._setScrollOffset();
192224
} else {
193-
// The currently rendered range.
194-
const renderedRange = viewport.getRenderedRange();
195225
// The number of new items to render on the side the user is scrolling towards. Rather than
196226
// just filling the underscan space, we actually fill enough to have a buffer size of
197227
// `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 {
265295

266296
// Set the range and offset we calculated above.
267297
viewport.setRenderedRange(range);
268-
viewport.setRenderedContentOffset(contentOffset, contentOffsetTo);
298+
viewport.setRenderedContentOffset(contentOffset + offsetCorrection, contentOffsetTo);
269299
}
300+
} else if (offsetCorrection) {
301+
// Even if the rendered range didn't change, we may still need to adjust the content offset to
302+
// simulate scrolling slightly slower or faster than the user actually scrolled.
303+
viewport.setRenderedContentOffset(this._lastRenderedContentOffset + offsetCorrection);
270304
}
271305

272306
// 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 {
279313
*/
280314
private _checkRenderedContentSize() {
281315
const viewport = this._viewport!;
282-
this._lastRenderedContentOffset = viewport.getOffsetToRenderedContentStart()!;
283316
this._lastRenderedContentSize = viewport.measureRenderedContentSize();
284317
this._averager.addSample(viewport.getRenderedRange(), this._lastRenderedContentSize);
285318
this._updateTotalContentSize(this._lastRenderedContentSize);
286319
}
287320

321+
/** Checks the currently rendered content offset and saves the value for later use. */
322+
private _checkRenderedContentOffset() {
323+
const viewport = this._viewport!;
324+
this._lastRenderedContentOffset = viewport.getOffsetToRenderedContentStart()!;
325+
}
326+
288327
/**
289328
* Sets the scroll offset and renders the content we estimate should be shown at that point.
290329
* @param scrollOffset The offset to jump to. If not specified the scroll offset will not be

src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,20 +60,23 @@ export class FixedSizeVirtualScrollStrategy implements VirtualScrollStrategy {
6060
this._updateRenderedRange();
6161
}
6262

63-
/** Called when the viewport is scrolled. */
63+
/** @docs-private Implemented as part of VirtualScrollStrategy. */
6464
onContentScrolled() {
6565
this._updateRenderedRange();
6666
}
6767

68-
/** Called when the length of the data changes. */
68+
/** @docs-private Implemented as part of VirtualScrollStrategy. */
6969
onDataLengthChanged() {
7070
this._updateTotalContentSize();
7171
this._updateRenderedRange();
7272
}
7373

74-
/** Called when the range of items rendered in the DOM has changed. */
74+
/** @docs-private Implemented as part of VirtualScrollStrategy. */
7575
onContentRendered() { /* no-op */ }
7676

77+
/** @docs-private Implemented as part of VirtualScrollStrategy. */
78+
onRenderedOffsetChanged() { /* no-op */ }
79+
7780
/** Update the viewport's total content size. */
7881
private _updateTotalContentSize() {
7982
if (!this._viewport) {

src/cdk-experimental/scrolling/virtual-for-of.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
ViewContainerRef,
2525
} from '@angular/core';
2626
import {Observable, Subject} from 'rxjs';
27-
import {pairwise, shareReplay, startWith, switchMap} from 'rxjs/operators';
27+
import {pairwise, shareReplay, startWith, switchMap, takeUntil} from 'rxjs/operators';
2828
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
2929

3030

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

141+
private _destroyed = new Subject<void>();
142+
141143
constructor(
142144
/** The view container to add items to. */
143145
private _viewContainerRef: ViewContainerRef,
@@ -151,7 +153,7 @@ export class CdkVirtualForOf<T> implements CollectionViewer, DoCheck, OnDestroy
151153
this._data = data;
152154
this._onRenderedDataChange();
153155
});
154-
this._viewport.renderedRangeStream.subscribe(range => {
156+
this._viewport.renderedRangeStream.pipe(takeUntil(this._destroyed)).subscribe(range => {
155157
this._renderedRange = range;
156158
this.viewChange.next(this._renderedRange);
157159
this._onRenderedDataChange();
@@ -214,6 +216,9 @@ export class CdkVirtualForOf<T> implements CollectionViewer, DoCheck, OnDestroy
214216
this._dataSourceChanges.complete();
215217
this.viewChange.complete();
216218

219+
this._destroyed.next();
220+
this._destroyed.complete();
221+
217222
for (let view of this._templateCache) {
218223
view.destroy();
219224
}

src/cdk-experimental/scrolling/virtual-scroll-strategy.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,7 @@ export interface VirtualScrollStrategy {
3434

3535
/** Called when the range of items rendered in the DOM has changed. */
3636
onContentRendered();
37+
38+
/** Called when the offset of the rendered items changed. */
39+
onRenderedOffsetChanged();
3740
}

src/cdk-experimental/scrolling/virtual-scroll-viewport.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy {
7171
/** The transform used to offset the rendered content wrapper element. */
7272
_renderedContentTransform: SafeStyle;
7373

74+
/** The raw string version of the rendered content transform. */
75+
private _rawRenderedContentTransform: string;
76+
7477
/** The currently rendered range of indices. */
7578
private _renderedRange: ListRange = {start: 0, end: 0};
7679

@@ -214,14 +217,6 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy {
214217
//
215218
// The call to `onContentRendered` will happen after all of the updates have been applied.
216219
Promise.resolve().then(() => {
217-
// If the rendered content offset was specified as an offset to the end of the content,
218-
// rewrite it as an offset to the start of the content.
219-
if (this._renderedContentOffsetNeedsRewrite) {
220-
this._renderedContentOffset -= this.measureRenderedContentSize();
221-
this._renderedContentOffsetNeedsRewrite = false;
222-
this.setRenderedContentOffset(this._renderedContentOffset);
223-
}
224-
225220
this._scrollStrategy.onContentRendered();
226221
});
227222
}));
@@ -251,13 +246,26 @@ export class CdkVirtualScrollViewport implements DoCheck, OnInit, OnDestroy {
251246
transform += ` translate${axis}(-100%)`;
252247
this._renderedContentOffsetNeedsRewrite = true;
253248
}
254-
if (this._renderedContentTransform != transform) {
249+
if (this._rawRenderedContentTransform != transform) {
255250
// Re-enter the Angular zone so we can mark for change detection.
256251
this._ngZone.run(() => {
257252
// We know this value is safe because we parse `offset` with `Number()` before passing it
258253
// into the string.
254+
this._rawRenderedContentTransform = transform;
259255
this._renderedContentTransform = this._sanitizer.bypassSecurityTrustStyle(transform);
260256
this._changeDetectorRef.markForCheck();
257+
258+
// If the rendered content offset was specified as an offset to the end of the content,
259+
// rewrite it as an offset to the start of the content.
260+
this._ngZone.onStable.pipe(take(1)).subscribe(() => {
261+
if (this._renderedContentOffsetNeedsRewrite) {
262+
this._renderedContentOffset -= this.measureRenderedContentSize();
263+
this._renderedContentOffsetNeedsRewrite = false;
264+
this.setRenderedContentOffset(this._renderedContentOffset);
265+
} else {
266+
this._scrollStrategy.onRenderedOffsetChanged();
267+
}
268+
});
261269
});
262270
}
263271
}

src/demo-app/virtual-scroll/virtual-scroll-demo.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ <h2>Fixed size</h2>
5151

5252
<h2>Observable data</h2>
5353

54+
<button (click)="emitData()">Add item</button>
5455
<cdk-virtual-scroll-viewport class="demo-viewport" [itemSize]="50">
5556
<div *cdkVirtualFor="let size of observableData | async; let i = index" class="demo-item"
5657
[style.height.px]="size">

src/demo-app/virtual-scroll/virtual-scroll-demo.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,17 @@ export class VirtualScrollDemo {
9393
emitData() {
9494
let data = this.observableData.value.concat([50]);
9595
this.observableData.next(data);
96-
if (data.length < 1000) {
97-
setTimeout(() => this.emitData(), 1000);
98-
}
9996
}
10097

10198
sortBy(prop: 'name' | 'capital') {
10299
this.statesObservable.next(this.states.map(s => ({...s})).sort((a, b) => {
103100
const aProp = a[prop], bProp = b[prop];
104-
return aProp < bProp ? -1 : (aProp > bProp ? 1 : 0);
101+
if (aProp < bProp) {
102+
return -1;
103+
} else if (aProp > bProp) {
104+
return 1;
105+
}
106+
return 0;
105107
}));
106108
}
107109
}

0 commit comments

Comments
 (0)