• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IgniteUI / igniteui-angular / 26023601418

18 May 2026 08:57AM UTC coverage: 4.854% (-85.3%) from 90.174%
26023601418

Pull #17281

github

web-flow
Merge e7ce7a18e into 5a85df190
Pull Request #17281: feat: Added virtual scroll component and sample implementation

400 of 17347 branches covered (2.31%)

Branch coverage included in aggregate %.

63 of 222 new or added lines in 4 files covered. (28.38%)

27932 existing lines in 341 files now uncovered.

2022 of 32547 relevant lines covered (6.21%)

0.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

1.06
/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts
1
import {
2
  ChangeDetectionStrategy,
3
  Component,
4
  computed,
5
  contentChild,
6
  DOCUMENT,
7
  effect,
8
  ElementRef,
9
  EmbeddedViewRef,
10
  inject,
11
  input,
12
  NgZone,
13
  OnDestroy,
14
  output,
15
  PLATFORM_ID,
16
  signal,
17
  TemplateRef,
18
  untracked,
19
  viewChild,
20
  ViewContainerRef,
21
} from "@angular/core";
22
import { IgxVirtualItemDirective } from "./virtual-scroll-item.directive";
23
import {
24
  IgxVsItemContext,
25
  VirtualScrollDataRequest,
26
  VirtualScrollState,
27
} from "./types";
28
import { VirtualScrollEngine } from "./scroll-engine";
29
import { isPlatformBrowser } from "@angular/common";
30

31
const REMOTE_SCROLLING_THRESHOLD = 5;
3✔
32

