virtual lists¶
the virtual blob is a scrollable viewport that only materializes the rows you can see. the engine's per-frame work - tick, descriptor building, layout - is O(mounted panels) whether or not a panel is on screen, so a 10,000-item list built as plain children pays for all 10,000 every frame. Virtual caps existence at the visible window: scroll a half-million items and the panel count stays flat at what fits on screen.
var roster = Virtual.List( _players, itemHeight: 32, row: p => new Container
{
Padding = 8, FlexDirection = FlexDirection.Row, Gap = 8,
Children =
{
new Text( p.Name ) { FontColor = Color.White },
new Text( p.Score.ToString() ) { FontColor = new Color( 0.5f, 0.5f, 0.55f ) },
},
} );
when to reach for it¶
reach for Virtual when the item count is unbounded or large: a server browser, a chat log, an asset catalog, a kill feed that never stops growing. the tell is that the list's size is data-driven and can be big - hundreds of items and up.
when the count is small and bounded - a dozen settings rows, an eight-slot hotbar - use a plain scroll Container with Children.AddRange instead. it has none of virtualization's constraints (rows can be any height, all rows always exist), and at small counts the panels it mounts cost nothing worth saving. dynamic children covers that path.
list, grid, custom¶
three factories, one blob:
Virtual.List( items, itemHeight: 32, row ); // fixed-height vertical rows
Virtual.Grid( items, new Vector2( 260, 170 ), row ); // fixed-size cells, as many columns as fit
Virtual.Custom( items, () => new MyVirtualPanel(), row ); // your own BaseVirtualPanel layout strategy
row builds the blob for one item and runs only when a row scrolls into view or its item changes. style the blob itself with a with expression - it carries flex, sizing, spacing, background, and radius:
Virtual.Grid( _items, new Vector2( 260, 170 ), Card ) with
{
FlexGrow = 1, Width = Length.Percent( 100 ),
Gap = 8,
BackgroundColor = new Color( 0.05f, 0.05f, 0.06f ),
BorderRadius = 8,
}
this styled grid is the pattern Code/Demo/VirtualGridShowcaseUI.cs runs at 10,000 to 500,000 items, with a live stats line proving the mounted-panel count stays flat while the count buttons multiply the data.
two mechanical requirements: the viewport needs a bounded size on the scroll axis (FlexGrow = 1 in a column, or an explicit Height), and rows are fixed-size - itemHeight for a list, itemSize for a grid. there is no per-row measuring; variable-height content does not fit this blob. unlike a hand-rolled scroll container, you do not set OverflowY or PointerEvents - the viewport scrolls itself and arrives hittable.
how rows live¶
a visible row owns a persistent panel. when it scrolls out, the panel is pooled, not deleted; when a new row scrolls in, a pooled panel is recycled and the new item's blob is diffed into it. an unchanged item costs nothing, and a changed item mutates the existing panels in place - text updates, colors update, nothing is torn down.
that recycling is why the row builder should return the same shape for every item: one template whose content varies with the item. same-shaped trees diff cheaply; a recycled panel showing item 4,001 is just item 3's panels with new strings in them.
keying inside rows¶
the rows themselves take no keys - row identity is positional and the recycler owns it. keys matter one level down, inside the row template, in two places:
stateful nodes need a per-item key. TextEntry, ScenePanel, and WebPanel hold private engine state the blob diff cannot see - typed text, focus, render state, a loaded page. on a recycled panel that state survives the rediff, so without a key, the row that scrolls in inherits the text someone typed into the row that scrolled out. a key derived from the item's stable id forces a fresh panel when the item changes:
IBlob Row( Player p ) => new Container
{
Padding = 8, FlexDirection = FlexDirection.Row, Gap = 8,
Children =
{
new Text( p.Name ),
new Goo.TextEntry { DefaultText = p.Note, Key = $"note-{p.Id}", OnSubmit = v => SaveNote( p.Id, v ) },
},
};
conditional children need keys too. a nullable child that appears for some items and not others shifts its siblings' positions between items, and positional matching then diffs the wrong blobs into the wrong panels. key the conditional child and its siblings by item id, the same all-keyed rule as everywhere else in goo.
derive keys from a stable id, never from the row's index (the recycler reuses indexes - that is the whole point) and never from a display value that can repeat or change. the same rule extends to Cell.Mount inside a row: make the item id part of the cell's key, or a recycled row keeps the previous item's cell state.
the items contract¶
Virtual watches two things about Items: the list reference and the count. same reference, same count - nothing happens. a new reference or a changed count pushes the list to the engine, which skips the copy when contents compare equal, and each visible row rediffs only if its own item changed.
that gives you the working rules:
- to change content, build a new list. replacing one element and passing the new list rediffs exactly that row. make items records (or otherwise value-comparable) so unchanged rows compare equal and skip.
- appending in place works - the count change is caught on the next rebuild.
- never mutate an element in place. same reference, same count: the change is invisible until something else triggers a refresh.
void RenamePlayer( int id, string name )
{
_players = _players.Select( p => p.Id == id ? p with { Name = name } : p ).ToList();
Rebuild(); // or let a handler's auto-rebuild carry it
}
infinite scroll¶
OnLastRow fires when the last item materializes. load the next page there:
Virtual.List( _messages, itemHeight: 48, Row ) with
{
OnLastRow = () => _ = LoadOlderMessages(), // append + rebuild when the fetch lands
}
append the fetched items and rebuild; the count change refreshes the viewport, and the hook re-arms for the new last row.
when not to use it¶
- small bounded lists. the plain scroll container is simpler and unconstrained; virtualize when counts are data-driven and large, not by default.
- variable-height rows.
itemHeight/itemSizeare fixed; there is no measuring pass.Virtual.Customaccepts your ownBaseVirtualPanelsubclass if you need a different layout strategy, but that is engine-level work. - rows that animate in, out, or between positions. off-screen rows do not exist, so enter/leave transitions and cross-row movement have nothing to animate against.
- lists that size their parent. the viewport must be bounded by its parent, never the other way around.
see also¶
- virtual reference - the full property surface
- dynamic children -
AddRange,Each.Of, and the plain-list path for small counts - composition - keeping row templates as extracted functions
- cells -
Cell.Mountand per-item keys for stateful units inside rows - a pannable canvas - the other big-content strategy: one surface, transformed