Skip to content

Commit 1a3411e

Browse files
committed
virtual-scroll: add autosize scroll strategy (#10219)
* rename fixed size virtual scroll directive * add autosize virtual scroll strategy * add item size estimator class * add logic for jumping rendered content based on scroll position * address comments
1 parent 2ebdf4a commit 1a3411e

File tree

6 files changed

+274
-21
lines changed

6 files changed

+274
-21
lines changed
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {ListRange} from '@angular/cdk/collections';
10+
import {Directive, forwardRef, Input, OnChanges} from '@angular/core';
11+
import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy';
12+
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
13+
14+
15+
/**
16+
* A class that tracks the size of items that have been seen and uses it to estimate the average
17+
* item size.
18+
*/
19+
export class ItemSizeAverager {
20+
/** The total amount of weight behind the current average. */
21+
private _totalWeight = 0;
22+
23+
/** The current average item size. */
24+
private _averageItemSize: number;
25+
26+
/** @param defaultItemSize The default size to use for items when no data is available. */
27+
constructor(defaultItemSize = 50) {
28+
this._averageItemSize = defaultItemSize;
29+
}
30+
31+
/** Returns the average item size. */
32+
getAverageItemSize(): number {
33+
return this._averageItemSize;
34+
}
35+
36+
/**
37+
* Adds a measurement sample for the estimator to consider.
38+
* @param range The measured range.
39+
* @param size The measured size of the given range in pixels.
40+
*/
41+
addSample(range: ListRange, size: number) {
42+
const weight = range.end - range.start;
43+
const newTotalWeight = this._totalWeight + weight;
44+
if (newTotalWeight) {
45+
const newAverageItemSize =
46+
(size * weight + this._averageItemSize * this._totalWeight) / newTotalWeight;
47+
if (newAverageItemSize) {
48+
this._averageItemSize = newAverageItemSize;
49+
this._totalWeight = newTotalWeight;
50+
}
51+
}
52+
}
53+
}
54+
55+
56+
/** Virtual scrolling strategy for lists with items of unknown or dynamic size. */
57+
export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
58+
/** The attached viewport. */
59+
private _viewport: CdkVirtualScrollViewport | null = null;
60+
61+
/** The minimum amount of buffer rendered beyond the viewport (in pixels). */
62+
private _minBufferPx: number;
63+
64+
/** The number of buffer items to render beyond the edge of the viewport (in pixels). */
65+
private _addBufferPx: number;
66+
67+
/** The estimator used to estimate the size of unseen items. */
68+
private _averager: ItemSizeAverager;
69+
70+
/**
71+
* @param minBufferPx The minimum amount of buffer rendered beyond the viewport (in pixels).
72+
* If the amount of buffer dips below this number, more items will be rendered.
73+
* @param addBufferPx The number of pixels worth of buffer to shoot for when rendering new items.
74+
* If the actual amount turns out to be less it will not necessarily trigger an additional
75+
* rendering cycle (as long as the amount of buffer is still greater than `minBufferPx`).
76+
* @param averager The averager used to estimate the size of unseen items.
77+
*/
78+
constructor(minBufferPx: number, addBufferPx: number, averager = new ItemSizeAverager()) {
79+
this._minBufferPx = minBufferPx;
80+
this._addBufferPx = addBufferPx;
81+
this._averager = averager;
82+
}
83+
84+
/**
85+
* Attaches this scroll strategy to a viewport.
86+
* @param viewport The viewport to attach this strategy to.
87+
*/
88+
attach(viewport: CdkVirtualScrollViewport) {
89+
this._viewport = viewport;
90+
this._updateTotalContentSize();
91+
this._renderContentForOffset(this._viewport.measureScrollOffset());
92+
}
93+
94+
/** Detaches this scroll strategy from the currently attached viewport. */
95+
detach() {
96+
this._viewport = null;
97+
}
98+
99+
/** Called when the viewport is scrolled. */
100+
onContentScrolled() {
101+
if (this._viewport) {
102+
this._renderContentForOffset(this._viewport.measureScrollOffset());
103+
}
104+
}
105+
106+
/** Called when the length of the data changes. */
107+
onDataLengthChanged() {
108+
if (this._viewport) {
109+
this._updateTotalContentSize();
110+
this._renderContentForOffset(this._viewport.measureScrollOffset());
111+
}
112+
}
113+
114+
/**
115+
* Update the buffer parameters.
116+
* @param minBufferPx The minimum amount of buffer rendered beyond the viewport (in pixels).
117+
* @param addBufferPx The number of buffer items to render beyond the edge of the viewport (in
118+
* pixels).
119+
*/
120+
updateBufferSize(minBufferPx: number, addBufferPx: number) {
121+
this._minBufferPx = minBufferPx;
122+
this._addBufferPx = addBufferPx;
123+
}
124+
125+
/**
126+
* Render the content that we estimate should be shown for the given scroll offset.
127+
* Note: must not be called if `this._viewport` is null
128+
*/
129+
private _renderContentForOffset(scrollOffset: number) {
130+
const viewport = this._viewport!;
131+
const itemSize = this._averager.getAverageItemSize();
132+
const firstVisibleIndex =
133+
Math.min(viewport.getDataLength() - 1, Math.floor(scrollOffset / itemSize));
134+
const bufferSize = Math.ceil(this._addBufferPx / itemSize);
135+
const range = this._expandRange(
136+
this._getVisibleRangeForIndex(firstVisibleIndex), bufferSize, bufferSize);
137+
138+
viewport.setRenderedRange(range);
139+
viewport.setRenderedContentOffset(itemSize * range.start);
140+
}
141+
142+
// TODO: maybe move to base class, can probably share with fixed size strategy.
143+
/**
144+
* Gets the visible range of data for the given start index. If the start index is too close to
145+
* the end of the list it may be backed up to ensure the estimated size of the range is enough to
146+
* fill the viewport.
147+
* Note: must not be called if `this._viewport` is null
148+
* @param startIndex The index to start the range at
149+
* @return a range estimated to be large enough to fill the viewport when rendered.
150+
*/
151+
private _getVisibleRangeForIndex(startIndex: number): ListRange {
152+
const viewport = this._viewport!;
153+
const range: ListRange = {
154+
start: startIndex,
155+
end: startIndex +
156+
Math.ceil(viewport.getViewportSize() / this._averager.getAverageItemSize())
157+
};
158+
const extra = range.end - viewport.getDataLength();
159+
if (extra > 0) {
160+
range.start = Math.max(0, range.start - extra);
161+
}
162+
return range;
163+
}
164+
165+
// TODO: maybe move to base class, can probably share with fixed size strategy.
166+
/**
167+
* Expand the given range by the given amount in either direction.
168+
* Note: must not be called if `this._viewport` is null
169+
* @param range The range to expand
170+
* @param expandStart The number of items to expand the start of the range by.
171+
* @param expandEnd The number of items to expand the end of the range by.
172+
* @return The expanded range.
173+
*/
174+
private _expandRange(range: ListRange, expandStart: number, expandEnd: number): ListRange {
175+
const viewport = this._viewport!;
176+
const start = Math.max(0, range.start - expandStart);
177+
const end = Math.min(viewport.getDataLength(), range.end + expandEnd);
178+
return {start, end};
179+
}
180+
181+
/** Update the viewport's total content size. */
182+
private _updateTotalContentSize() {
183+
const viewport = this._viewport!;
184+
viewport.setTotalContentSize(viewport.getDataLength() * this._averager.getAverageItemSize());
185+
}
186+
}
187+
188+
/**
189+
* Provider factory for `AutoSizeVirtualScrollStrategy` that simply extracts the already created
190+
* `AutoSizeVirtualScrollStrategy` from the given directive.
191+
* @param autoSizeDir The instance of `CdkAutoSizeVirtualScroll` to extract the
192+
* `AutoSizeVirtualScrollStrategy` from.
193+
*/
194+
export function _autoSizeVirtualScrollStrategyFactory(autoSizeDir: CdkAutoSizeVirtualScroll) {
195+
return autoSizeDir._scrollStrategy;
196+
}
197+
198+
199+
/** A virtual scroll strategy that supports unknown or dynamic size items. */
200+
@Directive({
201+
selector: 'cdk-virtual-scroll-viewport[autosize]',
202+
providers: [{
203+
provide: VIRTUAL_SCROLL_STRATEGY,
204+
useFactory: _autoSizeVirtualScrollStrategyFactory,
205+
deps: [forwardRef(() => CdkAutoSizeVirtualScroll)],
206+
}],
207+
})
208+
export class CdkAutoSizeVirtualScroll implements OnChanges {
209+
/**
210+
* The minimum amount of buffer rendered beyond the viewport (in pixels).
211+
* If the amount of buffer dips below this number, more items will be rendered.
212+
*/
213+
@Input() minBufferPx: number = 100;
214+
215+
/**
216+
* The number of pixels worth of buffer to shoot for when rendering new items.
217+
* If the actual amount turns out to be less it will not necessarily trigger an additional
218+
* rendering cycle (as long as the amount of buffer is still greater than `minBufferPx`).
219+
*/
220+
@Input() addBufferPx: number = 200;
221+
222+
/** The scroll strategy used by this directive. */
223+
_scrollStrategy = new AutoSizeVirtualScrollStrategy(this.minBufferPx, this.addBufferPx);
224+
225+
ngOnChanges() {
226+
this._scrollStrategy.updateBufferSize(this.minBufferPx, this.addBufferPx);
227+
}
228+
}

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

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
1313

