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..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; 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/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/virtual-scroll/scroll-engine.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts new file mode 100644 index 00000000000..2b89eafaf0e --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/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/virtual-scroll/types.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts new file mode 100644 index 00000000000..5db24a27e37 --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/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/virtual-scroll-item.directive.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll-item.directive.ts new file mode 100644 index 00000000000..f8a45fc305c --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/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/virtual-scroll.component.html b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.html new file mode 100644 index 00000000000..c8299c1d4d8 --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.html @@ -0,0 +1,14 @@ + diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss new file mode 100644 index 00000000000..a5648e40ddf --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/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/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts new file mode 100644 index 00000000000..0bc051f984f --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -0,0 +1,582 @@ +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) +// --------------------------------------------------------------------------- + +describe('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', () => { + // 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 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 new file mode 100644 index 00000000000..50be945b2d4 --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts @@ -0,0 +1,382 @@ +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 (!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), + ); + }); + + // 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 + } +}