Skip to content

Commit 3220b2d

Browse files
committed
feat(virtual-scroll): add initial viewport hint
A list inside a popup is hidden until the change detection pass that reveals it, so it has no size to measure and renders nothing in that pass. initialViewportSize gives that first render a size to work from; the measured size takes over as soon as the host can be measured.
1 parent 459758c commit 3220b2d

3 files changed

Lines changed: 206 additions & 6 deletions

File tree

projects/igniteui-angular/virtual-scroll/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,36 @@ export class MyComponent {
4343
| `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. |
4444
| `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`. |
4545
| `itemTemplate` | `TemplateRef<IgxVsItemContext<T>> \| null` | `null` | Programmatic template that takes precedence over a content `ng-template[igxVirtualItem]`. |
46+
| `initialViewportSize` | `number` | `0` | Viewport size in pixels to render the **first** window against, for a list that cannot be measured when it is first rendered. A hint for that render only: once the host measures above zero, the measured size takes over and this input is not read again. Negative, `NaN` and infinite values count as no hint. See [Lists inside a popup](#lists-inside-a-popup). |
47+
48+
49+
### Lists inside a popup
50+
51+
A list inside a drop-down, dialog or any other container that is hidden until it opens has
52+
no size to measure in the change detection pass that reveals it. The component learns its
53+
size from a `ResizeObserver` and from `afterNextRender`, both of which run after a render,
54+
so that first render is laid out against a viewport of zero and produces no rows. In a Karma
55+
reproduction of a list revealed by a single synchronous pass, it stayed empty for two
56+
`requestAnimationFrame` iterations before filling in.
57+
58+
A wrapper that reacts to whether the list has children can flip state between those passes,
59+
which Angular reports as `NG0100` in development mode.
60+
61+
Pass the size the container gives the list and the first window renders with it:
62+
63+
```html
64+
<igx-virtual-scroll [data]="items" [initialViewportSize]="320" style="height: 320px">
65+
<ng-template igxVirtualItem let-item>{{ item }}</ng-template>
66+
</igx-virtual-scroll>
67+
```
68+
69+
The value is a starting point, not an override. Once the host has been measured the measured
70+
size is the only one used, and later resizes are followed normally.
71+
72+
A measurement of zero is not recorded, because a hidden host measures zero and that says
73+
nothing about how large it will be when it is shown again. Keeping the last real measurement
74+
is what lets the list render its window in the pass that reopens it. The deliberate
75+
consequence is that the rendered window stays in the DOM while the host is hidden.
4676

4777
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.
4878

projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,33 @@ class TestHostComponent {
522522
}
523523
}
524524

525+
@Component({
526+
selector: 'test-virtual-scroll-popup',
527+
template: `
528+
<div [style.display]="open() ? 'block' : 'none'">
529+
<igx-virtual-scroll
530+
[data]="items()"
531+
[initialViewportSize]="initialViewportSize()"
532+
[style.height.px]="hostHeight()"
533+
style="display: block"
534+
>
535+
<ng-template igxVirtualItem let-item let-i="index">
536+
<span class="item" style="display: block; height: 50px">{{ i }}: {{ item }}</span>
537+
</ng-template>
538+
</igx-virtual-scroll>
539+
</div>
540+
`,
541+
imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective],
542+
})
543+
class TestPopupHostComponent {
544+
public readonly vs = viewChild.required(IgxVirtualScrollComponent);
545+
546+
public items = signal(generateItems(100));
547+
public initialViewportSize = signal(0);
548+
public hostHeight = signal<number | null>(300);
549+
public open = signal(false);
550+
}
551+
525552
@Component({
526553
selector: 'test-virtual-scroll-rtl',
527554
template: `
@@ -644,6 +671,7 @@ describe('IgxVirtualScrollComponent', () => {
644671
TestRtlHostComponent,
645672
TestNoTemplateHostComponent,
646673
TestProgrammaticTemplateComponent,
674+
TestPopupHostComponent,
647675
],
648676
}).compileComponents();
649677
}));
@@ -762,6 +790,114 @@ describe('IgxVirtualScrollComponent', () => {
762790
});
763791
});
764792

