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..142de11cbe6 --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/README.md @@ -0,0 +1,266 @@ +# 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, lists far larger than the browser's maximum scroll coordinate, 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 virtualize. Compared by reference. See [Updating `data`](#updating-data). | +| `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Scroll axis. | +| `overScan` | `number` | `2` | Extra items to render beyond each edge of the viewport. Higher values reduce blank flashes during fast scrolling at the cost of slightly more DOM nodes. Normalized to a non-negative integer. | +| `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. A non-positive value falls back to `50`. | +| `itemTemplate` | `TemplateRef> \| null` | `null` | Programmatic template that takes precedence over a content `ng-template[igxVirtualItem]`. | + +Changing `estimatedItemSize` re-applies it to every item that has **not** yet been measured in the DOM. Items that have been measured keep their real size. + +--- + +## Outputs + +| Output | Payload | Description | +|---|---|---| +| `stateChange` | `VirtualScrollState` | Emitted when the rendered virtual window changes. Consecutive renders that produce an identical window are not re-emitted. | +| `dataRequest` | `VirtualScrollDataRequest` | Emitted when the rendered window comes within a few items of the end of `data`. Use this to implement infinite / remote scrolling. | + +--- + +## Public API + +### `scrollToIndex(index: number, options?: ScrollIntoViewOptions): Promise` + +Scrolls the viewport to the item at `index`. + +Items outside the rendered window only have an *estimated* size, so the first jump can miss the target. The component measures the items at the landing point and corrects the scroll position, repeating until the offset is stable. The returned promise resolves on that final offset; callers that only need the first, approximate scroll can ignore it. + +```ts +@ViewChild(IgxVirtualScrollComponent) vs!: IgxVirtualScrollComponent; + +// Leading edge, instant (the default). +await this.vs.scrollToIndex(500); + +// Centered, animated. +await this.vs.scrollToIndex(500, { block: 'center', behavior: 'smooth' }); + +// Only scroll if the item is not already fully visible. +await this.vs.scrollToIndex(500, { block: 'nearest' }); +``` + +| Option | Values | Notes | +|---|---|---| +| `block` | `'start'` \| `'center'` \| `'end'` \| `'nearest'` | Alignment on the vertical axis. Defaults to `'start'`. | +| `inline` | same as `block` | Alignment on the horizontal axis; falls back to `block`. | +| `behavior` | `'auto'` \| `'smooth'` | Defaults to `'auto'`. | + +`'nearest'` leaves the scroll position untouched when the item is already fully in view, or when the item is larger than the viewport and currently covers it, matching native `scrollIntoView({ block: 'nearest' })`. + +Out-of-range indices are clamped to the data, and the resulting offset is clamped to the largest reachable scroll position. + +### `layoutComplete: Promise` + +Resolves once the virtual scroll has settled: the current render pass is complete, the item-size measurements it triggered are complete, and so are the renders those measurements scheduled. + +Useful when you need to read the resulting DOM after a `data` change, a scroll, or a viewport resize: + +```ts +this.items = await this.service.fetch(); +await this.vs.layoutComplete; +// The rendered window and the track size now reflect the new data. +``` + +--- + +## `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 +} +``` + +--- + +## Updating `data` + +`data` is compared **by reference**. Mutating the array in place (`items.push(...)`) does not trigger an update. Assign a new array instead. + +The component diffs the new array against the previous one to decide which item measurements it can keep: + +* **Appending** (`[...items, ...more]`) keeps the identity of every existing index, so all previous measurements are retained. +* **Replacing, filtering or sorting** invalidates every index from the first difference onwards; those items are measured again on their next render. + +--- + +## 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 }}
+
+
+``` + +Right-to-left (RTL) layouts are fully supported. When the component (or an ancestor) sets `dir="rtl"`, horizontal scrolling, content positioning, and `scrollToIndex` are mirrored automatically. No extra configuration is required. + +--- + +## 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]; + }); +} +``` + +`dataRequest` is also emitted on the **first render** when the initially loaded items do not fill the viewport, so an empty or short initial `data` array is enough to start the loading chain. + +Only one request is in flight at a time: the next one is emitted after `data` changes. If your source is exhausted and you reassign `data` without adding items, the component will not ask again for the same `startIndex`. + +--- + +## Programmatic template + +Pass a `TemplateRef` via `[itemTemplate]` when the template is defined outside the component: + +```html + +
{{ item }}
+
+ + +``` + +--- + +## Styling and DOM structure + +```html + + + +``` + +| 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. | +| `igx-vs__item` | Per-item wrapper | One per rendered item; carries `data-vs-index` and is the element the engine measures. | + +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. + +### Item sizing + +Items are measured by their **border box**, so margins are not included and accumulate as drift down the list. Use `padding` on the item, or a `gap` on a wrapper, instead of margins. + +### Accessibility + +Only the current window is in the DOM, so assistive technology cannot infer an item's position from the markup. The host carries `role="list"`; if your template renders a role with set semantics (`listitem`, `option`, `row`, ...), map the context's `index` and `count` onto `aria-posinset` and `aria-setsize`: + +```html + +
+ {{ item }} +
+
+``` + +--- + +## Very large lists + +Browsers cap how far an element can scroll. When the total item size exceeds that limit, the component compresses the virtual coordinate space into the range the browser can represent and scales scroll positions accordingly. Items still render at their real pixel size, so lists of millions of items scroll correctly with no configuration. 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..edac900205c --- /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..95ec0967a5f --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts @@ -0,0 +1,472 @@ +import { computed, signal } from "@angular/core"; +import { clamp } from "igniteui-angular/core"; +import type { ScrollAlignment, VisibleRange } from "./types"; + +/** The maximum scroll coordinate is a per-document constant, so probe once. */ +const _maxBrowserSizeCache = new WeakMap(); + +/** Measures the largest scroll coordinate the browser can represent. */ +function probeMaxBrowserSize(doc: Document): number { + const cached = _maxBrowserSizeCache.get(doc); + if (cached !== undefined) { + return cached; + } + + const container = doc.body ?? doc.documentElement; + if (!container) { + return Number.POSITIVE_INFINITY; + } + + const probe = doc.createElement("div"); + probe.style.position = "absolute"; + probe.style.top = `${Number.MAX_SAFE_INTEGER}px`; + probe.style.width = "0"; + probe.style.height = "0"; + probe.style.visibility = "hidden"; + container.appendChild(probe); + + // The rect is viewport relative, so add back how far the document is scrolled. + const scrollOffset = doc.documentElement?.scrollTop ?? 0; + const size = Math.abs(probe.getBoundingClientRect().top) + scrollOffset; + container.removeChild(probe); + + _maxBrowserSizeCache.set(doc, size); + return size; +} + +/** + * Fills `tree` with the partial range sums of `sizes` in one O(N) pass. + * `tree` is a 1-indexed Fenwick array of `sizes.length + 1` zeroed entries. + * Returns the total sum. + */ +function buildTree(tree: Float64Array, sizes: Float64Array): number { + const length = sizes.length; + let total = 0; + + for (let i = 1; i <= length; i++) { + tree[i] += sizes[i - 1]; + total += sizes[i - 1]; + + const parent = i + (i & -i); + if (parent <= length) { + tree[parent] += tree[i]; + } + } + return total; +} + +/** + * Binary Indexed Tree (Fenwick tree) over item sizes. Each hot-path operation + * is O(log N): point update (item measured), prefix sum (scroll offset), and + * index at offset (scroll to item, through binary lifting). + */ +class SizeTree { + public readonly length: number; + + /** A 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 calculation. */ + private readonly _sizes: Float64Array; + + /** + * Flags the indices holding a DOM-measured size (`1`) instead of an + * estimate (`0`). `applyEstimate` changes only the estimated entries. + */ + private readonly _measured: Uint8Array; + + /** Running total. Updated together with the tree in O(1). */ + private _total: number; + + /** + * The highest power of two <= `length`, for the binary lifting in + * `findIndexAtOffset`. Precomputed because that runs on each scroll event. + */ + private readonly _topBit: number; + + private constructor( + sizes: Float64Array, + tree: Float64Array, + total: number, + measured: Uint8Array, + ) { + this.length = sizes.length; + this._sizes = sizes; + this._tree = tree; + this._total = total; + this._measured = measured; + this._topBit = this.length > 0 ? 1 << (31 - Math.clz32(this.length)) : 0; + } + + /** Creates a tree of `length` unmeasured items, each set to `fillSize`. O(N). */ + public static filled(length: number, fillSize: number): SizeTree { + return SizeTree._build( + new Float64Array(length).fill(fillSize), + new Uint8Array(length), + ); + } + + /** Builds a tree from a sizes array and its matching measured flags. O(N). */ + private static _build(sizes: Float64Array, measured: Uint8Array): SizeTree { + const tree = new Float64Array(sizes.length + 1); + const total = buildTree(tree, sizes); + return new SizeTree(sizes, tree, total, measured); + } + + /** Total size of all items. O(1). */ + public 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). + */ + public prefixSum(i: number): number { + let sum = 0; + for (let j = i; j > 0; j -= j & -j) { + sum += this._tree[j]; + } + return sum; + } + + /** + * Sets the size of the item at a 0-based index and marks it measured, so + * later `applyEstimate` calls leave it alone. Returns true when the size + * changed. O(log N). + */ + public update(index: number, newSize: number): boolean { + if (index < 0 || index >= this.length) { + return false; + } + + const old = this._sizes[index]; + this._measured[index] = 1; + + 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; + } + + /** + * Returns a new tree of `newLength` items in one O(N) pass. Sizes and + * measured flags are kept up to `min(this.length, newLength, retainCount)`. + * The remainder is filled with `fillSize` and marked unmeasured. Pass a + * `retainCount` below the item count when the data behind those indices + * changed identity. + */ + public cloneResized( + newLength: number, + fillSize: number, + retainCount = newLength, + ): SizeTree { + const sizes = new Float64Array(newLength).fill(fillSize); + const measured = new Uint8Array(newLength); + const retained = Math.max(0, Math.min(this.length, newLength, retainCount)); + + sizes.set(this._sizes.subarray(0, retained)); + measured.set(this._measured.subarray(0, retained)); + return SizeTree._build(sizes, measured); + } + + /** + * Sets `estimatedSize` on each unmeasured item. Returns true when at least + * one size changed. + * + * One estimate change can touch most of the list, so this rebuilds in one + * O(N) pass instead of one O(log N) `update` per item. + */ + public applyEstimate(estimatedSize: number): boolean { + let changed = false; + + for (let i = 0; i < this.length; i++) { + if (!this._measured[i] && this._sizes[i] !== estimatedSize) { + this._sizes[i] = estimatedSize; + changed = true; + } + } + + if (!changed) { + return false; + } + + this._tree.fill(0); + this._total = buildTree(this._tree, this._sizes); + return true; + } + + /** + * Returns the 0-based index of the item containing the scroll `offset`: + * the largest i where `prefixSum(i) <= offset < prefixSum(i + 1)`. O(log N). + */ + public findIndexAtOffset(offset: number): number { + if (offset <= 0 || this.length === 0) { + return 0; + } + + let index = 0; + let remaining = offset; + + for (let bit = this._topBit; bit > 0; bit >>= 1) { + const next = index + bit; + if (next <= this.length && this._tree[next] <= remaining) { + index = next; + remaining -= this._tree[index]; + } + } + return Math.min(this.length - 1, index); + } +} + +/** + * Pure scroll-math engine for one axis of virtual scrolling. A Fenwick tree + * holds all size state, exposed through signals so downstream `computed()` + * values (visible range, spacer size, translate offset) react to any change + * of item sizes or item count. + * + * ### Virtual and DOM coordinates + * + * Browsers limit how far an element can scroll. When the total item size is + * larger than that limit, the engine compresses the *virtual* space + * (`0…totalSize`) into the *DOM* space the browser can represent + * (`0…domSize`) by the factor `_virtualRatio`. Offsets crossing that boundary + * are scaled: incoming scroll positions are multiplied by the ratio, outgoing + * offsets are divided by it. Items render at their real pixel size, so item + * sizes are always virtual. + */ +export class VirtualScrollEngine { + private _maxBrowserSize = Number.POSITIVE_INFINITY; + + /** + * `totalSize / maxBrowserSize` while the content is too large for the + * browser's scroll range; `1` otherwise. Maps virtual onto DOM positions. + */ + private _virtualRatio = 1; + + private _tree: SizeTree | null = null; + + /** Bumped on every structural change: resize, measurement or estimate. */ + private readonly _version = signal(0); + + /** + * Read this from a `computed()` to make it recompute on any size change. + * `totalSize` and `domSize` already do. + */ + public readonly version = this._version.asReadonly(); + + /** Total virtual size of all items in px. */ + public readonly totalSize = computed(() => { + this._version(); + return this._tree?.totalSize ?? 0; + }); + + /** + * Total size in DOM space, clamped to the maximum browser size. + * + * Depends on `_version` directly rather than on `totalSize()`. The ratio + * can change while the total does not (the browser maximum is probed after + * the first render), and an unchanged `totalSize` would not propagate. + */ + public readonly domSize = computed(() => { + this._version(); + const total = this._tree?.totalSize ?? 0; + return this._virtualRatio !== 1 ? this._maxBrowserSize : total; + }); + + /** + * Probes the document's maximum scroll coordinate and rescales. Notifies, + * because the probe can only run after the first render, by which point + * `domSize` has already been read at the uncompressed total. + */ + public initMaxBrowserSize(doc: Document): void { + this._maxBrowserSize = probeMaxBrowserSize(doc); + this._invalidate(); + } + + /** + * Resizes the internal sizes array to `length`. Measured sizes below + * `retainCount` are kept, the remainder falls back to `estimatedSize`. + * Callers that only append can keep the default `retainCount`. Callers + * whose data changed identity at some index must pass that index, so the + * stale measurements after it are discarded. + */ + public resize( + length: number, + estimatedSize: number, + retainCount = length, + ): void { + if (this._tree?.length === length && retainCount >= length) { + return; + } + + this._tree = this._tree + ? this._tree.cloneResized(length, estimatedSize, retainCount) + : SizeTree.filled(length, estimatedSize); + this._invalidate(); + } + + /** Records the measured DOM size for a single item. */ + public measureItem(index: number, size: number): void { + if (this._tree?.update(index, size)) { + this._invalidate(); + } + } + + /** + * Applies a new estimate to every item not yet measured in the DOM. Use + * this when `estimatedItemSize` changes but the item count does not, + * because `resize` is then a no-op. + */ + public updateEstimatedSize(estimatedSize: number): void { + if (this._tree?.applyEstimate(estimatedSize)) { + this._invalidate(); + } + } + + /** + * Returns the DOM scroll offset in px that puts the item at `index` at the + * leading edge of the viewport. + */ + public getScrollOffsetForIndex(index: number): number { + if (!this._tree || index <= 0) { + return 0; + } + return ( + this._tree.prefixSum(Math.min(index, this._tree.length)) / + this._virtualRatio + ); + } + + /** + * Returns the DOM scroll offset that positions the item at `index` in a + * `viewportSize` px viewport, aligned by `align` and clamped to the + * reachable scroll range. + * + * The slack is computed in virtual space against the item's real size and + * converted to DOM space once, at the end. One DOM pixel equals + * `_virtualRatio` virtual pixels, so mixed coordinates would scale the slack. + */ + public getAlignedScrollOffset( + index: number, + viewportSize: number, + align: ScrollAlignment, + ): number { + const bounds = this._itemBounds(index); + if (!bounds) { + return 0; + } + + const [start, end] = bounds; + const slack = viewportSize - Math.max(0, end - start); + let offset = start; + + if (align === "center") { + offset -= slack / 2; + } else if (align === "end") { + offset -= slack; + } + + return clamp( + offset / this._virtualRatio, + 0, + Math.max(0, this.domSize() - viewportSize), + ); + } + + /** + * Whether the item at `index` needs no further scrolling at the given DOM + * scroll position: it is either fully inside the viewport, or larger than + * the viewport and covering it. The second case matches native + * `scrollIntoView({ block: 'nearest' })`. + */ + public isIndexInView( + index: number, + scrollPosition: number, + viewportSize: number, + ): boolean { + const bounds = this._itemBounds(index); + if (!bounds) { + return false; + } + + const [start, end] = bounds; + const viewStart = Math.max(0, scrollPosition) * this._virtualRatio; + const viewEnd = viewStart + viewportSize; + + const contained = start >= viewStart && end <= viewEnd; + const spanning = start <= viewStart && end >= viewEnd; + + return contained || spanning; + } + + /** Returns the visible and over-scanned item range for the given scroll state. */ + public getVisibleRange( + scrollPosition: number, + viewportSize: number, + overScan: number, + ): VisibleRange { + if (!this._tree || this._tree.length === 0 || viewportSize <= 0) { + return { startIndex: 0, endIndex: -1 }; + } + + // The viewport is not scaled by the virtual ratio. Items render at their + // real pixel size, so a `viewportSize` px viewport always shows that many + // virtual pixels of items, at any compression of the scroll range. + const startOffset = Math.max(0, scrollPosition) * this._virtualRatio; + const first = this._tree.findIndexAtOffset(startOffset); + const last = this._tree.findIndexAtOffset(startOffset + viewportSize); + + return { + startIndex: Math.max(0, first - overScan), + endIndex: Math.min(this._tree.length - 1, last + overScan), + }; + } + + /** + * Sum of the real sizes of the items in [startIndex, endIndex]. The render + * pass uses it to clamp the content translate offset, so rendered items do + * not overflow past `domSize` under coordinate compression. + */ + 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); + } + + /** + * The virtual [start, end] offsets of the item at `index`, clamped into the + * item range. Null while there are no items. + */ + private _itemBounds(index: number): [number, number] | null { + if (!this._tree || this._tree.length === 0) { + return null; + } + + const clamped = clamp(index, 0, this._tree.length - 1); + return [this._tree.prefixSum(clamped), this._tree.prefixSum(clamped + 1)]; + } + + private _invalidate(): void { + this._updateVirtualRatio(); + this._version.update((v) => v + 1); + } + + private _updateVirtualRatio(): void { + const totalSize = this._tree?.totalSize ?? 0; + this._virtualRatio = + 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..14831ad044a --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts @@ -0,0 +1,64 @@ +/** Template context for a single item of the virtual scroll. */ +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; + } +} + +/** + * How `scrollToIndex` positions the requested item in the viewport. + * The subset of `ScrollLogicalPosition` that the engine supports. + */ +export type ScrollAlignment = "start" | "center" | "end"; + +/** The currently rendered (visible plus 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; +} + +/** Snapshot of the currently rendered virtual window. */ +export interface VirtualScrollState extends VisibleRange { + /** 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, emitted when the rendered window nears the end of + * the loaded items. Listen to it to implement infinite / remote scrolling. + */ +export interface VirtualScrollDataRequest { + /** The first index that does not yet have data. */ + 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..63857be259e --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.html @@ -0,0 +1,28 @@ + 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..d6e5ae1e00b --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss @@ -0,0 +1,55 @@ +:host { + display: block; + position: relative; + overflow: auto; +} + +:host(.igx-virtual-scroll--vertical) { + overflow-y: auto; + overflow-x: 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) { + overflow-x: auto; + overflow-y: hidden; + + .igx-vs__track { + height: 100%; + width: auto; + min-height: unset; + } + + .igx-vs__content { + display: flex; + flex-direction: row; + height: 100%; + width: auto; + } + + .igx-vs__item { + flex-shrink: 0; + height: 100%; + } +} + +:host(.igx-virtual-scroll--horizontal:dir(rtl)) { + .igx-vs__content { + left: auto; + right: 0; + } +} 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..2ffca51afca --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -0,0 +1,1200 @@ +import { Component, signal, viewChild } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { VirtualScrollEngine } from './scroll-engine'; +import { + IgxVsItemContext, + VirtualScrollDataRequest, + VirtualScrollState, +} from './types'; +import { IgxVirtualItemDirective } from './virtual-scroll-item.directive'; +import { IgxVirtualScrollComponent } from './virtual-scroll.component'; + +function generateItems(count: number): string[] { + return Array.from({ length: count }, (_, i) => `Item ${i}`); +} + +function engineOf(scroll: IgxVirtualScrollComponent): VirtualScrollEngine { + return (scroll as any)._engine; +} + +describe('VirtualScrollEngine', () => { + const ESTIMATE = 50; + + function createEngine(length = 100, estimate = ESTIMATE): VirtualScrollEngine { + const engine = new VirtualScrollEngine(); + engine.resize(length, estimate); + return engine; + } + + /** + * A stand-in document reporting `maxSize` as its largest reachable + * coordinate. A non-zero `scrollTop` models an already scrolled document: + * the probe's rect is viewport relative, so it comes back short by that + * much. `probes` counts how often a probe element was created. + */ + function createProbeDocument(maxSize: number, scrollTop = 0) { + const probe = { + style: {} as CSSStyleDeclaration, + getBoundingClientRect: () => ({ top: maxSize - scrollTop }), + }; + const state = { probes: 0 }; + const doc = { + body: { appendChild: () => undefined, removeChild: () => undefined }, + documentElement: { scrollTop }, + createElement: () => { + state.probes++; + return probe; + }, + } as unknown as Document; + + return { doc, state }; + } + + /** Builds an engine whose probed maximum browser size is `maxSize`. */ + function createEngineWithMaxSize( + maxSize: number, + length: number, + estimate = ESTIMATE, + ): VirtualScrollEngine { + const engine = new VirtualScrollEngine(); + engine.initMaxBrowserSize(createProbeDocument(maxSize).doc); + engine.resize(length, estimate); + return engine; + } + + describe('sizing', () => { + it('should fill new items with the estimated size', () => { + const engine = createEngine(10); + + expect(engine.totalSize()).toBe(500); + expect(engine.domSize()).toBe(500); + expect(engine.getScrollOffsetForIndex(0)).toBe(0); + expect(engine.getScrollOffsetForIndex(3)).toBe(150); + }); + + it('should report zero size before it is sized', () => { + const engine = new VirtualScrollEngine(); + + expect(engine.totalSize()).toBe(0); + expect(engine.getScrollOffsetForIndex(5)).toBe(0); + expect(engine.getPhysicalRangeSize(0, 10)).toBe(0); + expect(engine.getVisibleRange(0, 300, 2)).toEqual({ + startIndex: 0, + endIndex: -1, + }); + }); + + it('should apply a measured size to subsequent offsets', () => { + const engine = createEngine(10); + engine.measureItem(2, 120); + + expect(engine.totalSize()).toBe(570); + expect(engine.getScrollOffsetForIndex(2)).toBe(100); + expect(engine.getScrollOffsetForIndex(3)).toBe(220); + expect(engine.getPhysicalRangeSize(2, 2)).toBe(120); + }); + + it('should ignore measurements for out of range indices', () => { + const engine = createEngine(10); + engine.measureItem(10, 120); + engine.measureItem(-1, 120); + + expect(engine.totalSize()).toBe(500); + }); + + it('should clamp offsets to the item count', () => { + const engine = createEngine(10); + + expect(engine.getScrollOffsetForIndex(10)).toBe(500); + expect(engine.getScrollOffsetForIndex(999)).toBe(500); + expect(engine.getScrollOffsetForIndex(-5)).toBe(0); + }); + + it('should sum only the requested range, clamped to the item count', () => { + const engine = createEngine(10); + + expect(engine.getPhysicalRangeSize(2, 4)).toBe(150); + expect(engine.getPhysicalRangeSize(-5, 1)).toBe(100); + expect(engine.getPhysicalRangeSize(8, 999)).toBe(100); + expect(engine.getPhysicalRangeSize(4, 3)).toBe(0); + }); + }); + + describe('estimated size', () => { + it('should apply a new estimate to unmeasured items only', () => { + const engine = createEngine(10); + engine.measureItem(0, 30); + engine.updateEstimatedSize(100); + + expect(engine.totalSize()).toBe(30 + 9 * 100); + expect(engine.getScrollOffsetForIndex(1)).toBe(30); + }); + + it('should treat a measurement equal to the current size as measured', () => { + const engine = createEngine(10); + // The same value as the estimate: no size change, but the item must + // still be flagged as measured, so a later estimate cannot overwrite it. + engine.measureItem(0, ESTIMATE); + engine.updateEstimatedSize(100); + + expect(engine.totalSize()).toBe(ESTIMATE + 9 * 100); + }); + }); + + describe('resizing', () => { + it('should preserve measured sizes when items are appended', () => { + const engine = createEngine(10); + engine.measureItem(1, 30); + engine.resize(20, ESTIMATE); + + expect(engine.totalSize()).toBe(30 + 19 * ESTIMATE); + expect(engine.getScrollOffsetForIndex(2)).toBe(80); + }); + + it('should preserve measured sizes when items are removed', () => { + const engine = createEngine(10); + engine.measureItem(1, 30); + engine.resize(5, ESTIMATE); + + expect(engine.totalSize()).toBe(30 + 4 * ESTIMATE); + }); + + it('should discard measured sizes at and beyond retainCount', () => { + const engine = createEngine(10); + engine.measureItem(1, 30); + engine.measureItem(6, 30); + engine.resize(10, ESTIMATE, 4); + + // Item 1 is retained. Item 6 is set back to the estimate. + expect(engine.totalSize()).toBe(30 + 9 * ESTIMATE); + expect(engine.getScrollOffsetForIndex(2)).toBe(80); + }); + + it('should re-mark discarded items as unmeasured', () => { + const engine = createEngine(10); + engine.measureItem(6, 30); + engine.resize(10, ESTIMATE, 4); + engine.updateEstimatedSize(100); + + // Nothing is measured now, so each item follows the new estimate. + expect(engine.totalSize()).toBe(10 * 100); + }); + + it('should be a no-op when the length matches and everything is retained', () => { + const engine = createEngine(10); + engine.measureItem(1, 30); + + const version = engine.version(); + engine.resize(10, ESTIMATE); + + expect(engine.version()).toBe(version); + expect(engine.totalSize()).toBe(30 + 9 * ESTIMATE); + }); + }); + + describe('change notifications', () => { + it('should notify on resize, measurement and estimate changes', () => { + const engine = new VirtualScrollEngine(); + const version = engine.version(); + + engine.resize(10, ESTIMATE); + expect(engine.version()).toBe(version + 1); + + engine.measureItem(0, 30); + expect(engine.version()).toBe(version + 2); + + engine.updateEstimatedSize(80); + expect(engine.version()).toBe(version + 3); + }); + + it('should not notify when nothing actually changes', () => { + const engine = createEngine(10); + const version = engine.version(); + + engine.measureItem(0, ESTIMATE); + engine.updateEstimatedSize(ESTIMATE); + + expect(engine.version()).toBe(version); + }); + }); + + describe('visible range', () => { + it('should return an empty range without items or viewport', () => { + expect(createEngine(0).getVisibleRange(0, 300, 2)).toEqual({ + startIndex: 0, + endIndex: -1, + }); + expect(createEngine(10).getVisibleRange(0, 0, 2)).toEqual({ + startIndex: 0, + endIndex: -1, + }); + }); + + it('should cover the viewport from the top', () => { + const engine = createEngine(100); + + expect(engine.getVisibleRange(0, 300, 0)).toEqual({ + startIndex: 0, + endIndex: 6, + }); + }); + + it('should resolve an offset that falls exactly on an item boundary', () => { + const engine = createEngine(100); + + expect(engine.getVisibleRange(100, 100, 0)).toEqual({ + startIndex: 2, + endIndex: 4, + }); + }); + + it('should expand by the over-scan and clamp to the item count', () => { + const engine = createEngine(100); + + expect(engine.getVisibleRange(0, 300, 2)).toEqual({ + startIndex: 0, + endIndex: 8, + }); + expect(engine.getVisibleRange(5000, 300, 2)).toEqual({ + startIndex: 97, + endIndex: 99, + }); + }); + + it('should account for measured sizes', () => { + const engine = createEngine(100); + for (let i = 0; i < 10; i++) { + engine.measureItem(i, 100); + } + + expect(engine.getVisibleRange(0, 300, 0)).toEqual({ + startIndex: 0, + endIndex: 3, + }); + }); + }); + + describe('alignment', () => { + it('should align to the leading edge', () => { + const engine = createEngine(100); + + expect(engine.getAlignedScrollOffset(10, 300, 'start')).toBe(500); + }); + + it('should center the item within the viewport', () => { + const engine = createEngine(100); + + // 500 - (300 - 50) / 2 + expect(engine.getAlignedScrollOffset(10, 300, 'center')).toBe(375); + }); + + it('should align to the trailing edge', () => { + const engine = createEngine(100); + + // 500 - (300 - 50) + expect(engine.getAlignedScrollOffset(10, 300, 'end')).toBe(250); + }); + + it('should never return a negative offset', () => { + const engine = createEngine(100); + + expect(engine.getAlignedScrollOffset(0, 300, 'center')).toBe(0); + expect(engine.getAlignedScrollOffset(1, 300, 'end')).toBe(0); + }); + + it('should clamp to the largest reachable scroll offset', () => { + const engine = createEngine(100); + const maxOffset = engine.domSize() - 300; + + expect(maxOffset).toBe(5000 - 300); + expect(engine.getAlignedScrollOffset(99, 300, 'start')).toBe(maxOffset); + }); + + it('should report whether an item is fully in view', () => { + const engine = createEngine(100); + + expect(engine.isIndexInView(0, 0, 300)).toBeTrue(); + expect(engine.isIndexInView(5, 0, 300)).toBeTrue(); + // Item 6 spans 300-350, so it is only partially visible. + expect(engine.isIndexInView(6, 0, 300)).toBeFalse(); + expect(engine.isIndexInView(20, 0, 300)).toBeFalse(); + }); + + it('should treat an item larger than the viewport as in view once it covers it', () => { + const engine = createEngine(10); + engine.measureItem(0, 1000); + + // The item cannot fit inside the viewport. While it spans the whole + // viewport, there is nothing to scroll to, as with native + // `scrollIntoView({ block: 'nearest' })`. + expect(engine.isIndexInView(0, 0, 300)).toBeTrue(); + expect(engine.isIndexInView(0, 350, 300)).toBeTrue(); + // Scrolled past its trailing edge, the item no longer covers the viewport. + expect(engine.isIndexInView(0, 800, 300)).toBeFalse(); + }); + + it('should clamp an out of range index the same way as the alignment math', () => { + const engine = createEngine(100); + const last = engine.getAlignedScrollOffset(99, 300, 'start'); + + expect(engine.getAlignedScrollOffset(999, 300, 'start')).toBe(last); + expect(engine.isIndexInView(999, last, 300)).toBe( + engine.isIndexInView(99, last, 300), + ); + }); + + it('should stay within range on an empty tree', () => { + const engine = createEngine(0); + + expect(engine.getAlignedScrollOffset(0, 300, 'center')).toBe(0); + expect(engine.isIndexInView(0, 0, 300)).toBeFalse(); + }); + }); + + describe('coordinate compression', () => { + const MAX_SIZE = 10_000; + const ITEMS = 1000; // 50_000px total, a ratio of 5 + + it('should clamp the DOM size to the maximum browser size', () => { + const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS); + + expect(engine.totalSize()).toBe(50_000); + expect(engine.domSize()).toBe(MAX_SIZE); + }); + + it('should leave the DOM size untouched below the maximum', () => { + const engine = createEngineWithMaxSize(MAX_SIZE, 100); + + expect(engine.totalSize()).toBe(5000); + expect(engine.domSize()).toBe(5000); + }); + + it('should map DOM scroll positions onto the virtual space', () => { + const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS); + + // Halfway down the DOM range is halfway down the virtual range. + expect(engine.getVisibleRange(MAX_SIZE / 2, 300, 0).startIndex).toBe(500); + expect(engine.getScrollOffsetForIndex(500)).toBe(MAX_SIZE / 2); + }); + + it('should size the rendered window by the viewport, not by the ratio', () => { + const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS); + const compressed = engine.getVisibleRange(MAX_SIZE / 2, 300, 0); + + // A 300px viewport of 50px items shows 6 items at any compression of + // the virtual space, because the items render at their real size. + expect(compressed.endIndex - compressed.startIndex).toBe(6); + }); + + it('should convert the alignment slack into DOM space', () => { + const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS); + const start = engine.getAlignedScrollOffset(500, 300, 'start'); + const centered = engine.getAlignedScrollOffset(500, 300, 'center'); + + // The slack is 125 virtual px, which is 25 DOM px at a ratio of 5. + expect(start).toBe(MAX_SIZE / 2); + expect(centered).toBe(MAX_SIZE / 2 - 25); + }); + + it('should compress an already sized engine when the probe arrives later', () => { + const engine = new VirtualScrollEngine(); + engine.resize(ITEMS, ESTIMATE); + + // The component sizes the engine during change detection but can + // only probe the document after the first render, so `domSize` is + // read once before the maximum is known. + expect(engine.domSize()).toBe(50_000); + + engine.initMaxBrowserSize(createProbeDocument(MAX_SIZE).doc); + + expect(engine.domSize()).toBe(MAX_SIZE); + }); + + it('should probe a given document only once', () => { + const { doc, state } = createProbeDocument(MAX_SIZE); + + new VirtualScrollEngine().initMaxBrowserSize(doc); + new VirtualScrollEngine().initMaxBrowserSize(doc); + + expect(state.probes).toBe(1); + }); + + it('should probe the full extent from an already scrolled document', () => { + const { doc } = createProbeDocument(MAX_SIZE, 2500); + const engine = new VirtualScrollEngine(); + + engine.initMaxBrowserSize(doc); + engine.resize(ITEMS, ESTIMATE); + + // If the document scroll offset were not added back, the probe would + // report 7500 and the content would be compressed into it. + expect(engine.domSize()).toBe(MAX_SIZE); + }); + }); +}); + +describe('IgxVsItemContext', () => { + it('should expose item, index, and count', () => { + const context = new IgxVsItemContext('a', 2, 5); + + expect(context.$implicit).toBe('a'); + expect(context.index).toBe(2); + expect(context.count).toBe(5); + }); + + 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(); + }); +}); + +@Component({ + selector: 'test-virtual-scroll', + template: ` + + + {{ i }}: {{ item }} + + + `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +class TestHostComponent { + public readonly vs = viewChild.required(IgxVirtualScrollComponent); + + public items = signal(generateItems(100)); + public orientation = signal<'vertical' | 'horizontal'>('vertical'); + public overScan = signal(2); + public estimatedItemSize = signal(50); + public hostHeight = signal(300); + public hostWidth = signal(null); + public itemHeight = signal(50); + public itemWidth = signal(null); + + public states: VirtualScrollState[] = []; + public requests: VirtualScrollDataRequest[] = []; + + /** A 300x100 horizontal viewport of 50px wide items. */ + public useHorizontal(): void { + this.orientation.set('horizontal'); + this.hostHeight.set(100); + this.hostWidth.set(300); + this.itemHeight.set(null); + this.itemWidth.set(50); + } + + /** A 300px tall vertical viewport of 50px tall items. */ + public useVertical(): void { + this.orientation.set('vertical'); + this.hostHeight.set(300); + this.hostWidth.set(null); + this.itemHeight.set(50); + this.itemWidth.set(null); + } +} + +@Component({ + selector: 'test-virtual-scroll-rtl', + template: ` + + + {{ item }} + + + `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +class TestRtlHostComponent { + public readonly vs = viewChild.required(IgxVirtualScrollComponent); + public items = signal(generateItems(1000)); +} + +@Component({ + selector: 'test-virtual-scroll-no-template', + template: ` + + `, + imports: [IgxVirtualScrollComponent], +}) +class TestNoTemplateHostComponent { + public items = signal(generateItems(50)); +} + +@Component({ + selector: 'test-virtual-scroll-programmatic', + template: ` + + {{ i }}: {{ item }} + + + `, + imports: [IgxVirtualScrollComponent], +}) +class TestProgrammaticTemplateComponent { + public items = signal(generateItems(50)); +} + +function vsElement(fixture: ComponentFixture): HTMLElement { + return fixture.nativeElement.querySelector('igx-virtual-scroll'); +} + +function vsTrack(fixture: ComponentFixture): HTMLElement { + return fixture.nativeElement.querySelector('.igx-vs__track'); +} + +function vsContent(fixture: ComponentFixture): HTMLElement { + return fixture.nativeElement.querySelector('.igx-vs__content'); +} + +function vsItems(fixture: ComponentFixture): HTMLElement[] { + return Array.from(fixture.nativeElement.querySelectorAll('[data-vs-index]')); +} + +function vsIndices(fixture: ComponentFixture): number[] { + return vsItems(fixture).map((el) => Number(el.dataset['vsIndex'])); +} + +/** Runs change detection and waits for the measurement passes to settle. */ +async function settle( + fixture: ComponentFixture, + scroll: IgxVirtualScrollComponent, +): Promise { + fixture.detectChanges(); + await scroll.layoutComplete; + fixture.detectChanges(); + await scroll.layoutComplete; + fixture.detectChanges(); +} + +/** Sets a scroll offset on the given axis and dispatches a synthetic scroll. */ +async function scrollTo( + fixture: ComponentFixture, + scroll: IgxVirtualScrollComponent, + offset: number, + axis: 'top' | 'left' = 'top', +): Promise { + const element = vsElement(fixture); + + if (axis === 'top') { + element.scrollTop = offset; + } else { + element.scrollLeft = offset; + } + + element.dispatchEvent(new Event('scroll')); + await settle(fixture, scroll); +} + +describe('IgxVirtualScrollComponent', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let scroll: IgxVirtualScrollComponent; + + async function createFixture(): Promise { + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.autoDetectChanges(); + await fixture.whenStable(); + scroll = host.vs() as IgxVirtualScrollComponent; + await settle(fixture, scroll); + } + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TestHostComponent, + TestRtlHostComponent, + TestNoTemplateHostComponent, + TestProgrammaticTemplateComponent, + ], + }).compileComponents(); + })); + + describe('basic rendering', () => { + beforeEach(async () => { + await createFixture(); + }); + + it('should create the component', () => { + expect( + fixture.debugElement.query(By.directive(IgxVirtualScrollComponent)), + ).toBeTruthy(); + }); + + it('should have the igx-virtual-scroll class and role="list"', () => { + const element = vsElement(fixture); + + expect(element.classList).toContain('igx-virtual-scroll'); + expect(element.getAttribute('role')).toBe('list'); + }); + + it('should add the vertical modifier class by default', () => { + expect(vsElement(fixture).classList).toContain('igx-virtual-scroll--vertical'); + }); + + it('should render only a subset of the items', () => { + const rendered = vsItems(fixture); + + expect(rendered.length).toBeGreaterThan(0); + expect(rendered.length).toBeLessThan(100); + }); + + it('should render the track element sized to the full virtual extent', () => { + expect(vsTrack(fixture).style.height).toBe(`${100 * 50}px`); + }); + + it('should wrap every rendered item and tag it with its data index', () => { + const rendered = vsItems(fixture); + + for (const element of rendered) { + expect(element.classList).toContain('igx-vs__item'); + expect(element.getAttribute('role')).toBe('presentation'); + } + + expect(vsIndices(fixture)).toEqual( + rendered.map((_, i) => Number(rendered[0].dataset['vsIndex']) + i), + ); + }); + + it('should apply a transform to the content wrapper', () => { + expect(vsContent(fixture).style.transform).toMatch(/translateY\(/); + }); + + it('should reflect updated data', async () => { + host.items.set(generateItems(10)); + await settle(fixture, scroll); + + expect(vsTrack(fixture).style.height).toBe(`${10 * 50}px`); + // A 300px viewport of 50px items shows 0..6, plus an over-scan of 2. + expect(Math.max(...vsIndices(fixture))).toBe(8); + }); + + it('should render no items when data is empty', async () => { + host.items.set([]); + await settle(fixture, scroll); + + expect(vsItems(fixture).length).toBe(0); + expect(vsTrack(fixture)).toBeTruthy(); + }); + + it('should render nothing without an item template', async () => { + const noTemplate = TestBed.createComponent(TestNoTemplateHostComponent); + noTemplate.autoDetectChanges(); + await noTemplate.whenStable(); + + expect(vsItems(noTemplate).length).toBe(0); + }); + + it('should render items from a programmatic itemTemplate', async () => { + const programmatic = TestBed.createComponent(TestProgrammaticTemplateComponent); + programmatic.autoDetectChanges(); + await programmatic.whenStable(); + + expect(vsItems(programmatic).length).toBeGreaterThan(0); + expect(programmatic.nativeElement.textContent).toContain('0: Item 0'); + }); + }); + + describe('input normalization', () => { + beforeEach(async () => { + await createFixture(); + }); + + it('should fall back to the default for a non-positive estimatedItemSize', async () => { + host.estimatedItemSize.set(0); + host.items.set(generateItems(1000)); + host.itemHeight.set(20); + await settle(fixture, scroll); + + // Rendered items are measured at 20px; the rest must fall back to + // the default estimate of 50px rather than collapsing to zero. + const measured = vsItems(fixture).length; + const expected = measured * 20 + (1000 - measured) * 50; + + expect(vsTrack(fixture).style.height).toBe(`${expected}px`); + }); + + it('should clamp a negative overScan to zero', async () => { + host.overScan.set(-5); + host.itemHeight.set(50); + await settle(fixture, scroll); + + // A 300px viewport of 50px items shows 7 items and nothing extra. + expect(vsItems(fixture).length).toBe(7); + }); + }); + + describe('orientation', () => { + beforeEach(async () => { + await createFixture(); + }); + + it('should add the horizontal modifier class and size the track by width', async () => { + host.useHorizontal(); + await settle(fixture, scroll); + + const element = vsElement(fixture); + expect(element.classList).toContain('igx-virtual-scroll--horizontal'); + expect(vsTrack(fixture).style.width).toBe(`${100 * 50}px`); + expect(vsContent(fixture).style.transform).toMatch(/translateX\(/); + }); + + it('should re-read the scroll offset from the new axis when it changes', async () => { + host.useHorizontal(); + host.items.set(generateItems(1000)); + await settle(fixture, scroll); + + await scrollTo(fixture, scroll, 500, 'left'); + expect(Math.min(...vsIndices(fixture))).toBeGreaterThan(0); + + // The vertical axis was never scrolled. A switch to it must render + // from the top, not reuse the horizontal offset. + host.useVertical(); + await settle(fixture, scroll); + + expect(Math.min(...vsIndices(fixture))).toBe(0); + }); + }); + + describe('scroll handling', () => { + beforeEach(async () => { + await createFixture(); + }); + + it('should not invalidate the window for a scroll that stays inside it', async () => { + host.overScan.set(0); + host.items.set(generateItems(500)); + await settle(fixture, scroll); + + const tick = () => (scroll as any)._scrollTick() as number; + const before = tick(); + const element = vsElement(fixture); + + // Items are 50px tall and the over-scan is off, so any offset below + // the first item boundary renders the same window. + element.scrollTop = 10; + element.dispatchEvent(new Event('scroll')); + expect(tick()).toBe(before); + + element.scrollTop = 400; + element.dispatchEvent(new Event('scroll')); + expect(tick()).toBe(before + 1); + }); + + it('should render a later window after scrolling', async () => { + host.items.set(generateItems(500)); + await settle(fixture, scroll); + + await scrollTo(fixture, scroll, 2000); + + expect(Math.min(...vsIndices(fixture))).toBeGreaterThan(0); + }); + }); + + describe('events', () => { + beforeEach(async () => { + await createFixture(); + }); + + it('should emit stateChange with the rendered window', () => { + const state = host.states.at(-1); + + expect(state).toBeTruthy(); + expect(state!.startIndex).toBeLessThanOrEqual(state!.endIndex); + expect(state!.viewportSize).toBeGreaterThan(0); + expect(state!.totalSize).toBe(100 * 50); + }); + + it('should not re-emit stateChange when the window is unchanged', async () => { + host.items.set(generateItems(500)); + await settle(fixture, scroll); + + host.states.length = 0; + + // A scroll inside the current window changes nothing. + await scrollTo(fixture, scroll, 10); + expect(host.states.length).toBe(0); + + await scrollTo(fixture, scroll, 2000); + expect(host.states.length).toBeGreaterThan(0); + }); + + it('should emit dataRequest when the window reaches the end of data', async () => { + host.items.set(generateItems(4)); + await settle(fixture, scroll); + + expect(host.requests.at(-1)).toEqual({ startIndex: 4, count: 20 }); + }); + + it('should not re-request the same items when data is reassigned without growing', async () => { + host.items.set(generateItems(4)); + await settle(fixture, scroll); + + host.requests.length = 0; + + // A consumer whose source is exhausted, but which still reassigns in + // response to the request it cannot fulfil. Without the guard, this + // loops for as long as the consumer answers. + host.items.set(generateItems(4)); + await settle(fixture, scroll); + host.items.set(generateItems(4)); + await settle(fixture, scroll); + + expect(host.requests.length).toBe(0); + }); + + it('should request again once data actually grows', async () => { + host.items.set(generateItems(4)); + await settle(fixture, scroll); + + host.requests.length = 0; + + host.items.set(generateItems(8)); + await settle(fixture, scroll); + + expect(host.requests.at(-1)).toEqual({ startIndex: 8, count: 20 }); + }); + }); + + describe('engine integration', () => { + beforeEach(async () => { + await createFixture(); + }); + + it('should resize the track when data changes', async () => { + expect(vsTrack(fixture).style.height).toBe(`${100 * 50}px`); + + host.items.set(generateItems(200)); + await settle(fixture, scroll); + + expect(vsTrack(fixture).style.height).toBe(`${200 * 50}px`); + }); + + it('should apply a new estimatedItemSize when the item count is unchanged', async () => { + host.items.set(generateItems(1000)); + await settle(fixture, scroll); + + const engine = engineOf(scroll); + expect(engine.totalSize()).toBe(1000 * 50); + + // `resize` is a no-op at an unchanged item count, so this only takes + // effect through `updateEstimatedSize`. + host.estimatedItemSize.set(80); + await settle(fixture, scroll); + + // The rendered items keep their measured 50px, the rest follow 80px. + expect(engine.getScrollOffsetForIndex(1)).toBe(50); + expect(engine.totalSize()).toBeGreaterThan(1000 * 50); + expect(engine.totalSize()).toBeLessThan(1000 * 80); + }); + + it('should retain measurements on append and discard them on replacement', async () => { + host.items.set(generateItems(20)); + await settle(fixture, scroll); + + const engine = engineOf(scroll); + const resizeSpy = spyOn(engine, 'resize').and.callThrough(); + + // An append keeps the identity of each existing index, so all 20 + // measurements are retained. + host.items.update((items) => [...items, ...generateItems(5)]); + await settle(fixture, scroll); + + expect(resizeSpy.calls.mostRecent().args).toEqual([25, 50, 20]); + + // A replacement invalidates each index from the first difference on. + host.items.update((items) => items.map((item) => `${item}!`)); + await settle(fixture, scroll); + + expect(resizeSpy.calls.mostRecent().args).toEqual([25, 50, 0]); + }); + + it('should discard stale measurements when data of the same length is swapped', async () => { + host.items.set(generateItems(20)); + await settle(fixture, scroll); + + const engine = engineOf(scroll); + const resizeSpy = spyOn(engine, 'resize').and.callThrough(); + + // An identical item count used to make `resize` a no-op. That left + // the previous data's measurements on the new items. + host.items.set(generateItems(20).map((item) => `${item}!`)); + await settle(fixture, scroll); + + expect(resizeSpy.calls.mostRecent().args).toEqual([20, 50, 0]); + }); + + it('should not override the size of items already measured in the DOM', async () => { + host.items.set(generateItems(20)); + host.hostHeight.set(100); + host.itemHeight.set(30); + await settle(fixture, scroll); + + const engine = engineOf(scroll); + expect(engine.getScrollOffsetForIndex(1)).toBe(30); + + host.estimatedItemSize.set(200); + await settle(fixture, scroll); + + // Item 0 was measured in the DOM, so the new estimate cannot move it, + // while the unmeasured items at the end do follow it. + expect(engine.getScrollOffsetForIndex(1)).toBe(30); + expect(engine.totalSize()).toBeGreaterThan(20 * 30); + expect(engine.totalSize()).toBeLessThan(20 * 200); + }); + + it('should re-measure reused item elements when they host a different index', async () => { + host.items.set(generateItems(50)); + host.hostHeight.set(90); + host.itemHeight.set(30); + await settle(fixture, scroll); + + const element = vsElement(fixture); + + // Jump to the end. `@for` tracks by slot, so it reuses the wrapper + // elements for the new indices at an identical size and the + // ResizeObserver does not report that. Those indices used to keep + // their estimated size, which left a gap between the last item and + // the end of the track. Measurements at the bottom shrink the track, + // so apply the jump again until the scroll height is stable. + for (let i = 0; i < 10; i++) { + const height = element.scrollHeight; + await scrollTo(fixture, scroll, element.scrollHeight); + + if (element.scrollHeight === height) break; + } + + const items = vsItems(fixture); + const last = items[items.length - 1]; + + expect(last.dataset['vsIndex']).toBe('49'); + expect(last.getBoundingClientRect().bottom).toBeCloseTo( + vsTrack(fixture).getBoundingClientRect().bottom, + 0, + ); + }); + }); + + describe('scrollToIndex', () => { + beforeEach(async () => { + await createFixture(); + }); + + it('should scroll the vertical axis', async () => { + host.items.set(generateItems(1000)); + await settle(fixture, scroll); + + await scroll.scrollToIndex(100); + + expect(vsElement(fixture).scrollTop).toBe(100 * 50); + }); + + it('should scroll the horizontal axis', async () => { + host.useHorizontal(); + host.items.set(generateItems(1000)); + await settle(fixture, scroll); + + await scroll.scrollToIndex(100); + + expect(vsElement(fixture).scrollLeft).toBe(100 * 50); + }); + + it('should align the item to the center of the viewport', async () => { + host.items.set(generateItems(1000)); + await settle(fixture, scroll); + + await scroll.scrollToIndex(100, { block: 'center' }); + + // 100 * 50 - (300 - 50) / 2 + expect(vsElement(fixture).scrollTop).toBe(5000 - 125); + }); + + it('should align the item to the trailing edge of the viewport', async () => { + host.items.set(generateItems(1000)); + await settle(fixture, scroll); + + await scroll.scrollToIndex(100, { block: 'end' }); + + // 100 * 50 - (300 - 50) + expect(vsElement(fixture).scrollTop).toBe(5000 - 250); + }); + + it('should settle at the last index instead of waiting out the scroll timeout', async () => { + host.items.set(generateItems(1000)); + await settle(fixture, scroll); + + const element = vsElement(fixture); + + // The aligned offset for the final item lies past the reachable + // scroll range. Without a clamp, each correction pass would wait for + // a `scrollend` that the browser never fires. + await scroll.scrollToIndex(999, { block: 'end' }); + + expect(element.scrollTop).toBe(element.scrollHeight - element.clientHeight); + }); + + it('should not scroll for block: nearest when the item is already in view', async () => { + host.items.set(generateItems(1000)); + await settle(fixture, scroll); + + const element = vsElement(fixture); + const scrollToSpy = spyOn(element, 'scrollTo').and.callThrough(); + + await scroll.scrollToIndex(1, { block: 'nearest' }); + + expect(scrollToSpy).not.toHaveBeenCalled(); + expect(element.scrollTop).toBe(0); + }); + + it('should leave an item that already fills the viewport alone for block: nearest', async () => { + host.items.set(generateItems(20)); + host.estimatedItemSize.set(400); + host.itemHeight.set(400); + await settle(fixture, scroll); + + // Item 0 spans 0-400px and the viewport is 50-350px, so the item + // covers it fully. The item cannot fit inside the viewport, but + // there is also nothing to scroll to. + await scrollTo(fixture, scroll, 50); + + const element = vsElement(fixture); + const scrollToSpy = spyOn(element, 'scrollTo').and.callThrough(); + + await scroll.scrollToIndex(0, { block: 'nearest' }); + + expect(scrollToSpy).not.toHaveBeenCalled(); + expect(element.scrollTop).toBe(50); + }); + + it('should keep the requested index aligned once real sizes differ from the estimate', async () => { + host.items.set(generateItems(500)); + host.itemHeight.set(30); // smaller than the estimate of 50 + await settle(fixture, scroll); + + await scroll.scrollToIndex(250); + + expect(Math.min(...vsIndices(fixture))).toBe(250 - host.overScan()); + }); + + it('should clamp an out of range index', async () => { + host.items.set(generateItems(20)); + await settle(fixture, scroll); + + const element = vsElement(fixture); + await scroll.scrollToIndex(9999); + + expect(element.scrollTop).toBe(element.scrollHeight - element.clientHeight); + }); + }); + + describe('layoutComplete', () => { + beforeEach(async () => { + await createFixture(); + }); + + it('should settle when no animation frames are served', async () => { + const rafSpy = spyOn(window, 'requestAnimationFrame').and.returnValue(0); + + try { + host.items.set(generateItems(200)); + fixture.detectChanges(); + await scroll.layoutComplete; + } finally { + rafSpy.and.callThrough(); + } + + expect(rafSpy).toHaveBeenCalled(); + }); + }); + + describe('RTL', () => { + let rtlFixture: ComponentFixture; + let rtlScroll: IgxVirtualScrollComponent; + + beforeEach(async () => { + rtlFixture = TestBed.createComponent(TestRtlHostComponent); + rtlFixture.autoDetectChanges(); + await rtlFixture.whenStable(); + rtlScroll = rtlFixture.componentInstance.vs() as IgxVirtualScrollComponent; + await settle(rtlFixture, rtlScroll); + }); + + it('should normalize the negative scrollLeft into a positive engine offset', async () => { + // In RTL, browsers report scrollLeft as a negative value. + await scrollTo(rtlFixture, rtlScroll, -500, 'left'); + + expect(Math.min(...vsIndices(rtlFixture))).toBeGreaterThan(0); + }); + + it('should apply a negative translateX on the content wrapper when scrolled', async () => { + await scrollTo(rtlFixture, rtlScroll, -300, 'left'); + + expect(vsContent(rtlFixture).style.transform).toMatch( + /translateX\(-\d+(\.\d+)?px\)/, + ); + }); + + it('should scroll to a negative scrollLeft via scrollToIndex', async () => { + const element = vsElement(rtlFixture); + const scrollToSpy = spyOn(element, 'scrollTo').and.callThrough(); + + await rtlScroll.scrollToIndex(100); + + expect(scrollToSpy).toHaveBeenCalled(); + const args = scrollToSpy.calls.mostRecent().args[0] as ScrollToOptions; + expect(args.left).toBeLessThan(0); + }); + + it('should render the first data item as the right-most item', () => { + const items = vsItems(rtlFixture); + expect(items.length).toBeGreaterThan(1); + + const indices = vsIndices(rtlFixture); + // DOM order is ascending by data index... + expect(indices[0]).toBeLessThan(indices[1]); + + // ...but visually the lowest index sits to the right of the next. + expect(items[0].getBoundingClientRect().left).toBeGreaterThan( + items[1].getBoundingClientRect().left, + ); + }); + }); +}); 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..e943713ebff --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts @@ -0,0 +1,911 @@ +import { isPlatformBrowser, NgTemplateOutlet } from "@angular/common"; +import { + afterNextRender, + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + contentChild, + DOCUMENT, + effect, + ElementRef, + inject, + input, + NgZone, + OnDestroy, + output, + PLATFORM_ID, + signal, + TemplateRef, + untracked, + viewChild, +} from "@angular/core"; +import { clamp, isLeftToRight } from "igniteui-angular/core"; +import { VirtualScrollEngine } from "./scroll-engine"; +import { + IgxVsItemContext, + ScrollAlignment, + VirtualScrollDataRequest, + VirtualScrollState, + VisibleRange, +} from "./types"; +import { IgxVirtualItemDirective } from "./virtual-scroll-item.directive"; + +/** Defaults for the inputs, also used as the fallback for invalid values. */ +const DEFAULT_OVER_SCAN = 2; +const DEFAULT_ESTIMATED_ITEM_SIZE = 50; + +/** How close to the end of `data` the window must get to emit `dataRequest`. */ +const DATA_REQUEST_THRESHOLD = 5; +const DATA_REQUEST_MIN_COUNT = 20; +const DATA_REQUEST_OVER_SCAN_FACTOR = 4; + +/** Give-up bounds for the two loops that wait for the layout to stabilize. */ +const MAX_LAYOUT_SETTLE_PASSES = 20; +const MAX_SCROLL_CORRECTION_PASSES = 5; + +const SCROLL_END_TIMEOUT_MS = 2000; +const SCROLL_OFFSET_EPSILON_PX = 1; + +/** How long the scroll position must stay unchanged to count as settled. */ +const SCROLL_IDLE_MS = 100; + +/** + * Upper limit on one `requestAnimationFrame` wait. A hidden tab or a detached + * element gets no frames, and `layoutComplete` must still resolve there. + */ +const LAYOUT_FRAME_TIMEOUT_MS = 100; + +const EMPTY_RANGE: VisibleRange = Object.freeze({ startIndex: 0, endIndex: -1 }); + +function rangesEqual(a: VisibleRange, b: VisibleRange): boolean { + return a.startIndex === b.startIndex && a.endIndex === b.endIndex; +} + +function statesEqual( + a: VirtualScrollState | null, + b: VirtualScrollState, +): boolean { + return ( + a !== null && + rangesEqual(a, b) && + a.viewportSize === b.viewportSize && + a.totalSize === b.totalSize + ); +} + +/** The data index an item wrapper carries, or -1 when it has none. */ +function itemIndex(element: Element): number { + const index = Number.parseInt( + (element as HTMLElement).dataset["vsIndex"] ?? "", + 10, + ); + return Number.isInteger(index) && index >= 0 ? index : -1; +} + +function onAbort(abort: AbortSignal, cancel: () => void): void { + abort.addEventListener("abort", cancel, { once: true }); +} + +/** + * A virtual scroll component for large lists. Only the items visible in the + * viewport (plus a configurable over-scan) are rendered. + * + * @igxModule IgxVirtualScrollModule + * @igxTheme igx-virtual-scroll-theme + * @igxKeywords virtual, scroll, virtualization, list + * @igxGroup Grids & Lists + * + * @example + * ```html + * + * + *
{{ i }}: {{ item }}
+ *
+ *
+ * ``` + */ +@Component({ + selector: "igx-virtual-scroll", + templateUrl: "./virtual-scroll.component.html", + styleUrls: ["./virtual-scroll.component.scss"], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgTemplateOutlet], + 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 injection + + private readonly _hostRef = inject>(ElementRef); + private readonly _zone = inject(NgZone); + private readonly _document = inject(DOCUMENT); + private readonly _isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + //#endregion + + //#region Internal state + + private readonly _engine = new VirtualScrollEngine(); + + private _viewportResizeObserver: ResizeObserver | null = null; + private _itemResizeObserver: ResizeObserver | null = null; + private _onScroll: (() => void) | null = null; + + /** Elements currently registered with the item resize observer. */ + private readonly _observedItems = new Set(); + + /** The data index each observed wrapper element last hosted. */ + private readonly _observedItemIndexes = new WeakMap(); + + /** + * The live scroll offset on the active axis. A plain field, not a signal: + * `_visibleRange` reads it but is invalidated by `_scrollTick`, so a scroll + * that does not move the rendered window schedules no work. + */ + private _scrollPosition = 0; + + /** Bumped only when a scroll actually moves the rendered window. */ + private readonly _scrollTick = signal(0); + + private readonly _viewportSize = signal(0); + + /** The `data` array as of the previous change, for `_firstChangedIndex`. */ + private _previousData: T[] | undefined; + + private _lastEmittedState: VirtualScrollState | null = null; + private _hasPendingDataRequest = false; + + /** + * The `startIndex` of the last emitted `dataRequest`, which is also the + * item count at that emit. See `_checkDataRequest`. + */ + private _lastDataRequestIndex = -1; + + private _layoutCompletePromise: Promise | null = null; + private _scrollRequestId = 0; + + //#endregion + + //#region View and content children + + private readonly _itemDirective = contentChild(IgxVirtualItemDirective); + + private readonly _contentDivRef = + viewChild>("contentDiv"); + + //#endregion + + //#region Public inputs + + /** + * The array of items to virtualize. + * + * Compared by reference: mutating the array in place (`data.push(...)`) + * causes no update. Assign a new array instead. The `dataRequest` flow + * also expects a new array. + */ + 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(DEFAULT_OVER_SCAN); + + /** + * 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(DEFAULT_ESTIMATED_ITEM_SIZE); + + /** + * Item template provided programmatically. Takes precedence over a content + * `ng-template[igxVirtualItem]` when both are provided. + * + * Items are measured by their border box, so margins accumulate as drift + * down the list. Use padding on the item, or a gap on a wrapper, instead. + * + * Only the current window is in the DOM, so assistive technology cannot + * infer an item's position from the markup. Templates that render a role + * with set semantics (`listitem`, `option`, `row`, ...) should map the + * context's `index` and `count` onto `aria-posinset` and `aria-setsize`. + */ + public readonly itemTemplate = input> | null>( + null, + ); + + //#endregion + + //#region Public outputs + + /** Emitted when the rendered virtual window changes. */ + public readonly stateChange = output(); + + /** + * Emitted when the rendered window comes within a few items of the end of + * `data`. Also emitted on the first render, when the loaded items do not + * fill the viewport. Listen to this event to append more items + * (infinite / remote scrolling). + */ + public readonly dataRequest = output(); + + //#endregion + + //#region Derived state + + protected readonly _isVertical = computed( + () => this.orientation() === "vertical", + ); + + protected readonly _resolvedTemplate = computed( + () => this.itemTemplate() ?? this._itemDirective()?.template ?? null, + ); + + /** `data`, guarded against a nullish value set by the consumer. */ + private readonly _items = computed(() => this.data() ?? []); + + /** The configured `overScan`, normalized to a non-negative integer. */ + private readonly _normalizedOverScan = computed(() => { + const value = Number(this.overScan()); + return Number.isFinite(value) + ? Math.max(0, Math.floor(value)) + : DEFAULT_OVER_SCAN; + }); + + /** The configured `estimatedItemSize`, normalized to a positive number. */ + private readonly _normalizedItemSize = computed(() => { + const value = Number(this.estimatedItemSize()); + return Number.isFinite(value) && value > 0 + ? value + : DEFAULT_ESTIMATED_ITEM_SIZE; + }); + + /** + * The window to render for the current scroll position and viewport. Empty + * until an item template is resolved, because nothing renders without one. + * + * The scroll position is read from the plain `_scrollPosition` field so + * that a recompute triggered by a measurement uses the live offset, while + * `_scrollTick` limits recomputes to scrolls that actually move the window. + */ + private readonly _visibleRange = computed( + () => { + // Depend on the engine so the range recomputes whenever item sizes or + // the item count change. + this._engine.version(); + this._scrollTick(); + + return this._resolvedTemplate() + ? this._engine.getVisibleRange( + this._scrollPosition, + this._viewportSize(), + this._normalizedOverScan(), + ) + : EMPTY_RANGE; + }, + { equal: rangesEqual }, + ); + + /** The track size, in DOM space. */ + protected readonly _spaceSize = this._engine.domSize; + + /** The item contexts for the currently rendered window, in render order. */ + protected readonly _renderedItems = computed[]>(() => { + const { startIndex, endIndex } = this._visibleRange(); + const items = this._items(); + + const rendered: IgxVsItemContext[] = []; + for (let i = startIndex; i <= endIndex; i++) { + rendered.push(new IgxVsItemContext(items[i], i, items.length)); + } + return rendered; + }); + + /** + * The `translateY` / `translateX` for the content wrapper. It is absolutely + * positioned at the origin of a `domSize` px track, so translating it to + * the first rendered item's offset puts that item at its virtual position. + */ + protected readonly _contentTransform = computed(() => { + // The offsets below are plain reads of the engine's size state, so depend + // on its version explicitly. + this._engine.version(); + const range = this._visibleRange(); + + // Under coordinate compression item positions are scaled down but item + // sizes are not. Without this cap the rendered range would overflow past + // domSize at the end of the list, pushing the last items beyond the + // maximum browser scroll coordinate. + const position = clamp( + this._engine.getScrollOffsetForIndex(range.startIndex), + 0, + this._engine.domSize() - + this._engine.getPhysicalRangeSize(range.startIndex, range.endIndex), + ); + + if (this._isVertical()) { + return `translateY(${position}px)`; + } + + // In RTL the wrapper is anchored to the right edge of the track, so it + // translates towards the negative (leading) direction. + return `translateX(${this._isLTR() ? position : -position}px)`; + }); + + //#endregion + + constructor() { + // Sync the engine's item count with `data`, discarding the measurements + // of items whose identity changed. + effect(() => { + const items = this._items(); + untracked(() => { + const previous = this._previousData; + this._previousData = items; + this._engine.resize( + items.length, + this._normalizedItemSize(), + this._firstChangedIndex(previous, items), + ); + // New data (or a reset) clears any in-flight data request so the next + // approach to the end of the list can emit again. + this._hasPendingDataRequest = false; + }); + }); + + // Re-apply the estimate when it changes but the item count does not, + // because `resize` is then a no-op. + effect(() => { + const size = this._normalizedItemSize(); + untracked(() => this._engine.updateEstimatedSize(size)); + }); + + // The scroll offset of the previous axis does not carry over. + effect(() => { + this.orientation(); + untracked(() => { + if (!this._isBrowser) { + return; + } + + this._measureViewport(); + this._scrollPosition = this._currentAxisScroll(); + this._scrollTick.update((v) => v + 1); + }); + }); + + afterNextRender(() => { + this._engine.initMaxBrowserSize(this._document); + this._measureViewport(); + this._setupScrollListener(); + this._setupViewportResizeObserver(); + }); + + // Runs after the DOM reflects the current window, and re-runs whenever + // the window or the engine's sizes change. + afterRenderEffect({ + read: () => { + this._visibleRange(); + this._engine.version(); + untracked(() => { + this._scheduleItemMeasurement(); + this._checkDataRequest(); + this._emitStateChange(); + }); + }, + }); + } + + public ngOnDestroy(): void { + this._teardown(); + } + + //#region Public API + + /** + * Resolves when the virtual scroll has settled: the current render pass is + * complete, the item-size measurements it triggers are complete, and so + * are the renders those measurements schedule. + */ + public get layoutComplete(): Promise { + if (!this._layoutCompletePromise) { + this._layoutCompletePromise = this._resolveLayoutComplete(); + } + return this._layoutCompletePromise; + } + + /** + * Scrolls to the specified item index. + * + * Items outside the rendered window have only an estimated size, so the + * first jump can miss the target. The items at the landing point are then + * measured and the scroll position is corrected, until the offset is + * stable. The returned promise resolves on that final offset; callers that + * need only the first, approximate scroll can ignore it. + * + * @param index The index of the item to scroll to. + * @param options `block` / `inline` select the alignment (`start`, + * `center`, `end` or `nearest`); `behavior` selects `auto` or `smooth`. + */ + public async scrollToIndex( + index: number, + options?: ScrollIntoViewOptions, + ): Promise { + const clampedIndex = clamp(index, 0, Math.max(0, this._items().length - 1)); + + // A newer call supersedes a correction loop that still runs for a + // previous call, for example under rapid, repeated calls. + const requestId = ++this._scrollRequestId; + + let offset = this._getAlignedScrollOffset(clampedIndex, options); + await this._scrollAndWaitForEnd(offset, options?.behavior ?? "auto"); + + for (let i = 0; i < MAX_SCROLL_CORRECTION_PASSES; i++) { + await this.layoutComplete; + + if (requestId !== this._scrollRequestId) { + return; + } + + const corrected = this._getAlignedScrollOffset(clampedIndex, options); + if (Math.abs(corrected - offset) < SCROLL_OFFSET_EPSILON_PX) { + break; + } + + offset = corrected; + await this._scrollAndWaitForEnd(offset, "auto"); + + if (requestId !== this._scrollRequestId) { + return; + } + } + } + + //#endregion + + //#region Scrolling + + /** Whether the host element is laid out left-to-right. */ + private _isLTR(): boolean { + return isLeftToRight(this._hostRef.nativeElement); + } + + /** The current real scroll position on the active axis, normalized for RTL. */ + private _currentAxisScroll(): number { + const host = this._hostRef.nativeElement; + + if (this._isVertical()) { + return host.scrollTop; + } + + // Standards-compliant browsers expose a negative scrollLeft in RTL. + return this._isLTR() ? host.scrollLeft : -host.scrollLeft; + } + + /** Applies a scroll offset to the active axis, accounting for RTL. */ + private _applyScroll(offset: number, behavior: ScrollBehavior): void { + const host = this._hostRef.nativeElement; + + if (this._isVertical()) { + host.scrollTo({ top: offset, behavior }); + return; + } + + host.scrollTo({ left: this._isLTR() ? offset : -offset, behavior }); + } + + /** + * The scroll offset that aligns `index` in the viewport according to + * `options`, from the engine's current size data. As more items are + * measured, the same input can give a different, more accurate result. + * + * For `nearest` on an item already in view, returns the current offset, so + * no scroll occurs. + */ + private _getAlignedScrollOffset( + index: number, + options?: ScrollIntoViewOptions, + ): number { + const requested = this._isVertical() + ? (options?.block ?? "start") + : (options?.inline ?? options?.block ?? "start"); + const current = this._currentAxisScroll(); + + if ( + requested === "nearest" && + this._engine.isIndexInView(index, current, this._viewportSize()) + ) { + return current; + } + + const align: ScrollAlignment = + requested === "center" || requested === "end" ? requested : "start"; + + return this._engine.getAlignedScrollOffset( + index, + this._viewportSize(), + align, + ); + } + + /** + * Applies a scroll offset to the active axis and waits for the scroll, + * instant or smooth, to settle. + * + * `scrollend` does not fire when the requested offset does not move the + * scroll position, so that case resolves immediately. The deadline covers + * an event that never arrives, for example when the element is detached + * mid-scroll. + */ + private _scrollAndWaitForEnd( + offset: number, + behavior: ScrollBehavior, + ): Promise { + if ( + !this._isBrowser || + Math.abs(this._currentAxisScroll() - offset) < SCROLL_OFFSET_EPSILON_PX + ) { + return Promise.resolve(); + } + + return this._withDeadline(SCROLL_END_TIMEOUT_MS, (abort) => { + // `scrollend` reports exactly when a scroll has settled. Safari before + // 18.2 does not have it, and the scroll-idle timer stands in there. + const settled = + "onscrollend" in this._hostRef.nativeElement + ? this._waitForScrollEnd(abort) + : this._waitForScrollIdle(abort); + + // Applied only after the listener is attached, so an instant scroll + // cannot settle before something watches for it. + this._applyScroll(offset, behavior); + return settled; + }); + } + + private _waitForScrollEnd(abort: AbortSignal): Promise { + return this._promiseOutsideZone((resolve) => { + this._hostRef.nativeElement.addEventListener("scrollend", resolve, { + once: true, + signal: abort, + }); + }); + } + + /** + * Resolves when no `scroll` event arrives for `SCROLL_IDLE_MS`: the closest + * replacement for `scrollend`. The first timer starts immediately, so a + * scroll that does not move still settles. + */ + private _waitForScrollIdle(abort: AbortSignal): Promise { + return this._promiseOutsideZone((resolve) => { + let id = setTimeout(resolve, SCROLL_IDLE_MS); + + this._hostRef.nativeElement.addEventListener( + "scroll", + () => { + clearTimeout(id); + id = setTimeout(resolve, SCROLL_IDLE_MS); + }, + { passive: true, signal: abort }, + ); + + onAbort(abort, () => clearTimeout(id)); + }); + } + + //#endregion + + //#region Async helpers + + /** A promise whose subscription work stays out of the Angular zone. */ + private _promiseOutsideZone( + subscribe: (resolve: () => void) => void, + ): Promise { + return this._zone.runOutsideAngular( + () => new Promise((resolve) => subscribe(() => resolve())), + ); + } + + /** + * Resolves with `task` or with a deadline of `ms`, whichever comes first. + * The signal then tears down the other, so no live timer or dangling + * listener remains. + */ + private _withDeadline( + ms: number, + task: (abort: AbortSignal) => Promise, + ): Promise { + const controller = new AbortController(); + + return Promise.race([ + task(controller.signal), + this._promiseOutsideZone((resolve) => { + const id = setTimeout(resolve, ms); + onAbort(controller.signal, () => clearTimeout(id)); + }), + ]).finally(() => controller.abort()); + } + + /** + * Resolves on the next animation frame, or after `LAYOUT_FRAME_TIMEOUT_MS` + * when no frame arrives. A hidden tab or a detached element gets no frames + * and has no layout to wait for, so resolving early there is safe. + */ + private _nextFrame(): Promise { + if (!this._isBrowser) { + return Promise.resolve(); + } + + return this._withDeadline(LAYOUT_FRAME_TIMEOUT_MS, (abort) => + this._promiseOutsideZone((resolve) => { + const id = requestAnimationFrame(resolve); + onAbort(abort, () => cancelAnimationFrame(id)); + }), + ); + } + + /** + * Waits out the frames in which the item measurements land. Each + * measurement that changes a size bumps the engine's version and schedules + * another render, so the layout has settled once the version holds still + * across two consecutive frames. + */ + private async _resolveLayoutComplete(): Promise { + try { + let lastVersion = -1; + + for (let i = 0; i < MAX_LAYOUT_SETTLE_PASSES; i++) { + await this._nextFrame(); + + const version = untracked(this._engine.version); + if (version === lastVersion) { + break; + } + lastVersion = version; + } + } finally { + // Cleared here, not after the loop, so a run that throws cannot leave + // the getter with a permanently rejected promise. + this._layoutCompletePromise = null; + } + } + + //#endregion + + //#region Measurement + + private _measureViewport(): void { + const host = this._hostRef.nativeElement; + const size = this._isVertical() ? host.clientHeight : host.clientWidth; + + if (size !== untracked(this._viewportSize)) { + this._viewportSize.set(size); + } + } + + private _setupViewportResizeObserver(): void { + this._viewportResizeObserver?.disconnect(); + + this._zone.runOutsideAngular(() => { + this._viewportResizeObserver = new ResizeObserver(() => + this._measureViewport(), + ); + this._viewportResizeObserver.observe(this._hostRef.nativeElement); + }); + } + + private _setupScrollListener(): void { + const host = this._hostRef.nativeElement; + + if (this._onScroll) { + host.removeEventListener("scroll", this._onScroll); + } + + this._zone.runOutsideAngular(() => { + this._onScroll = () => this._handleScroll(); + host.addEventListener("scroll", this._onScroll, { passive: true }); + }); + } + + /** + * Records the new scroll offset and invalidates the rendered window only + * when it actually moves. Without the guard, a scroll inside a single item + * would schedule a change detection pass for an identical result. + */ + private _handleScroll(): void { + this._scrollPosition = this._currentAxisScroll(); + + const next = this._engine.getVisibleRange( + this._scrollPosition, + untracked(this._viewportSize), + untracked(this._normalizedOverScan), + ); + + if (!rangesEqual(next, untracked(this._visibleRange))) { + this._scrollTick.update((v) => v + 1); + } + } + + private _handleItemResize(entries: ResizeObserverEntry[]): void { + for (const entry of entries) { + const index = itemIndex(entry.target); + 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); + } + } + } + + /** + * Synchronizes the item observer with the rendered window, applying only + * the difference. A newly observed element gets one initial measurement. + * + * An element whose `data-vs-index` changed is re-registered, because + * `observe` on an already observed element is a no-op. `@for` tracks by + * slot and reuses the wrapper elements, so after a scroll the same element + * can host a different item at an identical size. The observer stays quiet + * about that and the new index would keep its estimated size. + */ + private _scheduleItemMeasurement(): void { + const content = this._contentDivRef()?.nativeElement; + if (!content) { + return; + } + + const observer = this._getItemResizeObserver(); + + for (const element of [...this._observedItems]) { + if (element.parentNode !== content) { + observer.unobserve(element); + this._observedItems.delete(element); + } + } + + for (const element of Array.from(content.children)) { + const index = itemIndex(element); + + if (this._observedItems.has(element)) { + if (this._observedItemIndexes.get(element) === index) { + continue; + } + observer.unobserve(element); + } + + observer.observe(element); + this._observedItems.add(element); + this._observedItemIndexes.set(element, index); + } + } + + private _getItemResizeObserver(): ResizeObserver { + if (!this._itemResizeObserver) { + this._itemResizeObserver = this._zone.runOutsideAngular( + () => new ResizeObserver((entries) => this._handleItemResize(entries)), + ); + } + return this._itemResizeObserver; + } + + //#endregion + + //#region Events + + /** + * The number of leading items that kept their identity across a `data` + * change: the index of the first item whose measured size no longer + * matches its rendered content. An append (the `dataRequest` flow) retains + * all items. A filter or a replacement retains only the unchanged prefix. + */ + private _firstChangedIndex(previous: T[] | undefined, current: T[]): number { + if (!previous) { + return 0; + } + + const shared = Math.min(previous.length, current.length); + for (let i = 0; i < shared; i++) { + if (previous[i] !== current[i]) { + return i; + } + } + return shared; + } + + /** + * Emits `stateChange`. Skipped when the window is empty or equal to the + * last reported one, because measurement passes re-render without a window + * change. + */ + private _emitStateChange(): void { + const { startIndex, endIndex } = untracked(this._visibleRange); + if (endIndex < startIndex) { + return; + } + + const state: VirtualScrollState = { + startIndex, + endIndex, + viewportSize: untracked(this._viewportSize), + totalSize: untracked(this._engine.totalSize), + }; + + if (statesEqual(this._lastEmittedState, state)) { + return; + } + + this._lastEmittedState = { ...state }; + this.stateChange.emit(state); + } + + private _checkDataRequest(): void { + if (this._hasPendingDataRequest) { + return; + } + + const { endIndex } = untracked(this._visibleRange); + const total = untracked(this._items).length; + + if (total === 0 || endIndex < total - DATA_REQUEST_THRESHOLD) { + return; + } + + // Each `data` change clears `_hasPendingDataRequest`, including one that + // appends nothing. Without this second guard, a consumer whose source is + // exhausted, and that reassigns `data` in response to a request, would + // receive the same request on each reassignment. + if (this._lastDataRequestIndex === total) { + return; + } + + this._hasPendingDataRequest = true; + this._lastDataRequestIndex = total; + + this.dataRequest.emit({ + startIndex: total, + count: Math.max( + untracked(this._normalizedOverScan) * DATA_REQUEST_OVER_SCAN_FACTOR, + DATA_REQUEST_MIN_COUNT, + ), + }); + } + + //#endregion + + private _teardown(): void { + const host = this._hostRef.nativeElement; + + if (this._onScroll) { + host.removeEventListener("scroll", this._onScroll); + this._onScroll = null; + } + + this._viewportResizeObserver?.disconnect(); + this._viewportResizeObserver = null; + + this._itemResizeObserver?.disconnect(); + this._itemResizeObserver = null; + this._observedItems.clear(); + } +} diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 70fa48524b2..c4b175ed2ef 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -750,6 +750,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 a735384c980..631d9747b60 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -157,6 +157,7 @@ import { GridRecreateSampleComponent } from './grid-re-create/grid-re-create.sam import { HierarchicalGridAdvancedFilteringSampleComponent } from './hierarchical-grid-advanced-filtering/hierarchical-grid-advanced-filtering.sample'; import { GridLiteSampleComponent } from './grid-lite/grid-lite.sample'; import { SelectSampleComponent } from './select/select.sample'; +import { VirtualScrollSampleComponent } from './virtual-scroll/virtual-scroll.sample'; export const appRoutes: Routes = [ { @@ -759,5 +760,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..8bdf338587f --- /dev/null +++ b/src/app/virtual-scroll/virtual-scroll.sample.html @@ -0,0 +1,176 @@ +
+

Virtual Scroll Samples

+ + + + +
+

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. +

+

+ scrollToIndex() returns a promise that resolves once the + position has settled: unrendered items only have an estimated size, so + the first jump lands near the target and is then corrected against the + real measurements. +

+ +
+ + + + +
+ + + +
+ #{{ 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..d061eeb5f0a --- /dev/null +++ b/src/app/virtual-scroll/virtual-scroll.sample.scss @@ -0,0 +1,145 @@ +.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; +} + +.vs-demo__controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 12px; + + label { + display: inline-flex; + align-items: center; + gap: 6px; + } + + input[type='number'] { + width: 110px; + } +} 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..b5129c24597 --- /dev/null +++ b/src/app/virtual-scroll/virtual-scroll.sample.ts @@ -0,0 +1,106 @@ +import { ChangeDetectionStrategy, Component, signal, viewChild } 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 verticalScroll = + viewChild>('verticalScroll'); + + protected readonly verticalItems = signal(makeItems(0, 1_000_000)); + 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); + + protected readonly targetIndex = signal(500_000); + protected readonly alignment = signal('start'); + protected readonly smoothScroll = signal(false); + protected readonly isScrolling = signal(false); + + /** + * Items outside the rendered window have only an estimated size, so the + * first jump lands near the target rather than on it. `scrollToIndex` + * measures the items there and corrects itself; the promise resolves once + * the position is stable. + */ + protected async scrollToTarget(): Promise { + const scroll = this.verticalScroll(); + if (!scroll) return; + + this.isScrolling.set(true); + try { + await scroll.scrollToIndex(this.targetIndex(), { + block: this.alignment(), + behavior: this.smoothScroll() ? 'smooth' : 'auto', + }); + } finally { + this.isScrolling.set(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 + } +}