Skip to content

Commit 1932c53

Browse files
committed
virtual-scroll: add logic to correct the scroll position as user moves toward the top
1 parent 285fe6c commit 1932c53

File tree

8 files changed

+210
-30
lines changed

8 files changed

+210
-30
lines changed

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

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,13 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
144144
}
145145
}
146146

147+
/** 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);
168175

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.
176+
// The currently rendered range.
177+
const renderedRange = viewport.getRenderedRange();
178+
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,16 @@ 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+
private _checkRenderedContentOffset() {
322+
const viewport = this._viewport!;
323+
this._lastRenderedContentOffset = viewport.getOffsetToRenderedContentStart()!;
324+
}
325+
288326
/**
289327
* Sets the scroll offset and renders the content we estimate should be shown at that point.
290328
* @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+
/** Implemented as part of VirtualScrollStrategy. */
6464
onContentScrolled() {
6565
this._updateRenderedRange();
6666
}
6767

68-
/** Called when the length of the data changes. */
68+
/** 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+
/** Implemented as part of VirtualScrollStrategy. */
7575
onContentRendered() { /* no-op */ }
7676

77+
/** 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: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,15 @@ export class CdkVirtualForOf<T> implements CollectionViewer, DoCheck, OnDestroy
147147
private _differs: IterableDiffers,
148148
/** The virtual scrolling viewport that these items are being rendered in. */
149149
@SkipSelf() private _viewport: CdkVirtualScrollViewport) {
150-
this.dataStream.subscribe(data => this._data = data);
151-
this._viewport.renderedRangeStream.subscribe(range => this._onRenderedRangeChange(range));
150+
this.dataStream.subscribe(data => {
151+
this._data = data;
152+
this._onRenderedDataChange();
153+
});
154+
this._viewport.renderedRangeStream.subscribe(range => {
155+
this._renderedRange = range;
156+
this.viewChange.next(this._renderedRange);
157+
this._onRenderedDataChange();
158+
});
152159
this._viewport.attach(this);
153160
}
154161

@@ -213,9 +220,10 @@ export class CdkVirtualForOf<T> implements CollectionViewer, DoCheck, OnDestroy
213220
}
214221

215222
/** React to scroll state changes in the viewport. */
216-
private _onRenderedRangeChange(renderedRange: ListRange) {
217-
this._renderedRange = renderedRange;
218-
this.viewChange.next(this._renderedRange);
223+
private _onRenderedDataChange() {
224+
if (!this._renderedRange) {
225+
return;
226+
}
219227
this._renderedItems = this._data.slice(this._renderedRange.start, this._renderedRange.end);
220228
if (!this._differ) {
221229
this._differ = this._differs.find(this._renderedItems).create(this.cdkVirtualForTrackBy);

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: 18 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,27 @@ 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+
if (this._renderedContentOffsetNeedsRewrite) {
261+
this._ngZone.onStable.pipe(take(1)).subscribe(() => {
262+
this._renderedContentOffset -= this.measureRenderedContentSize();
263+
this._renderedContentOffsetNeedsRewrite = false;
264+
this.setRenderedContentOffset(this._renderedContentOffset);
265+
});
266+
} else {
267+
this._ngZone.onStable.pipe(take(1))
268+
.subscribe(() => this._scrollStrategy.onRenderedOffsetChanged());
269+
}
261270
});
262271
}
263272
}

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,46 @@ <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">
55-
<div *cdkVirtualFor="let size of observableData; let i = index" class="demo-item"
56+
<div *cdkVirtualFor="let size of observableData | async; let i = index" class="demo-item"
5657
[style.height.px]="size">
5758
Item #{{i}} - ({{size}}px)
5859
</div>
5960
</cdk-virtual-scroll-viewport>
61+
62+
<h2>No trackBy</h2>
63+
64+
<button (click)="sortBy('name')">Sort by state name</button>
65+
<button (click)="sortBy('capital')">Sort by state capital</button>
66+
<cdk-virtual-scroll-viewport class="demo-viewport" [itemSize]="60">
67+
<div *cdkVirtualFor="let state of statesObservable | async"
68+
class="demo-state-item">
69+
<div class="demo-state">{{state.name}}</div>
70+
<div class="demo-capital">{{state.capital}}</div>
71+
</div>
72+
</cdk-virtual-scroll-viewport>
73+
74+
<h2>trackBy index</h2>
75+
76+
<button (click)="sortBy('name')">Sort by state name</button>
77+
<button (click)="sortBy('capital')">Sort by state capital</button>
78+
<cdk-virtual-scroll-viewport class="demo-viewport" [itemSize]="60">
79+
<div *cdkVirtualFor="let state of statesObservable | async; trackBy: indexTrackFn"
80+
class="demo-state-item">
81+
<div class="demo-state">{{state.name}}</div>
82+
<div class="demo-capital">{{state.capital}}</div>
83+
</div>
84+
</cdk-virtual-scroll-viewport>
85+
86+
<h2>trackBy state name</h2>
87+
88+
<button (click)="sortBy('name')">Sort by state name</button>
89+
<button (click)="sortBy('capital')">Sort by state capital</button>
90+
<cdk-virtual-scroll-viewport class="demo-viewport" [itemSize]="60">
91+
<div *cdkVirtualFor="let state of statesObservable | async; trackBy: nameTrackFn"
92+
class="demo-state-item">
93+
<div class="demo-state">{{state.name}}</div>
94+
<div class="demo-capital">{{state.capital}}</div>
95+
</div>
96+
</cdk-virtual-scroll-viewport>

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,19 @@
2424
writing-mode: vertical-lr;
2525
}
2626
}
27+
28+
.demo-state-item {
29+
height: 60px;
30+
display: flex;
31+
flex-direction: column;
32+
justify-content: center;
33+
}
34+
35+
.demo-state {
36+
font-size: 20px;
37+
font-weight: 500;
38+
}
39+
40+
.demo-capital {
41+
font-size: 14px;
42+
}

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

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
import {Component, ViewEncapsulation} from '@angular/core';
1010
import {BehaviorSubject} from 'rxjs/index';
1111