793+
describe('initial viewport size', () => {
794+
let popup: ComponentFixture<TestPopupHostComponent>;
795+
let popupHost: TestPopupHostComponent;
796+
let popupScroll: IgxVirtualScrollComponent<string>;
797+
798+
/** Creates the fixture with the list hidden, the way a closed drop-down holds one. */
799+
async function createPopup(initialViewportSize = 0): Promise<void> {
800+
popup = TestBed.createComponent(TestPopupHostComponent);
801+
popupHost = popup.componentInstance;
802+
popupHost.initialViewportSize.set(initialViewportSize);
803+
popup.detectChanges();
804+
popupScroll = popupHost.vs() as IgxVirtualScrollComponent<string>;
805+
}
806+
807+
/** Shows the list in one synchronous pass, the way opening a drop-down does. */
808+
function reveal(): void {
809+
popupHost.open.set(true);
810+
popup.detectChanges();
811+
}
812+
813+
/** Settles repeatedly until `predicate` holds, so a resize report is not raced. */
814+
async function settleUntil(predicate: () => boolean): Promise<void> {
815+
for (let i = 0; i < 20 && !predicate(); i++) {
816+
await settle(popup, popupScroll);
817+
}
818+
}
819+
820+
it('should render nothing in the pass that reveals it when the input is omitted', async () => {
821+
await createPopup();
822+
reveal();
823+
824+
expect(vsItems(popup).length).toBe(0);
825+
});
826+
827+
it('should render the first window in the pass that reveals it', async () => {
828+
await createPopup(300);
829+
reveal();
830+
831+
// A 300px viewport of 50px rows shows 0..6, plus an over-scan of 2.
832+
expect(vsIndices(popup)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
833+
});
834+
835+
it('should let the measured size replace an initial value that was too large', async () => {
836+
await createPopup(2000);
837+
reveal();
838+
await settleUntil(() => vsItems(popup).length === 9);
839+
840+
// The host is 300px, so the window settles at what it really holds rather than
841+
// the 40 rows 2000px would.
842+
expect(vsIndices(popup)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
843+
});
844+
845+
it('should follow a later resize of the host', async () => {
846+
await createPopup(300);
847+
reveal();
848+
expect(vsIndices(popup)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
849+
850+
popupHost.hostHeight.set(600);
851+
await settleUntil(() => vsItems(popup).length > 9);
852+
853+
// 600px of 50px rows shows 0..12, plus an over-scan of 2.
854+
expect(Math.max(...vsIndices(popup))).toBe(14);
855+
});
856+
857+
it('should keep the last measured size when the host is hidden', async () => {
858+
// The hint and the host disagree, so the window says which one is in use: the
859+
// 300px hint gives 9 rows, the 600px host gives 15.
860+
await createPopup(300);
861+
popupHost.hostHeight.set(600);
862+
reveal();
863+
await settleUntil(() => vsItems(popup).length === 15);
864+
expect(vsItems(popup).length).toBe(15);
865+
866+
// A value that would be unmistakable if the input were read again.
867+
popupHost.initialViewportSize.set(2000);
868+
popupHost.open.set(false);
869+
await settle(popup, popupScroll);
870+
871+
expect(vsItems(popup).length).toBe(15);
872+
});
873+
874+
it('should not start empty when the host is shown again', async () => {
875+
await createPopup(300);
876+
popupHost.hostHeight.set(600);
877+
reveal();
878+
await settleUntil(() => vsItems(popup).length === 15);
879+
880+
popupHost.open.set(false);
881+
await settle(popup, popupScroll);
882+
reveal();
883+
884+
expect(vsItems(popup).length).toBe(15);
885+
});
886+
887+
for (const [label, value] of [
888+
['negative', -300],
889+
['NaN', Number.NaN],
890+
['infinite', Number.POSITIVE_INFINITY],
891+
] as [string, number][]) {
892+
it(`should treat a ${label} initial size as no hint at all`, async () => {
893+
await createPopup(value);
894+
reveal();
895+
896+
expect(vsItems(popup).length).toBe(0);
897+
});
898+
}
899+
});
900+
765901
describe('orientation', () => {
766902
beforeEach(async () => {
767903
await createFixture();

projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export class IgxVirtualScrollComponent<T> implements OnDestroy {
152152
/** Bumped only when a scroll actually moves the rendered window. */
153153
private readonly _scrollTick = signal(0);
154154

155+
/** The last size the host measured above zero. See `_measureViewport`. */
155156
private readonly _viewportSize = signal(0);
156157

157158
/** The `data` array as of the previous change, for `_firstChangedIndex`. */
@@ -213,6 +214,22 @@ export class IgxVirtualScrollComponent<T> implements OnDestroy {
213214
*/
214215
public readonly estimatedItemSize = input<number>(DEFAULT_ESTIMATED_ITEM_SIZE);
215216

217+
/**
218+
* Viewport size in pixels to render the first window against, for a list that is hidden
219+
* until the change detection pass that reveals it and so has no size to measure in it.
220+
*
221+
* A hint for that first render only: once the host measures above zero the measured size
222+
* takes over. Negative, `NaN` and infinite values count as no hint.
223+
*
224+
* @example
225+
* ```html
226+
* <igx-virtual-scroll [data]="items" [initialViewportSize]="320" style="height: 320px">
227+
* <ng-template igxVirtualItem let-item>{{ item }}</ng-template>
228+
* </igx-virtual-scroll>
229+
* ```
230+
*/
231+
public readonly initialViewportSize = input<number>(0);
232+
216233
/**
217234
* Item template provided programmatically. Takes precedence over a content
218235
* `ng-template[igxVirtualItem]` when both are provided.
@@ -259,6 +276,18 @@ export class IgxVirtualScrollComponent<T> implements OnDestroy {
259276
/** `data`, guarded against a nullish value set by the consumer. */
260277
private readonly _items = computed<T[]>(() => this.data() ?? []);
261278

279+
/** `initialViewportSize`, normalized to a non-negative number. */
280+
private readonly _normalizedInitialViewportSize = computed(() => {
281+
const value = Number(this.initialViewportSize());
282+
return Number.isFinite(value) ? Math.max(0, value) : 0;
283+
});
284+
285+
/** The measured size once the host has been measurable, the hint until then. */
286+
private readonly _effectiveViewportSize = computed(() => {
287+
const measured = this._viewportSize();
288+
return measured > 0 ? measured : this._normalizedInitialViewportSize();
289+
});
290+
262291
/** The configured `overScan`, normalized to a non-negative integer. */
263292
private readonly _normalizedOverScan = computed(() => {
264293
const value = Number(this.overScan());
@@ -293,7 +322,7 @@ export class IgxVirtualScrollComponent<T> implements OnDestroy {
293322
return this._resolvedTemplate()
294323
? this._engine.getVisibleRange(
295324
this._scrollPosition,
296-
this._viewportSize(),
325+
this._effectiveViewportSize(),
297326
this._normalizedOverScan(),
298327
)
299328
: EMPTY_RANGE;
@@ -383,6 +412,8 @@ export class IgxVirtualScrollComponent<T> implements OnDestroy {
383412
return;
384413
}
385414

415+
// The size of the previous axis says nothing about the new one.
416+
this._viewportSize.set(0);
386417
this._measureViewport();
387418
this._scrollPosition = this._currentAxisScroll();
388419
this._scrollTick.update((v) => v + 1);
@@ -528,7 +559,7 @@ export class IgxVirtualScrollComponent<T> implements OnDestroy {
528559

529560
if (
530561
requested === "nearest" &&
531-
this._engine.isIndexInView(index, current, this._viewportSize())
562+
this._engine.isIndexInView(index, current, this._effectiveViewportSize())
532563
) {
533564
return current;
534565
}
@@ -538,7 +569,7 @@ export class IgxVirtualScrollComponent<T> implements OnDestroy {
538569

539570
return this._engine.getAlignedScrollOffset(
540571
index,
541-
this._viewportSize(),
572+
this._effectiveViewportSize(),
542573
align,
543574
);
544575
}
@@ -694,7 +725,10 @@ export class IgxVirtualScrollComponent<T> implements OnDestroy {
694725
const host = this._hostRef.nativeElement;
695726
const size = this._isVertical() ? host.clientHeight : host.clientWidth;
696727

697-
if (size !== untracked(this._viewportSize)) {
728+
// A hidden host measures zero, which says nothing about its size once shown. Keeping
729+
// the last real measurement lets it render again in the pass that reveals it, at the
730+
// cost of leaving the window rendered while it is hidden.
731+
if (size > 0 && size !== untracked(this._viewportSize)) {
698732
this._viewportSize.set(size);
699733
}
700734
}
@@ -733,7 +767,7 @@ export class IgxVirtualScrollComponent<T> implements OnDestroy {
733767

734768
const next = this._engine.getVisibleRange(
735769
this._scrollPosition,
736-
untracked(this._viewportSize),
770+
untracked(this._effectiveViewportSize),
737771
untracked(this._normalizedOverScan),
738772
);
739773

@@ -847,7 +881,7 @@ export class IgxVirtualScrollComponent<T> implements OnDestroy {
847881
const state: VirtualScrollState = {
848882
startIndex,
849883
endIndex,
850-
viewportSize: untracked(this._viewportSize),
884+
viewportSize: untracked(this._effectiveViewportSize),
851885
totalSize: untracked(this._engine.totalSize),
852886
};
853887

0 commit comments

Comments
 (0)