1414

1515
/** Virtual scrolling strategy for lists with items of known fixed size. */
16-
export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy {
16+
export class FixedSizeVirtualScrollStrategy implements VirtualScrollStrategy {
1717
/** The attached viewport. */
1818
private _viewport: CdkVirtualScrollViewport | null = null;
1919

@@ -25,7 +25,7 @@ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy {
2525

2626
/**
2727
* @param itemSize The size of the items in the virtually scrolling list.
28-
* @param bufferSize he number of buffer items to render beyond the edge of the viewport.
28+
* @param bufferSize The number of buffer items to render beyond the edge of the viewport.
2929
*/
3030
constructor(itemSize: number, bufferSize: number) {
3131
this._itemSize = itemSize;
@@ -59,7 +59,7 @@ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy {
5959
this._updateRenderedRange();
6060
}
6161

62-
/** Called when the viewport is scrolled (debounced using requestAnimationFrame). */
62+
/** Called when the viewport is scrolled. */
6363
onContentScrolled() {
6464
this._updateRenderedRange();
6565
}
@@ -115,12 +115,12 @@ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy {
115115

116116

117117
/**
118-
* Provider factory for `VirtualScrollFixedSizeStrategy` that simply extracts the already created
119-
* `VirtualScrollFixedSizeStrategy` from the given directive.
120-
* @param fixedSizeDir The instance of `CdkVirtualScrollFixedSize` to extract the
121-
* `VirtualScrollFixedSizeStrategy` from.
118+
* Provider factory for `FixedSizeVirtualScrollStrategy` that simply extracts the already created
119+
* `FixedSizeVirtualScrollStrategy` from the given directive.
120+
* @param fixedSizeDir The instance of `CdkFixedSizeVirtualScroll` to extract the
121+
* `FixedSizeVirtualScrollStrategy` from.
122122
*/
123-
export function _virtualScrollFixedSizeStrategyFactory(fixedSizeDir: CdkVirtualScrollFixedSize) {
123+
export function _fixedSizeVirtualScrollStrategyFactory(fixedSizeDir: CdkFixedSizeVirtualScroll) {
124124
return fixedSizeDir._scrollStrategy;
125125
}
126126

@@ -130,19 +130,19 @@ export function _virtualScrollFixedSizeStrategyFactory(fixedSizeDir: CdkVirtualS
130130
selector: 'cdk-virtual-scroll-viewport[itemSize]',
131131
providers: [{
132132
provide: VIRTUAL_SCROLL_STRATEGY,
133-
useFactory: _virtualScrollFixedSizeStrategyFactory,
134-
deps: [forwardRef(() => CdkVirtualScrollFixedSize)],
133+
useFactory: _fixedSizeVirtualScrollStrategyFactory,
134+
deps: [forwardRef(() => CdkFixedSizeVirtualScroll)],
135135
}],
136136
})
137-
export class CdkVirtualScrollFixedSize implements OnChanges {
137+
export class CdkFixedSizeVirtualScroll implements OnChanges {
138138
/** The size of the items in the list (in pixels). */
139139
@Input() itemSize = 20;
140140

141141
/** The number of extra elements to render on either side of the scrolling viewport. */
142142
@Input() bufferSize = 5;
143143

144144
/** The scroll strategy used by this directive. */
145-
_scrollStrategy = new VirtualScrollFixedSizeStrategy(this.itemSize, this.bufferSize);
145+
_scrollStrategy = new FixedSizeVirtualScrollStrategy(this.itemSize, this.bufferSize);
146146

147147
ngOnChanges() {
148148
this._scrollStrategy.updateItemAndBufferSize(this.itemSize, this.bufferSize);

src/cdk-experimental/scrolling/public-api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
export * from './auto-size-virtual-scroll';
10+
export * from './fixed-size-virtual-scroll';
911
export * from './scrolling-module';
1012
export * from './virtual-for-of';
11-
export * from './virtual-scroll-fixed-size';
1213
export * from './virtual-scroll-strategy';
1314
export * from './virtual-scroll-viewport';

src/cdk-experimental/scrolling/scrolling-module.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,23 @@
77
*/
88

99
import {NgModule} from '@angular/core';
10+
import {CdkAutoSizeVirtualScroll} from './auto-size-virtual-scroll';
11+
import {CdkFixedSizeVirtualScroll} from './fixed-size-virtual-scroll';
1012
import {CdkVirtualForOf} from './virtual-for-of';
11-
import {CdkVirtualScrollFixedSize} from './virtual-scroll-fixed-size';
1213
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
1314

1415

1516
@NgModule({
1617
exports: [
18+
CdkAutoSizeVirtualScroll,
19+
CdkFixedSizeVirtualScroll,
1720
CdkVirtualForOf,
18-
CdkVirtualScrollFixedSize,
1921
CdkVirtualScrollViewport,
2022
],
2123
declarations: [
24+
CdkAutoSizeVirtualScroll,
25+
CdkFixedSizeVirtualScroll,
2226
CdkVirtualForOf,
23-
CdkVirtualScrollFixedSize,
2427
CdkVirtualScrollViewport,
2528
],
2629
})
Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
1-
<cdk-virtual-scroll-viewport class="demo-viewport" [itemSize]="20">
2-
<div *cdkVirtualFor="let size of data; let i = index" class="demo-item" [style.height.px]="size">
1+
<h2>Autosize</h2>
2+
3+
<cdk-virtual-scroll-viewport class="demo-viewport" autosize>
4+
<div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-item"
5+
[style.height.px]="size">
6+
Item #{{i}} - ({{size}}px)
7+
</div>
8+
</cdk-virtual-scroll-viewport>
9+
10+
<cdk-virtual-scroll-viewport class="demo-viewport" autosize>
11+
<div *cdkVirtualFor="let size of randomData; let i = index" class="demo-item"
12+
[style.height.px]="size">
13+
Item #{{i}} - ({{size}}px)
14+
</div>
15+
</cdk-virtual-scroll-viewport>
16+
17+
<h2>Fixed size</h2>
18+
19+
<cdk-virtual-scroll-viewport class="demo-viewport" [itemSize]="50">
20+
<div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-item"
21+
[style.height.px]="size">
322
Item #{{i}} - ({{size}}px)
423
</div>
524
</cdk-virtual-scroll-viewport>
625

7-
<cdk-virtual-scroll-viewport class="demo-viewport demo-horizontal" [itemSize]="20"
26+
<cdk-virtual-scroll-viewport class="demo-viewport demo-horizontal" [itemSize]="50"
827
orientation="horizontal">
9-
<div *cdkVirtualFor="let size of data; let i = index" class="demo-item" [style.width.px]="size">
28+
<div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-item"
29+
[style.width.px]="size">
1030
Item #{{i}} - ({{size}}px)
1131
</div>
1232
</cdk-virtual-scroll-viewport>

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ import {Component, ViewEncapsulation} from '@angular/core';
1616
encapsulation: ViewEncapsulation.None,
1717
})
1818
export class VirtualScrollDemo {
19-
data = Array(10000).fill(20);
19+
fixedSizeData = Array(10000).fill(50);
20+
randomData = Array(10000).fill(0).map(() => Math.round(Math.random() * 100));
2021
}

0 commit comments

Comments
 (0)