Skip to content

Commit a4fd7ca

Browse files
authored
virtual-scroll: add incremental scroll logic in AutosizeVirtualScrollStrategy (#10504)
* virtual-scroll: add incremental scroll logic in `AutosizeVirtualScrollStrategy`. This still has a couple issues that need to be ironed out and it doesn't have the code for correcting the error between the predicted and actual scroll position. (See various TODOs for additional things that need work). * fix lint * address comments * address comments
1 parent 13a99f3 commit a4fd7ca

File tree

5 files changed

+344
-103
lines changed

5 files changed

+344
-103
lines changed

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

Lines changed: 134 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,12 @@ export class ItemSizeAverager {
2323
/** The current average item size. */
2424
private _averageItemSize: number;
2525

26+
/** The default size to use for items when no data is available. */
27+
private _defaultItemSize: number;
28+
2629
/** @param defaultItemSize The default size to use for items when no data is available. */
2730
constructor(defaultItemSize = 50) {
31+
this._defaultItemSize = defaultItemSize;
2832
this._averageItemSize = defaultItemSize;
2933
}
3034

@@ -49,6 +53,12 @@ export class ItemSizeAverager {
4953
}
5054
}
5155
}
56+
57+
/** Resets the averager. */
58+
reset() {
59+
this._averageItemSize = this._defaultItemSize;
60+
this._totalWeight = 0;
61+
}
5262
}
5363

5464

@@ -66,6 +76,15 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
6676
/** The estimator used to estimate the size of unseen items. */
6777
private _averager: ItemSizeAverager;
6878

79+
/** The last measured scroll offset of the viewport. */
80+
private _lastScrollOffset: number;
81+
82+
/** The last measured size of the rendered content in the viewport. */
83+
private _lastRenderedContentSize: number;
84+
85+
/** The last measured size of the rendered content in the viewport. */
86+
private _lastRenderedContentOffset: number;
87+
6988
/**
7089
* @param minBufferPx The minimum amount of buffer rendered beyond the viewport (in pixels).
7190
* If the amount of buffer dips below this number, more items will be rendered.
@@ -85,8 +104,9 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
85104
* @param viewport The viewport to attach this strategy to.
86105
*/
87106
attach(viewport: CdkVirtualScrollViewport) {
107+
this._averager.reset();
88108
this._viewport = viewport;
89-
this._renderContentForOffset(this._viewport.measureScrollOffset());
109+
this._setScrollOffset();
90110
}
91111

92112
/** Detaches this scroll strategy from the currently attached viewport. */
@@ -97,14 +117,15 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
97117
/** Implemented as part of VirtualScrollStrategy. */
98118
onContentScrolled() {
99119
if (this._viewport) {
100-
this._renderContentForOffset(this._viewport.measureScrollOffset());
120+
this._updateRenderedContentAfterScroll();
101121
}
102122
}
103123

104124
/** Implemented as part of VirtualScrollStrategy. */
105125
onDataLengthChanged() {
106126
if (this._viewport) {
107-
this._renderContentForOffset(this._viewport.measureScrollOffset());
127+
// TODO(mmalebra): Do something smarter here.
128+
this._setScrollOffset();
108129
}
109130
}
110131

@@ -126,23 +147,127 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
126147
this._addBufferPx = addBufferPx;
127148
}
128149