33
@Component({
34
  selector: "igx-virtual-scroll",
35
  templateUrl: "./virtual-scroll.component.html",
36
  styleUrls: ["./virtual-scroll.component.scss"],
37
  changeDetection: ChangeDetectionStrategy.OnPush,
38
  host: {
39
    class: "igx-virtual-scroll",
40
    role: "list",
41
    "[class.igx-virtual-scroll--vertical]": "_isVertical()",
42
    "[class.igx-virtual-scroll--horizontal]": "!_isVertical()",
43
  },
44
})
45
export class IgxVirtualScrollComponent<T> implements OnDestroy {
3✔
46
  //#region Dependency Injections
NEW
47
  private readonly _hostRef = inject<ElementRef<HTMLElement>>(ElementRef);
×
NEW
48
  private readonly _zone = inject(NgZone);
×
NEW
49
  private readonly _document = inject(DOCUMENT);
×
NEW
50
  private readonly _platformId = inject(PLATFORM_ID);
×
51

52
  //#endregion
53

NEW
54
  private _viewportResizeObserver: ResizeObserver | null = null;
×
NEW
55
  private _itemResizeObserver: ResizeObserver | null = null;
×
NEW
56
  private _onScroll: ((e: Event) => void) | null = null;
×
57

58
  /** Views currently inserted into the VCR, ordered by rendered item index. */
NEW
59
  private readonly _activeItems: EmbeddedViewRef<IgxVsItemContext<T>>[] = [];
×
60

61
  /** Detached views available for reuse. */
NEW
62
  private readonly _pooledItems: EmbeddedViewRef<IgxVsItemContext<T>>[] = [];
×
63

NEW
64
  private readonly _scrollPosition = signal(0);
×
NEW
65
  private readonly _viewportSize = signal(0);
×
66

NEW
67
  private readonly _visibleRange = computed(() =>
×
NEW
68
    this._engine.getVisibleRange(
×
69
      this._scrollPosition(),
70
      this._viewportSize(),
71
      this.overScan(),
72
      this.data().length,
73
    ),
74
  );
75

NEW
76
  protected readonly _engine = new VirtualScrollEngine();
×
NEW
77
  protected readonly _isVertical = computed(
×
NEW
78
    () => this.orientation() === "vertical",
×
79
  );
NEW
80
  protected readonly _spaceSize = computed(() => this._engine.domSize());
×
NEW
81
  protected readonly _contentTransform = computed(() => {
×
NEW
82
    const position = this._engine.getContentPosition(
×
83
      this._visibleRange().startIndex,
84
    );
NEW
85
    return this._isVertical()
×
86
      ? `translateY(${position}px)`
87
      : `translateX(${position}px)`;
88
  });
89

90
  //#region View and Content Children
91

NEW
92
  private readonly _itemDirective = contentChild(IgxVirtualItemDirective);
×
93

NEW
94
  private readonly _itemsViewContainer = viewChild<unknown, ViewContainerRef>(
×
95
    "itemsAnchor",
96
    { read: ViewContainerRef },
97
  );
98

NEW
99
  private readonly _contentDivRef =
×
100
    viewChild<ElementRef<HTMLElement>>("contentDiv");
101

NEW
102
  protected readonly _resolvedTemplate = computed(() => {
×
NEW
103
    return this.itemTemplate() ?? this._itemDirective()?.template ?? null;
×
104
  });
105

106
  //#endregion
107

108
  /** The array of items to virtualize. */
NEW
109
  public readonly data = input<T[]>([]);
×
110

111
  /**
112
   * Scroll orientation of the virtual scroll.
113
   * Can be either "vertical" or "horizontal".
114
   * Default is "vertical".
115
   */
NEW
116
  public readonly orientation = input<"vertical" | "horizontal">("vertical");
×
117

118
  /**
119
   * Number of extra items to render beyond the visible area of the viewport.
120
   * Higher values reduce blank flashes during fast scrolling but may impact performance.
121
   * Default is 2.
122
   */
NEW
123
  public readonly overScan = input<number>(2);
×
124

125
  /**
126
   * Estimated item size in pixels used before an item is measured in the DOM.
127
   * The engine replaces this with the actual measured size after the first render of each item.
128
   * Default is 50 pixels.
129
   * Setting this to a value close to the actual average item size can improve initial rendering performance.
130
   */
NEW
131
  public readonly estimatedItemSize = input<number>(50);
×
132

133
  /**
134
   * Item template provided programmatically (takes precedence over content template if both are provided).
135
   *
136
   * This template will be used to render each item in the virtual scroll.
137
   * The context for the template will include the item data and its index.
138
   * If not provided, the component will look for an `ng-template` with the `igxVirtualItem` directive in its content.
139
   */
NEW
140
  public readonly itemTemplate = input<TemplateRef<IgxVsItemContext<T>> | null>(
×
141
    null,
142
  );
143

144
  /**
145
   * Emitted after each render pass with a snapshot of the current virtual window.
146
   */
NEW
147
  public readonly stateChange = output<VirtualScrollState>();
×
148

149
  /**
150
   * Emitted when the scroll position approaches the end of the available data.
151
   * Listen to this event to append more items (infinite / remote scrolling).
152
   */
NEW
153
  public readonly dataRequest = output<VirtualScrollDataRequest>();
×
154

155
  constructor() {
156
    // Sync engine item count with data changes.
NEW
157
    effect(() => {
×
NEW
158
      const count = this.data().length;
×
NEW
159
      const estimated = this.estimatedItemSize();
×
NEW
160
      untracked(() => this._engine.resize(count, estimated));
×
161
    });
162

163
    // Browser setup: runs after first render and whenever orientation changes.
NEW
164
    effect(() => {
×
NEW
165
      const vertical = this._isVertical();
×
NEW
166
      void vertical; // Ensure vertical is tracked before accessing the engine.
×
NEW
167
      untracked(() => {
×
NEW
168
        if (!isPlatformBrowser(this._platformId)) return;
×
169

NEW
170
        this._engine.initMaxBrowserSize(this._document);
×
NEW
171
        this._measureViewport();
×
NEW
172
        this._setupScrollListener();
×
NEW
173
        this._setupViewportResizeObserver();
×
174
      });
175
    });
176

177
    // Re-render whenever the visible range, data, or template changes.
NEW
178
    effect(() => {
×
NEW
179
      const range = this._visibleRange();
×
NEW
180
      const data = this.data();
×
NEW
181
      const template = this._resolvedTemplate();
×
NEW
182
      const vcr = this._itemsViewContainer();
×
NEW
183
      if (!template || !vcr || range.endIndex < range.startIndex) return;
×
184

NEW
185
      untracked(() =>
×
NEW
186
        this._renderRange(range.startIndex, range.endIndex, data, template),
×
187
      );
188
    });
189

190
    // Remote scroll: fire dataRequest when approaching the end.
NEW
191
    effect(() => {
×
NEW
192
      const range = this._visibleRange();
×
NEW
193
      const total = this.data().length;
×
194

NEW
195
      if (total > 0 && range.endIndex >= total - REMOTE_SCROLLING_THRESHOLD) {
×
NEW
196
        this.dataRequest.emit({
×
197
          startIndex: total,
198
          count: Math.max(this.overScan() * 4, 20),
199
        });
200
      }
201
    });
202
  }
203

204
  public ngOnDestroy(): void {
NEW
205
    this._teardown();
×
206
  }
207

208
  /** Programmatically scrolls to the specified item index. */
209
  public scrollToIndex(index: number): void {
NEW
210
    const host = this._hostRef.nativeElement;
×
NEW
211
    const offset = this._engine.getScrollOffsetForIndex(index);
×
212

NEW
213
    if (this._isVertical()) {
×
NEW
214
      host.scrollTop = offset;
×
215
    } else {
NEW
216
      host.scrollLeft = offset;
×
217
    }
218
  }
219

220
  private _renderRange(
221
    startIndex: number,
222
    endIndex: number,
223
    data: T[],
224
    template: TemplateRef<IgxVsItemContext<T>>,
225
  ): void {
NEW
226
    const count = data.length;
×
NEW
227
    const newCount = Math.max(0, endIndex - startIndex + 1);
×
NEW
228
    const vcr = this._itemsViewContainer();
×
NEW
229
    if (!vcr) return;
×
230

231
    // Grow: pull from pool or create new views until we have enough.
NEW
232
    while (this._activeItems.length < newCount) {
×
NEW
233
      let view = this._pooledItems.pop() ?? null;
×
NEW
234
      if (view) {
×
NEW
235
        vcr.insert(view);
×
236
      } else {
NEW
237
        view = vcr.createEmbeddedView(
×
238
          template,
239
          new IgxVsItemContext<T>(data[startIndex], startIndex, count),
240
        );
241
      }
NEW
242
      this._activeItems.push(view);
×
243
    }
244

245
    // Shrink: detach from VCR and return to pool.
NEW
246
    while (this._activeItems.length > newCount) {
×
NEW
247
      const view = this._activeItems.pop()!;
×
NEW
248
      const index = vcr.indexOf(view);
×
NEW
249
      if (index > -1) {
×
NEW
250
        vcr.detach(index);
×
251
      }
NEW
252
      this._pooledItems.push(view);
×
253
    }
254

255
    // Update contexts in place - zero DOM allocations on steady-state scroll.
NEW
256
    for (let i = 0; i < newCount; i++) {
×
NEW
257
      const itemIndex = startIndex + i;
×
NEW
258
      const view = this._activeItems[i];
×
NEW
259
      const context = view.context;
×
NEW
260
      context.$implicit = data[itemIndex];
×
NEW
261
      context.index = itemIndex;
×
NEW
262
      context.count = count;
×
NEW
263
      view.markForCheck();
×
264
    }
265

266
    // Measure rendered items after the browser paints.
NEW
267
    this._scheduleItemMeasurement(startIndex, newCount);
×
268

NEW
269
    this.stateChange.emit({
×
270
      startIndex,
271
      endIndex,
272
      viewportSize: this._viewportSize(),
273
      totalSize: this._engine.totalSize(),
274
    });
275
  }
276

277
  private _scheduleItemMeasurement(startIndex: number, count: number): void {
NEW
278
    if (!isPlatformBrowser(this._platformId)) return;
×
279

NEW
280
    this._itemResizeObserver?.disconnect();
×
NEW
281
    this._itemResizeObserver = new ResizeObserver((entries) => {
×
NEW
282
      let anyChanged = false;
×
NEW
283
      for (const entry of entries) {
×
NEW
284
        const el = entry.target as HTMLElement;
×
NEW
285
        const index = parseInt(el.dataset["vsIndex"] ?? "-1", 10);
×
NEW
286
        if (index < 0) continue;
×
287

NEW
288
        const measured = this._isVertical()
×
289
          ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height)
×
290
          : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);
×
291

NEW
292
        if (measured > 0) {
×
NEW
293
          this._engine.measureItem(index, measured);
×
NEW
294
          anyChanged = true;
×
295
        }
296
      }
297
    });
