VirtualizingStackPanel - content virtualization and advanced scroll position calculation supporting varying item sizes - #20993
Conversation
|
@MrJul @kekekeks I am sorry for bothering you. From my point of view, this is a necessity, not an option. Now to be fair: This PR is not complete yet. If you do not find this interesting, I will just extract out a "MyVirtualizingStackpanel" and let it live in our codebase. |
|
Hi @gentledepp, First of all, thank you for your contribution! General performance improvements are always welcome. Right now, as you might have noticed, we're in the process of shipping Avalonia v12 and won't be able to review this PR before the final release. Personally, I really like the concept of reusing content across templates. It makes a lot of sense. Assuming the implementation is good enough and well tested (and it looks like it is!), this is something I'm interested in merging, so please keep it open for now :) One thing that could really help us get this PR merged faster is to split it. For example, can all the improvements in scrolling calculation be extracted to a separate PR? That way, we could review the |
9ecc3d3 to
2a1d9bf
Compare
|
I'm eager to see this project come to fruition so after for a VirtualizingCanvas for ItemsControls for 2D virtualization management, allowing to display 2D graphs with numerous controls on an unlimited canvas :-) |
2ec7aa9 to
a2ebb10
Compare
6cb8ad8 to
6812547
Compare
bb50f4b to
4814948
Compare
|
Two questions for the Avalonia team, plus a fallback offer. Both are decisions about framework contracts rather than work we still owe, and we'd rather have your answer before pushing further in either direction. 1. Is Reset-preservation wanted at all?Stock treats That is not a bug fix. It is us overriding a framework contract because it suits one usage pattern. You may reasonably say the scroll-anchor system is the right owner of scroll stability, or that One consequence worth knowing before deciding: the Tier A correctness fix in this PR only exists because we preserve. 2. View lifecycle events under container virtualizationFor templates that opted in, a container's Three options, all implementable, none of them ours to pick:
Whichever you choose sets a framework-wide precedent for what those events mean under virtualization, which is why we'd like the call to be yours rather than ours. Fallback: a separate panel is fine by usIf the answer to either question is "not in |
|
|
@MrJul — the description has been rewritten against the current diff, and the two things it was waiting on are now done, so this is at the point where a scope decision from your side would help most. Is this split acceptable to you? The benchmarks turned what used to be a judgement call into something concrete:
So the proposal is two PRs plus a deferral: panel correctness as one, container virtualization as the other, and Two other things worth your attention:
The earlier questions from this comment are still open too, in particular whether Reset-preservation is wanted at all. If it is not, the Tier A correctness fix goes with it, since the wrong-index render only exists on the preserving path. |
… opt-in container virtualization Three separable things, all in VirtualizingStackPanel and the ItemsControl plumbing behind it. 1. The extent estimate no longer depends on which items happen to be realized. A persistent per-item size record replaces the average over the realized window, so revisiting an offset reports the same extent, and once every item has been measured the extent is the exact total. The running sum is maintained incrementally at the single upsert site with Neumaier compensation, so a measure pass stays O(realized window) as in stock. Every fork-added tuning constant that used to damp the oscillation goes with it: EMA smoothing, extent freezing and boundary clamping, the layout cycle breaker, estimate caching, the sub-pixel resize threshold, warmup head sampling. 2. Reset preservation now requires every realized element to still validate, rather than a bare majority. A mid-list insert or remove coalesced into a single Reset used to preserve a stale mapping, so shifted items rendered under the wrong containers. 3. Container-level virtualization, opt-in per template. A template that hands out a recycle key through IVirtualizingDataTemplate, which means EnableVirtualization in XAML or RecycleKeySelector on a FuncDataTemplate, has its container and its child recycled as one unit instead of the child subtree being torn down and rebuilt on every scroll. Over heterogeneous complex rows that is ~3.4x faster scrolling and 83% less allocated, at identical container prepare counts. A template that does not opt in keeps stock behaviour, and ContainerVirtualization.IsEnabled is a kill switch rather than the opt-in. Supporting changes: constant-free anchor compensation in ValidateStartU, RetainMatchingContainers for disjunct scrolls, opt-in warmup that grows the pool off encountered keys, a guard against 0x0 effective viewports, and ContentPresenter batch updates so Content and ContentTemplate land together. VirtualizingPanel.Refresh becomes virtual so a template or theme change reaches already-realized containers. Note that an opted-in container is hidden and kept in Panel.Children rather than torn down, so Loaded/Unloaded and AttachedToVisualTree/ DetachedFromVisualTree fire once for the first item a container displayed and not per item. Templates that need per-item work should use DataContextChanged. Tests: VirtualizingStackPanelTests grows to 153 methods over 310 cases, with new ContainerVirtualizationTests and ContentPresenterTests_BatchUpdate. Benchmarks in tests/Avalonia.Benchmarks/Controls cover scroll and jump timings, deterministic container counts, and what the size record costs in memory and per pass. Sample: ListBoxComplexLayoutPage in ControlCatalog.
2c7e2b3 to
5e83c60
Compare
Adds a page covering the opt-in in both forms (EnableVirtualization in XAML, RecycleKeySelector on a FuncDataTemplate), how to pick a recycle key, warmup, the per-item memory the panel retains, and the process-wide kill switch. Calls out that opted-in containers stay in the visual tree and are only hidden, so Loaded/Unloaded and AttachedToVisualTree/DetachedFromVisual- Tree no longer fire per item. Documents the API proposed in AvaloniaUI/Avalonia#20993.
It looks reasonable: it will be way easier to review and merge the correctness fixes independently of the new content virtualization feature.
Since the feature is opt-in, I think it's completely fine for it to come with new, documented restrictions. They look reasonable to me. We can then wait for feedback and remove them in the future if there's demand.
My opinion is that it isn't wanted. |
|
I will try to review this, but for reference @gentledepp: that PR description is useless, I'm not reading that wall of incomprehensible text. Can you get your LLM to rewrite it in non-Claude speak, fix line breaks and condense it to the useful information for a reviewer? |
|
From the little of the description that I did read, it looks like this PR merges two things:
That makes the PR even more difficult to review. Tempted to close this as it's IMO unreviewable in this state. I know you say that you've put a lot of your time into this, but honestly reading that LLM-generated wall of text just put me in a really bad mood, and the code isn't much better. I don't mind AI-generated stuff (I use them myself), but this is just shovelling slop on the poor reviewer. |
|
Yeah apologies, I'm going to close this. I can't even tell what it's supposed to do. Not a good use of my time. Please, open an issue describing the feature you'd like, and I'll tell you if it's a good idea. I think I know what this is wanting to achieve but I really can't be sure. |
What does the pull request do?
Two separable things, plus one correctness fix.
1. Makes
VirtualizingStackPanel's extent estimate independent of which items happen to berealized. This is the root-cause fix, and it is the part of the PR I would most like reviewed.
2. Adds opt-in container-level virtualization. A data template can implement
IVirtualizingDataTemplate— in XAML by settingEnableVirtualization="True", in code by giving aFuncDataTemplateaRecycleKeySelector— to have its containerand its child recycled as a single unit, instead of the child being destroyed and rebuilt on every
scroll. Nothing changes for anyone who does not ask for it. Over a heterogeneous list of complex rows
this is ~3.4× faster scrolling with 83% less allocated and ~20× lower frame-to-frame variance, at
identical container prepare counts — see Performance.
3. Fixes a wrong-index render on collection edits coalesced into a
Reset. Independent of bothof the above; see "Tier A correctness fix" below.
Important
Opting a template in changes what view lifecycle events mean, and this is the one way the feature
can break a working list. Container-level virtualization exists precisely because it stops tearing
the child down: a recycled container is hidden (
IsVisible = false) and kept inPanel.Childrenwith the template's control tree still attached to it. Nothing leaves the visual tree.
So for an opted-in template,
Loaded/UnloadedandAttachedToVisualTree/DetachedFromVisualTreefire once, for the first item those controlsever displayed. They do not fire again as the container is reused for other items, and the unload
side does not fire when an item scrolls out of view. Any control that initialises per item on load
or releases state on unload will misbehave: it initialises once against the wrong item and never
cleans up. Media players, map and chart controls, and anything that subscribes to a service in
OnAttachedToVisualTreeare the usual cases.The way out is to move per-item work to
DataContextChanged, or to leave that template opted out,in which case recycling behaves exactly as it does today. What the framework should do about this
is an open question for you, not a settled decision by me (see Checklist), and it is the reason
this is called out here rather than only under Breaking changes.
The headline: every fork-added tuning constant is gone
25_lastEstimatedElementSizeUinit3DefaultWarmupPoolSizePerKeyMinPoolSizePerKey. Affects first-scroll performance only — never layout or correctness — and only when warmup is explicitly enabled.That is the complete inventory. No threshold, no smoothing factor, no reversal counter, no pixel
tolerance beyond Avalonia's own
LayoutHelper.LayoutEpsilon. If a value in this diff looks like atuning constant, it is a bug — please flag it.
What is the current behavior?
Stock
VirtualizingStackPanelassumes item containers measure deterministically: the same itemmeasures to the same size on every pass. Real templates violate that routinely:
ObservableAsPropertyHelper-backed properties that propagate a pass late, so pass 1 sees the oldvalue and pass 2 the new.
EstimateElementSizeUestimates un-realized items from an average over the currently-realizedset. That set's membership changes on every scroll pass, so the estimate swings, so the reported
extent swings, so the
ScrollViewerre-measures the panel, so different items realize — and aroundagain. Symptoms: scroll drift, a scrollbar whose length depends on where you scrolled from, and
repeated layout passes.
Separately, on the content side: only containers (
ListBoxItem,ContentPresenter) are recycled.The content built by the data template — for a form or card template, a subtree of 10–50 controls —
is destroyed and rebuilt every time an item scrolls into view.
What is the updated/expected behavior with this PR?
1. A persistent per-item size record replaces the realized-window average
_measuredSizes(Dictionary<int, double>: last-measured size along the scrolling axis, per itemindex) plus a running sum:
EstimateElementSizeUupserts every currently-realized, measure-valid element's size, then returnsthe mean over all recorded sizes — a function of everything ever measured, not of the current
window.
CacheBasedExtentU(itemCount)returnsknownSum + unknownCount * mean. Once every item has beenmeasured,
unknownCount == 0and the extent is exactly the true total.Why this is upstreamable where the dampers were not:
identical to stock's realized average.
bimodal, extreme outliers (20px rows with occasional 2000px), a monotonic ramp, async-grow
(84→292) and cross-region shapes.
axis that is. The tests run as theories over
Orientation, and two deliberately-injected axis-onlydefects turned only horizontal cases red.
26000px → 1567px when this landed.
Record lifecycle: inserts and removes remap in place (an append — the infinite-scroll case —
moves nothing and allocates nothing);
Moveand a non-preservingResetclear it, because theindex→item mapping is no longer trustworthy.
What it costs, because this is the first thing you will want to profile. One entry per item ever
measured, never trimmed. Measured against the merge-base, with the item collection itself
excluded from the delta:
So ~4.2 MiB for a 100k-item list, held for as long as the panel is. It is not negotiable downwards —
trimming to the realized window is exactly the window-dependence the record exists to remove, and
the tests say so.
What it does not cost is per-pass time: the running sum is maintained incrementally at the single
upsert site (with Neumaier compensation), never by sweeping the record, so a measure pass stays
O(realized window) as in stock however large the record has grown. That is measured too, not just
argued — the same 40-pass scroll burst at the head of the collection, run once with the record empty
and once with an entry for every one of 100k items, differs by 0.053 ms: inside the run's own
standard deviation, and smaller than the same gap at 10k entries. A per-pass sweep would have grown
by 10×.
Staleness is safe by construction, and please don't "fix" it. While an item is out of view its
entry can go stale if its view model changes — no container exists to re-measure it. The record is
consulted only for items that are not currently realized; a visible item is always sized by its
live measure. On re-realization the container is re-measured and the entry is overwritten, so the
extent self-heals. Worst case is a transient scrollbar-length error for off-screen mutations,
corrected on scroll-in — inherent to all virtualization, and strictly better than stock, which
remembers nothing.
2. Tier A correctness fix: Reset preservation only when every element still validates
This fork preserves realized containers across a
Resetfor scroll stability (the infinite-scrollappend case). The gate used to be a bare majority (
preservedCount > realizedCount / 2). A mid-listinsert or remove coalesced into a single
Reset— which DynamicData'sBindreset-thresholddoes routinely — leaves the prefix matching but shifts everything after the edit point. A bare
majority therefore preserved the whole stale mapping: shifted items pinned to the wrong
containers, children rendered under the wrong headline. Visible corruption.
Now preservation requires
preservedCount == realizedCount; any partial match falls through to thefull-reset path. Cited tests:
Reset_With_MidList_Insert_Realizes_Shifted_Items_At_Correct_Indexand the 28-case
Collection_Edit_Keeps_Every_Container_On_Its_Own_Itemmatrix (7 edit kinds × 4positions — the edit has to land past the middle of the realized window to reproduce, which is why
the matrix and not a single test).
Related:
VirtualizingPanel.Refresh()is nowvirtual. AnItemTemplate/ItemContainerTheme/DisplayMemberBindingchange reaches the panel as a syntheticResetin which every elementstill matches its item — so preservation always kicked in,
PrepareItemContainerwas never called,and those three properties had no effect on already-realized containers of a virtualized
ItemsControl.VirtualizingStackPaneloverridesRefresh()to recycle first.3. Constant-free anchor compensation
The anchor sits at
StartU + Σ sizes before it. If that sum grew bypreDelta,StartUmust shrinkby the same amount or the anchor visually jumps. That is all
ValidateStartUdoes now:StartU -= preDelta, withMathUtilities.AreClose(..., LayoutHelper.LayoutEpsilon)— Avalonia's ownlayout-significance epsilon — absorbing float noise and nothing else. Sizes are recorded and
re-checked through one accessor (
GetElementSizeU), so the two can never disagree and read as aresize on every pass. New signature:
4. Container-level virtualization, opt-in per template
Core principle: container + child = one reusable unit. On the way out, content clearing is
skipped and the container is pooled under its recycle key with the child still attached. On the way
back in,
PrepareContainerForItemOverridesetsContent, andContentPresenter.CreateChildpassesthe attached
oldChildto the template'sBuild, which returns it unchanged — so there is no visualtree mutation at all, only a
DataContextchange and the binding updates that follow.Recycle-key selection in
ItemsControl.NeedsContainer<T>:Step 1 must come first, or items that are their own containers get wrapped — that ordering was a
real bug fix. Step 2 applies only when the kill switch is on and the panel is a
VirtualizingStackPanel, and it resolves the template throughGetEffectiveItemTemplate(), the samepath everything else keys off, so keying and pool-capping cannot disagree.
It extends
IRecyclingDataTemplate, soBuild(data, existing)comes from the base interface. XAMLDataTemplateimplements it withEnableVirtualization(defaultfalse);GetKeyreturnsnullunless that is set.
A
<DataTemplate DataType="local:Person">that does not opt in behaves exactly as in stock,DefaultRecycleKeyand all.MaxPoolSizePerKeyis honoured only for keys anIVirtualizingDataTemplatehanded out;DefaultRecycleKeypooling stays uncapped, as in stock.From code,
FuncDataTemplateopts in through a key selector:Null by default, so nothing changes for the code-defined templates that already exist — including
FuncDataTemplate.Defaultand the framework's own. A selector rather than a bool because the keyhas to identify the shape the build function produced, not the data's type: a template that
branches on a property builds different subtrees for one CLR type, which is what a heterogeneous
list is made of, and which
DataType-based keying cannot express. Where the build function does notbranch,
d => d?.GetType()is the natural choice. Returning null for a particular item opts thatitem back out.
ContainerVirtualization.IsEnabled(defaulttrue) is a kill switch, not the opt-in:setting it
falseforces everyItemsControlback to stock recycling, which is how you establishwhether a layout problem comes from virtualization at all. It used to be called
ContentVirtualizationDiagnostics; a behaviour switch does not belong in a class named fordiagnostics, so it is now named for the feature it switches. Where that switch should live at all is
still on the API-review list below.
Supporting machinery in
ItemsControl/ContentPresenter:SetIfUnsetOrDifferent— unlikeSetIfUnset, forces the update when a recycled containeralready holds a different value. Without it a reused container keeps the previous item.
ContentPresenter.BeginBatchUpdate/EndBatchUpdate—ContentandContentTemplatemustland together, or
UpdateChildruns once with a mismatched pair and rebuilds the child fornothing. The depth counter makes batches nest (only the outermost
Endpublishes) and anunbalanced
Enda no-op;Endalso runs theUpdatePseudoClasses/InvalidateMeasurethatContentChangedskipped for the whole batch.PrepareContainerForItemOverridesetsContentTemplatefromItemTemplate/DisplayMemberBindingand from nothing else; a template living in aDataTemplatescollection is resolved by theContentPresenteritself, as in stock, so theItemsControland the presenter can never disagree about which template an item uses.5.
RetainMatchingContainers(disjunct-scroll reuse)On a viewport jump everything is recycled. Before that,
RetainMatchingContainerspulls outrealized containers whose
DataContextmatches an item in the estimated new viewport;GetOrCreateElementgives those a lightweightItemContainerIndexChangedinstead of a fullPrepareItemContainer.RealizedStackElements.NullifyElementremoves an element from the realizedlist without recycling it.
Correctness-safe by construction: keyed on the item reference, so a container is only ever reused for
the same item it already held. Because it skips
PrepareItemContainerit is the one path that candefeat the staleness self-heal above, hence
Retained_Container_Reuse_Remeasures_Changed_Item.Now measured: ~10% fewer container prepares on backwards scrolling and paging (132 → 120 over a
40-step wheel-up; 83 → 75 over 10 page-ups), and over complex rows ~7% fewer subtree rebuilds with
it. Forward scrolling and scrollbar jumps are unchanged. Real, but small, and below the noise floor of
the timing runs — so if the scope split below is wanted, this is the first thing I would move to its
own PR. It also carries the most machinery per unit of benefit (
NullifyElement,_retainedForReuse,RecycleUnusedRetainedContainers, and the scroll-anchor bookkeeping).6. Opt-in warmup, default off
EnableWarmup(defaultfalse) pre-creates and pre-measures containers on a background dispatchertick, so the first scroll does not pay for container construction.
The pool grows off the template keys the panel has actually needed a container for, topping up
when a new key appears and forgetting keys the collection no longer contains. There is no head
sampling: sampling the first N items assumes the head represents the whole collection's key
distribution, which is false for any grouped or sorted list. Warmup is skipped — not cancelled — when
its dispatcher tick lands on a panel that has left the visual tree, so a navigated-back-to page warms
up on re-attach.
7. The zero-viewport guard
OnEffectiveViewportChangedreturns immediately when the incoming effective viewport is empty,without touching
_viewport, the extent, or invalidating measure. This reads as a defensive nicety;it is load-bearing. On-device trace: a user scrolls into a list, launches the camera, and returns to
find the scroll position jumped. The cause is not the photo resizing — it is the window viewport
collapsing to 0×0 while the camera activity is in front:
#01478vpY=983.8 startU=435.9 realized=[2..9] anchor=item3@821.7#01499effVp=0,983.8,0,0(0×0) → needsMeasure#01504realized=[0..0] startU=0(anchor lost)#01532vpY=983.8#01535anchorIdx=-1(unrecoverable)#01544effVpjumps983.8 → 123.7Any tab switch, minimise or foreign activity produces the same 0×0 viewport; this is not
camera-specific. Covered by
Collapsing_Viewport_To_Empty_And_Restoring_Preserves_Scroll_Position(red/green verified: with the guard disabled,
FirstRealizedIndexcollapses 20 → 0 across the roundtrip).
Performance
Benchmarks are in
tests/Avalonia.Benchmarks/Controls/— BenchmarkDotNet for timings,dotnet Avalonia.Benchmarks.dll --virtualization-reportfor the deterministic container counts. Thebaseline is a worktree at the merge-base, built and run the same way, rather than a toggle inside one
build.
Container-level virtualization (item 4) is where the win is
5,000 heterogeneous rows — four row kinds of 10–20 visuals each with bound text, i.e. a form or feed
list.
Plainis a template that does not opt in, which puts it on the stock path(
DefaultRecycleKey, content cleared on recycle, subtree rebuilt);Virtualizedopts in. MediumRun,i7-10750H, two independent runs shown so the spread is visible:
That last row matters as much as the mean for a scrolling UI: jank is variance, not average cost.
The deterministic counts say why. Container prepare counts are identical in both arms — nothing
is being skipped or realized lazily; the entire difference is the child subtree surviving recycling:
Once the per-key pools have filled, paging rebuilds nothing at all.
Two caveats stated up front rather than left for review to find:
It is a fair one because a non-opted-in template is put back on stock's exact path, and that is
checked rather than assumed: the
Plainarm is also run at the merge-base, where it comes outindistinguishable from this branch's.
Canvasrow templatethe same benchmark shows no benefit whatsoever — there is nothing to preserve when building the
child costs a single allocation. These figures describe templates with real content, which is the
case the feature exists for. Desktop x64 is a lower bound: the avoided work is subtree
construction, binding setup and text layout, all of which cost proportionally more on mobile, where
this pattern hurts most. No mobile numbers have been taken.
The panel changes (items 1–3, 5) on their own
Over the same complex rows, this branch vs. the merge-base with a non-opted-in template:
indistinguishable — 18.51 / 20.93 ms stock against 24.08 / 19.80 ms here, the ordering flipping
between runs, both arms GC-dominated at ~8 MB per operation. Over a trivial one-
Canvastemplate thebranch is 16–34% faster and allocates 12–23% less across a 1k/100k × uniform/variable matrix, with
equal or lower container counts, which is the regime where panel bookkeeping is the whole cost.
Read that as the extent rework does not regress, and helps when templates are cheap. The
performance case for this PR is the section above it; the case for items 1–3 is correctness and the
constants inventory, not speed.
Accepted trades
Stating these explicitly rather than letting review find them:
LayoutManager's cap. Removing the layout-cyclebreaker means a template reporting a different size on every measure drives repeated measure
passes until Avalonia's own iteration cap, instead of being hard-capped at one pass per layout
cycle by the panel. Deliberate: "one pass suffices" was an assumption, the cap also dropped
legitimate work (a second resize inside one measure→arrange cycle never reached
ValidateStartU),and the deferred re-measure lagged real size changes by a dispatcher tick — applying a size change
at the next scroll position, producing the very jump it was meant to prevent. Templates that
settle converge. A template that never settles is a defect in the template, and it behaves the same
way in a plain
StackPanel.Panel.Childrenretains invisible pooled containers — see Breaking changes.How was the solution implemented (if it's not obvious)?
Most of the above is in the "updated behavior" section. What is worth adding is what was removed,
because several of these were in the first revision of this PR and a reviewer may go looking for
them:
EstimateElementSizeU0.3smoothing,>50%overlap gate, realized-range skip_frozenExtentU, reversal counters, boundary clamping, dampening)100px,2reversals,2pxnoise floor,5px/2passes,0.5/10%/0.3dampeningScrollViewer— i.e. made the scrollbar wrong on purpose. Probing showed the whole method was dead: disabling it changed no test outcome, because its anchor-drift compensation duplicatedValidateStartU's constant-freeStartU -= preDelta._consecutiveMeasureCount,_measurePostponed, deferredDispatcher.Post(InvalidateMeasure))>1ValidateStartUbespoke logic (lockSizes,_suppressValidateStartU)1px"real resize" threshold, once-per-arrange suppressionLayoutHelper.LayoutEpsilon._lastEstimateFirstIndex/LastIndex)WarmupSampleSize)50, validated1..1000RealizeElements3remaining itemsunknownCountreaches 0, and the extent is the exact total.ITypedDataTemplatewithDataTypeset, and anitem.GetType()fallbackItemsControl5ItemsControl(_templateCache)PrepareItemContainerruns beforeAddInternalChild, so an item type's first lookup happens on an unparented container, finds nothing, and memoizesnullfor good. Making it work would have been worse than deleting it — it would setContentTemplatefrom aDataTemplates-collection template whileNeedsContainerstill returnedDefaultRecycleKey, so an opted-in template in a collection would buy the skip-clear while its containers sat in the single shared pool.anchorIndex < FirstIndex || anchorIndex > LastIndex.[VSP-*]trace logging +IsTracingEnabledGetPoolStats,ClearPools,ContentPoolStats,PoolEntryContainerVirtualizationis now just the kill switch.Checklist
Add unit tests
Added XML documentation to any related classes?
Benchmarks — added in
tests/Avalonia.Benchmarks/Controls/, results in Performance above.Container-level virtualization is ~3.4× on scroll and −83% allocation over complex rows; the
size record costs 43.7 bytes per item and no per-pass time;
RetainMatchingContainerssaves~10% of prepares on backwards scrolling only. The one thing still unmeasured is mobile —
every figure here is desktop x64, which understates the case, but I have not taken Android
numbers and have not extrapolated any.
Scope split — I would like guidance here, and the benchmarks now make a concrete proposal
possible. This used to be "each of these is defensible alone"; the numbers say something
sharper than that. Item 4 (container-level virtualization, plus warmup and the
IVirtualizingDataTemplatesurface) carries all of the speed: ~3.4× and −83% allocation.Items 1–3 (size record, Reset-preservation fix, constant-free anchor compensation) measure as
performance-neutral and stand on correctness and the constants inventory instead. Item 5
(
RetainMatchingContainers) is ~10% of prepares on backwards scrolling only, and carries themost machinery per unit of benefit.
Is Reset-preservation wanted at all? Stock treats
Resetas a full rebuild. Preservingacross it is non-upstream by nature; the scroll-anchor system may be the right owner instead.
API review / naming —
EnableVirtualization,RecycleKeySelector,MaxPoolSizePerKey/MinPoolSizePerKey,EnableWarmup, and whether a process-global switch belongs in aContainerVirtualizationstatic class at all.Test seam — fixed.
AdjustElementSizewasprotected internal virtualpurely so testscould inject non-deterministic measurement, and the
protectedhalf made it public API byaccident. It is now
internal Func<int, double, double>? ElementSizeAdjustmentForTesting,applied in
GetElementSizeUexactly where the method was called; the three test panels thatoverrode it assign the delegate instead. (
TryGetMeasuredSizeForTestingandRecyclePoolForTestingwere alreadyinternal.)View lifecycle events — synthesise
Loaded/Unloadedfor recycled containers, addvirtualization-aware equivalents, or document the trade? All three are implementable; each sets
a framework-wide precedent for what those events mean under virtualization, which is why it is
your call rather than mine. See the callout at the top.
User documentation — Document container virtualization for VirtualizingStackPanel avalonia-docs#1113, opened as a draft against
main. Itcovers both opt-in forms, choosing a recycle key, warmup, the size record's memory cost, and
the lifecycle-event caveat above. It should not be merged before this PR, and it needs
rewriting if the API review renames anything.
Optional
FuncDataTemplate— done.RecycleKeySelectoris the code-sideopt-in, so the feature is no longer XAML-only.
Implement for— deliberately out of scope for this PR.VirtualizingPaneltooBreaking changes
Virtualization itself is opt-in: a template that does not implement
IVirtualizingDataTemplate(orset
EnableVirtualization="True") gets stock behaviour, includingDefaultRecycleKeyand contentclearing on recycle. Two behavioural changes are worth calling out anyway:
View lifecycle events, for opted-in templates only. Because the child stays attached across
recycling,
Loaded/UnloadedandAttachedToVisualTree/DetachedFromVisualTreefire once forthe first item a container displayed and never again. A template that forwards those to its view
model, or a control that initialises on load and releases on unload, will not work inside an
opted-in template without moving that work to
DataContextChanged. Unresolved — see the calloutat the top and the Checklist. Since virtualization became opt-in, this is only paid by templates
that asked for it, and it is documented in the docs PR below.
Panel.Childrenretains invisible pooled containers, for everyone. Stock'sRecycleElementOnItemRemovedunparented the container (RemoveInternalChild); this PR pools itinstead, so it stays in
ChildrenwithIsVisible = false. That is the point — unparenting isexactly the detach/reattach churn container-level virtualization exists to avoid. It is not a
ghost (invisible and absent from the realized set means nothing renders it; focus navigation
filters on
IsEffectivelyVisible), but it is publicly observable: anything enumeratingPanel.Childrenand assuming every child is a live item will now see extras. Stock alreadyretained the parent on the ordinary scroll-recycle path; only the item-removed path changed.
ListBoxVirtualizationIssueTests.GhostItemTest_FocusManagementasserted the old contract and wasupdated.
Obsoletions / Deprecations
None.
Fixed issues
Fixes #20259
Test coverage
Avalonia.Controls.UnitTestsis green: 3,840 cases, 3,839 passed, 0 failures, the one skip beinga pre-existing
CalendarDatePickerskip unrelated to this change.Avalonia.Markup.Xaml.UnitTestsis green: 591 cases, 590 passed, 0 failures, again with onepre-existing skip.
VirtualizingStackPanelTests.csContainerVirtualizationTests.cs(new)Presenters/ContentPresenterTests_BatchUpdate.cs(new)What they cover, by area:
Move/Reset, one entry per itemever measured, not trimmed when items leave the viewport, survives detach but not a re-attach.
(84→292), cross-region. Extent stability and, for the cross-region case, extent correctness
(converged on the true 34000px total — a stability-only assertion cannot tell a settled estimate
from a settled wrong one).
Collection_Edit_Keeps_Every_Container_On_Its_Own_Itemmatrix,plus reorder/append coalesced into a
Reset.the same arrange cycle, anchor stability across repeated passes with few realized items.
GetKey/Build(data, existing)/EnableVirtualization, the typed-template skip-clear,NeedsContainer<T>ordering, template resolution, nested/recursive virtualization, batch update.MinPoolSizePerKey,and the dispatcher-tick-after-detach case.
reuse re-measures a changed item, visible items always use the live measure.
Everything whose contract is axis-independent runs as a theory over
Orientation. That half is notdecorative: two deliberately-injected axis-only defects (reading
DesiredSize.HeightinGetElementSizeU; readingviewport.Y/.BottominCalculateMeasureViewport) turned 27 and 50cases red respectively, no vertical case either time.
Files changed
Production
src/Avalonia.Controls/VirtualizingStackPanel.cs(+1309/-127) — the size record and its lifecycle,CacheBasedExtentU, constant-freeValidateStartUuse,RetainMatchingContainers, Resetpreservation gate,
Refresh()override, warmup, the zero-viewport guard.src/Avalonia.Controls/Utils/RealizedStackElements.cs(+150/-11) —NullifyElement, andValidateStartU's new signature(int anchorIndex, Func<Control,int,double> getSizeU, out double preDelta).src/Avalonia.Controls/ItemsControl.cs(+150/-10) — recycle keys, conditional content clearing,SetIfUnsetOrDifferent,GetEffectiveItemTemplate,GetMaxPoolSizePerKey/GetMinPoolSizePerKey,ContainerVirtualization.src/Avalonia.Controls/Presenters/ContentPresenter.cs(+50/-3) —BeginBatchUpdate/EndBatchUpdate(nesting depth counter) and the pseudo-class/measure refresh they owe the batch.src/Avalonia.Controls/Templates/IVirtualizingDataTemplate.cs(+34) — new.src/Avalonia.Controls/Templates/FuncDataTemplate.cs(+48/-2) — implementsIVirtualizingDataTemplate;RecycleKeySelector(null by default) is the opt-in for code-defined templates.src/Markup/Avalonia.Markup.Xaml/Templates/DataTemplate.cs(+38/-1) —EnableVirtualization,MaxPoolSizePerKey,MinPoolSizePerKey.src/Avalonia.Controls/VirtualizingPanel.cs(+14/-1) —Refresh()madevirtual.Sample —
samples/ControlCatalog/Pages/ListBoxComplexLayoutPage.{xaml,xaml.cs},Pages/FieldTemplateSelector.cs,Converter/MarkdownToInlinesConverter.cs,ViewModels/ListBoxComplexLayoutPageViewModel.cs, plus the page-list entry and smallListBoxPageedits.
Tests —
VirtualizingStackPanelTests.cs, newContainerVirtualizationTests.cs, newPresenters/ContentPresenterTests_BatchUpdate.cs, and updates toItemsControlTests.cs/ListBoxVirtualizationIssueTests.cs.