150+
/** Update the rendered content after the user scrolls. */
151+
private _updateRenderedContentAfterScroll() {
152+
const viewport = this._viewport!;
153+
154+
// The current scroll offset.
155+
const scrollOffset = viewport.measureScrollOffset();
156+
// The delta between the current scroll offset and the previously recorded scroll offset.
157+
const scrollDelta = scrollOffset - this._lastScrollOffset;
158+
// The magnitude of the scroll delta.
159+
const scrollMagnitude = Math.abs(scrollDelta);
160+
161+
// TODO(mmalerba): Record error between actual scroll offset and predicted scroll offset given
162+
// the index of the first rendered element. Fudge the scroll delta to slowly eliminate the error
163+
// as the user scrolls.
164+
165+
// The current amount of buffer past the start of the viewport.
166+
const startBuffer = this._lastScrollOffset - this._lastRenderedContentOffset;
167+
// The current amount of buffer past the end of the viewport.
168+
const endBuffer = (this._lastRenderedContentOffset + this._lastRenderedContentSize) -
169+
(this._lastScrollOffset + viewport.getViewportSize());
170+
// The amount of unfilled space that should be filled on the side the user is scrolling toward
171+
// in order to safely absorb the scroll delta.
172+
const underscan = scrollMagnitude + this._minBufferPx -
173+
(scrollDelta < 0 ? startBuffer : endBuffer);
174+
175+
// Check if there's unfilled space that we need to render new elements to fill.
176+
if (underscan > 0) {
177+
// Check if the scroll magnitude was larger than the viewport size. In this case the user
178+
// won't notice a discontinuity if we just jump to the new estimated position in the list.
179+
// However, if the scroll magnitude is smaller than the viewport the user might notice some
180+
// jitteriness if we just jump to the estimated position. Instead we make sure to scroll by
181+
// the same number of pixels as the scroll magnitude.
182+
if (scrollMagnitude >= viewport.getViewportSize()) {
183+
this._setScrollOffset();
184+
} else {
185+
// The number of new items to render on the side the user is scrolling towards. Rather than
186+
// just filling the underscan space, we actually fill enough to have a buffer size of
187+
// `addBufferPx`. This gives us a little wiggle room in case our item size estimate is off.
188+
const addItems = Math.max(0, Math.ceil((underscan - this._minBufferPx + this._addBufferPx) /
189+
this._averager.getAverageItemSize()));
190+
// The amount of filled space beyond what is necessary on the side the user is scrolling
191+
// away from.
192+
const overscan = (scrollDelta < 0 ? endBuffer : startBuffer) - this._minBufferPx +
193+
scrollMagnitude;
194+
// The number of currently rendered items to remove on the side the user is scrolling away
195+
// from.
196+
const removeItems = Math.max(0, Math.floor(overscan / this._averager.getAverageItemSize()));
197+
198+
// The currently rendered range.
199+
const renderedRange = viewport.getRenderedRange();
200+
// The new range we will tell the viewport to render. We first expand it to include the new
201+
// items we want rendered, we then contract the opposite side to remove items we no longer
202+
// want rendered.
203+
const range = this._expandRange(
204+
renderedRange, scrollDelta < 0 ? addItems : 0, scrollDelta > 0 ? addItems : 0);
205+
if (scrollDelta < 0) {
206+
range.end = Math.max(range.start + 1, range.end - removeItems);
207+
} else {
208+
range.start = Math.min(range.end - 1, range.start + removeItems);
209+
}
210+
211+
// The new offset we want to set on the rendered content. To determine this we measure the
212+
// number of pixels we removed and then adjust the offset to the start of the rendered
213+
// content or to the end of the rendered content accordingly (whichever one doesn't require
214+
// that the newly added items to be rendered to calculate.)
215+
let contentOffset: number;
216+
let contentOffsetTo: 'to-start' | 'to-end';
217+
if (scrollDelta < 0) {
218+
const removedSize = viewport.measureRangeSize({
219+
start: range.end,
220+
end: renderedRange.end,
221+
});
222+
contentOffset =
223+
this._lastRenderedContentOffset + this._lastRenderedContentSize - removedSize;
224+
contentOffsetTo = 'to-end';
225+
} else {
226+
const removedSize = viewport.measureRangeSize({
227+
start: renderedRange.start,
228+
end: range.start,
229+
});
230+
contentOffset = this._lastRenderedContentOffset + removedSize;
231+
contentOffsetTo = 'to-start';
232+
}
233+
234+
// Set the range and offset we calculated above.
235+
viewport.setRenderedRange(range);
236+
viewport.setRenderedContentOffset(contentOffset, contentOffsetTo);
237+
}
238+
}
239+
240+
// Save the scroll offset to be compared to the new value on the next scroll event.
241+
this._lastScrollOffset = scrollOffset;
242+
}
243+
129244
/**
130245
* Checks the size of the currently rendered content and uses it to update the estimated item size
131246
* and estimated total content size.
132247
*/
133248
private _checkRenderedContentSize() {
134249
const viewport = this._viewport!;
135-
const renderedContentSize = viewport.measureRenderedContentSize();
136-
this._averager.addSample(viewport.getRenderedRange(), renderedContentSize);
137-
this._updateTotalContentSize(renderedContentSize);
250+
this._lastRenderedContentOffset = viewport.measureRenderedContentOffset();
251+
this._lastRenderedContentSize = viewport.measureRenderedContentSize();
252+
this._averager.addSample(viewport.getRenderedRange(), this._lastRenderedContentSize);
253+
this._updateTotalContentSize(this._lastRenderedContentSize);
138254
}
139255

