diff --git a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts new file mode 100644 index 000000000000..3d6d77830013 --- /dev/null +++ b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ListRange} from '@angular/cdk/collections'; +import {Directive, forwardRef, Input, OnChanges} from '@angular/core'; +import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy'; +import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; + + +/** + * A class that tracks the size of items that have been seen and uses it to estimate the average + * item size. + */ +export class ItemSizeAverager { + /** The total amount of weight behind the current average. */ + private _totalWeight = 0; + + /** The current average item size. */ + private _averageItemSize: number; + + /** @param defaultItemSize The default size to use for items when no data is available. */ + constructor(defaultItemSize = 50) { + this._averageItemSize = defaultItemSize; + } + + /** Returns the average item size. */ + getAverageItemSize(): number { + return this._averageItemSize; + } + + /** + * Adds a measurement sample for the estimator to consider. + * @param range The measured range. + * @param size The measured size of the given range in pixels. + */ + addSample(range: ListRange, size: number) { + const weight = range.end - range.start; + const newTotalWeight = this._totalWeight + weight; + if (newTotalWeight) { + const newAverageItemSize = + (size * weight + this._averageItemSize * this._totalWeight) / newTotalWeight; + if (newAverageItemSize) { + this._averageItemSize = newAverageItemSize; + this._totalWeight = newTotalWeight; + } + } + } +} + + +/** Virtual scrolling strategy for lists with items of unknown or dynamic size. */ +export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy { + /** The attached viewport. */ + private _viewport: CdkVirtualScrollViewport | null = null; + + /** The minimum amount of buffer rendered beyond the viewport (in pixels). */ + private _minBufferPx: number; + + /** The number of buffer items to render beyond the edge of the viewport (in pixels). */ + private _addBufferPx: number; + + /** The estimator used to estimate the size of unseen items. */ + private _averager: ItemSizeAverager; + + /** + * @param minBufferPx The minimum amount of buffer rendered beyond the viewport (in pixels). + * If the amount of buffer dips below this number, more items will be rendered. + * @param addBufferPx The number of pixels worth of buffer to shoot for when rendering new items. + * If the actual amount turns out to be less it will not necessarily trigger an additional + * rendering cycle (as long as the amount of buffer is still greater than `minBufferPx`). + * @param averager The averager used to estimate the size of unseen items. + */ + constructor(minBufferPx: number, addBufferPx: number, averager = new ItemSizeAverager()) { + this._minBufferPx = minBufferPx; + this._addBufferPx = addBufferPx; + this._averager = averager; + } + + /** + * Attaches this scroll strategy to a viewport. + * @param viewport The viewport to attach this strategy to. + */ + attach(viewport: CdkVirtualScrollViewport) { + this._viewport = viewport; + this._updateTotalContentSize(); + this._renderContentForOffset(this._viewport.measureScrollOffset()); + } + + /** Detaches this scroll strategy from the currently attached viewport. */ + detach() { + this._viewport = null; + } + + /** Called when the viewport is scrolled. */ + onContentScrolled() { + if (this._viewport) { + this._renderContentForOffset(this._viewport.measureScrollOffset()); + } + } + + /** Called when the length of the data changes. */ + onDataLengthChanged() { + if (this._viewport) { + this._updateTotalContentSize(); + this._renderContentForOffset(this._viewport.measureScrollOffset()); + } + } + + /** + * Update the buffer parameters. + * @param minBufferPx The minimum amount of buffer rendered beyond the viewport (in pixels). + * @param addBufferPx The number of buffer items to render beyond the edge of the viewport (in + * pixels). + */ + updateBufferSize(minBufferPx: number, addBufferPx: number) { + this._minBufferPx = minBufferPx; + this._addBufferPx = addBufferPx; + } + + /** + * Render the content that we estimate should be shown for the given scroll offset. + * Note: must not be called if `this._viewport` is null + */ + private _renderContentForOffset(scrollOffset: number) { + const viewport = this._viewport!; + const itemSize = this._averager.getAverageItemSize(); + const firstVisibleIndex = + Math.min(viewport.getDataLength() - 1, Math.floor(scrollOffset / itemSize)); + const bufferSize = Math.ceil(this._addBufferPx / itemSize); + const range = this._expandRange( + this._getVisibleRangeForIndex(firstVisibleIndex), bufferSize, bufferSize); + + viewport.setRenderedRange(range); + viewport.setRenderedContentOffset(itemSize * range.start); + } + + // TODO: maybe move to base class, can probably share with fixed size strategy. + /** + * Gets the visible range of data for the given start index. If the start index is too close to + * the end of the list it may be backed up to ensure the estimated size of the range is enough to + * fill the viewport. + * Note: must not be called if `this._viewport` is null + * @param startIndex The index to start the range at + * @return a range estimated to be large enough to fill the viewport when rendered. + */ + private _getVisibleRangeForIndex(startIndex: number): ListRange { + const viewport = this._viewport!; + const range: ListRange = { + start: startIndex, + end: startIndex + + Math.ceil(viewport.getViewportSize() / this._averager.getAverageItemSize()) + }; + const extra = range.end - viewport.getDataLength(); + if (extra > 0) { + range.start = Math.max(0, range.start - extra); + } + return range; + } + + // TODO: maybe move to base class, can probably share with fixed size strategy. + /** + * Expand the given range by the given amount in either direction. + * Note: must not be called if `this._viewport` is null + * @param range The range to expand + * @param expandStart The number of items to expand the start of the range by. + * @param expandEnd The number of items to expand the end of the range by. + * @return The expanded range. + */ + private _expandRange(range: ListRange, expandStart: number, expandEnd: number): ListRange { + const viewport = this._viewport!; + const start = Math.max(0, range.start - expandStart); + const end = Math.min(viewport.getDataLength(), range.end + expandEnd); + return {start, end}; + } + + /** Update the viewport's total content size. */ + private _updateTotalContentSize() { + const viewport = this._viewport!; + viewport.setTotalContentSize(viewport.getDataLength() * this._averager.getAverageItemSize()); + } +} + +/** + * Provider factory for `AutoSizeVirtualScrollStrategy` that simply extracts the already created + * `AutoSizeVirtualScrollStrategy` from the given directive. + * @param autoSizeDir The instance of `CdkAutoSizeVirtualScroll` to extract the + * `AutoSizeVirtualScrollStrategy` from. + */ +export function _autoSizeVirtualScrollStrategyFactory(autoSizeDir: CdkAutoSizeVirtualScroll) { + return autoSizeDir._scrollStrategy; +} + + +/** A virtual scroll strategy that supports unknown or dynamic size items. */ +@Directive({ + selector: 'cdk-virtual-scroll-viewport[autosize]', + providers: [{ + provide: VIRTUAL_SCROLL_STRATEGY, + useFactory: _autoSizeVirtualScrollStrategyFactory, + deps: [forwardRef(() => CdkAutoSizeVirtualScroll)], + }], +}) +export class CdkAutoSizeVirtualScroll implements OnChanges { + /** + * The minimum amount of buffer rendered beyond the viewport (in pixels). + * If the amount of buffer dips below this number, more items will be rendered. + */ + @Input() minBufferPx: number = 100; + + /** + * The number of pixels worth of buffer to shoot for when rendering new items. + * If the actual amount turns out to be less it will not necessarily trigger an additional + * rendering cycle (as long as the amount of buffer is still greater than `minBufferPx`). + */ + @Input() addBufferPx: number = 200; + + /** The scroll strategy used by this directive. */ + _scrollStrategy = new AutoSizeVirtualScrollStrategy(this.minBufferPx, this.addBufferPx); + + ngOnChanges() { + this._scrollStrategy.updateBufferSize(this.minBufferPx, this.addBufferPx); + } +} diff --git a/src/cdk-experimental/scrolling/virtual-scroll-fixed-size.ts b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts similarity index 83% rename from src/cdk-experimental/scrolling/virtual-scroll-fixed-size.ts rename to src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts index 08226613c397..46d523b34484 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-fixed-size.ts +++ b/src/cdk-experimental/scrolling/fixed-size-virtual-scroll.ts @@ -13,7 +13,7 @@ import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; /** Virtual scrolling strategy for lists with items of known fixed size. */ -export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { +export class FixedSizeVirtualScrollStrategy implements VirtualScrollStrategy { /** The attached viewport. */ private _viewport: CdkVirtualScrollViewport | null = null; @@ -25,7 +25,7 @@ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { /** * @param itemSize The size of the items in the virtually scrolling list. - * @param bufferSize he number of buffer items to render beyond the edge of the viewport. + * @param bufferSize The number of buffer items to render beyond the edge of the viewport. */ constructor(itemSize: number, bufferSize: number) { this._itemSize = itemSize; @@ -59,7 +59,7 @@ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { this._updateRenderedRange(); } - /** Called when the viewport is scrolled (debounced using requestAnimationFrame). */ + /** Called when the viewport is scrolled. */ onContentScrolled() { this._updateRenderedRange(); } @@ -115,12 +115,12 @@ export class VirtualScrollFixedSizeStrategy implements VirtualScrollStrategy { /** - * Provider factory for `VirtualScrollFixedSizeStrategy` that simply extracts the already created - * `VirtualScrollFixedSizeStrategy` from the given directive. - * @param fixedSizeDir The instance of `CdkVirtualScrollFixedSize` to extract the - * `VirtualScrollFixedSizeStrategy` from. + * Provider factory for `FixedSizeVirtualScrollStrategy` that simply extracts the already created + * `FixedSizeVirtualScrollStrategy` from the given directive. + * @param fixedSizeDir The instance of `CdkFixedSizeVirtualScroll` to extract the + * `FixedSizeVirtualScrollStrategy` from. */ -export function _virtualScrollFixedSizeStrategyFactory(fixedSizeDir: CdkVirtualScrollFixedSize) { +export function _fixedSizeVirtualScrollStrategyFactory(fixedSizeDir: CdkFixedSizeVirtualScroll) { return fixedSizeDir._scrollStrategy; } @@ -130,11 +130,11 @@ export function _virtualScrollFixedSizeStrategyFactory(fixedSizeDir: CdkVirtualS selector: 'cdk-virtual-scroll-viewport[itemSize]', providers: [{ provide: VIRTUAL_SCROLL_STRATEGY, - useFactory: _virtualScrollFixedSizeStrategyFactory, - deps: [forwardRef(() => CdkVirtualScrollFixedSize)], + useFactory: _fixedSizeVirtualScrollStrategyFactory, + deps: [forwardRef(() => CdkFixedSizeVirtualScroll)], }], }) -export class CdkVirtualScrollFixedSize implements OnChanges { +export class CdkFixedSizeVirtualScroll implements OnChanges { /** The size of the items in the list (in pixels). */ @Input() itemSize = 20; @@ -142,7 +142,7 @@ export class CdkVirtualScrollFixedSize implements OnChanges { @Input() bufferSize = 5; /** The scroll strategy used by this directive. */ - _scrollStrategy = new VirtualScrollFixedSizeStrategy(this.itemSize, this.bufferSize); + _scrollStrategy = new FixedSizeVirtualScrollStrategy(this.itemSize, this.bufferSize); ngOnChanges() { this._scrollStrategy.updateItemAndBufferSize(this.itemSize, this.bufferSize); diff --git a/src/cdk-experimental/scrolling/public-api.ts b/src/cdk-experimental/scrolling/public-api.ts index 31a7b9aadb81..176164b1ed6c 100644 --- a/src/cdk-experimental/scrolling/public-api.ts +++ b/src/cdk-experimental/scrolling/public-api.ts @@ -6,8 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ +export * from './auto-size-virtual-scroll'; +export * from './fixed-size-virtual-scroll'; export * from './scrolling-module'; export * from './virtual-for-of'; -export * from './virtual-scroll-fixed-size'; export * from './virtual-scroll-strategy'; export * from './virtual-scroll-viewport'; diff --git a/src/cdk-experimental/scrolling/scrolling-module.ts b/src/cdk-experimental/scrolling/scrolling-module.ts index e625b67a5ab9..cccaaacb3eb4 100644 --- a/src/cdk-experimental/scrolling/scrolling-module.ts +++ b/src/cdk-experimental/scrolling/scrolling-module.ts @@ -7,20 +7,23 @@ */ import {NgModule} from '@angular/core'; +import {CdkAutoSizeVirtualScroll} from './auto-size-virtual-scroll'; +import {CdkFixedSizeVirtualScroll} from './fixed-size-virtual-scroll'; import {CdkVirtualForOf} from './virtual-for-of'; -import {CdkVirtualScrollFixedSize} from './virtual-scroll-fixed-size'; import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; @NgModule({ exports: [ + CdkAutoSizeVirtualScroll, + CdkFixedSizeVirtualScroll, CdkVirtualForOf, - CdkVirtualScrollFixedSize, CdkVirtualScrollViewport, ], declarations: [ + CdkAutoSizeVirtualScroll, + CdkFixedSizeVirtualScroll, CdkVirtualForOf, - CdkVirtualScrollFixedSize, CdkVirtualScrollViewport, ], }) diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.html b/src/demo-app/virtual-scroll/virtual-scroll-demo.html index afae552a0678..c77bbd2b5cfb 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.html +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.html @@ -1,12 +1,32 @@ - -
+

Autosize

+ + +
+ Item #{{i}} - ({{size}}px) +
+
+ + +
+ Item #{{i}} - ({{size}}px) +
+
+ +

Fixed size

+ + +
Item #{{i}} - ({{size}}px)
- -
+
Item #{{i}} - ({{size}}px)
diff --git a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts index 9df25f7ef301..2f09db639df6 100644 --- a/src/demo-app/virtual-scroll/virtual-scroll-demo.ts +++ b/src/demo-app/virtual-scroll/virtual-scroll-demo.ts @@ -16,5 +16,6 @@ import {Component, ViewEncapsulation} from '@angular/core'; encapsulation: ViewEncapsulation.None, }) export class VirtualScrollDemo { - data = Array(10000).fill(20); + fixedSizeData = Array(10000).fill(50); + randomData = Array(10000).fill(0).map(() => Math.round(Math.random() * 100)); }