298

NEW
299
    const content = this._contentDivRef()?.nativeElement;
×
NEW
300
    if (!content) return;
×
301

NEW
302
    const itemRoots = Array.from(content.children) as HTMLElement[];
×
NEW
303
    for (let i = 0; i < Math.min(count, itemRoots.length); i++) {
×
NEW
304
      const el = itemRoots[i];
×
NEW
305
      el.dataset["vsIndex"] = (startIndex + i).toString();
×
NEW
306
      this._itemResizeObserver.observe(el);
×
307
    }
308
  }
309

310
  private _measureViewport(): void {
NEW
311
    const host = this._hostRef.nativeElement;
×
NEW
312
    const size = this._isVertical() ? host.clientHeight : host.clientWidth;
×
NEW
313
    if (size !== this._viewportSize()) {
×
NEW
314
      this._viewportSize.set(size);
×
315
    }
316
  }
317

318
  private _setupViewportResizeObserver(): void {
NEW
319
    if (!isPlatformBrowser(this._platformId)) return;
×
320

NEW
321
    this._viewportResizeObserver?.disconnect();
×
NEW
322
    this._viewportResizeObserver = new ResizeObserver(() => {
×
NEW
323
      const host = this._hostRef.nativeElement;
×
NEW
324
      const newSize = this._isVertical() ? host.clientHeight : host.clientWidth;
×
NEW
325
      if (newSize !== this._viewportSize()) {
×
NEW
326
        this._viewportSize.set(newSize);
×
327
      }
328
    });
329

NEW
330
    this._viewportResizeObserver.observe(this._hostRef.nativeElement);
×
331
  }
