From 2c3168dd6bc8f045c630f62a50447ddf2fe40869 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 18 May 2026 11:07:22 +0300 Subject: [PATCH 1/6] feat: Added virtual scroll component and sample implementation --- projects/igniteui-angular/src/public_api.ts | 1 + .../igniteui-angular/virtual-scroll/README.md | 182 ++++++ .../igniteui-angular/virtual-scroll/index.ts | 3 + .../virtual-scroll/ng-package.json | 1 + .../virtual-scroll/src/scroll-engine.ts | 214 +++++++ .../virtual-scroll/src/types.ts | 62 ++ .../src/virtual-scroll-item.directive.ts | 22 + .../src/virtual-scroll.component.html | 14 + .../src/virtual-scroll.component.scss | 44 ++ .../src/virtual-scroll.component.spec.ts | 578 ++++++++++++++++++ .../src/virtual-scroll.component.ts | 367 +++++++++++ src/app/app.component.ts | 5 + src/app/app.routes.ts | 5 + .../virtual-scroll/virtual-scroll.sample.html | 135 ++++ .../virtual-scroll/virtual-scroll.sample.scss | 127 ++++ .../virtual-scroll/virtual-scroll.sample.ts | 77 +++ 16 files changed, 1837 insertions(+) create mode 100644 projects/igniteui-angular/virtual-scroll/README.md create mode 100644 projects/igniteui-angular/virtual-scroll/index.ts create mode 100644 projects/igniteui-angular/virtual-scroll/ng-package.json create mode 100644 projects/igniteui-angular/virtual-scroll/src/scroll-engine.ts create mode 100644 projects/igniteui-angular/virtual-scroll/src/types.ts create mode 100644 projects/igniteui-angular/virtual-scroll/src/virtual-scroll-item.directive.ts create mode 100644 projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.html create mode 100644 projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.scss create mode 100644 projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.spec.ts create mode 100644 projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.ts create mode 100644 src/app/virtual-scroll/virtual-scroll.sample.html create mode 100644 src/app/virtual-scroll/virtual-scroll.sample.scss create mode 100644 src/app/virtual-scroll/virtual-scroll.sample.ts diff --git a/projects/igniteui-angular/src/public_api.ts b/projects/igniteui-angular/src/public_api.ts index 1360707be6f..79e91ae467c 100644 --- a/projects/igniteui-angular/src/public_api.ts +++ b/projects/igniteui-angular/src/public_api.ts @@ -66,3 +66,4 @@ export * from 'igniteui-angular/tabs'; export * from 'igniteui-angular/time-picker'; export * from 'igniteui-angular/toast'; export * from 'igniteui-angular/tree'; +export * from 'igniteui-angular/virtual-scroll'; diff --git a/projects/igniteui-angular/virtual-scroll/README.md b/projects/igniteui-angular/virtual-scroll/README.md new file mode 100644 index 00000000000..a83796894dd --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/README.md @@ -0,0 +1,182 @@ +# IgxVirtualScrollComponent + +A high-performance virtual-scrolling component that renders only the items visible inside the viewport (plus a configurable over-scan buffer). It supports both vertical and horizontal axes, variable item sizes measured at runtime, and remote / infinite scrolling through the `dataRequest` event. + +## Imports + +```ts +import { + IgxVirtualScrollComponent, + IgxVirtualItemDirective, +} from 'igniteui-angular/virtual-scroll'; +``` + +--- + +## Basic usage + +Define your list and provide a template using the `igxVirtualItem` directive: + +```html + + +
{{ i }}: {{ item.name }}
+
+
+``` + +```ts +@Component({ /* ... */ }) +export class MyComponent { + items = Array.from({ length: 10_000 }, (_, i) => ({ name: `Item ${i}` })); +} +``` + +--- + +## Inputs + +| Input | Type | Default | Description | +|---|---|---|---| +| `data` | `T[]` | `[]` | The array of items to virtualise. | +| `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Scroll axis. | +| `overScan` | `number` | `2` | Extra items to render beyond each edge of the viewport. Higher values reduce blank-flash artefacts during fast scrolling at the cost of slightly more DOM nodes. | +| `estimatedItemSize` | `number` | `50` | Pixel size used for items before they are measured in the DOM. Set this close to the real average size for the best initial-render accuracy. | +| `itemTemplate` | `TemplateRef> \| null` | `null` | Programmatic template that takes precedence over a content `ng-template[igxVirtualItem]`. | + +--- + +## Outputs + +| Output | Payload | Description | +|---|---|---| +| `stateChange` | `VirtualScrollState` | Emitted after every render pass with a snapshot of the current virtual window. | +| `dataRequest` | `VirtualScrollDataRequest` | Emitted when the scroll position approaches the end of loaded data. Use this to implement infinite / remote scrolling. | + +--- + +## Public API + +### `scrollToIndex(index: number): void` + +Programmatically scrolls the viewport so that the item at `index` is at the leading edge. + +```ts +@ViewChild(IgxVirtualScrollComponent) vs!: IgxVirtualScrollComponent; + +this.vs.scrollToIndex(500); +``` + +--- + +## `IgxVirtualItemDirective` + +Marks an `ng-template` as the item template for the nearest `igx-virtual-scroll`. The template context is typed as `IgxVsItemContext`. + +### Template context variables + +| Variable | Type | Description | +|---|---|---| +| `$implicit` (or `let-item`) | `T` | The current item. | +| `index` | `number` | The item's index within the full data array. | +| `count` | `number` | Total number of items in `data`. | +| `first` | `boolean` | `true` when `index === 0`. | +| `last` | `boolean` | `true` when `index === count - 1`. | +| `even` | `boolean` | `true` when `index` is even. | +| `odd` | `boolean` | `true` when `index` is odd. | + +```html + +
{{ i }}: {{ item }}
+
+``` + +--- + +## Output type reference + +### `VirtualScrollState` + +```ts +interface VirtualScrollState { + startIndex: number; // First rendered item index + endIndex: number; // Last rendered item index (inclusive) + viewportSize: number; // Viewport height (or width) in px + totalSize: number; // Total virtual content size in px +} +``` + +### `VirtualScrollDataRequest` + +```ts +interface VirtualScrollDataRequest { + startIndex: number; // First index that does not yet have data + count: number; // Suggested number of items to fetch +} +``` + +--- + +## Horizontal scrolling + +Set `orientation="horizontal"`. Items are laid out in a row; ensure each item has an explicit `width` so the engine can measure sizes correctly. + +```html + + +
{{ item }}
+
+
+``` + +--- + +## Infinite / remote scrolling + +Listen to the `dataRequest` output and append more items to the `data` array: + +```html + + +
{{ item.label }}
+
+
+``` + +```ts +loadMore(req: VirtualScrollDataRequest) { + this.myService.fetch(req.startIndex, req.count).subscribe(newItems => { + this.items = [...this.items, ...newItems]; + }); +} +``` + +--- + +## Programmatic template + +Pass a `TemplateRef` via `[itemTemplate]` when the template is defined outside the component: + +```html + +
{{ item }}
+
+ + +``` + +--- + +## Styling + +The component exposes the following CSS classes: + +| Class | Element | Notes | +|---|---|---| +| `igx-virtual-scroll` | Host | Always present. | +| `igx-virtual-scroll--vertical` | Host | Added when `orientation="vertical"`. | +| `igx-virtual-scroll--horizontal` | Host | Added when `orientation="horizontal"`. | +| `igx-vs__track` | Inner spacer div | Sized to the full virtual height/width. | +| `igx-vs__content` | Rendered-items wrapper | Absolutely positioned; translated to the correct virtual offset. | + +The host element must have a **fixed height** (vertical) or **fixed width** (horizontal) and `overflow: auto` or `overflow: scroll` — the default styles already set this. diff --git a/projects/igniteui-angular/virtual-scroll/index.ts b/projects/igniteui-angular/virtual-scroll/index.ts new file mode 100644 index 00000000000..2bc0221a594 --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/index.ts @@ -0,0 +1,3 @@ +export { IgxVirtualScrollComponent } from './src/virtual-scroll.component'; +export { IgxVirtualItemDirective } from './src/virtual-scroll-item.directive'; +export { IgxVsItemContext, VirtualScrollDataRequest, VirtualScrollState } from './src/types'; diff --git a/projects/igniteui-angular/virtual-scroll/ng-package.json b/projects/igniteui-angular/virtual-scroll/ng-package.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/ng-package.json @@ -0,0 +1 @@ +{} diff --git a/projects/igniteui-angular/virtual-scroll/src/scroll-engine.ts b/projects/igniteui-angular/virtual-scroll/src/scroll-engine.ts new file mode 100644 index 00000000000..2b89eafaf0e --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/scroll-engine.ts @@ -0,0 +1,214 @@ +import { computed, signal } from "@angular/core"; + +const MAX_BROWSER_SIZE_PROBE_PX = Number.MAX_SAFE_INTEGER; + +/** + * Probes the browser for the maximum scrollable coordinate it supports. + */ +function getMaxBrowserSizeProbePx(doc: Document): number { + const div = doc.createElement("div"); + div.style.position = "absolute"; + div.style.top = `${MAX_BROWSER_SIZE_PROBE_PX}px`; + doc.body.appendChild(div); + const size = Math.abs(div.getBoundingClientRect().top); + doc.body.removeChild(div); + return size; +} + +/** + * Builds a prefix sums array from the given sizes array. + * The prefix sums array has one more element than the sizes array, + * where the first element is 0 and each subsequent element is the sum of all previous sizes. + * This allows for efficient calculation of the total size up to any index in the sizes array. + */ +function buildPrefixSums(sizes: readonly number[]): number[] { + const sums = new Array(sizes.length + 1); + sums[0] = 0; + for (let i = 0; i < sizes.length; i++) { + sums[i + 1] = sums[i] + sizes[i]; + } + return sums; +} + +/** + * Performs a binary search on the prefix sums array to find the largest index such that prefixSums[index] <= target. + * This is used to efficiently determine how many items can fit within a given scroll position. + * The function returns the index of the last item that fits within the target scroll position. + * If the target is smaller than the first prefix sum, it returns -1, indicating that no items fit. + */ +function binarySearchPrefixSums( + prefixSums: readonly number[], + target: number, +): number { + let low = 0; + let high = prefixSums.length - 1; + + while (low < high) { + const mid = (low + high + 1) >> 1; + if (prefixSums[mid] <= target) { + low = mid; + } else { + high = mid - 1; + } + } + + return Math.max(0, low - 1); +} + +/** + * Describes the currently visible (and over-scanned) range of items. + */ +export interface VisibleRange { + /** Index of the first rendered item (inclusive) */ + startIndex: number; + /** Index of the last rendered item (inclusive) */ + endIndex: number; +} + +/** + * Pure scroll-math engine for a single axis of virtual scrolling. + * + * Holds all size state as signals so that downstream `computed()` values + * (visible range, spacer size, translate offset) react automatically + * whenever item sizes are measured or the item count changes. + */ +export class VirtualScrollEngine { + private _maxBrowserSize = Infinity; + + /** + * The ratio `totalSize / maxBrowserSize` when `totalSize` exceeds the + * maximum DOM coordinate the browser supports; `1` otherwise. + * Used to map virtual scroll positions to DOM scroll positions. + */ + private _virtualRatio = 1; + + /** Per-item measured or estimated sizes in px. */ + private readonly _itemSizes = signal([]); + + /** + * Prefix-sum array of item sizes, where prefixSums[i] is the total size of items[0] through items[i-1]. + */ + public readonly prefixSums = computed(() => + buildPrefixSums(this._itemSizes()), + ); + + /** Total virtual size of all items in px. */ + public readonly totalSize = computed(() => { + const pSum = this.prefixSums(); + return pSum[pSum.length - 1] ?? 0; + }); + + /** Actual DOM space size (clamped to the maximum browser size) */ + public readonly domSize = computed(() => + this._virtualRatio !== 1 ? this._maxBrowserSize : this.totalSize(), + ); + + /** + * Initializes the maximum browser size by probing the document, and updates the virtual ratio accordingly. + */ + public initMaxBrowserSize(doc: Document): void { + this._maxBrowserSize = getMaxBrowserSizeProbePx(doc); + this._updateVirtualRatio(); + } + + /** + * Grows or shrinks the internal sizes array to `length`. + * New entries are filled with `estimatedSize`. + * Existing measured sizes are preserved. + */ + public resize(length: number, estimatedSize: number): void { + const current = this._itemSizes(); + if (length === current.length) return; + + const next = current.slice(0, length); + while (next.length < length) { + next.push(estimatedSize); + } + this._itemSizes.set(next); + this._updateVirtualRatio(); + } + + /** + * Records the measured DOM size for a single item. + * Triggers a signal update so all downstream computed values react. + */ + public measureItem(index: number, size: number): void { + const current = this._itemSizes(); + if (index < 0 || index >= current.length) return; + if (current[index] === size) return; + + const next = current.slice(); + next[index] = size; + this._itemSizes.set(next); + this._updateVirtualRatio(); + } + + /** + * Returns the DOM scroll offset in pixels that brings item at `index` into view + * at the leading edge of the viewport. + */ + public getScrollOffsetForIndex(index: number): number { + const pSums = this.prefixSums(); + if (index <= 0) return 0; + + const clamped = Math.min(index, pSums.length - 1); + const virtualOffset = pSums[clamped]; + return virtualOffset / this._virtualRatio; + } + + /** Returns the item index at the given DOM scroll position. */ + public getIndexAtScroll(scrollPosition: number): number { + const virtualPosition = scrollPosition * this._virtualRatio; + const pSum = this.prefixSums(); + if (virtualPosition <= 0 || pSum.length <= 1) return 0; + + return binarySearchPrefixSums(pSum, virtualPosition); + } + + /** + * Returns the visible + over-scanned item range for the given scroll state. + */ + public getVisibleRange( + scrollPosition: number, + viewportSize: number, + overScan: number, + totalItems: number, + ): VisibleRange { + if (totalItems === 0 || viewportSize <= 0) { + return { startIndex: 0, endIndex: -1 }; + } + + const start = Math.max(0, this.getIndexAtScroll(scrollPosition) - overScan); + const endScrollPosition = scrollPosition + viewportSize; + const endRaw = this.getIndexAtScroll(endScrollPosition); + const end = Math.min(totalItems - 1, endRaw + overScan); + + return { startIndex: start, endIndex: end }; + } + + /** + * Returns the CSS `translateY` / `translateX` value (px) to apply to the + * absolutely-positioned content wrapper. + * + * The content wrapper is `position: absolute; top: 0; left: 0` inside a + * track element that is `totalSize` px tall/wide. Translating it to + * `getContentPosition(startIndex)` places the first rendered item exactly + * at its virtual scroll position within the track. + */ + public getContentPosition(index: number): number { + const pSums = this.prefixSums(); + if (index <= 0) return 0; + + const clamped = Math.min(index, pSums.length - 1); + const virtualOffset = pSums[clamped]; + return virtualOffset / this._virtualRatio; + } + + private _updateVirtualRatio(): void { + const totalSize = this.totalSize(); + this._virtualRatio = + this._maxBrowserSize === Infinity || totalSize <= this._maxBrowserSize + ? 1 + : totalSize / this._maxBrowserSize; + } +} diff --git a/projects/igniteui-angular/virtual-scroll/src/types.ts b/projects/igniteui-angular/virtual-scroll/src/types.ts new file mode 100644 index 00000000000..5db24a27e37 --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/types.ts @@ -0,0 +1,62 @@ +/** + * Context for the item template in the virtual scroll component. + * Provides the item data, its index, and utility properties for template rendering. + */ +export class IgxVsItemContext { + constructor( + /** The current item in the virtual scroll. */ + public $implicit: T, + /** The index of the current item. */ + public index: number, + /** The total number of items in the virtual scroll. */ + public count: number, + ) {} + + /** Whether the current item is the first in the list. */ + public get first(): boolean { + return this.index === 0; + } + + /** Whether the current item is the last in the list. */ + public get last(): boolean { + return this.index === this.count - 1; + } + + /** Whether the current item is at an even index. */ + public get even(): boolean { + return this.index % 2 === 0; + } + + /** Whether the current item is at an odd index. */ + public get odd(): boolean { + return !this.even; + } +} + +/** + * Snapshot of the currently rendered virtual window. + */ +export interface VirtualScrollState { + /** The index of the first item currently rendered in the viewport. */ + startIndex: number; + /** The index of the last item currently rendered in the viewport (inclusive). */ + endIndex: number; + /** The size of the viewport in pixels. */ + viewportSize: number; + /** The total size of the virtual scroll content in pixels. */ + totalSize: number; +} + +/** + * Request for more data to be loaded in the virtual scroll, typically emitted when the user scrolls near the end of the currently loaded items. + * The consumer of the virtual scroll component can listen to this event and load more data as needed. + */ +export interface VirtualScrollDataRequest { + /** + * The first index that does not yet have data. + * Append at least `(endIndex - startIndex + 1)` more items starting here. + */ + startIndex: number; + /** Number of items being requested. */ + count: number; +} diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll-item.directive.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll-item.directive.ts new file mode 100644 index 00000000000..f8a45fc305c --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll-item.directive.ts @@ -0,0 +1,22 @@ +import { Directive, inject, TemplateRef } from "@angular/core"; +import { IgxVsItemContext } from "./types"; + +/** + * Directive to mark an `ng-template` as the item template for the virtual scroll component. + * The template provided by this directive will be used to render each item in the virtual scroll. + * The context for the template will include the item data and its index. + * + * @example + * ```html + * + * + *
{{ i }}: {{ item }}
+ *
+ *
+ * ``` + */ +@Directive({ selector: "ng-template[igxVirtualItem]" }) +export class IgxVirtualItemDirective { + public readonly template = + inject>>(TemplateRef); +} diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.html b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.html new file mode 100644 index 00000000000..c8299c1d4d8 --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.html @@ -0,0 +1,14 @@ + diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.scss b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.scss new file mode 100644 index 00000000000..a5648e40ddf --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.scss @@ -0,0 +1,44 @@ +:host { + display: block; + position: relative; + overflow: auto; +} + +:host(.igx-virtual-scroll--vertical) { + overflow-y: auto; + overflow-x: hidden; +} + +:host(.igx-virtual-scroll--horizontal) { + overflow-x: auto; + overflow-y: hidden; +} + +.igx-vs__track { + position: relative; + width: 100%; + min-height: 100%; +} + +.igx-vs__content { + position: absolute; + top: 0; + left: 0; + width: 100%; + will-change: transform; + contain: layout style paint; +} + +:host(.igx-virtual-scroll--horizontal) { + .igx-vs__track { + height: 100%; + min-height: unset; + } + + .igx-vs__content { + display: flex; + flex-direction: row; + height: 100%; + width: auto; + } +} diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.spec.ts new file mode 100644 index 00000000000..2d3cdf66d2f --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.spec.ts @@ -0,0 +1,578 @@ +import { + TestBed, + ComponentFixture, + fakeAsync, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { Component, TemplateRef, viewChild } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +import { IgxVirtualScrollComponent } from './virtual-scroll.component'; +import { IgxVirtualItemDirective } from './virtual-scroll-item.directive'; +import { IgxVsItemContext, VirtualScrollDataRequest, VirtualScrollState } from './types'; +import { VirtualScrollEngine } from './scroll-engine'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function generateItems(count: number): string[] { + return Array.from({ length: count }, (_, i) => `Item ${i}`); +} + +// --------------------------------------------------------------------------- +// VirtualScrollEngine (pure unit tests – no DOM required) +// --------------------------------------------------------------------------- + +fdescribe('VirtualScrollEngine', () => { + let engine: VirtualScrollEngine; + + beforeEach(() => { + engine = new VirtualScrollEngine(); + }); + + describe('resize', () => { + it('should initialize sizes with the estimated value', () => { + engine.resize(5, 40); + expect(engine.totalSize()).toBe(200); + }); + + it('should grow preserving existing sizes', () => { + engine.resize(3, 50); + engine.measureItem(0, 80); + engine.resize(5, 50); + // item 0 = 80, items 1-4 = 50 each => 80 + 4*50 = 280 + expect(engine.totalSize()).toBe(280); + }); + + it('should shrink the array', () => { + engine.resize(5, 50); + engine.resize(2, 50); + expect(engine.totalSize()).toBe(100); + }); + + it('should be a no-op when length is unchanged', () => { + engine.resize(3, 50); + const before = engine.totalSize(); + engine.resize(3, 99); + expect(engine.totalSize()).toBe(before); + }); + }); + + describe('measureItem', () => { + it('should update totalSize after a measurement', () => { + engine.resize(3, 50); + engine.measureItem(1, 120); + expect(engine.totalSize()).toBe(50 + 120 + 50); + }); + + it('should ignore out-of-range indices', () => { + engine.resize(3, 50); + engine.measureItem(-1, 100); + engine.measureItem(3, 100); + expect(engine.totalSize()).toBe(150); + }); + + it('should be a no-op when the size has not changed', () => { + engine.resize(3, 50); + const before = engine.prefixSums(); + engine.measureItem(0, 50); + expect(engine.prefixSums()).toEqual(before); + }); + }); + + describe('getScrollOffsetForIndex', () => { + it('should return 0 for index 0', () => { + engine.resize(3, 50); + expect(engine.getScrollOffsetForIndex(0)).toBe(0); + }); + + it('should return the cumulative size up to the given index', () => { + engine.resize(4, 50); + engine.measureItem(0, 30); + engine.measureItem(1, 60); + // offset for index 2 = item0 + item1 = 30 + 60 = 90 + expect(engine.getScrollOffsetForIndex(2)).toBe(90); + }); + + it('should clamp to totalSize for out-of-range indices', () => { + engine.resize(3, 50); + // pSums has length items+1; index clamps to pSums.length-1 = totalItems, + // which equals the total virtual size, not the last item's leading offset. + expect(engine.getScrollOffsetForIndex(100)).toBe(engine.totalSize()); + }); + }); + + describe('getIndexAtScroll', () => { + it('should return 0 when scrollPosition is 0', () => { + engine.resize(5, 50); + expect(engine.getIndexAtScroll(0)).toBe(0); + }); + + it('should return the last complete item before the scroll position', () => { + engine.resize(5, 50); + // binarySearchPrefixSums returns low-1: the last item whose end (prefixSums[i+1]) + // is at or before the target. At 125px item 1 ends at 100px, so index 1 is returned. + // getVisibleRange adds overscan on top, which covers the partially-visible item. + expect(engine.getIndexAtScroll(125)).toBe(1); + }); + }); + + describe('getVisibleRange', () => { + it('should return empty range when totalItems is 0', () => { + engine.resize(0, 50); + const range = engine.getVisibleRange(0, 300, 2, 0); + expect(range.startIndex).toBe(0); + expect(range.endIndex).toBe(-1); + }); + + it('should return empty range when viewportSize is 0', () => { + engine.resize(10, 50); + const range = engine.getVisibleRange(0, 0, 2, 10); + expect(range.startIndex).toBe(0); + expect(range.endIndex).toBe(-1); + }); + + it('should include over-scanned items beyond the visible edge', () => { + engine.resize(20, 50); + // Viewport 200px, scroll 0 → visible items 0-3; with overScan=2 => 0-5 + const range = engine.getVisibleRange(0, 200, 2, 20); + expect(range.startIndex).toBe(0); + expect(range.endIndex).toBeGreaterThanOrEqual(5); + }); + + it('should not exceed totalItems - 1 as endIndex', () => { + engine.resize(5, 50); + const range = engine.getVisibleRange(0, 10000, 10, 5); + expect(range.endIndex).toBe(4); + }); + + it('should not go below 0 as startIndex', () => { + engine.resize(10, 50); + const range = engine.getVisibleRange(0, 200, 10, 10); + expect(range.startIndex).toBe(0); + }); + }); + + describe('getContentPosition', () => { + it('should return 0 for index 0', () => { + engine.resize(3, 50); + expect(engine.getContentPosition(0)).toBe(0); + }); + + it('should match the prefix sum at the given index', () => { + engine.resize(4, 50); + engine.measureItem(0, 30); + engine.measureItem(1, 70); + // position for index 2 = sum of items 0 and 1 = 30 + 70 = 100 + expect(engine.getContentPosition(2)).toBe(100); + }); + }); +}); + +// --------------------------------------------------------------------------- +// IgxVsItemContext +// --------------------------------------------------------------------------- + +describe('IgxVsItemContext', () => { + it('should expose item, index, and count', () => { + const ctx = new IgxVsItemContext('hello', 3, 10); + expect(ctx.$implicit).toBe('hello'); + expect(ctx.index).toBe(3); + expect(ctx.count).toBe(10); + }); + + it('first should be true only at index 0', () => { + expect(new IgxVsItemContext('a', 0, 5).first).toBeTrue(); + expect(new IgxVsItemContext('a', 1, 5).first).toBeFalse(); + }); + + it('last should be true only at index count-1', () => { + expect(new IgxVsItemContext('a', 4, 5).last).toBeTrue(); + expect(new IgxVsItemContext('a', 3, 5).last).toBeFalse(); + }); + + it('even/odd should reflect index parity', () => { + expect(new IgxVsItemContext('a', 0, 5).even).toBeTrue(); + expect(new IgxVsItemContext('a', 0, 5).odd).toBeFalse(); + expect(new IgxVsItemContext('a', 1, 5).even).toBeFalse(); + expect(new IgxVsItemContext('a', 1, 5).odd).toBeTrue(); + }); +}); + +// --------------------------------------------------------------------------- +// Wrapper components used in TestBed tests +// --------------------------------------------------------------------------- + +@Component({ + selector: 'test-virtual-scroll-basic', + template: ` + + +
{{ i }}: {{ item }}
+
+
+ `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +class TestBasicComponent { + public items = generateItems(100); +} + +@Component({ + selector: 'test-virtual-scroll-horizontal', + template: ` + + +
{{ item }}
+
+
+ `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +class TestHorizontalComponent { + public items = generateItems(50); +} + +@Component({ + selector: 'test-virtual-scroll-events', + template: ` + + +
{{ item }}
+
+
+ `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +class TestEventsComponent { + public items = generateItems(100); + public lastState: VirtualScrollState | null = null; + public lastDataRequest: VirtualScrollDataRequest | null = null; + + public onStateChange(state: VirtualScrollState) { + this.lastState = state; + } + + public onDataRequest(req: VirtualScrollDataRequest) { + this.lastDataRequest = req; + } +} + +@Component({ + selector: 'test-virtual-scroll-programmatic-template', + template: ` + +
{{ i }}: {{ item }}
+
+ + + `, + imports: [IgxVirtualScrollComponent], +}) +class TestProgrammaticTemplateComponent { + public items = generateItems(50); + public tpl = viewChild>>('tpl'); +} + +@Component({ + selector: 'test-virtual-scroll-empty', + template: ` + + +
{{ item }}
+
+
+ `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +class TestEmptyComponent { + public items: string[] = []; +} + +// --------------------------------------------------------------------------- +// IgxVirtualScrollComponent TestBed tests +// --------------------------------------------------------------------------- + +describe('IgxVirtualScrollComponent', () => { + describe('basic rendering', () => { + let fixture: ComponentFixture; + let component: TestBasicComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestBasicComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestBasicComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + const vs = fixture.debugElement.query(By.directive(IgxVirtualScrollComponent)); + expect(vs).toBeTruthy(); + }); + + it('should have the igx-virtual-scroll class and role="list"', () => { + const el: HTMLElement = fixture.debugElement.query( + By.directive(IgxVirtualScrollComponent) + ).nativeElement; + expect(el.classList).toContain('igx-virtual-scroll'); + expect(el.getAttribute('role')).toBe('list'); + }); + + it('should add the vertical modifier class by default', () => { + const el: HTMLElement = fixture.debugElement.query( + By.directive(IgxVirtualScrollComponent) + ).nativeElement; + expect(el.classList).toContain('igx-virtual-scroll--vertical'); + expect(el.classList).not.toContain('igx-virtual-scroll--horizontal'); + }); + + it('should render a subset of items (not all 100)', () => { + const items = fixture.debugElement.queryAll(By.css('.item')); + expect(items.length).toBeGreaterThan(0); + expect(items.length).toBeLessThan(component.items.length); + }); + + it('should render the track element with a non-zero height', () => { + const track: HTMLElement = fixture.debugElement.query( + By.css('.igx-vs__track') + ).nativeElement; + const heightPx = parseInt(track.style.height, 10); + expect(heightPx).toBeGreaterThan(0); + }); + + it('should contain a content wrapper with a transform style', () => { + const content: HTMLElement = fixture.debugElement.query( + By.css('.igx-vs__content') + ).nativeElement; + expect(content.style.transform).toMatch(/translateY/); + }); + + it('should reflect updated data after input change', () => { + component.items = generateItems(5); + fixture.detectChanges(); + const items = fixture.debugElement.queryAll(By.css('.item')); + expect(items.length).toBe(5); + }); + + it('should render no items when data is empty', () => { + component.items = []; + fixture.detectChanges(); + const items = fixture.debugElement.queryAll(By.css('.item')); + expect(items.length).toBe(0); + }); + }); + + describe('horizontal orientation', () => { + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestHorizontalComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestHorizontalComponent); + fixture.detectChanges(); + }); + + it('should add the horizontal modifier class', () => { + const el: HTMLElement = fixture.debugElement.query( + By.directive(IgxVirtualScrollComponent) + ).nativeElement; + expect(el.classList).toContain('igx-virtual-scroll--horizontal'); + expect(el.classList).not.toContain('igx-virtual-scroll--vertical'); + }); + + it('should set a width on the track element instead of height', () => { + const track: HTMLElement = fixture.debugElement.query( + By.css('.igx-vs__track') + ).nativeElement; + expect(track.style.width).toBeTruthy(); + }); + + it('should apply a translateX transform to the content wrapper', () => { + const content: HTMLElement = fixture.debugElement.query( + By.css('.igx-vs__content') + ).nativeElement; + expect(content.style.transform).toMatch(/translateX/); + }); + }); + + describe('events', () => { + let fixture: ComponentFixture; + let component: TestEventsComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestEventsComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestEventsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should emit stateChange after initial render', () => { + expect(component.lastState).not.toBeNull(); + }); + + it('stateChange should include startIndex, endIndex, viewportSize, and totalSize', () => { + const state = component.lastState!; + expect(state.startIndex).toBeDefined(); + expect(state.endIndex).toBeDefined(); + expect(state.viewportSize).toBeDefined(); + expect(state.totalSize).toBeGreaterThan(0); + }); + + it('stateChange startIndex should be less than or equal to endIndex', () => { + expect(component.lastState!.startIndex).toBeLessThanOrEqual( + component.lastState!.endIndex + ); + }); + + it('should emit dataRequest when near the end of data', fakeAsync(() => { + // Provide a very small list so the initial render is near the end + component.items = generateItems(3); + fixture.detectChanges(); + tick(); + expect(component.lastDataRequest).not.toBeNull(); + expect(component.lastDataRequest!.startIndex).toBe(3); + })); + }); + + describe('programmatic itemTemplate input', () => { + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestProgrammaticTemplateComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestProgrammaticTemplateComponent); + fixture.detectChanges(); + }); + + it('should render items using the programmatic template', () => { + const items = fixture.debugElement.queryAll(By.css('.item')); + expect(items.length).toBeGreaterThan(0); + }); + }); + + describe('empty data', () => { + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestEmptyComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestEmptyComponent); + fixture.detectChanges(); + }); + + it('should not render any items', () => { + const items = fixture.debugElement.queryAll(By.css('.item')); + expect(items.length).toBe(0); + }); + + it('should still render the track element', () => { + const track = fixture.debugElement.query(By.css('.igx-vs__track')); + expect(track).toBeTruthy(); + }); + }); + + describe('scrollToIndex', () => { + let fixture: ComponentFixture; + let vsComponent: IgxVirtualScrollComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestBasicComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestBasicComponent); + fixture.detectChanges(); + vsComponent = fixture.debugElement.query( + By.directive(IgxVirtualScrollComponent) + ).componentInstance; + }); + + it('should not throw when scrolling to a valid index', () => { + expect(() => vsComponent.scrollToIndex(10)).not.toThrow(); + }); + + it('should not throw when scrolling to index 0', () => { + expect(() => vsComponent.scrollToIndex(0)).not.toThrow(); + }); + + it('should not throw when scrolling to the last index', () => { + expect(() => vsComponent.scrollToIndex(99)).not.toThrow(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// IgxVirtualItemDirective +// --------------------------------------------------------------------------- + +describe('IgxVirtualItemDirective', () => { + @Component({ + selector: 'test-directive-host', + template: ` + + +
{{ item }}
+
+
+ `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], + }) + class DirectiveHostComponent { + public items = generateItems(10); + } + + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [DirectiveHostComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DirectiveHostComponent); + fixture.detectChanges(); + }); + + it('should be picked up as a content child of IgxVirtualScrollComponent', () => { + const directive = fixture.debugElement.query(By.directive(IgxVirtualItemDirective)); + expect(directive).toBeTruthy(); + }); + + it('should expose a non-null TemplateRef', () => { + const directiveInstance: IgxVirtualItemDirective = fixture.debugElement + .query(By.directive(IgxVirtualItemDirective)) + .injector.get(IgxVirtualItemDirective); + expect(directiveInstance.template).toBeTruthy(); + }); +}); diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.ts new file mode 100644 index 00000000000..70603dac13a --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.ts @@ -0,0 +1,367 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + contentChild, + DOCUMENT, + effect, + ElementRef, + EmbeddedViewRef, + inject, + input, + NgZone, + OnDestroy, + output, + PLATFORM_ID, + signal, + TemplateRef, + untracked, + viewChild, + ViewContainerRef, +} from "@angular/core"; +import { IgxVirtualItemDirective } from "./virtual-scroll-item.directive"; +import { + IgxVsItemContext, + VirtualScrollDataRequest, + VirtualScrollState, +} from "./types"; +import { VirtualScrollEngine } from "./scroll-engine"; +import { isPlatformBrowser } from "@angular/common"; + +const REMOTE_SCROLLING_THRESHOLD = 5; + +@Component({ + selector: "igx-virtual-scroll", + templateUrl: "./virtual-scroll.component.html", + styleUrls: ["./virtual-scroll.component.scss"], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "igx-virtual-scroll", + role: "list", + "[class.igx-virtual-scroll--vertical]": "_isVertical()", + "[class.igx-virtual-scroll--horizontal]": "!_isVertical()", + }, +}) +export class IgxVirtualScrollComponent implements OnDestroy { + //#region Dependency Injections + private readonly _hostRef = inject>(ElementRef); + private readonly _zone = inject(NgZone); + private readonly _document = inject(DOCUMENT); + private readonly _platformId = inject(PLATFORM_ID); + + //#endregion + + private _viewportResizeObserver: ResizeObserver | null = null; + private _itemResizeObserver: ResizeObserver | null = null; + private _onScroll: ((e: Event) => void) | null = null; + + /** Views currently inserted into the VCR, ordered by rendered item index. */ + private readonly _activeItems: EmbeddedViewRef>[] = []; + + /** Detached views available for reuse. */ + private readonly _pooledItems: EmbeddedViewRef>[] = []; + + private readonly _scrollPosition = signal(0); + private readonly _viewportSize = signal(0); + + private readonly _visibleRange = computed(() => + this._engine.getVisibleRange( + this._scrollPosition(), + this._viewportSize(), + this.overScan(), + this.data().length, + ), + ); + + protected readonly _engine = new VirtualScrollEngine(); + protected readonly _isVertical = computed( + () => this.orientation() === "vertical", + ); + protected readonly _spaceSize = computed(() => this._engine.domSize()); + protected readonly _contentTransform = computed(() => { + const position = this._engine.getContentPosition( + this._visibleRange().startIndex, + ); + return this._isVertical() + ? `translateY(${position}px)` + : `translateX(${position}px)`; + }); + + //#region View and Content Children + + private readonly _itemDirective = contentChild(IgxVirtualItemDirective); + + private readonly _itemsViewContainer = viewChild( + "itemsAnchor", + { read: ViewContainerRef }, + ); + + private readonly _contentDivRef = + viewChild>("contentDiv"); + + protected readonly _resolvedTemplate = computed(() => { + return this.itemTemplate() ?? this._itemDirective()?.template ?? null; + }); + + //#endregion + + /** The array of items to virtualize. */ + public readonly data = input([]); + + /** + * Scroll orientation of the virtual scroll. + * Can be either "vertical" or "horizontal". + * Default is "vertical". + */ + public readonly orientation = input<"vertical" | "horizontal">("vertical"); + + /** + * Number of extra items to render beyond the visible area of the viewport. + * Higher values reduce blank flashes during fast scrolling but may impact performance. + * Default is 2. + */ + public readonly overScan = input(2); + + /** + * Estimated item size in pixels used before an item is measured in the DOM. + * The engine replaces this with the actual measured size after the first render of each item. + * Default is 50 pixels. + * Setting this to a value close to the actual average item size can improve initial rendering performance. + */ + public readonly estimatedItemSize = input(50); + + /** + * Item template provided programmatically (takes precedence over content template if both are provided). + * + * This template will be used to render each item in the virtual scroll. + * The context for the template will include the item data and its index. + * If not provided, the component will look for an `ng-template` with the `igxVirtualItem` directive in its content. + */ + public readonly itemTemplate = input> | null>( + null, + ); + + /** + * Emitted after each render pass with a snapshot of the current virtual window. + */ + public readonly stateChange = output(); + + /** + * Emitted when the scroll position approaches the end of the available data. + * Listen to this event to append more items (infinite / remote scrolling). + */ + public readonly dataRequest = output(); + + constructor() { + // Sync engine item count with data changes. + effect(() => { + const count = this.data().length; + const estimated = this.estimatedItemSize(); + untracked(() => this._engine.resize(count, estimated)); + }); + + // Browser setup: runs after first render and whenever orientation changes. + effect(() => { + const vertical = this._isVertical(); + void vertical; // Ensure vertical is tracked before accessing the engine. + untracked(() => { + if (!isPlatformBrowser(this._platformId)) return; + + this._engine.initMaxBrowserSize(this._document); + this._measureViewport(); + this._setupScrollListener(); + this._setupViewportResizeObserver(); + }); + }); + + // Re-render whenever the visible range, data, or template changes. + effect(() => { + const range = this._visibleRange(); + const data = this.data(); + const template = this._resolvedTemplate(); + const vcr = this._itemsViewContainer(); + if (!template || !vcr || range.endIndex < range.startIndex) return; + + untracked(() => + this._renderRange(range.startIndex, range.endIndex, data, template), + ); + }); + + // Remote scroll: fire dataRequest when approaching the end. + effect(() => { + const range = this._visibleRange(); + const total = this.data().length; + + if (total > 0 && range.endIndex >= total - REMOTE_SCROLLING_THRESHOLD) { + this.dataRequest.emit({ + startIndex: total, + count: Math.max(this.overScan() * 4, 20), + }); + } + }); + } + + public ngOnDestroy(): void { + this._teardown(); + } + + /** Programmatically scrolls to the specified item index. */ + public scrollToIndex(index: number): void { + const host = this._hostRef.nativeElement; + const offset = this._engine.getScrollOffsetForIndex(index); + + if (this._isVertical()) { + host.scrollTop = offset; + } else { + host.scrollLeft = offset; + } + } + + private _renderRange( + startIndex: number, + endIndex: number, + data: T[], + template: TemplateRef>, + ): void { + const count = data.length; + const newCount = Math.max(0, endIndex - startIndex + 1); + const vcr = this._itemsViewContainer(); + if (!vcr) return; + + // Grow: pull from pool or create new views until we have enough. + while (this._activeItems.length < newCount) { + let view = this._pooledItems.pop() ?? null; + if (view) { + vcr.insert(view); + } else { + view = vcr.createEmbeddedView( + template, + new IgxVsItemContext(data[startIndex], startIndex, count), + ); + } + this._activeItems.push(view); + } + + // Shrink: detach from VCR and return to pool. + while (this._activeItems.length > newCount) { + const view = this._activeItems.pop()!; + const index = vcr.indexOf(view); + if (index > -1) { + vcr.detach(index); + } + this._pooledItems.push(view); + } + + // Update contexts in place - zero DOM allocations on steady-state scroll. + for (let i = 0; i < newCount; i++) { + const itemIndex = startIndex + i; + const view = this._activeItems[i]; + const context = view.context; + context.$implicit = data[itemIndex]; + context.index = itemIndex; + context.count = count; + view.markForCheck(); + } + + // Measure rendered items after the browser paints. + this._scheduleItemMeasurement(startIndex, newCount); + + this.stateChange.emit({ + startIndex, + endIndex, + viewportSize: this._viewportSize(), + totalSize: this._engine.totalSize(), + }); + } + + private _scheduleItemMeasurement(startIndex: number, count: number): void { + if (!isPlatformBrowser(this._platformId)) return; + + this._itemResizeObserver?.disconnect(); + this._itemResizeObserver = new ResizeObserver((entries) => { + let anyChanged = false; + for (const entry of entries) { + const el = entry.target as HTMLElement; + const index = parseInt(el.dataset["vsIndex"] ?? "-1", 10); + if (index < 0) continue; + + const measured = this._isVertical() + ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height) + : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width); + + if (measured > 0) { + this._engine.measureItem(index, measured); + anyChanged = true; + } + } + }); + + const content = this._contentDivRef()?.nativeElement; + if (!content) return; + + const itemRoots = Array.from(content.children) as HTMLElement[]; + for (let i = 0; i < Math.min(count, itemRoots.length); i++) { + const el = itemRoots[i]; + el.dataset["vsIndex"] = (startIndex + i).toString(); + this._itemResizeObserver.observe(el); + } + } + + private _measureViewport(): void { + const host = this._hostRef.nativeElement; + const size = this._isVertical() ? host.clientHeight : host.clientWidth; + if (size !== this._viewportSize()) { + this._viewportSize.set(size); + } + } + + private _setupViewportResizeObserver(): void { + if (!isPlatformBrowser(this._platformId)) return; + + this._viewportResizeObserver?.disconnect(); + this._viewportResizeObserver = new ResizeObserver(() => { + const host = this._hostRef.nativeElement; + const newSize = this._isVertical() ? host.clientHeight : host.clientWidth; + if (newSize !== this._viewportSize()) { + this._viewportSize.set(newSize); + } + }); + + this._viewportResizeObserver.observe(this._hostRef.nativeElement); + } + + private _setupScrollListener(): void { + if (!isPlatformBrowser(this._platformId)) return; + + const host = this._hostRef.nativeElement; + if (this._onScroll) { + host.removeEventListener("scroll", this._onScroll); + } + + this._zone.runOutsideAngular(() => { + this._onScroll = (e: Event) => { + const target = e.target as HTMLElement; + const scrollPos = this._isVertical() + ? target.scrollTop + : target.scrollLeft; + this._zone.run(() => this._scrollPosition.set(scrollPos)); + }; + host.addEventListener("scroll", this._onScroll!, { passive: true }); + }); + } + + private _teardown(): void { + const host = this._hostRef.nativeElement; + if (this._onScroll) { + host.removeEventListener("scroll", this._onScroll); + this._onScroll = null; + } + this._viewportResizeObserver?.disconnect(); + this._itemResizeObserver?.disconnect(); + for (const view of [...this._activeItems, ...this._pooledItems]) { + view.destroy(); + } + this._activeItems.length = 0; + this._pooledItems.length = 0; + } +} diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 9da1b765f44..ac976870db2 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -724,6 +724,11 @@ export class AppComponent implements OnInit { icon: 'view_column', name: 'Pivot Grid State Persistance' + }, + { + link: '/virtual-scroll', + icon: 'view_column', + name: 'Virtual Scroll' } ].sort((componentLink1, componentLink2) => componentLink1.name > componentLink2.name ? 1 : -1); diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 127a34167c5..5837a3b13ae 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -153,6 +153,7 @@ import { LabelSampleComponent } from "./label/label.sample"; import { GridRecreateSampleComponent } from './grid-re-create/grid-re-create.sample'; import { HierarchicalGridAdvancedFilteringSampleComponent } from './hierarchical-grid-advanced-filtering/hierarchical-grid-advanced-filtering.sample'; import { GridLiteSampleComponent } from './grid-lite/grid-lite.sample'; +import { VirtualScrollSampleComponent } from './virtual-scroll/virtual-scroll.sample'; export const appRoutes: Routes = [ { @@ -739,5 +740,9 @@ export const appRoutes: Routes = [ { path: 'labelDirective', component: LabelSampleComponent + }, + { + path: 'virtual-scroll', + component: VirtualScrollSampleComponent } ]; diff --git a/src/app/virtual-scroll/virtual-scroll.sample.html b/src/app/virtual-scroll/virtual-scroll.sample.html new file mode 100644 index 00000000000..6900db52ae9 --- /dev/null +++ b/src/app/virtual-scroll/virtual-scroll.sample.html @@ -0,0 +1,135 @@ +
+

Virtual Scroll Samples

+ + + + +
+

Vertical - variable-height items (100 items)

+

+ Each item has a different height. The engine measures sizes after the first + render and adjusts scroll calculations automatically. +

+ + + +
+ #{{ i }} + {{ item.label }} + h={{ item.height }}px +
+
+
+
+ + + + +
+

Vertical - constant item size (500 items)

+

+ All items share the same height of 48 px. Providing a uniform estimated + size lets the engine skip re-measurement, giving the best performance. +

+ + + +
+ #{{ i }} + {{ item.label }} +
+
+
+
+ + + + +
+

Horizontal - variable column widths (300 columns)

+

+ Each column has a different width. The engine measures sizes after the + first render and updates scroll calculations automatically. +

+ + + +
+ {{ i }} + {{ col.label }} + {{ col.width }}px +
+
+
+
+ + + + +
+

Horizontal - fixed column widths (200 columns)

+

+ Set orientation="horizontal" to scroll along the x-axis. +

+ + + +
+ {{ i }} + {{ col }} +
+
+
+
+ + + + +
+

Remote / infinite scrolling

+

+ The dataRequest event fires when the viewport approaches the + end of loaded data. Append new items to trigger another render pass. +

+ +
+ Loaded: {{ remoteItems().length }} items + @if (isLoading()) { + — loading… + } +
+ + + +
+ #{{ i }} + {{ item }} +
+
+
+
+
diff --git a/src/app/virtual-scroll/virtual-scroll.sample.scss b/src/app/virtual-scroll/virtual-scroll.sample.scss new file mode 100644 index 00000000000..c46571104cd --- /dev/null +++ b/src/app/virtual-scroll/virtual-scroll.sample.scss @@ -0,0 +1,127 @@ +.vs-demo { + max-width: 760px; + margin: 0 auto; + padding: 24px 16px; + font-family: sans-serif; +} + +.vs-demo__title { + font-size: 1.6rem; + margin-bottom: 24px; +} + +.vs-demo__card { + background: #fafafa; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 20px; + margin-bottom: 32px; + + h2 { + font-size: 1.1rem; + margin: 0 0 8px; + } +} + +.vs-demo__desc { + color: #555; + font-size: 0.875rem; + margin: 0 0 16px; +} + +.vs-demo__status { + font-size: 0.85rem; + color: #666; + margin-bottom: 8px; +} + +.vs-demo__loading { + color: #2196f3; +} + +/* ------------------------------------------------------------------ */ +/* Shared viewport styles */ +/* ------------------------------------------------------------------ */ + +.vs-demo__viewport { + display: block; + height: 320px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.vs-demo__viewport--horizontal { + height: 72px; +} + +/* ------------------------------------------------------------------ */ +/* Vertical row */ +/* ------------------------------------------------------------------ */ + +.vs-demo__row { + display: flex; + align-items: center; + gap: 12px; + padding: 0 12px; + border-left: 4px solid #5f4cf1; + border-bottom: 1px solid #efefef; + background: #fff; + box-sizing: border-box; + + &--odd { + background: #f5f5f5; + } +} + +.vs-demo__row-index { + font-weight: 600; + font-size: 0.8rem; + color: #888; + width: 48px; + flex-shrink: 0; +} + +.vs-demo__row-meta { + margin-left: auto; + font-size: 0.75rem; + color: #aaa; +} + +.vs-demo__row--fixed { + height: 48px; +} + +/* ------------------------------------------------------------------ */ +/* Horizontal column */ +/* ------------------------------------------------------------------ */ + +.vs-demo__col { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100px; + height: 100%; + border-right: 1px solid #e0e0e0; + padding: 0 8px; + flex-shrink: 0; + box-sizing: border-box; + font-size: 0.8rem; +} + +.vs-demo__col-index { + font-weight: 600; + font-size: 0.7rem; + color: #888; +} + +.vs-demo__col--variable { + width: unset; + border-top: 3px solid #5f4cf1; + border-right: 1px solid #e0e0e0; +} + +.vs-demo__col-meta { + font-size: 0.7rem; + color: #aaa; +} diff --git a/src/app/virtual-scroll/virtual-scroll.sample.ts b/src/app/virtual-scroll/virtual-scroll.sample.ts new file mode 100644 index 00000000000..9d82f7760a7 --- /dev/null +++ b/src/app/virtual-scroll/virtual-scroll.sample.ts @@ -0,0 +1,77 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { IgxVirtualScrollComponent, IgxVirtualItemDirective, VirtualScrollDataRequest } from 'igniteui-angular/virtual-scroll'; + +export interface VsSampleItem { + id: number; + label: string; + height: number; + color: string; +} + +export interface VsHorizontalItem { + label: string; + width: number; + color: string; +} + +const COLORS = ['#5f4cf1', '#2196f3', '#4caf50', '#ff9800', '#e91e63']; + +function makeItems(start: number, count: number): VsSampleItem[] { + return Array.from({ length: count }, (_, i) => { + const id = start + i; + return { + id, + label: `Item #${id}`, + height: 40 + (id % 5) * 20, + color: COLORS[id % COLORS.length], + }; + }); +} + +@Component({ + selector: 'app-virtual-scroll-sample', + templateUrl: './virtual-scroll.sample.html', + styleUrls: ['./virtual-scroll.sample.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +export class VirtualScrollSampleComponent { + protected readonly verticalItems = signal(makeItems(0, 100)); + protected readonly verticalConstantItems = signal( + Array.from({ length: 500 }, (_, i) => ({ + id: i, + label: `Row ${i}`, + height: 48, + color: COLORS[i % COLORS.length], + })) + ); + protected readonly horizontalItems = signal( + Array.from({ length: 200 }, (_, i) => `Col ${i}`) + ); + protected readonly horizontalVariableItems = signal( + Array.from({ length: 300 }, (_, i) => ({ + label: `Col ${i}`, + width: 60 + (i % 7) * 30, + color: COLORS[i % COLORS.length], + })) + ); + protected readonly remoteItems = signal(makeItems(0, 20).map(it => it.label)); + protected readonly isLoading = signal(false); + + /** Append 20 more items when the virtual scroll requests more data. */ + protected onDataRequest(req: VirtualScrollDataRequest): void { + if (this.isLoading()) return; + this.isLoading.set(true); + + // Simulate an async fetch with a short delay + setTimeout(() => { + const current = this.remoteItems(); + const next = [ + ...current, + ...Array.from({ length: req.count }, (_, i) => `Remote item ${current.length + i}`), + ]; + this.remoteItems.set(next); + this.isLoading.set(false); + }, Math.random() * 1000 + 500); // Random delay between 500ms and 1500ms + } +} From e7ce7a18e03cbe6a7112cd5d2e7df1fa2998019f Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 18 May 2026 11:57:07 +0300 Subject: [PATCH 2/6] fix: ng-packagr build errors for virtual scroll component --- projects/igniteui-angular/virtual-scroll/index.ts | 4 +--- projects/igniteui-angular/virtual-scroll/src/public_api.ts | 3 +++ .../virtual-scroll/src/{ => virtual-scroll}/scroll-engine.ts | 0 .../virtual-scroll/src/{ => virtual-scroll}/types.ts | 0 .../src/{ => virtual-scroll}/virtual-scroll-item.directive.ts | 0 .../src/{ => virtual-scroll}/virtual-scroll.component.html | 0 .../src/{ => virtual-scroll}/virtual-scroll.component.scss | 0 .../src/{ => virtual-scroll}/virtual-scroll.component.spec.ts | 0 .../src/{ => virtual-scroll}/virtual-scroll.component.ts | 0 9 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 projects/igniteui-angular/virtual-scroll/src/public_api.ts rename projects/igniteui-angular/virtual-scroll/src/{ => virtual-scroll}/scroll-engine.ts (100%) rename projects/igniteui-angular/virtual-scroll/src/{ => virtual-scroll}/types.ts (100%) rename projects/igniteui-angular/virtual-scroll/src/{ => virtual-scroll}/virtual-scroll-item.directive.ts (100%) rename projects/igniteui-angular/virtual-scroll/src/{ => virtual-scroll}/virtual-scroll.component.html (100%) rename projects/igniteui-angular/virtual-scroll/src/{ => virtual-scroll}/virtual-scroll.component.scss (100%) rename projects/igniteui-angular/virtual-scroll/src/{ => virtual-scroll}/virtual-scroll.component.spec.ts (100%) rename projects/igniteui-angular/virtual-scroll/src/{ => virtual-scroll}/virtual-scroll.component.ts (100%) diff --git a/projects/igniteui-angular/virtual-scroll/index.ts b/projects/igniteui-angular/virtual-scroll/index.ts index 2bc0221a594..decc72d85bc 100644 --- a/projects/igniteui-angular/virtual-scroll/index.ts +++ b/projects/igniteui-angular/virtual-scroll/index.ts @@ -1,3 +1 @@ -export { IgxVirtualScrollComponent } from './src/virtual-scroll.component'; -export { IgxVirtualItemDirective } from './src/virtual-scroll-item.directive'; -export { IgxVsItemContext, VirtualScrollDataRequest, VirtualScrollState } from './src/types'; +export * from './src/public_api'; diff --git a/projects/igniteui-angular/virtual-scroll/src/public_api.ts b/projects/igniteui-angular/virtual-scroll/src/public_api.ts new file mode 100644 index 00000000000..8597f642012 --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/public_api.ts @@ -0,0 +1,3 @@ +export { IgxVirtualScrollComponent} from './virtual-scroll/virtual-scroll.component'; +export { IgxVirtualItemDirective } from './virtual-scroll/virtual-scroll-item.directive'; +export * from './virtual-scroll/types'; diff --git a/projects/igniteui-angular/virtual-scroll/src/scroll-engine.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts similarity index 100% rename from projects/igniteui-angular/virtual-scroll/src/scroll-engine.ts rename to projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts diff --git a/projects/igniteui-angular/virtual-scroll/src/types.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts similarity index 100% rename from projects/igniteui-angular/virtual-scroll/src/types.ts rename to projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll-item.directive.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll-item.directive.ts similarity index 100% rename from projects/igniteui-angular/virtual-scroll/src/virtual-scroll-item.directive.ts rename to projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll-item.directive.ts diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.html b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.html similarity index 100% rename from projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.html rename to projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.html diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.scss b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss similarity index 100% rename from projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.scss rename to projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts similarity index 100% rename from projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.spec.ts rename to projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts similarity index 100% rename from projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.ts rename to projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts From 5aa53cf07feb07f3d615233f2bb474c7517f193c Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 18 May 2026 13:23:41 +0300 Subject: [PATCH 3/6] chore: update virtual scroll component spec --- .../src/virtual-scroll/virtual-scroll.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts index 2d3cdf66d2f..41d704cf13d 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -25,7 +25,7 @@ function generateItems(count: number): string[] { // VirtualScrollEngine (pure unit tests – no DOM required) // --------------------------------------------------------------------------- -fdescribe('VirtualScrollEngine', () => { +describe('VirtualScrollEngine', () => { let engine: VirtualScrollEngine; beforeEach(() => { From 90dc5ecfb4ec77a01b1e3f8f94a80a70b054383b Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 18 May 2026 14:40:05 +0300 Subject: [PATCH 4/6] fix: fixes the issue with the virtual scroll component not updating the view when the data source changes. --- .../virtual-scroll.component.spec.ts | 16 ++++++++++------ .../virtual-scroll/virtual-scroll.component.ts | 17 ++++++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts index 41d704cf13d..0bc051f984f 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -565,14 +565,18 @@ describe('IgxVirtualItemDirective', () => { }); it('should be picked up as a content child of IgxVirtualScrollComponent', () => { - const directive = fixture.debugElement.query(By.directive(IgxVirtualItemDirective)); - expect(directive).toBeTruthy(); + // By.directive() is unreliable for ng-template nodes in headless environments; + // access the component's contentChild signal directly instead. + const vs = fixture.debugElement + .query(By.directive(IgxVirtualScrollComponent)) + .componentInstance as any; + expect(vs._itemDirective()).not.toBeNull(); }); it('should expose a non-null TemplateRef', () => { - const directiveInstance: IgxVirtualItemDirective = fixture.debugElement - .query(By.directive(IgxVirtualItemDirective)) - .injector.get(IgxVirtualItemDirective); - expect(directiveInstance.template).toBeTruthy(); + const vs = fixture.debugElement + .query(By.directive(IgxVirtualScrollComponent)) + .componentInstance as any; + expect(vs._itemDirective()?.template).toBeTruthy(); }); }); diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts index 70603dac13a..50be945b2d4 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts @@ -180,7 +180,22 @@ export class IgxVirtualScrollComponent implements OnDestroy { const data = this.data(); const template = this._resolvedTemplate(); const vcr = this._itemsViewContainer(); - if (!template || !vcr || range.endIndex < range.startIndex) return; + if (!vcr) return; + + if (range.endIndex < range.startIndex) { + // Data is empty or viewport has no size — clear any previously rendered views. + untracked(() => { + while (this._activeItems.length > 0) { + const view = this._activeItems.pop()!; + const idx = vcr.indexOf(view); + if (idx > -1) vcr.detach(idx); + this._pooledItems.push(view); + } + }); + return; + } + + if (!template) return; untracked(() => this._renderRange(range.startIndex, range.endIndex, data, template), From fe32facce120bb264ed5519daf4ac90d48528860 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 22 May 2026 10:58:36 +0300 Subject: [PATCH 5/6] feat: Use Binary Indexed Tree for the scroll engine Fixed several issues with virtual coordinates mapping and the scroll engine in general. The Binary Indexed Tree (BIT) is used to efficiently calculate the cumulative heights of items in the virtual scroll, which allows for faster updates and smoother scrolling experience. --- .../src/virtual-scroll/scroll-engine.ts | 237 ++++++++++++------ .../virtual-scroll.component.spec.ts | 6 +- .../virtual-scroll.component.ts | 82 ++++-- .../virtual-scroll/virtual-scroll.sample.html | 2 +- .../virtual-scroll/virtual-scroll.sample.ts | 2 +- 5 files changed, 224 insertions(+), 105 deletions(-) diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts index 2b89eafaf0e..376a47ab698 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts @@ -16,43 +16,127 @@ function getMaxBrowserSizeProbePx(doc: Document): number { } /** - * Builds a prefix sums array from the given sizes array. - * The prefix sums array has one more element than the sizes array, - * where the first element is 0 and each subsequent element is the sum of all previous sizes. - * This allows for efficient calculation of the total size up to any index in the sizes array. + * Binary Indexed Tree over item sizes. + * + * Replaces the previous O(N) full prefix-sum rebuild that occurred on every + * `measureItem` call. All hot-path operations are now O(log N): + * - Point update (item measured) : O(log N) + * - Prefix sum (scroll offset) : O(log N) + * - Index at offset (scroll -> item) : O(log N) via binary lifting */ -function buildPrefixSums(sizes: readonly number[]): number[] { - const sums = new Array(sizes.length + 1); - sums[0] = 0; - for (let i = 0; i < sizes.length; i++) { - sums[i + 1] = sums[i] + sizes[i]; +class BIT { + public readonly length: number; + + /** 1-indexed BIT; each cell holds a partial range sum. */ + private readonly _tree: Float64Array; + + /** Raw per-item sizes (0-indexed) — kept for O(1) reads and delta calc. */ + private readonly _sizes: Float64Array; + + /** Running total maintained alongside tree updates in O(1). */ + private _total: number; + + constructor(length: number, fillSize: number) { + this.length = length; + this._sizes = new Float64Array(length).fill(fillSize); + this._tree = new Float64Array(length + 1); + this._total = length * fillSize; + + // O(N) build (vs O(N log N) for N individual insertions) + for (let i = 1; i <= length; i++) { + this._tree[i] += fillSize; + const j = i + (i & -i); + if (j <= length) { + this._tree[j] += this._tree[i]; + } + } } - return sums; -} -/** - * Performs a binary search on the prefix sums array to find the largest index such that prefixSums[index] <= target. - * This is used to efficiently determine how many items can fit within a given scroll position. - * The function returns the index of the last item that fits within the target scroll position. - * If the target is smaller than the first prefix sum, it returns -1, indicating that no items fit. - */ -function binarySearchPrefixSums( - prefixSums: readonly number[], - target: number, -): number { - let low = 0; - let high = prefixSums.length - 1; - - while (low < high) { - const mid = (low + high + 1) >> 1; - if (prefixSums[mid] <= target) { - low = mid; - } else { - high = mid - 1; + /** Total size of all items. O(1). */ + get totalSize(): number { + return this._total; + } + + /** + * Prefix sum of items [0, i) — the virtual scroll offset at the leading + * edge of item i. O(log N). + */ + prefixSum(i: number): number { + let sum = 0; + for (let j = i; j > 0; j -= j & -j) { + sum += this._tree[j]; + } + return sum; + } + + /** + * Update the size of item at 0-based index. + * Returns true when the size actually changed. O(log N). + */ + update(index: number, newSize: number): boolean { + if (index < 0 || index >= this.length) return false; + + const old = this._sizes[index]; + if (old === newSize) return false; + + const delta = newSize - old; + this._sizes[index] = newSize; + this._total += delta; + for (let i = index + 1; i <= this.length; i += i & -i) { + this._tree[i] += delta; } + return true; } - return Math.max(0, low - 1); + /** + * Returns a new BIT of `newLength` items. + * Existing measured sizes are preserved up to `min(this.length, newLength)`; + * new slots are filled with `fillSize`. Rebuilds the BIT in O(N). + */ + cloneResized(newLength: number, fillSize: number): BIT { + const next = new BIT(newLength, fillSize); + const copyLen = Math.min(this.length, newLength); + + // Copy raw sizes from the old tree. + next._sizes.set(this._sizes.subarray(0, copyLen)); + + // Rebuild BIT and total from scratch in O(N). + next._tree.fill(0); + next._total = 0; + for (let i = 1; i <= newLength; i++) { + next._tree[i] += next._sizes[i - 1]; + next._total += next._sizes[i - 1]; + const j = i + (i & -i); + if (j <= newLength) { + next._tree[j] += next._tree[i]; + } + } + return next; + } + + /** + * Returns the 0-based index of the last item whose cumulative end offset + * is ≤ the given scroll offset (binary lifting on the internal tree). + * O(log N) — semantics are identical to the previous binarySearchPrefixSums + * result so `getVisibleRange` / overscan logic is unchanged. + */ + findIndexAtOffset(offset: number): number { + if (offset <= 0 || this.length === 0) return 0; + + let idx = 0; + for ( + let bit = 1 << (31 - Math.clz32(this.length)); + bit > 0; + bit >>= 1 + ) { + const next = idx + bit; + if (next <= this.length && this._tree[next] <= offset) { + idx = next; + offset -= this._tree[idx]; + } + } + return Math.max(0, idx - 1); + } } /** @@ -82,26 +166,30 @@ export class VirtualScrollEngine { */ private _virtualRatio = 1; - /** Per-item measured or estimated sizes in px. */ - private readonly _itemSizes = signal([]); + private _tree: BIT | null = null; /** - * Prefix-sum array of item sizes, where prefixSums[i] is the total size of items[0] through items[i-1]. + * Incremented on every structural change (resize or measurement). + * Downstream `computed()` values depend on this to stay reactive. */ - public readonly prefixSums = computed(() => - buildPrefixSums(this._itemSizes()), - ); + private readonly _version = signal(0); /** Total virtual size of all items in px. */ public readonly totalSize = computed(() => { - const pSum = this.prefixSums(); - return pSum[pSum.length - 1] ?? 0; + this._version(); + return this._tree?.totalSize ?? 0; }); /** Actual DOM space size (clamped to the maximum browser size) */ - public readonly domSize = computed(() => - this._virtualRatio !== 1 ? this._maxBrowserSize : this.totalSize(), - ); + public readonly domSize = computed(() => { + // Always read totalSize() to maintain a stable dependency on _version. + // If we branch on _virtualRatio first and return early, totalSize() is + // never called in the compression case, so Angular drops the dependency + // and domSize is frozen forever — subsequent measureItem() calls would + // never invalidate it. + const total = this.totalSize(); + return this._virtualRatio !== 1 ? this._maxBrowserSize : total; + }); /** * Initializes the maximum browser size by probing the document, and updates the virtual ratio accordingly. @@ -117,15 +205,13 @@ export class VirtualScrollEngine { * Existing measured sizes are preserved. */ public resize(length: number, estimatedSize: number): void { - const current = this._itemSizes(); - if (length === current.length) return; + if (this._tree?.length === length) return; - const next = current.slice(0, length); - while (next.length < length) { - next.push(estimatedSize); - } - this._itemSizes.set(next); + this._tree = this._tree + ? this._tree.cloneResized(length, estimatedSize) + : new BIT(length, estimatedSize); this._updateVirtualRatio(); + this._version.update((v) => v + 1); } /** @@ -133,14 +219,10 @@ export class VirtualScrollEngine { * Triggers a signal update so all downstream computed values react. */ public measureItem(index: number, size: number): void { - const current = this._itemSizes(); - if (index < 0 || index >= current.length) return; - if (current[index] === size) return; + if (!this._tree?.update(index, size)) return; - const next = current.slice(); - next[index] = size; - this._itemSizes.set(next); this._updateVirtualRatio(); + this._version.update((v) => v + 1); } /** @@ -148,21 +230,16 @@ export class VirtualScrollEngine { * at the leading edge of the viewport. */ public getScrollOffsetForIndex(index: number): number { - const pSums = this.prefixSums(); - if (index <= 0) return 0; + if (!this._tree || index <= 0) return 0; - const clamped = Math.min(index, pSums.length - 1); - const virtualOffset = pSums[clamped]; - return virtualOffset / this._virtualRatio; + const clamped = Math.min(index, this._tree.length); + return this._tree.prefixSum(clamped) / this._virtualRatio; } /** Returns the item index at the given DOM scroll position. */ public getIndexAtScroll(scrollPosition: number): number { - const virtualPosition = scrollPosition * this._virtualRatio; - const pSum = this.prefixSums(); - if (virtualPosition <= 0 || pSum.length <= 1) return 0; - - return binarySearchPrefixSums(pSum, virtualPosition); + if (!this._tree || scrollPosition <= 0) return 0; + return this._tree.findIndexAtOffset(scrollPosition * this._virtualRatio); } /** @@ -179,13 +256,27 @@ export class VirtualScrollEngine { } const start = Math.max(0, this.getIndexAtScroll(scrollPosition) - overScan); - const endScrollPosition = scrollPosition + viewportSize; - const endRaw = this.getIndexAtScroll(endScrollPosition); - const end = Math.min(totalItems - 1, endRaw + overScan); + const end = Math.min( + totalItems - 1, + this.getIndexAtScroll(scrollPosition + viewportSize) + overScan, + ); return { startIndex: start, endIndex: end }; } + /** + * Returns the sum of actual measured sizes for items in [startIndex, endIndex]. + * Used by the component to detect when the rendered range overflows `domSize` + * under coordinate compression (variable heights + large datasets). + */ + public getPhysicalRangeSize(startIndex: number, endIndex: number): number { + if (!this._tree) return 0; + + const start = Math.max(0, startIndex); + const end = Math.min(Math.max(endIndex + 1, start), this._tree.length); + return this._tree.prefixSum(end) - this._tree.prefixSum(start); + } + /** * Returns the CSS `translateY` / `translateX` value (px) to apply to the * absolutely-positioned content wrapper. @@ -196,16 +287,14 @@ export class VirtualScrollEngine { * at its virtual scroll position within the track. */ public getContentPosition(index: number): number { - const pSums = this.prefixSums(); - if (index <= 0) return 0; + if (!this._tree || index <= 0) return 0; - const clamped = Math.min(index, pSums.length - 1); - const virtualOffset = pSums[clamped]; - return virtualOffset / this._virtualRatio; + const clamped = Math.min(index, this._tree.length); + return this._tree.prefixSum(clamped) / this._virtualRatio; } private _updateVirtualRatio(): void { - const totalSize = this.totalSize(); + const totalSize = this._tree?.totalSize ?? 0; this._virtualRatio = this._maxBrowserSize === Infinity || totalSize <= this._maxBrowserSize ? 1 diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts index 0bc051f984f..89b3dc79e07 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -76,9 +76,9 @@ describe('VirtualScrollEngine', () => { it('should be a no-op when the size has not changed', () => { engine.resize(3, 50); - const before = engine.prefixSums(); - engine.measureItem(0, 50); - expect(engine.prefixSums()).toEqual(before); + const before = engine.totalSize(); + engine.measureItem(0, 50); // size is already 50 + expect(engine.totalSize()).toBe(before); }); }); diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts index 50be945b2d4..fa8685a86ff 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts @@ -61,27 +61,48 @@ export class IgxVirtualScrollComponent implements OnDestroy { /** Detached views available for reuse. */ private readonly _pooledItems: EmbeddedViewRef>[] = []; + protected readonly _engine = new VirtualScrollEngine(); + private readonly _scrollPosition = signal(0); private readonly _viewportSize = signal(0); - private readonly _visibleRange = computed(() => - this._engine.getVisibleRange( + private readonly _visibleRange = computed(() => { + + // Establish a reactive dependency on domSize so the range recomputes + // whenever measureItem() changes _virtualRatio. Without this, the range + // stays stale while scroll position is unchanged but sizes have updated: + // - items smaller than estimated -> viewport is not fully filled + // - virtual-ratio increase at end -> last items are in the DOM but + // positioned beyond the max scroll coordinate + void this._engine.domSize(); + + return this._engine.getVisibleRange( this._scrollPosition(), this._viewportSize(), this.overScan(), this.data().length, - ), - ); + ); + }); - protected readonly _engine = new VirtualScrollEngine(); protected readonly _isVertical = computed( () => this.orientation() === "vertical", ); protected readonly _spaceSize = computed(() => this._engine.domSize()); protected readonly _contentTransform = computed(() => { - const position = this._engine.getContentPosition( - this._visibleRange().startIndex, + const range = this._visibleRange(); + let position = this._engine.getContentPosition(range.startIndex); + + // Under coordinate compression (_virtualRatio > 1) item virtual positions + // are scaled down but item physical heights are not. Without this cap the + // rendered range overflows past domSize at the end of the list, pushing + // the last items beyond the maximum browser scroll coordinate. + const physicalRangeSize = this._engine.getPhysicalRangeSize( + range.startIndex, + range.endIndex, ); + const domSize = this._engine.domSize(); + position = Math.max(0, Math.min(position, domSize - physicalRangeSize)); + return this._isVertical() ? `translateY(${position}px)` : `translateX(${position}px)`; @@ -207,6 +228,12 @@ export class IgxVirtualScrollComponent implements OnDestroy { const range = this._visibleRange(); const total = this.data().length; + // Guard: do not fire on the initial render. The effect runs eagerly + // before any user interaction, and with a small initial dataset the + // visible range may already reach near the end of the loaded items. + // Only emit once the user has actually scrolled (scrollPosition > 0). + if (this._scrollPosition() === 0) return; + if (total > 0 && range.endIndex >= total - REMOTE_SCROLLING_THRESHOLD) { this.dataRequest.emit({ startIndex: total, @@ -292,32 +319,35 @@ export class IgxVirtualScrollComponent implements OnDestroy { private _scheduleItemMeasurement(startIndex: number, count: number): void { if (!isPlatformBrowser(this._platformId)) return; - this._itemResizeObserver?.disconnect(); - this._itemResizeObserver = new ResizeObserver((entries) => { - let anyChanged = false; - for (const entry of entries) { - const el = entry.target as HTMLElement; - const index = parseInt(el.dataset["vsIndex"] ?? "-1", 10); - if (index < 0) continue; - - const measured = this._isVertical() - ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height) - : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width); - - if (measured > 0) { - this._engine.measureItem(index, measured); - anyChanged = true; + if (!this._itemResizeObserver) { + this._itemResizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const el = entry.target as HTMLElement; + const index = parseInt(el.dataset["vsIndex"] ?? "-1", 10); + if (index < 0) continue; + + const measured = this._isVertical() + ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height) + : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width); + + if (measured > 0) { + this._engine.measureItem(index, measured); + } } - } - }); + }); + } + + this._itemResizeObserver.disconnect(); const content = this._contentDivRef()?.nativeElement; if (!content) return; const itemRoots = Array.from(content.children) as HTMLElement[]; - for (let i = 0; i < Math.min(count, itemRoots.length); i++) { + const max = Math.min(count, itemRoots.length); + + for (let i = 0; i < max; i++) { const el = itemRoots[i]; - el.dataset["vsIndex"] = (startIndex + i).toString(); + el.dataset["vsIndex"] = String(startIndex + i); this._itemResizeObserver.observe(el); } } diff --git a/src/app/virtual-scroll/virtual-scroll.sample.html b/src/app/virtual-scroll/virtual-scroll.sample.html index 6900db52ae9..128f76f9e38 100644 --- a/src/app/virtual-scroll/virtual-scroll.sample.html +++ b/src/app/virtual-scroll/virtual-scroll.sample.html @@ -5,7 +5,7 @@

Virtual Scroll Samples

-

Vertical - variable-height items (100 items)

+

Vertical - variable-height items ({{ verticalItems().length }} )

Each item has a different height. The engine measures sizes after the first render and adjusts scroll calculations automatically. diff --git a/src/app/virtual-scroll/virtual-scroll.sample.ts b/src/app/virtual-scroll/virtual-scroll.sample.ts index 9d82f7760a7..1656f9e8895 100644 --- a/src/app/virtual-scroll/virtual-scroll.sample.ts +++ b/src/app/virtual-scroll/virtual-scroll.sample.ts @@ -36,7 +36,7 @@ function makeItems(start: number, count: number): VsSampleItem[] { imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], }) export class VirtualScrollSampleComponent { - protected readonly verticalItems = signal(makeItems(0, 100)); + protected readonly verticalItems = signal(makeItems(0, 1_000_000)); protected readonly verticalConstantItems = signal( Array.from({ length: 500 }, (_, i) => ({ id: i, From b987e22bcb57c10b704602f780d884c5ab2ecf21 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 22 May 2026 11:02:59 +0300 Subject: [PATCH 6/6] chore: Lint error in scroll engine --- .../virtual-scroll/src/virtual-scroll/scroll-engine.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts index 376a47ab698..12bc2778649 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts @@ -53,7 +53,7 @@ class BIT { } /** Total size of all items. O(1). */ - get totalSize(): number { + public get totalSize(): number { return this._total; } @@ -61,7 +61,7 @@ class BIT { * Prefix sum of items [0, i) — the virtual scroll offset at the leading * edge of item i. O(log N). */ - prefixSum(i: number): number { + public prefixSum(i: number): number { let sum = 0; for (let j = i; j > 0; j -= j & -j) { sum += this._tree[j]; @@ -73,7 +73,7 @@ class BIT { * Update the size of item at 0-based index. * Returns true when the size actually changed. O(log N). */ - update(index: number, newSize: number): boolean { + public update(index: number, newSize: number): boolean { if (index < 0 || index >= this.length) return false; const old = this._sizes[index]; @@ -93,7 +93,7 @@ class BIT { * Existing measured sizes are preserved up to `min(this.length, newLength)`; * new slots are filled with `fillSize`. Rebuilds the BIT in O(N). */ - cloneResized(newLength: number, fillSize: number): BIT { + public cloneResized(newLength: number, fillSize: number): BIT { const next = new BIT(newLength, fillSize); const copyLen = Math.min(this.length, newLength); @@ -120,7 +120,7 @@ class BIT { * O(log N) — semantics are identical to the previous binarySearchPrefixSums * result so `getVisibleRange` / overscan logic is unchanged. */ - findIndexAtOffset(offset: number): number { + public findIndexAtOffset(offset: number): number { if (offset <= 0 || this.length === 0) return 0; let idx = 0;