12+
13+
type State = {
14+
name: string,
15+
capital: string
16+
};
17+
18+
1219
@Component({
1320
moduleId: module.id,
1421
selector: 'virtual-scroll-demo',
@@ -23,6 +30,61 @@ export class VirtualScrollDemo {
2330
.map((_, i) => (1 + Math.floor((10000 - i) / 1000)) * 20);
2431
randomData = Array(10000).fill(0).map(() => Math.round(Math.random() * 100));
2532
observableData = new BehaviorSubject<number[]>([]);
33+
states = [
34+
{name: 'Alabama', capital: 'Montgomery'},
35+
{name: 'Alaska', capital: 'Juneau'},
36+
{name: 'Arizona', capital: 'Phoenix'},
37+
{name: 'Arkansas', capital: 'Little Rock'},
38+
{name: 'California', capital: 'Sacramento'},
39+
{name: 'Colorado', capital: 'Denver'},
40+
{name: 'Connecticut', capital: 'Hartford'},
41+
{name: 'Delaware', capital: 'Dover'},
42+
{name: 'Florida', capital: 'Tallahassee'},
43+
{name: 'Georgia', capital: 'Atlanta'},
44+
{name: 'Hawaii', capital: 'Honolulu'},
45+
{name: 'Idaho', capital: 'Boise'},
46+
{name: 'Illinois', capital: 'Springfield'},
47+
{name: 'Indiana', capital: 'Indianapolis'},
48+
{name: 'Iowa', capital: 'Des Moines'},
49+
{name: 'Kansas', capital: 'Topeka'},
50+
{name: 'Kentucky', capital: 'Frankfort'},
51+
{name: 'Louisiana', capital: 'Baton Rouge'},
52+
{name: 'Maine', capital: 'Augusta'},
53+
{name: 'Maryland', capital: 'Annapolis'},
54+
{name: 'Massachusetts', capital: 'Boston'},
55+
{name: 'Michigan', capital: 'Lansing'},
56+
{name: 'Minnesota', capital: 'St. Paul'},
57+
{name: 'Mississippi', capital: 'Jackson'},
58+
{name: 'Missouri', capital: 'Jefferson City'},
59+
{name: 'Montana', capital: 'Helena'},
60+
{name: 'Nebraska', capital: 'Lincoln'},
61+
{name: 'Nevada', capital: 'Carson City'},
62+
{name: 'New Hampshire', capital: 'Concord'},
63+
{name: 'New Jersey', capital: 'Trenton'},
64+
{name: 'New Mexico', capital: 'Santa Fe'},
65+
{name: 'New York', capital: 'Albany'},
66+
{name: 'North Carolina', capital: 'Raleigh'},
67+
{name: 'North Dakota', capital: 'Bismarck'},
68+
{name: 'Ohio', capital: 'Columbus'},
69+
{name: 'Oklahoma', capital: 'Oklahoma City'},
70+
{name: 'Oregon', capital: 'Salem'},
71+
{name: 'Pennsylvania', capital: 'Harrisburg'},
72+
{name: 'Rhode Island', capital: 'Providence'},
73+
{name: 'South Carolina', capital: 'Columbia'},
74+
{name: 'South Dakota', capital: 'Pierre'},
75+
{name: 'Tennessee', capital: 'Nashville'},
76+
{name: 'Texas', capital: 'Austin'},
77+
{name: 'Utah', capital: 'Salt Lake City'},
78+
{name: 'Vermont', capital: 'Montpelier'},
79+
{name: 'Virginia', capital: 'Richmond'},
80+
{name: 'Washington', capital: 'Olympia'},
81+
{name: 'West Virginia', capital: 'Charleston'},
82+
{name: 'Wisconsin', capital: 'Madison'},
83+
{name: 'Wyoming', capital: 'Cheyenne'},
84+
];
85+
statesObservable = new BehaviorSubject(this.states);
86+
indexTrackFn = (index: number) => index;
87+
nameTrackFn = (_: number, item: State) => item.name;
2688

2789
constructor() {
2890
this.emitData();
@@ -31,8 +93,12 @@ export class VirtualScrollDemo {
3193
emitData() {
3294
let data = this.observableData.value.concat([50]);
3395
this.observableData.next(data);
34-
if (data.length < 1000) {
35-
setTimeout(() => this.emitData(), 1000);
36-
}
96+
}
97+
98+
sortBy(prop: 'name' | 'capital') {
99+
this.statesObservable.next(this.states.map(s => ({...s})).sort((a, b) => {
100+
const aProp = a[prop], bProp = b[prop];
101+
return aProp < bProp ? -1 : (aProp > bProp ? 1 : 0);
102+
}));
37103
}
38104
}

0 commit comments

Comments
 (0)