332

333
  private _setupScrollListener(): void {
NEW
334
    if (!isPlatformBrowser(this._platformId)) return;
×
335

NEW
336
    const host = this._hostRef.nativeElement;
×
NEW
337
    if (this._onScroll) {
×
NEW
338
      host.removeEventListener("scroll", this._onScroll);
×
339
    }
340

NEW
341
    this._zone.runOutsideAngular(() => {
×
NEW
342
      this._onScroll = (e: Event) => {
×
NEW
343
        const target = e.target as HTMLElement;
×
NEW
344
        const scrollPos = this._isVertical()
×
345
          ? target.scrollTop
346
          : target.scrollLeft;
NEW
347
        this._zone.run(() => this._scrollPosition.set(scrollPos));
×
348
      };
NEW
349
      host.addEventListener("scroll", this._onScroll!, { passive: true });
×
350
    });
351
  }
352

353
  private _teardown(): void {
NEW
354
    const host = this._hostRef.nativeElement;
×
NEW
355
    if (this._onScroll) {
×
NEW
356
      host.removeEventListener("scroll", this._onScroll);
×
NEW
357
      this._onScroll = null;
×
358
    }
NEW
359
    this._viewportResizeObserver?.disconnect();
×
NEW
360
    this._itemResizeObserver?.disconnect();
×
NEW
361
    for (const view of [...this._activeItems, ...this._pooledItems]) {
×
NEW
362
      view.destroy();
×
363
    }
NEW
364
    this._activeItems.length = 0;
×
NEW
365
    this._pooledItems.length = 0;
×
366
  }
367
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc