Skip to content

VirtualizingStackPanel - content virtualization and advanced scroll position calculation supporting varying item sizes#20993

Draft
gentledepp wants to merge 2 commits intoAvaloniaUI:masterfrom
gentledepp:feature/20259_virtualizingdatatemplate_master
Draft

VirtualizingStackPanel - content virtualization and advanced scroll position calculation supporting varying item sizes#20993
gentledepp wants to merge 2 commits intoAvaloniaUI:masterfrom
gentledepp:feature/20259_virtualizingdatatemplate_master

Conversation

@gentledepp
Copy link
Copy Markdown
Contributor

What does the pull request do?

This PR implements content-level virtualization for Avalonia's ItemsControl and VirtualizingStackPanel, delivering 10-100x performance improvements for complex data templates. It introduces the new IVirtualizingDataTemplate interface for type-aware container recycling, enabling container + child controls to be reused as a single unit rather than destroying and recreating expensive UI elements during scrolling.

Additionally, this PR includes significant improvements to VirtualizingStackPanel's scrolling behavior for heterogeneous lists (items with varying heights), reducing measure passes from 2-3 down to 1 per scroll event and eliminating scroll jumping.

Key Features:

  • New IVirtualizingDataTemplate interface with opt-in EnableVirtualization property (I would like to get rid of this, but maybe it is good for A/b testing during the pr)
  • Type-aware container pooling by data type (e.g., PersonViewModel, TaskViewModel)
  • Container + child recycled together as a single unit (no visual tree churn)
  • Automatic virtualization for templates with DataType set
  • Smooth scrolling improvements for variable-height items
  • Warmup strategy for pre-creating containers to improve initial scroll performance

What is the current behavior?

Before this PR:

  1. Limited Recycling: Only containers (ListBoxItem, ContentPresenter) are recycled during virtualization. The content (the expensive nested UI created by data templates) is destroyed and recreated on every scroll.

  2. High Performance Cost:

    • Full Measure/Arrange cycle on every virtualization event
    • Heavy GC pressure from continuous allocation/deallocation
    • For a template with 10-50 nested controls (~1-10KB each), this happens thousands of times during scrolling
  3. Poor Scrolling Performance with Heterogeneous Lists:

    • Multiple measure passes (2-3) per scroll event instead of 1
    • Temporal mismatch: estimates calculated from PREVIOUS viewport before measuring CURRENT viewport
    • Scroll jumping and stuttering with variable-height items
    • Extent oscillation causing layout cycles
  4. Memory Issues:

    • Continuous creation/destruction of complex control hierarchies
    • No pooling of content across container reuse
    • Janky scrolling, especially on mobile devices

What is the updated/expected behavior with this PR?

After this PR:

  1. Container-Level Virtualization:

    • Container + child controls recycled together as a single unit
    • Type-aware pooling: ProductItem containers only reused for ProductItem data (template compatibility guaranteed)
    • Child controls stay attached during recycling (no visual tree detach/reattach)
    • Only data object changes → bindings update, minimal measure/arrange overhead
  2. Performance Improvements:

    • 50-90% reduction in Measure/Arrange cycles during scrolling
    • Significantly reduced GC pressure for complex templates
    • 10-100x faster scrolling for complex heterogeneous lists
    • Memory stabilizes after initial scroll (pooled containers reused)
  3. Smooth Scrolling:

    • Single measure pass per scroll event (down from 2-3)
    • No scroll jumping or stuttering with variable-height items
    • Accurate extent calculation from current viewport
    • No layout cycles or redundant re-estimation
    • Layout cycle breaker for non-deterministic measurements (async image loading, text wrapping, deferred bindings): after one full MeasureOverride pass, subsequent calls return the previous DesiredSize and post a deferred InvalidateMeasure at Loaded priority, preventing tight layout loops while ensuring items eventually settle at correct sizes
    • Sub-pixel tolerance in ValidateStartU: measurement fluctuations < 1px are absorbed silently, preventing cascade invalidation from non-deterministic controls. Only genuine resizes (≥ 1px) mark StartU as unstable
    • Extent oscillation detection and frozen extent: when the extent alternates direction across passes (e.g., async OAPH-backed property propagation causing 292px ↔ 84px swings), the extent is frozen at the pre-oscillation value, preventing the ScrollViewer from adjusting the offset in response to oscillating values. The frozen extent converges toward reality once measurements stabilize
    • Frozen extent boundary clamping: during frozen extent, top and bottom boundaries are clamped to prevent empty space above item 0 or scrolling past the last item
    • Stale anchor guard: when StartU is NaN (unstable), CaptureViewportAnchor always re-evaluates instead of returning a stale cached anchor — preventing positive-feedback drift in CompensateForExtentChange
    • Smart container retention during disjunct scrolling: before recycling all elements on a viewport jump, matching containers are retained and reused with a lightweight index update (ItemContainerIndexChanged) instead of full PrepareItemContainer + Measure
    • Improved anchor estimation for heterogeneous heights: forward/backward extrapolation from the last realized element's actual position instead of dividing from zero, plus item 0 position correction (forced to U=0 when realized, skipped during frozen extent)
    • Estimate caching: the average element size is only recalculated when the realized range changes, eliminating smoothing-induced extent drift across passes
    • Dampened extent compensation: when < 10% of items are realized and the extent changes by > 50%, the change is dampened to 30% to prevent wild viewport overshoot
    • Skip CompensateForExtentChange after StartU re-estimation: when ValidateStartU marks StartU as unstable and positions are re-estimated from scratch, CompensateForExtentChange is skipped to let the scroll anchor mechanism adjust naturally
  4. Easy Adoption:

    <!-- Explicit opt-in -->
    <DataTemplate DataType="local:Person" EnableVirtualization="True" MaxPoolSizePerKey="10">
        <Border>
            <StackPanel>
                <TextBlock Text="{Binding Name}" />
                <TextBlock Text="{Binding Email}" />
            </StackPanel>
        </Border>
    </DataTemplate>
    
    <!-- Automatic for templates with DataType -->
    <DataTemplate DataType="local:Person">
        <!-- Automatically benefits from virtualization! -->
    </DataTemplate>
  5. Enable/Disable globally:

    // Disable for debugging
    ContentVirtualizationDiagnostics.IsEnabled = false;

Note: I am aware that the naming ContentVirtualizationDiagnostics is terrible. But you may want me to delete this central way of enabling/disabling anyways.

How was the solution implemented (if it's not obvious)?

Phase 1: IVirtualizingDataTemplate Interface

Created new interface for explicit content virtualization with custom recycling keys:

public interface IVirtualizingDataTemplate : IDataTemplate
{
    object? GetKey(object? data);              // Custom recycling key
    Control? Build(object? data, Control? existing);  // Build or reuse
    int MaxPoolSizePerKey { get; }            // Configurable pool size
    int MinPoolSizePerKey { get; }            // Configurable pool size for warmup
}

Implemented in DataTemplate with EnableVirtualization XAML property.

Phase 2: Automatic Virtualization

Templates implementing both IRecyclingDataTemplate and ITypedDataTemplate with DataType set automatically benefit from pool-based recycling (default pool size: 5 controls per type).

Phase 3: Container-Level Virtualization (Critical Performance Fix)

Key Insight: When virtualization is active, the child should stay attached to its container. The container + child become a single reusable unit, pooled by data type.

Implementation:

  1. Type-Aware Container Recycling (ItemsControl.NeedsContainerOverride):

    • Uses IVirtualizingDataTemplate.GetKey() for explicit recycling keys
    • Uses DataType for automatic type-safe pooling
    • Fallback to item.GetType() for type-aware recycling
    • Result: Containers only reused for compatible data types
  2. Conditional Content Clearing (ItemsControl.ClearContainerForItemOverride):

    • When virtualization active: Skip clearing Content/ContentTemplate properties
    • Child stays attached to container during recycling
    • No visual tree churn (detach/reattach)
  3. Forced Content Update (ItemsControl.PrepareContainerForItemOverride):

    • Uses SetCurrentValue(ContentProperty, item) instead of SetIfUnset
    • Forces content property update even when already set
    • Ensures DataContext updates when container reused
  4. Template Reuse (ContentPresenter.CreateChild):

    • Template's Build() receives existing attached child
    • Returns it unchanged when newChild == oldChild
    • Skips visual tree changes (lines 601-612 in ContentPresenter)

Phase 4: Smooth Scrolling Improvements

Fixed multiple measure passes during scrolling with heterogeneous items:

  1. Eliminated Temporal Mismatch:

    • Moved estimate calculation AFTER RealizeElements() instead of before
    • Estimate now reflects CURRENT viewport composition, not previous
  2. Skip Redundant Re-estimation:

    • Track realized range used for estimation
    • Skip recalculation if range unchanged
    • Prevents convergence oscillation
  3. Direct Averaging (No Smoothing) for Larger Samples:

    • Removed exponential smoothing for samples ≥5 items
    • Immediate adaptation to new item sizes
    • Accurate extent on first pass
  4. Preserve Estimate Tracking on Reset:

    • Only reset tracking when actually recycling elements
    • Preserves estimate stability during infinite scroll
  5. Item 0 Clipping Fix:

    • Three-layer protection to ensure item 0 always at position 0
    • Forward loop protection, extent compensation protection, safety net
    • Eliminates clipping when scrolling to top fast
  6. Layout Cycle Breaker:

    • Non-deterministic controls (async image loading, text wrapping, dynamic visibility bindings) can produce fluctuating DesiredSize results across passes, causing extent oscillation and infinite re-measurement
    • After one full MeasureOverride pass, subsequent calls return the previous DesiredSize and post a deferred InvalidateMeasure at Loaded priority (before next input/scroll event)
    • A _measurePostponed flag ensures at most one deferred measure is queued; the counter resets in ArrangeOverride, OnEffectiveViewportChanged, and OnItemsChanged
  7. ValidateStartU Tolerance, Suppression, and Partial-State Detection:

    • Sub-pixel tolerance: changes < 1px are absorbed silently. Only changes ≥ 1px mark StartU as unstable
    • One-shot suppression: after ValidateStartU fires once, _suppressValidateStartU prevents re-firing until ArrangeOverride resets the flag
    • Partial layout-manager state detection: detects when items before the anchor have been re-measured but the anchor element has not yet (IsMeasureValid == false), marking StartU as unstable instead of incorrectly applying preDelta compensation
    • Anchor-aware compensation: when only items before the viewport anchor changed and the anchor is stable, StartU is adjusted by the accumulated preDelta to preserve the anchor's visual position
    • preDelta compensation during frozen extent: when lockSizes=true, size diffs for items at or before the anchor (itemIndex <= anchorIndex) are tracked and compensated via _startU -= preDelta, keeping visible items at their current positions without invalidating the estimate cache
  8. Stale Anchor Guard:

    • Added !double.IsNaN(startU) to CaptureViewportAnchor's skip-cache condition
    • When StartU is unstable, the anchor is always re-evaluated, preventing a positive-feedback drift loop in CompensateForExtentChange
  9. Smart Container Reuse During Disjunct Scroll:

    • RetainMatchingContainers: before RecycleAllElements, extracts containers whose DataContext matches items in the estimated new viewport, stored in _retainedForReuse
    • GetOrCreateElement integration: checks _retainedForReuse first; matching containers get a lightweight ItemContainerIndexChanged instead of full PrepareItemContainer
    • NullifyElement in RealizedStackElements: removes an element from the realized list without recycling it
    • RecycleUnusedRetainedContainers: properly recycles any retained containers that weren't reused
  10. Improved Anchor Estimation for Heterogeneous Heights:

    • Forward/backward extrapolation from the last (or first) realized element's actual position using the estimated size, rather than dividing from zero
    • Item 0 position correction: when item 0 is realized, forced to U=0 (skipped during frozen extent to prevent false disjunct detection)
  11. Estimate Caching:

    • Tracks the realized range (_lastEstimateFirstIndex, _lastEstimateLastIndex); if unchanged, returns the cached estimate
    • Cache invalidated when ValidateStartU detects a genuine resize
  12. CompensateForExtentChange Dampening:

    • When extent change ratio > 50% and < 10% of items are realized, the change is dampened to 30% of the full delta to prevent wild viewport overshoot
  13. Skip CompensateForExtentChange After StartU Re-estimation:

    • When ValidateStartU marks StartU as unstable (NaN) and positions are re-estimated from scratch, CompensateForExtentChange is skipped — the scroll anchor mechanism adjusts the viewport naturally in a subsequent layout pass. The extent tracking (_lastMeasuredExtentU) is still updated to maintain a correct baseline
  14. NeedsContainer Check Order Fix:

    • Moved the item is T check before the virtualization logic so items that are their own containers are not incorrectly wrapped
  15. Extent Oscillation Detection and Frozen Extent:

    • Tracks the sign of extent deltas across passes; when the sign alternates (up-down-up), increments an oscillation counter. Large swings (>100px) freeze on first reversal; small swings wait for 2 reversals
    • Frozen extent (_frozenExtentU): locks the extent reported to ScrollViewer at the pre-oscillation value, converging toward reality when the actual extent stabilizes within ±5px for 2+ consecutive passes
    • Resets in OnItemsChanged and OnDetachedFromVisualTree
    • Item 0 position correction skipped during frozen extent to prevent false disjunct detection (gap between item 0 at position 0 and item 1 at estimated position)
    • Top boundary clamping: when item 0 is realized during frozen extent and the viewport start is above item 0's position, shifts all items so item 0 aligns with the viewport start and reduces _frozenExtentU accordingly
    • Bottom boundary clamping: when the last item is realized during frozen extent and the frozen extent exceeds the actual content end, caps _frozenExtentU at contentEndU

Phase 5: Warmup Strategy

Pre-creates and measures containers before they're needed:

  • Scans items collection for upcoming data types
  • Creates containers for each type up to target pool size
  • Pre-measures containers to populate layout cache
  • Improves initial scroll performance
<ItemsControl.ItemsPanel>
	<ItemsPanelTemplate>
		<VirtualizingStackPanel EnableWarmup="True" WarmupSampleSize="100" />
	</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>

Checklist

  • Add unit tests
  • Added XML documentation to any related classes?
  • remove some of the AI generated mardown files
  • Consider submitting a PR to https://github.com/AvaloniaUI/avalonia-docs with user documentation
    • document, that view lifecycle events (loaded/unloaded, added/removed to/from visual/logical tree) will not work as expected
  • Naming - please do an API review

Optional

  • Implement for VirtualizingPanel too?
  • Add virtualization support to FuncDataTemplate

Breaking changes

This is a fully backward-compatible enhancement with one exception:

  • Existing code continues to work unchanged
  • Virtualization is opt-in via EnableVirtualization="True"
  • When disabled, original behavior is preserved
  • Automatic virtualization only applies when explicitly using DataType

Exception: If your data templates use view lifecycle events (loaded/unloaded, added/removed to/from visual/logical tree) and forward these events to their viewmodels, this behavior changes now.
Should we "fake-fire" those events for recycled controls?
Or add new events that viewmodels can listen on that work with virtualization as well?

Obsoletions / Deprecations

None.

Fixed issues

Fixes #20259

Test coverage

Added 22 new tests covering:

  • Layout cycle prevention (cycle breaker limits, genuine resize still works)
  • ValidateStartU tolerance (sub-pixel absorption) and suppression (one-shot per arrange)
  • CaptureViewportAnchor NaN guard (stale anchor not reused)
  • NullifyElement (smart reuse infrastructure)
  • Disjunct scroll container reuse
  • Item 0 position correction
  • Estimate caching (skip recalculation, invalidation on resize)
  • Extent dampening with few realized items
  • End-to-end: scroll down then back to top with mixed heights (contiguity check)
  • Rapid scrolling stress test (20 jumps with mixed heights)
  • Anchor-self-size-change compensation during frozen extent (preDelta with <= anchor)
  • Item 0 correction skipped during frozen extent (no false disjunct)
  • Frozen extent top boundary clamping (no empty space above item 0)
  • Frozen extent bottom boundary capping (no scrolling past last item)
  • Scroll-to-top-and-back with frozen extent preserves contiguity

Files changed

  • src/Avalonia.Controls/VirtualizingStackPanel.cs — All panel-level changes (cycle breaker, frozen extent, boundary clamping, item 0 correction guard, extent oscillation detection)
  • src/Avalonia.Controls/Utils/RealizedStackElements.csNullifyElement, ValidateStartU tolerance/suppression/lockSizes preDelta, CompensateStartU
  • src/Avalonia.Controls/ItemsControl.csNeedsContainer check order
  • tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs — 22 new tests
  • samples/ControlCatalog/Pages/ComplexVirtualizationPage.xaml — PhotoItem template with image display
  • samples/ControlCatalog/ViewModels/ComplexVirtualizationPageViewModel.cs — PhotoItem image/height properties, sample image loading

@gentledepp gentledepp changed the title VirtualizingStackPanel - Warmup for IVirtualizedDataTemplates VirtualizingStackPanel - content virtualization and advanced scroll position calculation supporting varying item sizes Mar 26, 2026
@gentledepp
Copy link
Copy Markdown
Contributor Author

@MrJul @kekekeks I am sorry for bothering you.
I have put a decent amount of work into this already (rebasing on master, I saw that some of my changes already found their way into the VirtualizingStackPanel) and burned through hundreds of dollars in AI.
So before I put in even more effort, I'd like to know if this is of any interest of you.

From my point of view, this is a necessity, not an option.
As an avalonia user, I expected those features to already be in there, only to find out that our app scrolls slower than with Xamarin.Forms - so I got a platform developer.

Now to be fair: This PR is not complete yet.
There is a virtualizingstackpanel_test_todo.md file that outlines some of the fixes I introduced with the help of AI that cannot be easily tested.
And I'd have to remove some markdown files and put more effort into the sample app to showcase how my contribution actually improves the whole scrolling experience by a mile or two.

If you do not find this interesting, I will just extract out a "MyVirtualizingStackpanel" and let it live in our codebase.

@MrJul
Copy link
Copy Markdown
Member

MrJul commented Mar 26, 2026

Hi @gentledepp,

First of all, thank you for your contribution!

General performance improvements are always welcome. VirtualizingStackPanel is no different. However, it is a critical and complex component, and major changes like this need a thorough review and usually a team discussion (especially since new APIs are involved).

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 IVirtualizingDataTemplate separately, and have improvements be delivered incrementally.

VirtualizingStackPanel can handle items with (very) different heights (no scroll jumping, no layout cycles)
IVirtualizingDataTemplate implementation for ItemsControls (not only ItemContainers are re-used but also the Content)
@gentledepp gentledepp force-pushed the feature/20259_virtualizingdatatemplate_master branch from 9ecc3d3 to 2a1d9bf Compare March 27, 2026 06:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

VirtualizingDataTemplate - Content-Level Virtualization for virtualized ItemsControls

2 participants