From d5426779758fd1820bf88bb58f967af1d3fe6555 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 16 Jul 2026 22:33:18 +0900 Subject: [PATCH 1/2] fix dynamic virtual scroll geometry --- projects/scroll-strategies/README.md | 11 +- ...namic-size-virtual-scroll-strategy.spec.ts | 580 +++++++----------- .../dynamic-size-virtual-scroll-strategy.ts | 314 ++++++---- 3 files changed, 410 insertions(+), 495 deletions(-) diff --git a/projects/scroll-strategies/README.md b/projects/scroll-strategies/README.md index e92870d..708b98d 100644 --- a/projects/scroll-strategies/README.md +++ b/projects/scroll-strategies/README.md @@ -14,8 +14,9 @@ This is a simple coding concept: Use `[itemDynamicSizes]` directive instead of `[itemSize]` or `[autosize]` directive. `[itemDynamicSizes]` value's type is `itemDynamicSize[]`. -This library is based largely on this blog: https://dev.to/georgii/virtual-scrolling-of-content-with-variable-height-with-angular-3a52 +Every data item must have one corresponding `itemDynamicSizes` entry in the same order. Each `itemSize` must be a finite number greater than zero. If Angular updates the data and size signals in separate turns, the strategy keeps the last complete geometry until their lengths match; it never estimates unknown heights. +This library is based largely on this blog: https://dev.to/georgii/virtual-scrolling-of-content-with-variable-height-with-angular-3a52 ## Installation @@ -67,7 +68,6 @@ Other than this, it works the same way as `@angular/cdk/scrolling`. - Demo: https://rdlabo-ionic-angular-library.netlify.app/main/scroll-strategies/advanced - Source: https://github.com/rdlabo-team/ionic-angular-library/blob/main/projects/demo/src/app/scroll-strategies/pages/scroll-advanced - ### Reverse Usage > This is a demo for reverse scrolling like WeChat. @@ -120,8 +120,10 @@ div.reverse-items { } ``` -__In Reverse Scroll, CdkVirtualScrollViewport's measureScrollOffset does not work. Please use the scrollOffset of this directive.__ -https://github.com/rdlabo-team/ionic-angular-library/blob/main/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts#L383-L386 +**In Reverse Scroll, CdkVirtualScrollViewport's measureScrollOffset does not work. Please use the scrollOffset of this directive.** +https://github.com/rdlabo-team/ionic-angular-library/blob/main/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts + +The reverse layout uses negative native `scrollTop` values. `scrollToIndex()` accepts a logical item index as usual and converts its cumulative offset to that native coordinate internally. ### Optional @@ -134,6 +136,7 @@ import { DynamicSizeVirtualScrollService } from '@rdlabo/ngx-cdk-scroll-strategi Detail is here: https://github.com/rdlabo-team/ionic-angular-library/blob/main/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.ts ## FQA + ### Why don't use `autosize` directive? `autosize` directive use average item size. This is not support "item size is changed" "item is removed". Because don't have item size cache. diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.spec.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.spec.ts index a3fd9a7..ecc65c2 100644 --- a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.spec.ts +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.spec.ts @@ -1,385 +1,215 @@ -import { calcIndex, itemDynamicSize } from './dynamic-size-virtual-scroll-strategy'; +import { ListRange } from '@angular/cdk/collections'; +import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; +import { + calcIndex, + calculateItemCountForPixelDistance, + DynamicSizeVirtualScrollStrategy, + itemDynamicSize, + sumItemSize, +} from './dynamic-size-virtual-scroll-strategy'; + +interface ViewportHarness { + viewport: CdkVirtualScrollViewport; + range: ListRange; + contentOffset: number; + totalContentSize: number; + dataLength: number; +} + +const createViewport = ( + sizes: number[], + { viewportSize = 100, scrollOffset = 0, reverse = false }: { viewportSize?: number; scrollOffset?: number; reverse?: boolean } = {}, +): ViewportHarness => { + const harness: ViewportHarness = { + viewport: undefined as unknown as CdkVirtualScrollViewport, + range: { start: 0, end: 0 }, + contentOffset: 0, + totalContentSize: 0, + dataLength: sizes.length, + }; + harness.viewport = { + getDataLength: () => harness.dataLength, + getViewportSize: () => viewportSize, + getRenderedRange: () => harness.range, + measureScrollOffset: () => scrollOffset, + getElementRef: () => ({ nativeElement: { scrollTop: reverse ? -scrollOffset : scrollOffset } }), + setRenderedRange: (range: ListRange) => (harness.range = { ...range }), + setRenderedContentOffset: (offset: number) => (harness.contentOffset = offset), + setTotalContentSize: (size: number) => (harness.totalContentSize = size), + scrollToOffset: vi.fn(), + checkViewportSize: vi.fn(), + } as unknown as CdkVirtualScrollViewport; + return harness; +}; + +const items = (...sizes: number[]): itemDynamicSize[] => sizes.map((itemSize) => ({ itemSize })); describe('DynamicSizeVirtualScrollStrategy', () => { - const dynamicSizes: itemDynamicSize[] = [...Array(100)].map((_, i) => ({ - itemSize: i, - trackId: i, - })); - - it('calcIndex', () => { - expect(calcIndex(dynamicSizes, 10, 5)).toEqual(0.833333333333333); - expect(calcIndex(dynamicSizes, 100)).toEqual(13.642857142857142); + it('uses cumulative pixel boundaries for the initial rendered range', () => { + const sizes = [30, 70, 20, 80, 40]; + const harness = createViewport(sizes, { viewportSize: 90 }); + const strategy = new DynamicSizeVirtualScrollStrategy(items(...sizes), 20, 50, false); + + strategy.attach(harness.viewport); + + // viewport + max buffer = 140px; P[4] = 200 is the first boundary at or after it. + expect(harness.range).toEqual({ start: 0, end: 4 }); + expect(harness.totalContentSize).toBe(240); + expect(harness.contentOffset).toBe(0); + }); + + it('selects the item beginning at an exact boundary as the first visible item', () => { + const sizes = [30, 70, 20, 80, 40, 60]; + const harness = createViewport(sizes, { viewportSize: 80, scrollOffset: 100 }); + const strategy = new DynamicSizeVirtualScrollStrategy(items(...sizes), 10, 30, false); + const indexes: number[] = []; + strategy.scrolledIndexChange.subscribe((index) => indexes.push(index)); + + strategy.attach(harness.viewport); + + expect(indexes.at(-1)).toBe(2); + expect(harness.range).toEqual({ start: 1, end: 5 }); + expect(harness.contentOffset).toBe(30); + }); + + it('keeps the existing range until a buffer falls below minBufferPx', () => { + const sizes = [50, 50, 50, 50, 50, 50, 50, 50]; + let scrollOffset = 100; + const harness = createViewport(sizes, { viewportSize: 100, scrollOffset }); + harness.viewport.measureScrollOffset = () => scrollOffset; + const strategy = new DynamicSizeVirtualScrollStrategy(items(...sizes), 50, 100, false); + + strategy.attach(harness.viewport); + expect(harness.range).toEqual({ start: 0, end: 6 }); + + scrollOffset = 149; + strategy.onContentScrolled(); + expect(harness.range).toEqual({ start: 0, end: 6 }); + + scrollOffset = 151; + strategy.onContentScrolled(); + expect(harness.range).toEqual({ start: 2, end: 8 }); + }); + + it('uses the same cumulative coordinate system for reverse scrolling', () => { + const sizes = [30, 70, 20, 80, 40, 60]; + const harness = createViewport(sizes, { viewportSize: 80, scrollOffset: 100, reverse: true }); + const strategy = new DynamicSizeVirtualScrollStrategy(items(...sizes), 10, 30, true); + + strategy.attach(harness.viewport); + + expect(strategy.measureScrollOffset).toBe(100); + expect(harness.range).toEqual({ start: 1, end: 5 }); + expect(harness.contentOffset).toBe(-30); + }); + + it('writes the inverse scrollTop coordinate when scrolling to an index in the column-reverse layout', () => { + const sizes = [30, 70, 20, 80]; + const harness = createViewport(sizes, { reverse: true }); + const strategy = new DynamicSizeVirtualScrollStrategy(items(...sizes), 10, 30, true); + strategy.attach(harness.viewport); + + strategy.scrollToIndex(2, 'smooth'); + + expect(harness.viewport.scrollToOffset).toHaveBeenCalledWith(-100, 'smooth'); }); - it('calcIndexMin', () => { - const dynamic = [ - { - itemSize: 55, - }, - { - itemSize: 55, - }, - { - itemSize: 42, - }, - ]; - expect(calcIndex(dynamic, 50)).toEqual(0); - expect(calcIndex(dynamic, 60)).toEqual(0.09090909090909091); + it('covers the viewport at every integer offset for irregular item sizes', () => { + const sizes = [17, 61, 5, 103, 29, 47]; + const prefixSums = sizes.reduce((sums, size) => [...sums, sums[sums.length - 1] + size], [0]); + const viewportSize = 47; + let scrollOffset = 0; + let firstVisibleIndex = 0; + const harness = createViewport(sizes, { viewportSize }); + harness.viewport.measureScrollOffset = () => scrollOffset; + const strategy = new DynamicSizeVirtualScrollStrategy(items(...sizes), 13, 31, false); + strategy.scrolledIndexChange.subscribe((index) => (firstVisibleIndex = index)); + strategy.attach(harness.viewport); + + for (scrollOffset = 0; scrollOffset <= prefixSums.at(-1)! - viewportSize; scrollOffset += 1) { + strategy.onContentScrolled(); + const expectedFirstVisible = sizes.findIndex((_, index) => prefixSums[index + 1] > scrollOffset); + + expect(firstVisibleIndex).toBe(expectedFirstVisible); + expect(harness.range.start).toBeLessThanOrEqual(expectedFirstVisible); + expect(harness.range.end).toBeGreaterThan(expectedFirstVisible); + expect(prefixSums[harness.range.end]).toBeGreaterThanOrEqual(scrollOffset + viewportSize); + expect(harness.contentOffset).toBe(prefixSums[harness.range.start]); + } + }); + + it('clamps a stale offset after the data shrinks', () => { + const originalSizes = items(100, 100, 100, 100, 100); + const harness = createViewport([100, 100, 100, 100, 100], { viewportSize: 100, scrollOffset: 400 }); + harness.range = { start: 3, end: 5 }; + const strategy = new DynamicSizeVirtualScrollStrategy(originalSizes, 20, 50, false); + + strategy.attach(harness.viewport); + harness.dataLength = 2; + strategy.onDataLengthChanged(); + + // The incomplete model does not replace the last exact geometry. + expect(harness.totalContentSize).toBe(500); + expect(harness.range).toEqual({ start: 3, end: 5 }); + + strategy.updateItemAndBufferSize(items(100, 100), 20, 50, false); + + expect(strategy.measureScrollOffset).toBe(100); + expect(harness.range).toEqual({ start: 0, end: 2 }); + }); + + it('renders one bootstrap item while the size model is temporarily empty', () => { + const harness = createViewport([100, 100]); + const strategy = new DynamicSizeVirtualScrollStrategy([], 20, 50, false); + + strategy.attach(harness.viewport); + + expect(harness.range).toEqual({ start: 0, end: 1 }); + expect(harness.totalContentSize).toBe(0); + }); + + it('retains the last complete geometry while data and size signals have different lengths', () => { + const harness = createViewport([50, 50, 50], { viewportSize: 50 }); + const strategy = new DynamicSizeVirtualScrollStrategy(items(50, 50, 50), 10, 20, false); + strategy.attach(harness.viewport); + const completeRange = { ...harness.range }; + + harness.dataLength = 4; + strategy.onDataLengthChanged(); + + expect(harness.totalContentSize).toBe(150); + expect(harness.range).toEqual(completeRange); + expect(harness.viewport.scrollToOffset).not.toHaveBeenCalled(); + + strategy.updateItemAndBufferSize(items(50, 50, 50, 80), 10, 20, false); + expect(harness.totalContentSize).toBe(230); + }); + + it('rejects sizes that cannot form a strictly increasing prefix sum', () => { + expect(() => new DynamicSizeVirtualScrollStrategy(items(50, 0), 20, 50, false)).toThrow(/index 1/); + expect(() => new DynamicSizeVirtualScrollStrategy(items(50), -1, 50, false)).toThrow(/buffers/); + }); +}); + +describe('size helpers', () => { + it('sums complete items before an index', () => { + expect(sumItemSize(items(30, 70, 20), 2)).toBe(100); + expect(sumItemSize(items(30, 70, 20), 99)).toBe(120); }); - it('calcReverseIndex', () => { - expect(calcIndex(dynamicSizes, 10, 2, true)).toEqual(2); - expect(calcIndex(dynamicSizes, 200, 30, true)).toEqual(6.818181818181813); + it('preserves the legacy calcIndex results for existing consumers', () => { + const sizes = items(55, 55, 42); + expect(calcIndex(sizes, 50)).toBe(0); + expect(calcIndex(sizes, 60)).toBe(0.09090909090909091); }); - it('RangeChangeStartHasTrouble', () => { - const dynamic = [ - { - itemSize: 55, - trackId: 5803450, - source: 'cache', - }, - { - itemSize: 55, - trackId: 5803467, - source: 'cache', - }, - { - itemSize: 42, - trackId: 5803578, - source: 'cache', - }, - { - itemSize: 42, - trackId: 5803580, - source: 'cache', - }, - { - itemSize: 42, - trackId: 5803582, - source: 'cache', - }, - { - itemSize: 42, - trackId: 5803584, - source: 'cache', - }, - { - itemSize: 42, - trackId: 5806334, - source: 'cache', - }, - { - itemSize: 42, - trackId: 5806335, - source: 'cache', - }, - { - itemSize: 50, - trackId: 5810037, - source: 'cache', - }, - { - itemSize: 50, - trackId: 5810038, - source: 'cache', - }, - { - itemSize: 42, - trackId: 5810441, - source: 'cache', - }, - { - itemSize: 42, - trackId: 5810801, - source: 'cache', - }, - { - itemSize: 42, - trackId: 5811405, - source: 'cache', - }, - { - itemSize: 248, - trackId: 8548047, - source: 'cache', - }, - { - itemSize: 47, - trackId: 8548161, - source: 'cache', - }, - { - itemSize: 256, - trackId: 8549402, - source: 'cache', - }, - { - itemSize: 400, - trackId: 8549909, - source: 'cache', - }, - { - itemSize: 47, - trackId: 8552783, - source: 'cache', - }, - { - itemSize: 47, - trackId: 8552784, - source: 'cache', - }, - { - itemSize: 42, - trackId: 8552795, - source: 'cache', - }, - { - itemSize: 47, - trackId: 8602039, - source: 'cache', - }, - { - itemSize: 47, - trackId: 8602043, - source: 'cache', - }, - { - itemSize: 47, - trackId: 8602049, - source: 'cache', - }, - { - itemSize: 47, - trackId: 8602061, - source: 'cache', - }, - { - itemSize: 47, - trackId: 8602081, - source: 'cache', - }, - { - itemSize: 47, - trackId: 8602098, - source: 'cache', - }, - { - itemSize: 47, - trackId: 8602289, - source: 'cache', - }, - { - itemSize: 50, - trackId: 8604018, - source: 'cache', - }, - { - itemSize: 47, - trackId: 8604027, - source: 'cache', - }, - { - itemSize: 55, - trackId: 8604034, - source: 'cache', - }, - { - itemSize: 47, - trackId: 8606476, - source: 'cache', - }, - { - itemSize: 55, - trackId: 8606490, - source: 'cache', - }, - { - itemSize: 47, - trackId: 8606497, - source: 'cache', - }, - { - itemSize: 256, - trackId: 8606501, - source: 'cache', - }, - { - itemSize: 55, - trackId: 8606504, - source: 'cache', - }, - { - itemSize: 59, - trackId: 8606514, - source: 'cache', - }, - { - itemSize: 55, - trackId: 8606518, - source: 'cache', - }, - { - itemSize: 59, - trackId: 8607015, - source: 'cache', - }, - { - itemSize: 50, - trackId: 8607062, - source: 'cache', - }, - { - itemSize: 248, - trackId: 9987383, - source: 'cache', - }, - { - itemSize: 59, - trackId: 9987384, - source: 'cache', - }, - { - itemSize: 59, - trackId: 9987385, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987386, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987387, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987388, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987389, - source: 'cache', - }, - { - itemSize: 90, - trackId: 9987390, - source: 'cache', - }, - { - itemSize: 96, - trackId: 9987395, - source: 'cache', - }, - { - itemSize: 96, - trackId: 9987396, - source: 'cache', - }, - { - itemSize: 96, - trackId: 9987397, - source: 'cache', - }, - { - itemSize: 280, - trackId: 9987419, - source: 'cache', - }, - { - itemSize: 66, - trackId: 9987420, - source: 'cache', - }, - { - itemSize: 208, - trackId: 9987421, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987422, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987423, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987424, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987425, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987426, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987427, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987428, - source: 'cache', - }, - { - itemSize: 116, - trackId: 9987429, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987430, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987431, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987432, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987433, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987434, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987435, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987436, - source: 'cache', - }, - { - itemSize: 47, - trackId: 9987437, - source: 'cache', - }, - ]; - expect(calcIndex(dynamic, 1610, 50.95357142856153)).toEqual(18.04642857143847); + it('converts pixels to a mathematically continuous item count through the explicit helper', () => { + const sizes = items(30, 70, 20); + expect(calculateItemCountForPixelDistance(sizes, 0)).toBe(0); + expect(calculateItemCountForPixelDistance(sizes, 30)).toBe(1); + expect(calculateItemCountForPixelDistance(sizes, 65)).toBe(1.5); + expect(calculateItemCountForPixelDistance(sizes, 100)).toBe(2); + expect(calculateItemCountForPixelDistance(sizes, 10, 2)).toBe(0.5); + expect(calculateItemCountForPixelDistance(sizes, 10, 2, true)).toBeCloseTo(1 / 7); }); }); diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts index 431e7fc..d0b4f1a 100644 --- a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts @@ -9,7 +9,7 @@ import { Observable, Subject } from 'rxjs'; import { distinctUntilChanged } from 'rxjs/operators'; import { CdkVirtualScrollViewport, VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy } from '@angular/cdk/scrolling'; -/** Virtual scrolling strategy for lists with items of known fixed size. */ +/** Virtual scrolling strategy for lists whose item sizes are known in advance. */ export class DynamicSizeVirtualScrollStrategy implements VirtualScrollStrategy { private readonly _scrolledIndexChange = new Subject(); @@ -22,6 +22,10 @@ export class DynamicSizeVirtualScrollStrategy implements VirtualScrollStrategy { /** The size of the items in the virtually scrolling list. */ private _itemDynamicSize: itemDynamicSize[]; + /** Cumulative item boundaries. Rebuilt only when the size model or data length changes. */ + private _prefixSums: number[] = [0]; + private _prefixDataLength = -1; + /** The minimum amount of buffer rendered beyond the viewport (in pixels). */ private _minBufferPx: number; @@ -30,10 +34,9 @@ export class DynamicSizeVirtualScrollStrategy implements VirtualScrollStrategy { /** This is added for reverse virtual scroll **/ private _isReverse: boolean; - measureScrollOffset = 0; - /** This is added for change dataLength **/ - private _latestDataLength = 0; + /** Last normalized scroll offset, exposed because CDK cannot measure the reverse layout. */ + measureScrollOffset = 0; /** * @param itemSize The size of the items in the virtually scrolling list. @@ -42,6 +45,7 @@ export class DynamicSizeVirtualScrollStrategy implements VirtualScrollStrategy { * @param isReverse Added from rdlabo for reverse */ constructor(itemSize: itemDynamicSize[], minBufferPx: number, maxBufferPx: number, isReverse: boolean) { + validateConfiguration(itemSize, minBufferPx, maxBufferPx); this._itemDynamicSize = itemSize; this._minBufferPx = minBufferPx; this._maxBufferPx = maxBufferPx; @@ -72,10 +76,9 @@ export class DynamicSizeVirtualScrollStrategy implements VirtualScrollStrategy { * @param isReverse */ updateItemAndBufferSize(itemDynamicSize: itemDynamicSize[], minBufferPx: number, maxBufferPx: number, isReverse: boolean) { - if (maxBufferPx < minBufferPx) { - throw Error('CDK virtual scroll: maxBufferPx must be greater than or equal to minBufferPx'); - } + validateConfiguration(itemDynamicSize, minBufferPx, maxBufferPx); this._itemDynamicSize = itemDynamicSize; + this._prefixDataLength = -1; this._minBufferPx = minBufferPx; this._maxBufferPx = maxBufferPx; this._isReverse = isReverse; @@ -111,8 +114,13 @@ export class DynamicSizeVirtualScrollStrategy implements VirtualScrollStrategy { */ scrollToIndex(index: number, behavior: ScrollBehavior): void { if (this._viewport) { - const size = sumItemSize(this._itemDynamicSize, index); - this._viewport.scrollToOffset(size, behavior); + if (!this._hasCompleteSizeModel(this._viewport.getDataLength())) { + return; + } + const prefixSums = this._getPrefixSums(this._viewport.getDataLength()); + const boundedIndex = Math.min(prefixSums.length - 1, Math.max(0, Math.trunc(index))); + const offset = prefixSums[boundedIndex]; + this._viewport.scrollToOffset(this._isReverse ? -offset : offset, behavior); } } @@ -122,8 +130,17 @@ export class DynamicSizeVirtualScrollStrategy implements VirtualScrollStrategy { return; } - const size = sumItemSize(this._itemDynamicSize, this._viewport.getDataLength()); - this._viewport.setTotalContentSize(size); + const dataLength = this._viewport.getDataLength(); + if (dataLength === 0) { + this._viewport.setTotalContentSize(0); + return; + } + if (!this._hasCompleteSizeModel(dataLength)) { + return; + } + + const prefixSums = this._getPrefixSums(dataLength); + this._viewport.setTotalContentSize(prefixSums[prefixSums.length - 1]); } /** Update the viewport's rendered range. */ @@ -142,117 +159,80 @@ export class DynamicSizeVirtualScrollStrategy implements VirtualScrollStrategy { const viewportSize = this._viewport.getViewportSize(); const dataLength = this._viewport.getDataLength(); - // Reverse offset if _isReverse - let scrollOffset = !this._isReverse - ? this._viewport.measureScrollOffset() - : Math.max(0, this._viewport.getElementRef().nativeElement.scrollTop * -1); - - // let firstVisibleIndex = this._itemDynamicSize > 0 ? scrollOffset / this._itemDynamicSize : 0; - let firstVisibleIndex = this._itemDynamicSize.length > 0 ? Math.floor(calcIndex(this._itemDynamicSize, scrollOffset)) : 0; - - // If user scrolls to the bottom of the list and data changes to a smaller list - if (newRange.end > dataLength) { - // We have to recalculate the first visible index based on new data length and viewport size. - const maxVisibleItems = Math.ceil(calcIndex(this._itemDynamicSize, viewportSize)); - const newVisibleIndex = Math.max(0, Math.min(firstVisibleIndex, dataLength - maxVisibleItems)); - - // If first visible index changed we must update scroll offset to handle start/end buffers - // Current range must also be adjusted to cover the new position (bottom of new list). - if (firstVisibleIndex != newVisibleIndex) { - firstVisibleIndex = newVisibleIndex; - // scrollOffset = newVisibleIndex * this._itemDynamicSize; - scrollOffset = sumItemSize(this._itemDynamicSize, newVisibleIndex); - newRange.start = Math.floor(firstVisibleIndex); - } - - newRange.end = Math.max(0, Math.min(dataLength, newRange.start + maxVisibleItems)); - } - - // const startBuffer = scrollOffset - newRange.start * this._itemDynamicSize; - const startBuffer = scrollOffset - sumItemSize(this._itemDynamicSize, newRange.start); - if (startBuffer < this._minBufferPx && newRange.start != 0) { - // const expandStart = Math.ceil((this._maxBufferPx - startBuffer) / this._itemDynamicSize); - const expandStart = Math.ceil(calcIndex(this._itemDynamicSize, this._maxBufferPx - startBuffer, newRange.start, true)); - newRange.start = Math.max(0, newRange.start - expandStart); - // newRange.end = Math.min(dataLength, Math.ceil(firstVisibleIndex + (viewportSize + this._minBufferPx) / this._itemDynamicSize)); - newRange.end = Math.min( - dataLength, - Math.ceil(firstVisibleIndex + calcIndex(this._itemDynamicSize, viewportSize + this._minBufferPx, firstVisibleIndex)) + 1, // firstVisibleIndexを削った影響 - ); - - // console.log( - // 'expandStart', - // firstVisibleIndex, - // '"' + newRange.start + '-' + newRange.end + '"', - // dataLength, - // viewportSize + this._minBufferPx, - // Math.ceil(firstVisibleIndex + calcIndex(this._itemDynamicSize, viewportSize + this._minBufferPx, firstVisibleIndex)), - // ); - } else { - // const endBuffer = newRange.end * this._itemSize - (scrollOffset + viewportSize); - const endBuffer = Math.max(sumItemSize(this._itemDynamicSize, newRange.end) - (scrollOffset + viewportSize), 0); - if (endBuffer < this._minBufferPx && newRange.end != dataLength) { - // const expandEnd = Math.ceil((this._maxBufferPx - endBuffer) / this._itemDynamicSize); - const expandEnd = Math.ceil(calcIndex(this._itemDynamicSize, this._maxBufferPx - endBuffer, newRange.end)); - - if (expandEnd > 0) { - newRange.end = Math.min(dataLength, newRange.end + expandEnd); - // Math.floor(firstVisibleIndex - this._minBufferPx / this._itemSize), - newRange.start = Math.max( - 0, - Math.floor(firstVisibleIndex - calcIndex(this._itemDynamicSize, this._minBufferPx, firstVisibleIndex, true)), - ); - // console.log( - // 'expandEnd', - // firstVisibleIndex, - // '"' + newRange.start + '-' + newRange.end + '"', - // dataLength, - // Math.ceil(firstVisibleIndex + calcIndex(this._itemDynamicSize, viewportSize + this._minBufferPx, firstVisibleIndex)), - // ); - } - } + if (dataLength === 0) { + this._viewport.setRenderedRange({ start: 0, end: 0 }); + this._viewport.setRenderedContentOffset(0); + this.measureScrollOffset = 0; + this._scrolledIndexChange.next(0); + return; } - if (newRange.start === 0 && newRange.end === 0 && dataLength > 0) { - // Have items but not rendered yet. - // This can happen when itemDynamicSizes is still empty while dataLength is already available. - // Use the same policy as the initial render in fixed-size virtual scroll: viewport + maxBuffer. - if (this._itemDynamicSize.length === 0) { - newRange.end = Math.min(dataLength, 1); - } else { - const initialEnd = Math.ceil(calcIndex(this._itemDynamicSize, viewportSize + this._maxBufferPx, 0)) + 1; - newRange.end = Math.min(dataLength, Math.max(1, initialEnd)); + // Angular can update cdkVirtualForOf and itemDynamicSizes in separate turns. A partial size + // model cannot define an exact total height or offset, so retain the last complete geometry + // until both lengths agree. On first attachment, render one item so a measuring consumer can + // obtain its initial size. + if (!this._hasCompleteSizeModel(dataLength)) { + if (newRange.start === 0 && newRange.end === 0) { + this._viewport.setRenderedRange({ start: 0, end: 1 }); + this._viewport.setRenderedContentOffset(0); } + return; } - if (firstVisibleIndex === 0 && newRange.start > firstVisibleIndex) { - // This is bug fix. If newRange.start > firstVisibleIndex, can't visible '0' - newRange.start = firstVisibleIndex; - } + const prefixSums = this._getPrefixSums(dataLength); + const totalContentSize = prefixSums[dataLength]; - if (newRange.start > newRange.end) { - // This is bug fix. If newRange.start > newRange.end, it will cause infinite loop. - newRange.end = Math.min( - dataLength, - Math.ceil(newRange.start + calcIndex(this._itemDynamicSize, viewportSize + this._minBufferPx, newRange.start)), - ); + // Reverse offset if _isReverse + const measuredScrollOffset = !this._isReverse + ? this._viewport.measureScrollOffset() + : Math.max(0, this._viewport.getElementRef().nativeElement.scrollTop * -1); + // The browser clamps the actual offset to this interval. Applying the same clamp immediately + // also makes a data-length shrink deterministic before the next native scroll event. + const scrollOffset = Math.min(Math.max(0, measuredScrollOffset), Math.max(0, totalContentSize - viewportSize)); + const firstVisibleIndex = indexAtOffset(prefixSums, scrollOffset); + + const rangeIsInvalid = newRange.start < 0 || newRange.start >= newRange.end || newRange.end > dataLength; + if (rangeIsInvalid) { + setRangeForBuffer(newRange, prefixSums, scrollOffset, viewportSize, this._maxBufferPx); + } else { + const startBuffer = scrollOffset - prefixSums[newRange.start]; + const endBuffer = prefixSums[newRange.end] - (scrollOffset + viewportSize); + + if (startBuffer < this._minBufferPx && newRange.start > 0) { + newRange.start = startIndexForOffset(prefixSums, scrollOffset - this._maxBufferPx); + newRange.end = endIndexForOffset(prefixSums, scrollOffset + viewportSize + this._minBufferPx); + } else if (endBuffer < this._minBufferPx && newRange.end < dataLength) { + newRange.end = endIndexForOffset(prefixSums, scrollOffset + viewportSize + this._maxBufferPx); + newRange.start = startIndexForOffset(prefixSums, scrollOffset - this._minBufferPx); + } } this._viewport.setRenderedRange(newRange); - // this._viewport.setRenderedContentOffset(this._itemDynamicSize * newRange.start); if (!this._isReverse) { - this._viewport.setRenderedContentOffset(sumItemSize(this._itemDynamicSize, newRange.start)); + this._viewport.setRenderedContentOffset(prefixSums[newRange.start]); } else { - let offset = Math.min(0, sumItemSize(this._itemDynamicSize, newRange.start) * -1); + let offset = Math.min(0, prefixSums[newRange.start] * -1); if (offset === 0) { offset = 0; } this._viewport.setRenderedContentOffset(offset); } this.measureScrollOffset = scrollOffset; - this._latestDataLength = dataLength; this._scrolledIndexChange.next(firstVisibleIndex); } + + private _getPrefixSums(dataLength: number): number[] { + const modeledLength = dataLength; + if (modeledLength !== this._prefixDataLength) { + this._prefixSums = createPrefixSums(this._itemDynamicSize, modeledLength); + this._prefixDataLength = modeledLength; + } + return this._prefixSums; + } + + private _hasCompleteSizeModel(dataLength: number): boolean { + return this._itemDynamicSize.length === dataLength; + } } /** @@ -265,29 +245,27 @@ export function _dynamicSizeVirtualScrollStrategyFactory(fixedSizeDir: CdkDynami return fixedSizeDir._scrollStrategy; } +/** Describes the exact pixel size of an item and any consumer-defined tracking metadata. */ export type itemDynamicSize = { itemSize: number } & Record; +/** Returns the cumulative size of all items before `endIndex`. */ export const sumItemSize = (dynamicSize: itemDynamicSize[], endIndex: number): number => { return dynamicSize.slice(0, endIndex).reduce((acc, item) => acc + item.itemSize, 0); }; /** - * TODO: 計算方法の見直し - * 0.5 = 0個目の半分はマイナスの計算が生まれるため、1個目からカウント + * Retains the package's legacy pixel-to-index calculation for compatibility. + * @deprecated Use `calculateItemCountForPixelDistance` for mathematically continuous results. */ export const calcIndex = (dynamicSize: itemDynamicSize[], itemSizeRange: number, startIndex = 0, isReverse = false): number => { let sum = 0; let diffIndex = 0; - const item = isReverse ? structuredClone(dynamicSize).reverse() : dynamicSize; + const item = isReverse ? [...dynamicSize].reverse() : dynamicSize; if (isReverse) { startIndex = dynamicSize.length - startIndex; } - const calcIndex = item.reduce((acc, currentValue, index) => { - if (index < startIndex) { - return acc; - } - if (acc !== -1) { - // 既に見つかった場合、何もしない + const calculatedIndex = item.reduce((acc, currentValue, index) => { + if (index < startIndex || acc !== -1) { return acc; } sum += currentValue.itemSize; @@ -295,20 +273,124 @@ export const calcIndex = (dynamicSize: itemDynamicSize[], itemSizeRange: number, if (sum === itemSizeRange) { return index - startIndex; } - const diff = sum - itemSizeRange; const getOver = item[index].itemSize - diff; diffIndex = getOver / item[index].itemSize; return Math.max(0, index - 1 + diffIndex - startIndex); - } else if (index === item.length - 1) { + } + if (index === item.length - 1) { return index - startIndex + 1; } return acc; }, -1); - return calcIndex === -1 ? 0 : calcIndex; + return calculatedIndex === -1 ? 0 : calculatedIndex; +}; + +/** + * Converts a pixel distance into an exact fractional item count. + * + * Forward measurement starts at `startIndex`. Reverse measurement starts immediately before + * `startIndex`, matching the items that precede a rendered range. The result is bounded by the + * number of available items in that direction. + */ +export const calculateItemCountForPixelDistance = ( + dynamicSize: itemDynamicSize[], + itemSizeRange: number, + startIndex = 0, + isReverse = false, +): number => { + let remaining = Math.max(0, itemSizeRange); + let count = 0; + let index = isReverse ? Math.min(dynamicSize.length, Math.max(0, Math.trunc(startIndex))) - 1 : Math.max(0, Math.trunc(startIndex)); + const step = isReverse ? -1 : 1; + + while (index >= 0 && index < dynamicSize.length && remaining > 0) { + const size = dynamicSize[index].itemSize; + if (remaining < size) { + return count + remaining / size; + } + remaining -= size; + count += 1; + index += step; + } + + return count; +}; + +const createPrefixSums = (dynamicSize: itemDynamicSize[], length: number): number[] => { + const prefixSums = new Array(length + 1); + prefixSums[0] = 0; + for (let index = 0; index < length; index += 1) { + prefixSums[index + 1] = prefixSums[index] + dynamicSize[index].itemSize; + } + return prefixSums; +}; + +const lowerBound = (values: number[], target: number): number => { + let low = 0; + let high = values.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (values[middle] < target) { + low = middle + 1; + } else { + high = middle; + } + } + return low; +}; + +const upperBound = (values: number[], target: number): number => { + let low = 0; + let high = values.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (values[middle] <= target) { + low = middle + 1; + } else { + high = middle; + } + } + return low; +}; + +const indexAtOffset = (prefixSums: number[], offset: number): number => { + const itemCount = prefixSums.length - 1; + return Math.min(itemCount - 1, Math.max(0, upperBound(prefixSums, offset) - 1)); +}; + +const startIndexForOffset = (prefixSums: number[], offset: number): number => { + const itemCount = prefixSums.length - 1; + return Math.min(itemCount - 1, Math.max(0, upperBound(prefixSums, offset) - 1)); +}; + +const endIndexForOffset = (prefixSums: number[], offset: number): number => { + return Math.min(prefixSums.length - 1, Math.max(1, lowerBound(prefixSums, offset))); +}; + +const setRangeForBuffer = ( + range: { start: number; end: number }, + prefixSums: number[], + scrollOffset: number, + viewportSize: number, + bufferPx: number, +): void => { + range.start = startIndexForOffset(prefixSums, scrollOffset - bufferPx); + range.end = endIndexForOffset(prefixSums, scrollOffset + viewportSize + bufferPx); +}; + +const validateConfiguration = (dynamicSize: itemDynamicSize[], minBufferPx: number, maxBufferPx: number): void => { + if (!Number.isFinite(minBufferPx) || minBufferPx < 0 || !Number.isFinite(maxBufferPx) || maxBufferPx < minBufferPx) { + throw Error('CDK virtual scroll: buffers must be finite, non-negative, and maxBufferPx must be greater than or equal to minBufferPx'); + } + dynamicSize.forEach(({ itemSize }, index) => { + if (!Number.isFinite(itemSize) || itemSize <= 0) { + throw Error(`CDK virtual scroll: item size at index ${index} must be a finite number greater than zero`); + } + }); }; -/** A virtual scroll strategy that supports fixed-size items. */ +/** Directive that installs dynamic-size virtual scrolling on a CDK viewport. */ @Directive({ selector: 'cdk-virtual-scroll-viewport[itemDynamicSizes]', providers: [ From b831f71e1eb6f5e3ece1133fd1dd17ea2f613936 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 16 Jul 2026 22:49:46 +0900 Subject: [PATCH 2/2] refactor virtual scroll utilities --- ...namic-size-virtual-scroll-strategy.spec.ts | 32 +--- .../dynamic-size-virtual-scroll-strategy.ts | 154 +----------------- .../dynamic-size-virtual-scroll.service.ts | 2 +- .../dynamic-size-virtual-scroll.util.spec.ts | 54 ++++++ .../lib/dynamic-size-virtual-scroll.util.ts | 150 +++++++++++++++++ projects/scroll-strategies/src/public-api.ts | 1 + 6 files changed, 217 insertions(+), 176 deletions(-) create mode 100644 projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.spec.ts create mode 100644 projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.ts diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.spec.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.spec.ts index ecc65c2..201478d 100644 --- a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.spec.ts +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.spec.ts @@ -1,12 +1,7 @@ import { ListRange } from '@angular/cdk/collections'; import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; -import { - calcIndex, - calculateItemCountForPixelDistance, - DynamicSizeVirtualScrollStrategy, - itemDynamicSize, - sumItemSize, -} from './dynamic-size-virtual-scroll-strategy'; +import { DynamicSizeVirtualScrollStrategy } from './dynamic-size-virtual-scroll-strategy'; +import { itemDynamicSize } from './dynamic-size-virtual-scroll.util'; interface ViewportHarness { viewport: CdkVirtualScrollViewport; @@ -190,26 +185,3 @@ describe('DynamicSizeVirtualScrollStrategy', () => { expect(() => new DynamicSizeVirtualScrollStrategy(items(50), -1, 50, false)).toThrow(/buffers/); }); }); - -describe('size helpers', () => { - it('sums complete items before an index', () => { - expect(sumItemSize(items(30, 70, 20), 2)).toBe(100); - expect(sumItemSize(items(30, 70, 20), 99)).toBe(120); - }); - - it('preserves the legacy calcIndex results for existing consumers', () => { - const sizes = items(55, 55, 42); - expect(calcIndex(sizes, 50)).toBe(0); - expect(calcIndex(sizes, 60)).toBe(0.09090909090909091); - }); - - it('converts pixels to a mathematically continuous item count through the explicit helper', () => { - const sizes = items(30, 70, 20); - expect(calculateItemCountForPixelDistance(sizes, 0)).toBe(0); - expect(calculateItemCountForPixelDistance(sizes, 30)).toBe(1); - expect(calculateItemCountForPixelDistance(sizes, 65)).toBe(1.5); - expect(calculateItemCountForPixelDistance(sizes, 100)).toBe(2); - expect(calculateItemCountForPixelDistance(sizes, 10, 2)).toBe(0.5); - expect(calculateItemCountForPixelDistance(sizes, 10, 2, true)).toBeCloseTo(1 / 7); - }); -}); diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts index d0b4f1a..8b68858 100644 --- a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts @@ -8,6 +8,15 @@ import { Directive, effect, ElementRef, forwardRef, inject, input } from '@angul import { Observable, Subject } from 'rxjs'; import { distinctUntilChanged } from 'rxjs/operators'; import { CdkVirtualScrollViewport, VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy } from '@angular/cdk/scrolling'; +import { + createPrefixSums, + endIndexForOffset, + indexAtOffset, + itemDynamicSize, + setRangeForBuffer, + startIndexForOffset, + validateConfiguration, +} from './dynamic-size-virtual-scroll.util'; /** Virtual scrolling strategy for lists whose item sizes are known in advance. */ export class DynamicSizeVirtualScrollStrategy implements VirtualScrollStrategy { @@ -245,151 +254,6 @@ export function _dynamicSizeVirtualScrollStrategyFactory(fixedSizeDir: CdkDynami return fixedSizeDir._scrollStrategy; } -/** Describes the exact pixel size of an item and any consumer-defined tracking metadata. */ -export type itemDynamicSize = { itemSize: number } & Record; - -/** Returns the cumulative size of all items before `endIndex`. */ -export const sumItemSize = (dynamicSize: itemDynamicSize[], endIndex: number): number => { - return dynamicSize.slice(0, endIndex).reduce((acc, item) => acc + item.itemSize, 0); -}; - -/** - * Retains the package's legacy pixel-to-index calculation for compatibility. - * @deprecated Use `calculateItemCountForPixelDistance` for mathematically continuous results. - */ -export const calcIndex = (dynamicSize: itemDynamicSize[], itemSizeRange: number, startIndex = 0, isReverse = false): number => { - let sum = 0; - let diffIndex = 0; - const item = isReverse ? [...dynamicSize].reverse() : dynamicSize; - if (isReverse) { - startIndex = dynamicSize.length - startIndex; - } - const calculatedIndex = item.reduce((acc, currentValue, index) => { - if (index < startIndex || acc !== -1) { - return acc; - } - sum += currentValue.itemSize; - if (sum >= itemSizeRange) { - if (sum === itemSizeRange) { - return index - startIndex; - } - const diff = sum - itemSizeRange; - const getOver = item[index].itemSize - diff; - diffIndex = getOver / item[index].itemSize; - return Math.max(0, index - 1 + diffIndex - startIndex); - } - if (index === item.length - 1) { - return index - startIndex + 1; - } - return acc; - }, -1); - return calculatedIndex === -1 ? 0 : calculatedIndex; -}; - -/** - * Converts a pixel distance into an exact fractional item count. - * - * Forward measurement starts at `startIndex`. Reverse measurement starts immediately before - * `startIndex`, matching the items that precede a rendered range. The result is bounded by the - * number of available items in that direction. - */ -export const calculateItemCountForPixelDistance = ( - dynamicSize: itemDynamicSize[], - itemSizeRange: number, - startIndex = 0, - isReverse = false, -): number => { - let remaining = Math.max(0, itemSizeRange); - let count = 0; - let index = isReverse ? Math.min(dynamicSize.length, Math.max(0, Math.trunc(startIndex))) - 1 : Math.max(0, Math.trunc(startIndex)); - const step = isReverse ? -1 : 1; - - while (index >= 0 && index < dynamicSize.length && remaining > 0) { - const size = dynamicSize[index].itemSize; - if (remaining < size) { - return count + remaining / size; - } - remaining -= size; - count += 1; - index += step; - } - - return count; -}; - -const createPrefixSums = (dynamicSize: itemDynamicSize[], length: number): number[] => { - const prefixSums = new Array(length + 1); - prefixSums[0] = 0; - for (let index = 0; index < length; index += 1) { - prefixSums[index + 1] = prefixSums[index] + dynamicSize[index].itemSize; - } - return prefixSums; -}; - -const lowerBound = (values: number[], target: number): number => { - let low = 0; - let high = values.length; - while (low < high) { - const middle = low + Math.floor((high - low) / 2); - if (values[middle] < target) { - low = middle + 1; - } else { - high = middle; - } - } - return low; -}; - -const upperBound = (values: number[], target: number): number => { - let low = 0; - let high = values.length; - while (low < high) { - const middle = low + Math.floor((high - low) / 2); - if (values[middle] <= target) { - low = middle + 1; - } else { - high = middle; - } - } - return low; -}; - -const indexAtOffset = (prefixSums: number[], offset: number): number => { - const itemCount = prefixSums.length - 1; - return Math.min(itemCount - 1, Math.max(0, upperBound(prefixSums, offset) - 1)); -}; - -const startIndexForOffset = (prefixSums: number[], offset: number): number => { - const itemCount = prefixSums.length - 1; - return Math.min(itemCount - 1, Math.max(0, upperBound(prefixSums, offset) - 1)); -}; - -const endIndexForOffset = (prefixSums: number[], offset: number): number => { - return Math.min(prefixSums.length - 1, Math.max(1, lowerBound(prefixSums, offset))); -}; - -const setRangeForBuffer = ( - range: { start: number; end: number }, - prefixSums: number[], - scrollOffset: number, - viewportSize: number, - bufferPx: number, -): void => { - range.start = startIndexForOffset(prefixSums, scrollOffset - bufferPx); - range.end = endIndexForOffset(prefixSums, scrollOffset + viewportSize + bufferPx); -}; - -const validateConfiguration = (dynamicSize: itemDynamicSize[], minBufferPx: number, maxBufferPx: number): void => { - if (!Number.isFinite(minBufferPx) || minBufferPx < 0 || !Number.isFinite(maxBufferPx) || maxBufferPx < minBufferPx) { - throw Error('CDK virtual scroll: buffers must be finite, non-negative, and maxBufferPx must be greater than or equal to minBufferPx'); - } - dynamicSize.forEach(({ itemSize }, index) => { - if (!Number.isFinite(itemSize) || itemSize <= 0) { - throw Error(`CDK virtual scroll: item size at index ${index} must be a finite number greater than zero`); - } - }); -}; - /** Directive that installs dynamic-size virtual scrolling on a CDK viewport. */ @Directive({ selector: 'cdk-virtual-scroll-viewport[itemDynamicSizes]', diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.ts index 9610103..481e800 100644 --- a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.ts +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.ts @@ -1,6 +1,6 @@ import { computed, Injectable, Signal } from '@angular/core'; import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; -import { itemDynamicSize } from './dynamic-size-virtual-scroll-strategy'; +import { itemDynamicSize } from './dynamic-size-virtual-scroll.util'; @Injectable({ providedIn: 'root', diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.spec.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.spec.ts new file mode 100644 index 0000000..d5bef02 --- /dev/null +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.spec.ts @@ -0,0 +1,54 @@ +import { + calcIndex, + calculateItemCountForPixelDistance, + createPrefixSums, + endIndexForOffset, + indexAtOffset, + itemDynamicSize, + setRangeForBuffer, + startIndexForOffset, + sumItemSize, + validateConfiguration, +} from './dynamic-size-virtual-scroll.util'; + +const items = (...sizes: number[]): itemDynamicSize[] => sizes.map((itemSize) => ({ itemSize })); + +describe('dynamic size virtual scroll utilities', () => { + it('sums complete items before an index', () => { + expect(sumItemSize(items(30, 70, 20), 2)).toBe(100); + expect(sumItemSize(items(30, 70, 20), 99)).toBe(120); + }); + + it('preserves the legacy calcIndex results for existing consumers', () => { + const sizes = items(55, 55, 42); + expect(calcIndex(sizes, 50)).toBe(0); + expect(calcIndex(sizes, 60)).toBe(0.09090909090909091); + }); + + it('converts pixels to a mathematically continuous item count', () => { + const sizes = items(30, 70, 20); + expect(calculateItemCountForPixelDistance(sizes, 0)).toBe(0); + expect(calculateItemCountForPixelDistance(sizes, 30)).toBe(1); + expect(calculateItemCountForPixelDistance(sizes, 65)).toBe(1.5); + expect(calculateItemCountForPixelDistance(sizes, 100)).toBe(2); + expect(calculateItemCountForPixelDistance(sizes, 10, 2)).toBe(0.5); + expect(calculateItemCountForPixelDistance(sizes, 10, 2, true)).toBeCloseTo(1 / 7); + }); + + it('maps cumulative pixel boundaries to rendered indexes', () => { + const prefixSums = createPrefixSums(items(30, 70, 20, 80), 4); + expect(prefixSums).toEqual([0, 30, 100, 120, 200]); + expect(indexAtOffset(prefixSums, 100)).toBe(2); + expect(startIndexForOffset(prefixSums, 99)).toBe(1); + expect(endIndexForOffset(prefixSums, 121)).toBe(4); + + const range = { start: 0, end: 0 }; + setRangeForBuffer(range, prefixSums, 100, 20, 30); + expect(range).toEqual({ start: 1, end: 4 }); + }); + + it('rejects invalid sizes and buffers', () => { + expect(() => validateConfiguration(items(50, 0), 20, 50)).toThrow(/index 1/); + expect(() => validateConfiguration(items(50), -1, 50)).toThrow(/buffers/); + }); +}); diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.ts new file mode 100644 index 0000000..582dbf0 --- /dev/null +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.ts @@ -0,0 +1,150 @@ +/** Describes the exact pixel size of an item and any consumer-defined tracking metadata. */ +export type itemDynamicSize = { itemSize: number } & Record; + +/** Returns the cumulative size of all items before `endIndex`. */ +export const sumItemSize = (dynamicSize: itemDynamicSize[], endIndex: number): number => { + return dynamicSize.slice(0, endIndex).reduce((acc, item) => acc + item.itemSize, 0); +}; + +/** + * Retains the package's legacy pixel-to-index calculation for compatibility. + * @deprecated Use `calculateItemCountForPixelDistance` for mathematically continuous results. + */ +export const calcIndex = (dynamicSize: itemDynamicSize[], itemSizeRange: number, startIndex = 0, isReverse = false): number => { + let sum = 0; + let diffIndex = 0; + const item = isReverse ? [...dynamicSize].reverse() : dynamicSize; + if (isReverse) { + startIndex = dynamicSize.length - startIndex; + } + const calculatedIndex = item.reduce((acc, currentValue, index) => { + if (index < startIndex || acc !== -1) { + return acc; + } + sum += currentValue.itemSize; + if (sum >= itemSizeRange) { + if (sum === itemSizeRange) { + return index - startIndex; + } + const diff = sum - itemSizeRange; + const getOver = item[index].itemSize - diff; + diffIndex = getOver / item[index].itemSize; + return Math.max(0, index - 1 + diffIndex - startIndex); + } + if (index === item.length - 1) { + return index - startIndex + 1; + } + return acc; + }, -1); + return calculatedIndex === -1 ? 0 : calculatedIndex; +}; + +/** + * Converts a pixel distance into an exact fractional item count. + * + * Forward measurement starts at `startIndex`. Reverse measurement starts immediately before + * `startIndex`, matching the items that precede a rendered range. The result is bounded by the + * number of available items in that direction. + */ +export const calculateItemCountForPixelDistance = ( + dynamicSize: itemDynamicSize[], + itemSizeRange: number, + startIndex = 0, + isReverse = false, +): number => { + let remaining = Math.max(0, itemSizeRange); + let count = 0; + let index = isReverse ? Math.min(dynamicSize.length, Math.max(0, Math.trunc(startIndex))) - 1 : Math.max(0, Math.trunc(startIndex)); + const step = isReverse ? -1 : 1; + + while (index >= 0 && index < dynamicSize.length && remaining > 0) { + const size = dynamicSize[index].itemSize; + if (remaining < size) { + return count + remaining / size; + } + remaining -= size; + count += 1; + index += step; + } + + return count; +}; + +/** Builds cumulative item boundaries from zero through `length`. */ +export const createPrefixSums = (dynamicSize: itemDynamicSize[], length: number): number[] => { + const prefixSums = new Array(length + 1); + prefixSums[0] = 0; + for (let index = 0; index < length; index += 1) { + prefixSums[index + 1] = prefixSums[index] + dynamicSize[index].itemSize; + } + return prefixSums; +}; + +const lowerBound = (values: number[], target: number): number => { + let low = 0; + let high = values.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (values[middle] < target) { + low = middle + 1; + } else { + high = middle; + } + } + return low; +}; + +const upperBound = (values: number[], target: number): number => { + let low = 0; + let high = values.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (values[middle] <= target) { + low = middle + 1; + } else { + high = middle; + } + } + return low; +}; + +/** Returns the item containing `offset`, choosing the next item at an exact boundary. */ +export const indexAtOffset = (prefixSums: number[], offset: number): number => { + const itemCount = prefixSums.length - 1; + return Math.min(itemCount - 1, Math.max(0, upperBound(prefixSums, offset) - 1)); +}; + +/** Returns the earliest rendered index whose boundary supplies the requested start buffer. */ +export const startIndexForOffset = (prefixSums: number[], offset: number): number => { + const itemCount = prefixSums.length - 1; + return Math.min(itemCount - 1, Math.max(0, upperBound(prefixSums, offset) - 1)); +}; + +/** Returns the exclusive rendered end index whose boundary covers `offset`. */ +export const endIndexForOffset = (prefixSums: number[], offset: number): number => { + return Math.min(prefixSums.length - 1, Math.max(1, lowerBound(prefixSums, offset))); +}; + +/** Mutates a rendered range so it covers the viewport plus equal start and end buffers. */ +export const setRangeForBuffer = ( + range: { start: number; end: number }, + prefixSums: number[], + scrollOffset: number, + viewportSize: number, + bufferPx: number, +): void => { + range.start = startIndexForOffset(prefixSums, scrollOffset - bufferPx); + range.end = endIndexForOffset(prefixSums, scrollOffset + viewportSize + bufferPx); +}; + +/** Validates item sizes and virtual scroll buffer configuration. */ +export const validateConfiguration = (dynamicSize: itemDynamicSize[], minBufferPx: number, maxBufferPx: number): void => { + if (!Number.isFinite(minBufferPx) || minBufferPx < 0 || !Number.isFinite(maxBufferPx) || maxBufferPx < minBufferPx) { + throw Error('CDK virtual scroll: buffers must be finite, non-negative, and maxBufferPx must be greater than or equal to minBufferPx'); + } + dynamicSize.forEach(({ itemSize }, index) => { + if (!Number.isFinite(itemSize) || itemSize <= 0) { + throw Error(`CDK virtual scroll: item size at index ${index} must be a finite number greater than zero`); + } + }); +}; diff --git a/projects/scroll-strategies/src/public-api.ts b/projects/scroll-strategies/src/public-api.ts index 67ce72e..ebcc03a 100644 --- a/projects/scroll-strategies/src/public-api.ts +++ b/projects/scroll-strategies/src/public-api.ts @@ -4,3 +4,4 @@ export * from './lib/dynamic-size-virtual-scroll.service'; export * from './lib/dynamic-size-virtual-scroll-strategy'; +export { calcIndex, calculateItemCountForPixelDistance, sumItemSize, type itemDynamicSize } from './lib/dynamic-size-virtual-scroll.util';