140256
/**
141-
* Render the content that we estimate should be shown for the given scroll offset.
142-
* Note: must not be called if `this._viewport` is null
257+
* Sets the scroll offset and renders the content we estimate should be shown at that point.
258+
* @param scrollOffset The offset to jump to. If not specified the scroll offset will not be
259+
* changed, but the rendered content will be recalculated based on our estimate of what should
260+
* be shown at the current scroll offset.
143261
*/
144-
private _renderContentForOffset(scrollOffset: number) {
262+
private _setScrollOffset(scrollOffset?: number) {
145263
const viewport = this._viewport!;
264+
if (scrollOffset == null) {
265+
scrollOffset = viewport.measureScrollOffset();
266+
} else {
267+
viewport.setScrollOffset(scrollOffset);
268+
}
269+
this._lastScrollOffset = scrollOffset;
270+
146271
const itemSize = this._averager.getAverageItemSize();
147272
const firstVisibleIndex =
148273
Math.min(viewport.getDataLength() - 1, Math.floor(scrollOffset / itemSize));

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

Lines changed: 33 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ export type CdkVirtualForOfContext<T> = {
4545
};
4646

4747

48+
/** Helper to extract size from a ClientRect. **/
49+
function getSize(orientation: 'horizontal' | 'vertical', rect: ClientRect): number {
50+
return orientation == 'horizontal' ? rect.width : rect.height;
51+
}
52+
53+
4854
/**
4955
* A directive similar to `ngForOf` to be used for rendering data inside a virtual scrolling
5056
* container.
@@ -151,47 +157,37 @@ export class CdkVirtualForOf<T> implements CollectionViewer, DoCheck, OnDestroy
151157
}
152158

153159
/**
154-
* Get the client rect for the given index.
155-
* @param index The index of the data element whose client rect we want to measure.
156-
* @return The combined client rect for all DOM elements rendered as part of the given index.
157-
* Or null if no DOM elements are rendered for the given index.
158-
* @throws If the given index is not in the rendered range.
160+
* Measures the combined size (width for horizontal orientation, height for vertical) of all items
161+
* in the specified range. Throws an error if the range includes items that are not currently
162+
* rendered.
159163
*/
160-
measureClientRect(index: number): ClientRect | null {
161-
if (index < this._renderedRange.start || index >= this._renderedRange.end) {
162-
throw Error(`Error: attempted to measure an element that isn't rendered.`);
164+
measureRangeSize(range: ListRange, orientation: 'horizontal' | 'vertical'): number {
165+
if (range.start >= range.end) {
166+
return 0;
167+
}
168+
if (range.start < this._renderedRange.start || range.end > this._renderedRange.end) {
169+
throw Error(`Error: attempted to measure an item that isn't rendered.`);
163170
}
164-
const renderedIndex = index - this._renderedRange.start;
165-
let view = this._viewContainerRef.get(renderedIndex) as
166-
EmbeddedViewRef<CdkVirtualForOfContext<T>> | null;
167-
if (view && view.rootNodes.length) {
168-
// There may be multiple root DOM elements for a single data element, so we merge their rects.
169-
// These variables keep track of the minimum top and left as well as maximum bottom and right
170-
// that we have encoutnered on any rectangle, so that we can merge the results into the
171-
// smallest possible rect that contains all of the root rects.
172-
let minTop = Infinity;
173-
let minLeft = Infinity;
174-
let maxBottom = -Infinity;
175-
let maxRight = -Infinity;
176-
177-
for (let i = view.rootNodes.length - 1; i >= 0 ; i--) {
178-
let rect = (view.rootNodes[i] as Element).getBoundingClientRect();
179-
minTop = Math.min(minTop, rect.top);
180-
minLeft = Math.min(minLeft, rect.left);
181-
maxBottom = Math.max(maxBottom, rect.bottom);
182-
maxRight = Math.max(maxRight, rect.right);
183-
}
184171

185-
return {
186-
top: minTop,
187-
left: minLeft,
188-
bottom: maxBottom,
189-
right: maxRight,
190-
height: maxBottom - minTop,
191-
width: maxRight - minLeft
192-
};
172+
// The index into the list of rendered views for the first item in the range.
173+
const renderedStartIndex = range.start - this._renderedRange.start;
174+
// The length of the range we're measuring.
175+
const rangeLen = range.end - range.start;
176+
177+
// Loop over all root nodes for all items in the range and sum up their size.
178+
// TODO(mmalerba): Make this work with non-element nodes.
179+
let totalSize = 0;
180+
let i = rangeLen;
181+
while (i--) {
182+
const view = this._viewContainerRef.get(i + renderedStartIndex) as
183+
EmbeddedViewRef<CdkVirtualForOfContext<T>> | null;
184+
let j = view ? view.rootNodes.length : 0;
185+
while (j--) {
186+
totalSize += getSize(orientation, (view!.rootNodes[j] as Element).getBoundingClientRect());
187+
}
193188
}
194-
return null;
189+
190+
return totalSize;
195191
}
196192

197193
ngDoCheck() {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {Component, ViewChild} from '@angular/core';
2+
import {ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing';
3+
import {ScrollingModule} from './scrolling-module';
4+
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
5+
6+
describe('Basic CdkVirtualScrollViewport', () => {
7+
let fixture: ComponentFixture<BasicViewport>;
8+
let viewport: CdkVirtualScrollViewport;
9+
10+
beforeEach(() => {
11+
TestBed.configureTestingModule({
12+
imports: [ScrollingModule],
13+
declarations: [BasicViewport],
14+
}).compileComponents();
15+
16+
fixture = TestBed.createComponent(BasicViewport);
17+
viewport = fixture.componentInstance.viewport;
18+
});
19+
20+
it('should sanitize transform inputs', fakeAsync(() => {
21+
fixture.detectChanges();
22+
flush();
23+
24+
viewport.orientation = 'arbitrary string as orientation' as any;
25+
viewport.setRenderedContentOffset(
26+
'arbitrary string as offset' as any, 'arbitrary string as to' as any);
27+
fixture.detectChanges();
28+
flush();
29+
30+
expect((viewport._renderedContentTransform as any).changingThisBreaksApplicationSecurity)
31+
.toEqual('translateY(NaNpx)');
32+
}));
33+
});
34+
35+
@Component({
36+
template: `
37+
<cdk-virtual-scroll-viewport itemSize="50">
38+
<span *cdkVirtualFor="let item of items">{{item}}</span>
39+
</cdk-virtual-scroll-viewport>
40+
`
41+
})
42+
class BasicViewport {
43+
@ViewChild(CdkVirtualScrollViewport) viewport;
44+
45+
items = Array(10).fill(0);
46+
}

0 commit comments

Comments
 (0)