diff --git a/samples/ControlCatalog/Converter/MarkdownToInlinesConverter.cs b/samples/ControlCatalog/Converter/MarkdownToInlinesConverter.cs
new file mode 100644
index 00000000000..2a13889107d
--- /dev/null
+++ b/samples/ControlCatalog/Converter/MarkdownToInlinesConverter.cs
@@ -0,0 +1,178 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Avalonia.Controls.Documents;
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+
+namespace ControlCatalog.Converter
+{
+ ///
+ /// Turns a small subset of markdown into s for a TextBlock: ATX headings,
+ /// bullet and numbered lists, **bold** , *italic* and `code` .
+ ///
+ /// Deliberately not a markdown library. The point for this sample is that the *height* of the
+ /// row is a function of the text and of how it wraps, so the virtualizing panel cannot know it
+ /// without measuring — the same situation any rich-text row puts it in.
+ ///
+ public class MarkdownToInlinesConverter : IValueConverter
+ {
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is not string markdown || string.IsNullOrWhiteSpace(markdown))
+ return new InlineCollection();
+
+ var inlines = new InlineCollection();
+ var lines = markdown.Replace("\r\n", "\n").Split('\n');
+ var firstBlock = true;
+
+ foreach (var rawLine in lines)
+ {
+ var line = rawLine.Trim();
+
+ if (line.Length == 0)
+ continue;
+
+ if (!firstBlock)
+ inlines.Add(new LineBreak());
+ firstBlock = false;
+
+ if (TryTakePrefix(line, "### ", out var h3))
+ {
+ AddSpans(inlines, h3, FontWeight.SemiBold, 1.05);
+ }
+ else if (TryTakePrefix(line, "## ", out var h2))
+ {
+ AddSpans(inlines, h2, FontWeight.Bold, 1.15);
+ }
+ else if (TryTakePrefix(line, "# ", out var h1))
+ {
+ AddSpans(inlines, h1, FontWeight.Bold, 1.3);
+ }
+ else if (TryTakePrefix(line, "- ", out var bullet) || TryTakePrefix(line, "* ", out bullet))
+ {
+ inlines.Add(new Run(" • "));
+ AddSpans(inlines, bullet, FontWeight.Normal, 1.0);
+ }
+ else if (TryTakeOrderedPrefix(line, out var number, out var ordered))
+ {
+ inlines.Add(new Run($" {number}. "));
+ AddSpans(inlines, ordered, FontWeight.Normal, 1.0);
+ }
+ else
+ {
+ AddSpans(inlines, line, FontWeight.Normal, 1.0);
+ }
+ }
+
+ return inlines;
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+
+ private static bool TryTakePrefix(string line, string prefix, out string rest)
+ {
+ if (line.StartsWith(prefix, StringComparison.Ordinal))
+ {
+ rest = line.Substring(prefix.Length);
+ return true;
+ }
+
+ rest = line;
+ return false;
+ }
+
+ private static bool TryTakeOrderedPrefix(string line, out int number, out string rest)
+ {
+ var dot = line.IndexOf(". ", StringComparison.Ordinal);
+ if (dot > 0 && int.TryParse(line.Substring(0, dot), out number))
+ {
+ rest = line.Substring(dot + 2);
+ return true;
+ }
+
+ number = 0;
+ rest = line;
+ return false;
+ }
+
+ ///
+ /// Splits inline markup into runs. and
+ /// carry the enclosing block's styling (a heading stays bold
+ /// and larger even where it contains emphasis).
+ ///
+ private static void AddSpans(InlineCollection inlines, string text, FontWeight baseWeight, double sizeFactor)
+ {
+ foreach (var (span, kind) in SplitSpans(text))
+ {
+ var run = new Run(span);
+
+ if (sizeFactor != 1.0)
+ run.FontSize = 14 * sizeFactor;
+
+ run.FontWeight = kind == SpanKind.Bold ? FontWeight.Bold : baseWeight;
+
+ if (kind == SpanKind.Italic)
+ run.FontStyle = FontStyle.Italic;
+
+ if (kind == SpanKind.Code)
+ {
+ run.FontFamily = new FontFamily("Consolas, Menlo, monospace");
+ run.Background = new SolidColorBrush(Color.FromArgb(28, 128, 128, 128));
+ }
+
+ inlines.Add(run);
+ }
+ }
+
+ private enum SpanKind { Plain, Bold, Italic, Code }
+
+ ///
+ /// Single pass over the line, splitting on the three inline markers. Unclosed markers are
+ /// treated as literal text rather than swallowing the rest of the line.
+ ///
+ private static IEnumerable<(string Text, SpanKind Kind)> SplitSpans(string text)
+ {
+ var i = 0;
+ var plainStart = 0;
+
+ while (i < text.Length)
+ {
+ var (marker, kind) = text[i] switch
+ {
+ '*' when i + 1 < text.Length && text[i + 1] == '*' => ("**", SpanKind.Bold),
+ '*' => ("*", SpanKind.Italic),
+ '`' => ("`", SpanKind.Code),
+ _ => (null, SpanKind.Plain),
+ };
+
+ if (marker is null)
+ {
+ i++;
+ continue;
+ }
+
+ var contentStart = i + marker.Length;
+ var close = text.IndexOf(marker, contentStart, StringComparison.Ordinal);
+
+ if (close < 0 || close == contentStart)
+ {
+ i += marker.Length;
+ continue;
+ }
+
+ if (i > plainStart)
+ yield return (text.Substring(plainStart, i - plainStart), SpanKind.Plain);
+
+ yield return (text.Substring(contentStart, close - contentStart), kind);
+
+ i = close + marker.Length;
+ plainStart = i;
+ }
+
+ if (plainStart < text.Length)
+ yield return (text.Substring(plainStart), SpanKind.Plain);
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/FieldTemplateSelector.cs b/samples/ControlCatalog/Pages/FieldTemplateSelector.cs
new file mode 100644
index 00000000000..974799fa565
--- /dev/null
+++ b/samples/ControlCatalog/Pages/FieldTemplateSelector.cs
@@ -0,0 +1,106 @@
+using System.Collections.Generic;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Metadata;
+
+namespace ControlCatalog.Pages
+{
+ ///
+ /// Picks a row template by the row's type, and tells the virtualizing panel how to pool the
+ /// controls it builds.
+ ///
+ /// A flat list of heterogeneous rows cannot use one template, and a plain
+ /// ItemsControl.DataTemplates collection would recycle nothing: every scroll step would
+ /// rebuild a row's whole control tree. Implementing
+ /// instead gives the panel a recycle key per row kind, so a container built for one image row
+ /// is reused for the next image row and only its bindings change.
+ ///
+ ///
+ /// Templates are supplied as content, so the layouts stay in XAML next to the page:
+ ///
+ /// <pages:FieldTemplateSelector x:Key="FieldTemplates">
+ /// <DataTemplate DataType="vm:HeadlineItem">…</DataTemplate>
+ /// <DataTemplate DataType="vm:TextFieldItem">…</DataTemplate>
+ /// </pages:FieldTemplateSelector>
+ ///
+ ///
+ public class FieldTemplateSelector : IVirtualizingDataTemplate
+ {
+ [Content]
+ public List Templates { get; } = new();
+
+ ///
+ /// Whether a recycled container may keep the control tree it already has.
+ ///
+ /// Turn it off to see what a plain costs. A template that is not
+ /// an — or that ignores the control handed back to it,
+ /// which is what this flag simulates — makes ContentPresenter throw the row's control
+ /// tree away and build a new one every time a container is reused for a different row. The
+ /// container pooling still happens; it is the contents that get rebuilt.
+ ///
+ public bool RecycleContent { get; set; } = true;
+
+ ///
+ /// How many row control trees have been built from scratch. With recycling on this settles
+ /// at roughly the number of containers in play; with it off it climbs for as long as you
+ /// keep scrolling.
+ ///
+ public int Builds { get; private set; }
+
+ public void ResetBuilds() => Builds = 0;
+
+ /// Upper bound on idle containers kept per row kind.
+ public int MaxPoolSizePerKey { get; set; } = 6;
+
+ ///
+ /// How many containers warmup pre-builds per row kind. Warmup grows the pool off the row
+ /// kinds the panel has actually met, so a kind that first appears deep in the list (the
+ /// markdown and image rows here) is covered when the user reaches it rather than only if it
+ /// happened to occur near the top.
+ ///
+ public int MinPoolSizePerKey { get; set; } = 3;
+
+ ///
+ /// The recycle key. Row kind is the right granularity: two image rows have the same control
+ /// tree and differ only in their data, whereas an image row and a number field have nothing
+ /// in common and must never be swapped for one another.
+ ///
+ public object? GetKey(object? data) => data?.GetType();
+
+ public bool Match(object? data) => FindTemplate(data) is not null;
+
+ public Control? Build(object? data)
+ {
+ var built = FindTemplate(data)?.Build(data);
+ if (built is not null)
+ Builds++;
+ return built;
+ }
+
+ public Control? Build(object? data, Control? existing)
+ {
+ // The control tree handed back is already the right one for this row kind (the panel
+ // only offers a container whose recycle key matches), so keep it and let the
+ // DataContext change drive the update. Rebuilding here is what a plain IDataTemplate
+ // effectively does, and it is the expensive path — see RecycleContent.
+ if (existing is not null && RecycleContent)
+ return existing;
+
+ return Build(data);
+ }
+
+ private IDataTemplate? FindTemplate(object? data)
+ {
+ if (data is null)
+ return null;
+
+ foreach (var template in Templates)
+ {
+ if (template.Match(data))
+ return template;
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/ListBoxComplexLayoutPage.xaml b/samples/ControlCatalog/Pages/ListBoxComplexLayoutPage.xaml
new file mode 100644
index 00000000000..7543b488d1f
--- /dev/null
+++ b/samples/ControlCatalog/Pages/ListBoxComplexLayoutPage.xaml
@@ -0,0 +1,406 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ListBox — complex heterogeneous layout
+
+ A data-entry form as one flat, virtualized list: section headlines interleaved with fields of
+ many kinds. Rows differ in height by more than an order of magnitude, and the image rows
+ change height after they have been realized, when their
+ picture arrives.
+
+
+ Scroll down to a batch of placeholders and watch them grow, or park anywhere and press
+ “Re-download loaded images” to make several rows change height at once. The scroll position
+ should stay where you left it.
+
+
+
+
+ Reuse row content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/ListBoxComplexLayoutPage.xaml.cs b/samples/ControlCatalog/Pages/ListBoxComplexLayoutPage.xaml.cs
new file mode 100644
index 00000000000..f817ad75853
--- /dev/null
+++ b/samples/ControlCatalog/Pages/ListBoxComplexLayoutPage.xaml.cs
@@ -0,0 +1,110 @@
+using System;
+using System.ComponentModel;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using ControlCatalog.ViewModels;
+
+namespace ControlCatalog.Pages
+{
+ public partial class ListBoxComplexLayoutPage : ContentPage
+ {
+ private readonly ListBoxComplexLayoutPageViewModel _viewModel;
+ private readonly DispatcherTimer _statsTimer;
+
+ public ListBoxComplexLayoutPage()
+ {
+ InitializeComponent();
+
+ _viewModel = new ListBoxComplexLayoutPageViewModel();
+ DataContext = _viewModel;
+ _viewModel.PropertyChanged += OnViewModelPropertyChanged;
+
+ _statsTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
+ _statsTimer.Tick += (_, _) => UpdateRealizationStats();
+ }
+
+ /// The row templates, which are also where the build counter lives.
+ private FieldTemplateSelector? Templates =>
+ this.FindControl("FieldsListBox")?.ItemTemplate as FieldTemplateSelector;
+
+ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName != nameof(ListBoxComplexLayoutPageViewModel.RecycleContent))
+ return;
+
+ if (Templates is not { } templates)
+ return;
+
+ templates.RecycleContent = _viewModel.RecycleContent;
+
+ // The counter restarts so the two settings can be compared over the same scrolling.
+ // Rows already realized keep the control tree they have; the new setting takes effect
+ // as containers are recycled, i.e. as soon as you scroll. (Re-assigning ItemTemplate to
+ // force them all to rebuild leaves the presenters holding an empty child — the rows go
+ // blank and never recover.)
+ templates.ResetBuilds();
+ }
+
+ private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ _statsTimer.Start();
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ _statsTimer.Stop();
+ }
+
+ ///
+ /// The row this container shows has changed — either it was just built for an image row, or
+ /// it was recycled from one image row onto another. Both are the moment a real app would
+ /// start fetching. is idempotent, so a
+ /// row scrolled out and back in does not download twice.
+ ///
+ private void OnImageFieldDataContextChanged(object? sender, EventArgs e)
+ {
+ if (sender is Control { DataContext: ImageFieldItem item })
+ item.StartDownloadIfNeeded();
+ }
+
+ ///
+ /// Shows how little of the list is actually alive: the realized index range, how many
+ /// containers exist for it, and the extent the panel is reporting for the whole list.
+ ///
+ private void UpdateRealizationStats()
+ {
+ var listBox = this.FindControl("FieldsListBox");
+ var panel = listBox?.GetVisualDescendants().OfType().FirstOrDefault();
+
+ if (panel is null || panel.FirstRealizedIndex < 0)
+ {
+ _viewModel.RealizationStats = "Nothing realized yet.";
+ return;
+ }
+
+ var containers = panel.Children.Count(c => c.IsVisible);
+ var scroll = listBox!.GetVisualDescendants().OfType().FirstOrDefault();
+
+ var images = _viewModel.Fields.OfType().ToList();
+ var loaded = images.Count(i => i.HasValue);
+ var pending = images.Count(i => i.IsDownloading);
+
+ _viewModel.RealizationStats =
+ $"rows {panel.FirstRealizedIndex}..{panel.LastRealizedIndex} of {_viewModel.Fields.Count} " +
+ $"containers {containers} " +
+ $"row trees built {Templates?.Builds ?? 0} " +
+ (scroll is not null
+ ? $"offset {scroll.Offset.Y,8:0} extent {scroll.Extent.Height,9:0} "
+ : "") +
+ $"images loaded {loaded} downloading {pending}";
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml
index f73a4c18ed4..943b801582d 100644
--- a/samples/ControlCatalog/Pages/ListBoxPage.xaml
+++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml
@@ -43,9 +43,18 @@
+ WrapSelection="{Binding WrapSelection}">
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml.cs b/samples/ControlCatalog/Pages/ListBoxPage.xaml.cs
index 208b5cce4b3..c0027c668e2 100644
--- a/samples/ControlCatalog/Pages/ListBoxPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml.cs
@@ -1,4 +1,5 @@
using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
using ControlCatalog.ViewModels;
namespace ControlCatalog.Pages
@@ -10,5 +11,10 @@ public ListBoxPage()
InitializeComponent();
DataContext = new ListBoxPageViewModel();
}
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
}
}
diff --git a/samples/ControlCatalog/ViewModels/ListBoxComplexLayoutPageViewModel.cs b/samples/ControlCatalog/ViewModels/ListBoxComplexLayoutPageViewModel.cs
new file mode 100644
index 00000000000..e12784dafb0
--- /dev/null
+++ b/samples/ControlCatalog/ViewModels/ListBoxComplexLayoutPageViewModel.cs
@@ -0,0 +1,579 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using Avalonia.Threading;
+using MiniMvvm;
+
+namespace ControlCatalog.ViewModels
+{
+ ///
+ /// A form built the way a real data-entry form is: one flat list of heterogeneous rows —
+ /// section headlines interleaved with fields of many different kinds — virtualized by a single
+ /// VirtualizingStackPanel . Modelled on the VisibleFieldsControl of a production form
+ /// filler, which is where the panel's hardest requirements come from:
+ ///
+ /// * rows differ in height by more than an order of magnitude (a one-line number field next
+ /// to a 300px image or a long markdown block),
+ /// * a row's height changes *after* it has been realized, when async content arrives,
+ /// * the row kinds are not evenly distributed through the list,
+ /// * and the whole thing has to keep the scroll position steady while all of that happens.
+ ///
+ public class ListBoxComplexLayoutPageViewModel : ViewModelBase
+ {
+ private int _downloadDelayMs = 1200;
+ private double _cacheLength = 0.5;
+ private bool _recycleContent = true;
+ private string _realizationStats = "";
+
+ public ListBoxComplexLayoutPageViewModel()
+ {
+ Fields = new ObservableCollection(BuildForm(sectionCount: 120));
+
+ // Only the rows that have actually materialized a picture are re-fetched. Doing it to
+ // the whole list would decode hundreds of bitmaps nobody has looked at.
+ ReDownloadLoadedImagesCommand = MiniCommand.Create(() =>
+ {
+ foreach (var image in Fields.OfType())
+ {
+ if (image.HasValue || image.IsDownloading)
+ image.ReDownload();
+ }
+ });
+
+ AddSectionCommand = MiniCommand.Create(() =>
+ {
+ var number = Fields.OfType().Count() + 1;
+ foreach (var row in BuildSection(number, new Random(number)))
+ Fields.Add(row);
+ });
+
+ RemoveLastSectionCommand = MiniCommand.Create(() =>
+ {
+ var lastHeadline = -1;
+ for (var i = Fields.Count - 1; i >= 0; i--)
+ {
+ if (Fields[i] is HeadlineItem)
+ {
+ lastHeadline = i;
+ break;
+ }
+ }
+
+ if (lastHeadline < 0)
+ return;
+
+ while (Fields.Count > lastHeadline)
+ Fields.RemoveAt(Fields.Count - 1);
+ });
+ }
+
+ public ObservableCollection Fields { get; }
+
+ public MiniCommand ReDownloadLoadedImagesCommand { get; }
+ public MiniCommand AddSectionCommand { get; }
+ public MiniCommand RemoveLastSectionCommand { get; }
+
+ /// How long an image field pretends to spend downloading.
+ public int DownloadDelayMs
+ {
+ get => _downloadDelayMs;
+ set
+ {
+ if (RaiseAndSetIfChanged(ref _downloadDelayMs, value))
+ ImageFieldItem.DownloadDelayMs = value;
+ }
+ }
+
+ ///
+ /// How much beyond the viewport the panel realizes, as a multiple of the viewport. Raising
+ /// it gives an image row more time to finish loading before it is looked at, at the cost of
+ /// keeping more containers alive; dropping it to 0 makes rows settle in plain view.
+ ///
+ public double CacheLength
+ {
+ get => _cacheLength;
+ set => RaiseAndSetIfChanged(ref _cacheLength, value);
+ }
+
+ ///
+ /// Whether a recycled container keeps the control tree it already has. Turning it off makes
+ /// the row templates behave like plain IDataTemplate s: the container is still pooled,
+ /// but its whole control tree is rebuilt every time it is reused for another row.
+ ///
+ public bool RecycleContent
+ {
+ get => _recycleContent;
+ set => RaiseAndSetIfChanged(ref _recycleContent, value);
+ }
+
+ /// What the panel currently has realized — filled in by the page.
+ public string RealizationStats
+ {
+ get => _realizationStats;
+ set => RaiseAndSetIfChanged(ref _realizationStats, value);
+ }
+
+
+ private static IEnumerable BuildForm(int sectionCount)
+ {
+ for (var section = 1; section <= sectionCount; section++)
+ {
+ foreach (var row in BuildSection(section, new Random(section)))
+ yield return row;
+ }
+ }
+
+ ///
+ /// One section: a headline followed by a handful of fields. The row kinds are chosen so the
+ /// list is deliberately *not* uniform in either height or kind distribution — image and
+ /// markdown rows only appear in some sections, which is what defeats guessing the set of
+ /// row kinds from the first N rows.
+ ///
+ private static IEnumerable BuildSection(int number, Random random)
+ {
+ var titles = new[] { "Observation", "Defect", "Assessment", "Measurements", "Sign-off" };
+ var title = titles[(number - 1) % titles.Length];
+
+ yield return new HeadlineItem
+ {
+ Numbering = number.ToString(),
+ Title = title,
+ UnsatisfiedFields = random.Next(0, 4),
+ NegativeFields = random.Next(0, 3),
+ IsRepeatable = title is "Observation" or "Defect",
+ };
+
+ var rows = new List();
+
+ rows.Add(new TextFieldItem
+ {
+ Title = "Description",
+ FieldName = $"{title.ToLowerInvariant()}.description",
+ IsMandatory = true,
+ Value = string.Join(" ", Enumerable
+ .Range(0, random.Next(1, 9))
+ .Select(_ => Sentences[random.Next(Sentences.Length)])),
+ });
+
+ rows.Add(new NumberFieldItem
+ {
+ Title = "Quantity",
+ FieldName = $"{title.ToLowerInvariant()}.quantity",
+ Value = random.Next(1, 400),
+ Unit = random.Next(2) == 0 ? "m²" : "pcs",
+ });
+
+ // Every third section carries a markdown block, so the row kind first appears well
+ // past the head of the list.
+ if (number % 3 == 0)
+ {
+ rows.Add(new MarkdownFieldItem
+ {
+ Title = "Instructions",
+ FieldName = $"{title.ToLowerInvariant()}.instructions",
+ Markdown = MarkdownSamples[random.Next(MarkdownSamples.Length)],
+ });
+ }
+
+ rows.Add(new ChoiceFieldItem
+ {
+ Title = "Severity",
+ FieldName = $"{title.ToLowerInvariant()}.severity",
+ IsMandatory = true,
+ Options = new[] { "Low", "Medium", "High" },
+ SelectedOption = random.Next(3) switch { 0 => "Low", 1 => "Medium", _ => "High" },
+ });
+
+ if (random.Next(2) == 0)
+ {
+ rows.Add(new ChecklistFieldItem
+ {
+ Title = "Required actions",
+ FieldName = $"{title.ToLowerInvariant()}.actions",
+ Items = new ObservableCollection(Actions
+ .OrderBy(_ => random.Next())
+ .Take(random.Next(2, Actions.Length + 1))
+ .Select(a => new ChecklistOption { Text = a, IsChecked = random.Next(2) == 0 })),
+ });
+ }
+
+ // Every other section carries an image. This is the row whose height changes *after*
+ // it is realized: it starts as a small placeholder and grows once the "download"
+ // completes.
+ if (number % 2 == 0)
+ {
+ rows.Add(new ImageFieldItem
+ {
+ Title = "Photo",
+ FieldName = $"{title.ToLowerInvariant()}.photo",
+ AssetPath = SampleImages[random.Next(SampleImages.Length)],
+ LoadedHeight = 180 + random.Next(0, 5) * 40,
+ });
+ }
+
+ rows.Add(new DateTimeFieldItem
+ {
+ Title = "Recorded",
+ FieldName = $"{title.ToLowerInvariant()}.recorded",
+ Timestamp = new DateTime(2024, 1, 1).AddDays(number).AddMinutes(random.Next(0, 1440)),
+ });
+
+ for (var i = 0; i < rows.Count; i++)
+ {
+ rows[i].SectionTitle = title;
+ rows[i].IsLastFieldInSection = i == rows.Count - 1;
+ rows[i].LastModified = $"Modified {new DateTime(2024, 1, 1).AddDays(number):yyyy-MM-dd} by {Users[random.Next(Users.Length)]}";
+ rows[i].HasDescription = random.Next(3) == 0;
+ yield return rows[i];
+ }
+ }
+
+ private static readonly string[] Users = { "J. Doe", "A. Marek", "S. Fischer", "M. Weber" };
+
+ private static readonly string[] Actions =
+ {
+ "Immediate repair", "Follow-up inspection", "Documentation only",
+ "Notify site manager", "Isolate the area",
+ };
+
+ private static readonly string[] Sentences =
+ {
+ "Cracked concrete along the north wall, roughly two metres above floor level.",
+ "Surface corrosion visible on the exposed bracket.",
+ "Water ingress has stained the ceiling tiles in the adjacent room.",
+ "The seal has perished and no longer sits flush against the frame.",
+ "Measured deflection is within tolerance but trending upward since the last visit.",
+ "No defect found; recorded for completeness.",
+ "Access was restricted at the time of inspection, so this is a partial assessment.",
+ "Previous repair appears sound with no sign of movement.",
+ };
+
+ private static readonly string[] MarkdownSamples =
+ {
+ """
+ ## Important notes
+
+ Please make sure that:
+
+ - All **required** fields are filled in completely
+ - Asset tags are scanned *where available*
+ - Photos are clear and well lit
+
+ Contact the inspection coordinator with any questions. Use `FORM-1042` as the reference.
+ """,
+
+ """
+ ### Measurement procedure
+
+ 1. Zero the gauge before each reading
+ 2. Take **three** readings and record the *median*
+ 3. Note the ambient temperature
+
+ A reading outside `±0.5 mm` must be flagged as a defect and photographed.
+ """,
+
+ """
+ ## Safety
+
+ **Do not** enter the void without a second person present.
+
+ - Confirm isolation before opening any panel
+ - Wear eye protection at all times
+ - Report near misses the same day, however minor they seem
+
+ This section exists mainly to be *tall*: a markdown row is several times the height of a
+ number field, which is exactly the kind of variance the virtualizing panel has to price
+ into its extent estimate without the scroll position drifting.
+ """,
+ };
+
+ private static readonly string[] SampleImages =
+ {
+ "avares://ControlCatalog/Assets/delicate-arch-896885_640.jpg",
+ "avares://ControlCatalog/Assets/hirsch-899118_640.jpg",
+ "avares://ControlCatalog/Assets/maple-leaf-888807_640.jpg",
+ "avares://ControlCatalog/Assets/image1.jpg",
+ "avares://ControlCatalog/Assets/image2.jpg",
+ "avares://ControlCatalog/Assets/image3.jpg",
+ "avares://ControlCatalog/Assets/image4.jpg",
+ "avares://ControlCatalog/Assets/image5.jpg",
+ };
+ }
+
+ /// A section headline. Not a field — it is a row in the same flat list.
+ public class HeadlineItem : ViewModelBase
+ {
+ private bool _isExpanded = true;
+
+ public string Numbering { get; set; } = "";
+ public string Title { get; set; } = "";
+ public int UnsatisfiedFields { get; set; }
+ public int NegativeFields { get; set; }
+ public bool IsRepeatable { get; set; }
+
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set => RaiseAndSetIfChanged(ref _isExpanded, value);
+ }
+
+ public bool HasUnsatisfiedFields => UnsatisfiedFields > 0;
+ public bool HasNegativeFields => NegativeFields > 0;
+ }
+
+ ///
+ /// What every field row has in common — the chrome around the value: a mandatory bar, a title
+ /// that may wrap to several lines, an optional description button and an audit footer.
+ ///
+ public abstract class FieldItem : ViewModelBase
+ {
+ public string Title { get; set; } = "";
+ public string FieldName { get; set; } = "";
+ public string SectionTitle { get; set; } = "";
+ public bool IsMandatory { get; set; }
+ public bool HasDescription { get; set; }
+ public bool IsLastFieldInSection { get; set; }
+ public string LastModified { get; set; } = "";
+
+ public abstract bool HasValue { get; }
+
+ /// Mandatory-but-empty is the state a form has to make obvious.
+ public bool IsUnsatisfied => IsMandatory && !HasValue;
+
+ ///
+ /// The marker appended to the title of a mandatory field. A property rather than a second
+ /// control so it flows with the title text and wraps with it.
+ ///
+ public string MandatoryMarker => IsMandatory ? " *" : "";
+ }
+
+ public class TextFieldItem : FieldItem
+ {
+ private string _value = "";
+
+ public string Value
+ {
+ get => _value;
+ set
+ {
+ if (RaiseAndSetIfChanged(ref _value, value))
+ {
+ RaisePropertyChanged(nameof(HasValue));
+ RaisePropertyChanged(nameof(IsUnsatisfied));
+ }
+ }
+ }
+
+ public override bool HasValue => !string.IsNullOrWhiteSpace(Value);
+ }
+
+ public class NumberFieldItem : FieldItem
+ {
+ private double? _value;
+
+ public double? Value
+ {
+ get => _value;
+ set
+ {
+ if (RaiseAndSetIfChanged(ref _value, value))
+ {
+ RaisePropertyChanged(nameof(HasValue));
+ RaisePropertyChanged(nameof(IsUnsatisfied));
+ }
+ }
+ }
+
+ public string Unit { get; set; } = "";
+
+ public override bool HasValue => Value.HasValue;
+ }
+
+ public class DateTimeFieldItem : FieldItem
+ {
+ public DateTime Timestamp { get; set; }
+
+ public string DateText => Timestamp.ToString("yyyy-MM-dd");
+ public string TimeText => Timestamp.ToString("HH:mm");
+
+ public override bool HasValue => true;
+ }
+
+ /// A short set of mutually exclusive options, rendered as a row of buttons.
+ public class ChoiceFieldItem : FieldItem
+ {
+ private string? _selectedOption;
+
+ public IReadOnlyList Options { get; set; } = Array.Empty();
+
+ public string? SelectedOption
+ {
+ get => _selectedOption;
+ set
+ {
+ if (RaiseAndSetIfChanged(ref _selectedOption, value))
+ {
+ RaisePropertyChanged(nameof(HasValue));
+ RaisePropertyChanged(nameof(IsUnsatisfied));
+ }
+ }
+ }
+
+ public override bool HasValue => !string.IsNullOrEmpty(SelectedOption);
+ }
+
+ ///
+ /// A checklist. Its height depends on how many options it has, so two rows of the same *kind*
+ /// still differ in height — the panel cannot assume one size per row kind.
+ ///
+ public class ChecklistFieldItem : FieldItem
+ {
+ public ObservableCollection Items { get; set; } = new();
+
+ public override bool HasValue => Items.Any(i => i.IsChecked);
+ }
+
+ public class ChecklistOption : ViewModelBase
+ {
+ private bool _isChecked;
+
+ public string Text { get; set; } = "";
+
+ public bool IsChecked
+ {
+ get => _isChecked;
+ set => RaiseAndSetIfChanged(ref _isChecked, value);
+ }
+ }
+
+ /// Markdown rendered into TextBlock inlines. Tall, and its height depends on the text.
+ public class MarkdownFieldItem : FieldItem
+ {
+ public string Markdown { get; set; } = "";
+
+ public override bool HasValue => true;
+ }
+
+ ///
+ /// An image field that "downloads" its picture. This is the interesting row for the
+ /// virtualizing panel: it is realized at , and some time later —
+ /// while it may well be off screen, or while the user is scrolling past it — the picture
+ /// arrives and the row jumps to . Everything below it moves, and the
+ /// panel has to absorb that without dragging the scroll position with it.
+ ///
+ public class ImageFieldItem : FieldItem
+ {
+ /// Shared so the page's delay slider affects rows that have not started yet.
+ public static int DownloadDelayMs = 1200;
+
+ private Bitmap? _image;
+ private bool _isDownloading;
+ private CancellationTokenSource? _cts;
+
+ public ImageFieldItem()
+ {
+ ReDownloadCommand = MiniCommand.Create(ReDownload);
+ }
+
+ /// Drops this row's picture and fetches it again, so its height changes on demand.
+ public MiniCommand ReDownloadCommand { get; }
+
+ public string AssetPath { get; set; } = "";
+
+ /// Height of the "not downloaded yet" placeholder.
+ public double PlaceholderHeight => 84;
+
+ /// Height once the picture is in. Varies per row on purpose.
+ public double LoadedHeight { get; set; } = 260;
+
+ public Bitmap? Image
+ {
+ get => _image;
+ private set
+ {
+ if (RaiseAndSetIfChanged(ref _image, value))
+ RaisePropertyChanged(nameof(HasValue));
+ }
+ }
+
+ public bool IsDownloading
+ {
+ get => _isDownloading;
+ private set => RaiseAndSetIfChanged(ref _isDownloading, value);
+ }
+
+ public override bool HasValue => Image is not null;
+
+ ///
+ /// Called when the row is realized, i.e. when it first comes near the viewport — the same
+ /// moment a real app would start fetching. Idempotent: scrolling a row in and out again
+ /// must not restart a download or re-grow a row that is already loaded.
+ ///
+ public void StartDownloadIfNeeded()
+ {
+ if (Image is not null || IsDownloading)
+ return;
+
+ _cts = new CancellationTokenSource();
+ _ = DownloadAsync(_cts.Token);
+ }
+
+ /// Drops the picture, cancelling any download in flight.
+ public void Reset()
+ {
+ _cts?.Cancel();
+ _cts = null;
+ IsDownloading = false;
+ Image = null;
+ }
+
+ /// Drops the picture and fetches it again — the row shrinks, then grows.
+ public void ReDownload()
+ {
+ Reset();
+ StartDownloadIfNeeded();
+ }
+
+ private async Task DownloadAsync(CancellationToken cancellationToken)
+ {
+ IsDownloading = true;
+
+ try
+ {
+ await Task.Delay(DownloadDelayMs, cancellationToken).ConfigureAwait(false);
+
+ var bitmap = await Task.Run(() =>
+ {
+ using var stream = AssetLoader.Open(new Uri(AssetPath));
+ return new Bitmap(stream);
+ }, cancellationToken).ConfigureAwait(false);
+
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ bitmap.Dispose();
+ return;
+ }
+
+ Image = bitmap;
+ IsDownloading = false;
+ });
+ }
+ catch (OperationCanceledException)
+ {
+ await Dispatcher.UIThread.InvokeAsync(() => IsDownloading = false);
+ }
+ catch
+ {
+ // Asset missing or undecodable — leave the placeholder in place.
+ await Dispatcher.UIThread.InvokeAsync(() => IsDownloading = false);
+ }
+ }
+ }
+}
diff --git a/samples/ControlCatalog/ViewModels/MainWindowViewModel_PageList.cs b/samples/ControlCatalog/ViewModels/MainWindowViewModel_PageList.cs
index 89d20de6fea..3266a1741d0 100644
--- a/samples/ControlCatalog/ViewModels/MainWindowViewModel_PageList.cs
+++ b/samples/ControlCatalog/ViewModels/MainWindowViewModel_PageList.cs
@@ -64,6 +64,7 @@ partial class MainWindowViewModel
new PageItem("Label",() => new LabelsPage(), Icons.Tag),
new PageItem("LayoutTransformControl",() => new LayoutTransformControlPage(), Icons.Transform),
new PageItem("ListBox",() => new ListBoxPage(), Icons.List),
+ new PageItem("ListBox - Complex Layout",() => new ListBoxComplexLayoutPage(), Icons.List),
new PageItem("Menu",() => new MenuPage(), Icons.Menu),
new PageItem("NavigationPage",() => new NavigationDemoPage(), Icons.Navigation),
new PageItem("Notifications",() => new NotificationsPage(), Icons.Bell),
diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs
index a3900874a1a..7416ad17330 100644
--- a/src/Avalonia.Controls/ItemsControl.cs
+++ b/src/Avalonia.Controls/ItemsControl.cs
@@ -374,15 +374,35 @@ protected internal virtual void PrepareContainerForItemOverride(Control containe
}
else if (container is ContentControl cc)
{
- SetIfUnset(cc, ContentControl.ContentProperty, item);
+ // Begin batch update to ensure both Content and ContentTemplate are set together
+ if (cc.Presenter != null)
+ {
+ cc.Presenter.BeginBatchUpdate();
+ }
+
+ SetIfUnsetOrDifferent(cc, ContentControl.ContentProperty, item);
+
if (itemTemplate is not null)
- SetIfUnset(cc, ContentControl.ContentTemplateProperty, itemTemplate);
+ SetIfUnsetOrDifferent(cc, ContentControl.ContentTemplateProperty, itemTemplate);
+
+ // End batch update - triggers single UpdateChild with both properties set
+ if (cc.Presenter != null)
+ {
+ cc.Presenter.EndBatchUpdate();
+ }
}
else if (container is ContentPresenter p)
{
- SetIfUnset(p, ContentPresenter.ContentProperty, item);
+ // Begin batch update to ensure both Content and ContentTemplate are set together
+ p.BeginBatchUpdate();
+
+ SetIfUnsetOrDifferent(p, ContentPresenter.ContentProperty, item);
+
if (itemTemplate is not null)
- SetIfUnset(p, ContentPresenter.ContentTemplateProperty, itemTemplate);
+ SetIfUnsetOrDifferent(p, ContentPresenter.ContentTemplateProperty, itemTemplate);
+
+ // End batch update - triggers single UpdateChild with both properties set
+ p.EndBatchUpdate();
}
else if (container is ItemsControl ic)
{
@@ -448,13 +468,44 @@ protected internal virtual void ClearContainerForItemOverride(Control container)
}
else if (container is ContentControl cc)
{
- cc.ClearValue(ContentControl.ContentProperty);
- cc.ClearValue(ContentControl.ContentTemplateProperty);
+ // Check if we should skip clearing for virtualization
+ // ONLY skip when using a VirtualizingPanel that actually recycles containers, and
+ // only for a template that opted in by handing out a recycle key for this item -
+ // the same condition NeedsContainer keys on.
+ // When we skip clearing, the Child stays attached to this container
+ var shouldSkipClear = cc.Presenter != null &&
+ ContainerVirtualization.IsEnabled &&
+ Presenter?.Panel is VirtualizingStackPanel &&
+ cc.ContentTemplate is IVirtualizingDataTemplate vdt &&
+ vdt.GetKey(cc.Content) != null;
+
+ // Only clear if NOT being recycled for virtualization
+ // This keeps the Child attached, avoiding detach/reattach visual tree churn
+ if (!shouldSkipClear)
+ {
+ cc.ClearValue(ContentControl.ContentProperty);
+ cc.ClearValue(ContentControl.ContentTemplateProperty);
+ }
}
else if (container is ContentPresenter p)
{
- p.ClearValue(ContentPresenter.ContentProperty);
- p.ClearValue(ContentPresenter.ContentTemplateProperty);
+ // Check if we should skip clearing for virtualization
+ // ONLY skip when using a VirtualizingPanel that actually recycles containers, and
+ // only for a template that opted in by handing out a recycle key for this item -
+ // the same condition NeedsContainer keys on.
+ // When we skip clearing, the Child stays attached to this container
+ var shouldSkipClear = ContainerVirtualization.IsEnabled &&
+ Presenter?.Panel is VirtualizingStackPanel &&
+ p.ContentTemplate is IVirtualizingDataTemplate vdt &&
+ vdt.GetKey(p.Content) != null;
+
+ // Only clear if NOT being recycled for virtualization
+ // This keeps the Child attached, avoiding detach/reattach visual tree churn
+ if (!shouldSkipClear)
+ {
+ p.ClearValue(ContentPresenter.ContentProperty);
+ p.ClearValue(ContentPresenter.ContentTemplateProperty);
+ }
}
else if (container is ItemsControl ic)
{
@@ -513,16 +564,29 @@ protected internal virtual bool NeedsContainerOverride(object? item, int index,
///
protected bool NeedsContainer(object? item, out object? recycleKey) where T : Control
{
+ // If the item is already a container of the expected type, it can be used directly
+ // without wrapping. This must be checked first, before content virtualization logic.
if (item is T)
{
recycleKey = null;
return false;
}
- else
+
+ // Container-level virtualization is opt-in: a template has to hand out a recycle key
+ // for this item. Keys partition the container pool so a container is only ever reused
+ // for data that its retained Child can display. Anything else falls through to stock
+ // behaviour - a single shared pool under DefaultRecycleKey.
+ if (ContainerVirtualization.IsEnabled &&
+ Presenter?.Panel is VirtualizingStackPanel &&
+ GetEffectiveItemTemplate() is IVirtualizingDataTemplate vdt &&
+ vdt.GetKey(item) is { } key)
{
- recycleKey = DefaultRecycleKey;
+ recycleKey = key;
return true;
}
+
+ recycleKey = DefaultRecycleKey;
+ return true;
}
///
@@ -755,6 +819,15 @@ private void SetIfUnset(AvaloniaObject target, StyledProperty property, T
if (!target.IsSet(property))
target.SetCurrentValue(property, value);
}
+
+ private void SetIfUnsetOrDifferent(AvaloniaObject target, StyledProperty property, T value)
+ {
+ if (!target.IsSet(property))
+ target.SetCurrentValue(property, value);
+ // when re-using some contentpresenter/contentcontrol, we need to re-apply the content and contenttemplate
+ else if(!object.Equals(target.GetValue(property), value))
+ target.SetCurrentValue(property, value);
+ }
private void RemoveControlItemsFromLogicalChildren(IEnumerable? items)
{
@@ -793,6 +866,54 @@ private void RemoveControlItemsFromLogicalChildren(IEnumerable? items)
return _displayMemberItemTemplate;
}
+ ///
+ /// Gets the effective item template if it opted into container virtualization, otherwise null.
+ ///
+ ///
+ /// This resolves the template through the same path uses to
+ /// pick a recycle key, so pooling decisions can never key off one template while the panel
+ /// sizes its pools from another - a template or a
+ /// collection is not reachable through
+ /// alone.
+ ///
+ internal IVirtualizingDataTemplate? EffectiveVirtualizingItemTemplate =>
+ GetEffectiveItemTemplate() as IVirtualizingDataTemplate;
+
+ ///
+ /// Gets the maximum number of containers a should pool
+ /// under , or null if that pool is unbounded.
+ ///
+ ///
+ /// Only keys handed out by an are capped. Containers
+ /// pooled under - i.e. every item whose template did not opt
+ /// into container virtualization - stay uncapped, as in stock Avalonia.
+ ///
+ internal int? GetMaxPoolSizePerKey(object recycleKey)
+ {
+ return recycleKey != DefaultRecycleKey && EffectiveVirtualizingItemTemplate is { } vdt
+ ? vdt.MaxPoolSizePerKey
+ : null;
+ }
+
+ ///
+ /// Gets how many containers warmup should keep available under ,
+ /// or null to use the panel's own default depth.
+ ///
+ ///
+ /// Guarded on the key exactly as is, and for the same
+ /// reason: implementing is not the opt-in, handing
+ /// out a key is. implements the interface inertly —
+ /// it backs every code-defined template in the framework — so reading
+ /// off the template's *type* would
+ /// silently change warmup depth for all of them.
+ ///
+ internal int? GetMinPoolSizePerKey(object recycleKey)
+ {
+ return recycleKey != DefaultRecycleKey && EffectiveVirtualizingItemTemplate is { } vdt
+ ? vdt.MinPoolSizePerKey
+ : null;
+ }
+
private void UpdatePseudoClasses()
{
PseudoClasses.Set(":empty", ItemCount == 0);
@@ -859,4 +980,23 @@ bool IChildIndexProvider.TryGetTotalCount(out int count)
return true;
}
}
+
+ ///
+ /// Global switch for opt-in container-level virtualization.
+ ///
+ public static class ContainerVirtualization
+ {
+ ///
+ /// Gets or sets whether container-level virtualization is globally enabled.
+ ///
+ ///
+ /// This is a kill switch, not the opt-in. Virtualization is opted into per template, by
+ /// implementing and returning a non-null key from
+ /// - for a XAML
+ /// DataTemplate that means EnableVirtualization="True" . Setting this to false
+ /// forces every back to stock container recycling, which is useful
+ /// when isolating whether a layout problem comes from virtualization.
+ ///
+ public static bool IsEnabled { get; set; } = true;
+ }
}
diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs
index 183882ca506..3a78339f1db 100644
--- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs
+++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs
@@ -10,8 +10,6 @@
using Avalonia.LogicalTree;
using Avalonia.Media;
using Avalonia.Metadata;
-using Avalonia.Platform;
-using Avalonia.Styling;
using Avalonia.Utilities;
namespace Avalonia.Controls.Presenters
@@ -177,6 +175,7 @@ public class ContentPresenter : Control
private Control? _child;
private bool _createdChild;
private IRecyclingDataTemplate? _recyclingDataTemplate;
+ private int _batchUpdateDepth;
private (bool IsSet, object? Value) _overrideDataContext;
private readonly BorderRenderHelper _borderRenderer = new BorderRenderHelper();
@@ -421,6 +420,47 @@ public bool RecognizesAccessKey
///
internal IContentPresenterHost? Host { get; private set; }
+ ///
+ /// Begins a batch update, during which changes to and
+ /// do not rebuild the child. Call to
+ /// apply them together.
+ ///
+ ///
+ /// Batches nest: the child is rebuilt once, when the outermost batch ends. An
+ /// without a matching call here does nothing.
+ ///
+ /// A batch only suppresses rebuilds between property assignments, not across a layout pass:
+ /// is called from measure and does not consult the batch, so
+ /// a batch left open is applied by the next measure rather than stranding the child.
+ ///
+ ///
+ internal void BeginBatchUpdate()
+ {
+ ++_batchUpdateDepth;
+ }
+
+ ///
+ /// Ends a batch update started by , applying the changes made
+ /// during it. Has no effect if no batch is open, or if an outer batch is still open.
+ ///
+ internal void EndBatchUpdate()
+ {
+ if (_batchUpdateDepth == 0 || --_batchUpdateDepth > 0)
+ return;
+
+ if (((ILogical)this).IsAttachedToLogicalTree)
+ {
+ UpdateChild();
+ }
+
+ // ContentChanged returned early for every change made during the batch, so the state that
+ // depends on Content was never refreshed. Do it here, once, for the whole batch - without
+ // this, a container prepared through a batch keeps the ":empty" it was constructed with
+ // while holding content, and never invalidates its measure.
+ UpdatePseudoClasses();
+ InvalidateMeasure();
+ }
+
///
/// Sets the and properties atomically,
/// ensuring that the content's DataContext is never temporarily set to an incorrect value.
@@ -503,13 +543,13 @@ private void UpdateChild(object? content)
{
var contentTemplate = ContentTemplate;
var oldChild = Child;
+
var newChild = CreateChild(content, oldChild, contentTemplate);
var logicalChildren = GetEffectiveLogicalChildren();
// Remove the old child if we're not recycling it.
if (newChild != oldChild)
{
-
if (oldChild != null)
{
VisualChildren.Remove(oldChild);
@@ -637,6 +677,8 @@ public sealed override void Render(DrawingContext context)
: FuncDataTemplate.Default
);
+ // Use instance-based recycling (IRecyclingDataTemplate)
+ // Container-level virtualization handles pooling via VirtualizingStackPanel
if (dataTemplate is IRecyclingDataTemplate rdt)
{
var toRecycle = rdt == _recyclingDataTemplate ? oldChild : null;
@@ -750,6 +792,11 @@ private void ContentChanged(AvaloniaPropertyChangedEventArgs e)
{
_createdChild = false;
+ // Don't update child if we're in batch update mode - EndBatchUpdate applies it, along with
+ // the UpdatePseudoClasses/InvalidateMeasure skipped by this early return.
+ if (_batchUpdateDepth > 0)
+ return;
+
if (((ILogical)this).IsAttachedToLogicalTree)
{
if (e.Property.Name == nameof(Content))
diff --git a/src/Avalonia.Controls/Templates/FuncDataTemplate.cs b/src/Avalonia.Controls/Templates/FuncDataTemplate.cs
index 6fedf2b1cd5..baa189d1b86 100644
--- a/src/Avalonia.Controls/Templates/FuncDataTemplate.cs
+++ b/src/Avalonia.Controls/Templates/FuncDataTemplate.cs
@@ -7,7 +7,7 @@ namespace Avalonia.Controls.Templates
///
/// Builds a control for a piece of data.
///
- public class FuncDataTemplate : FuncTemplate, IRecyclingDataTemplate
+ public class FuncDataTemplate : FuncTemplate, IVirtualizingDataTemplate
{
///
/// The default data template used in the case where no matching data template is found.
@@ -95,6 +95,44 @@ public FuncDataTemplate(
_supportsRecycling = supportsRecycling;
}
+ ///
+ /// Gets or sets the function that assigns a piece of data to a container recycling pool,
+ /// opting this template into container-level virtualization.
+ ///
+ ///
+ ///
+ /// Null by default, which means no opt-in: the template behaves exactly as it always has.
+ /// Setting it is the code equivalent of EnableVirtualization="True" on a XAML
+ /// DataTemplate , and containers built for data with the same key are pooled together
+ /// with their child still attached.
+ ///
+ ///
+ /// The key must identify the *shape* the build function produced, not merely the data's
+ /// type: a template that branches on a property to build different subtrees has to key on
+ /// that property (d => ((Row)d!).Kind ), or a container built for one shape will be
+ /// handed data of another and display the wrong tree. Where the build function does not
+ /// branch, d => d?.GetType() is the natural choice.
+ ///
+ ///
+ /// Returning null for a particular piece of data opts that data out again, and it falls
+ /// back to stock recycling.
+ ///
+ ///
+ public Func? RecycleKeySelector { get; set; }
+
+ ///
+ /// Gets or sets the maximum number of containers to pool per recycle key. Only consulted
+ /// once has opted this template in.
+ ///
+ public int MaxPoolSizePerKey { get; set; } = 5;
+
+ ///
+ /// Gets or sets the number of containers per key that warmup pre-builds, when warmup is
+ /// enabled on the panel. Only consulted once has opted this
+ /// template in.
+ ///
+ public int MinPoolSizePerKey { get; set; } = 2;
+
///
/// Checks to see if this data template matches the specified data.
///
@@ -107,6 +145,9 @@ public bool Match(object? data)
return _match(data);
}
+ ///
+ public object? GetKey(object? data) => RecycleKeySelector?.Invoke(data);
+
///
/// Creates or recycles a control to display the specified data.
///
@@ -122,7 +163,12 @@ public bool Match(object? data)
///
public Control? Build(object? data, Control? existing)
{
- return _supportsRecycling && existing is object ? existing : Build(data);
+ // A template that opted into container-level virtualization must return the existing
+ // child, or the pooling buys nothing: the panel would keep handing back a container
+ // whose subtree this method then threw away and rebuilt.
+ var reuse = _supportsRecycling || RecycleKeySelector is not null;
+
+ return reuse && existing is object ? existing : Build(data);
}
///
diff --git a/src/Avalonia.Controls/Templates/IVirtualizingDataTemplate.cs b/src/Avalonia.Controls/Templates/IVirtualizingDataTemplate.cs
new file mode 100644
index 00000000000..0a2f29daae5
--- /dev/null
+++ b/src/Avalonia.Controls/Templates/IVirtualizingDataTemplate.cs
@@ -0,0 +1,34 @@
+namespace Avalonia.Controls.Templates
+{
+ ///
+ /// Extends IDataTemplate to enable content-level virtualization.
+ /// Templates implementing this interface can recycle content controls
+ /// based on custom keys, reducing allocation and layout pressure during virtualization.
+ ///
+ public interface IVirtualizingDataTemplate : IRecyclingDataTemplate
+ {
+ ///
+ /// Gets a key that identifies which recycling pool this data belongs to.
+ /// Controls created for data with the same key can be recycled together.
+ ///
+ /// The data object to get a key for.
+ ///
+ /// A key object for recycling (typically the data's Type), or null to create
+ /// a new control without recycling.
+ ///
+ object? GetKey(object? data);
+
+ ///
+ /// Gets the maximum number of controls to keep in the recycle pool
+ /// for each key. Default is 5.
+ ///
+ int MaxPoolSizePerKey { get; }
+
+ ///
+ /// Gets the minimum number of controls to keep in the recycle pool
+ /// for each key. Default is 0.
+ /// This is only used when warmup is enabled
+ ///
+ int MinPoolSizePerKey { get; }
+ }
+}
diff --git a/src/Avalonia.Controls/Utils/RealizedStackElements.cs b/src/Avalonia.Controls/Utils/RealizedStackElements.cs
index fab10b0ad96..d5231b00817 100644
--- a/src/Avalonia.Controls/Utils/RealizedStackElements.cs
+++ b/src/Avalonia.Controls/Utils/RealizedStackElements.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using Avalonia.Layout;
+using Avalonia.Logging;
using Avalonia.Utilities;
namespace Avalonia.Controls.Utils
@@ -382,6 +383,29 @@ public void RecycleElementsAfter(int index, Action recycleElement)
}
}
+ ///
+ /// Nullifies an element at the specified index without recycling it.
+ /// The element slot becomes null so that RecycleAllElements will skip it.
+ ///
+ /// The index in the source collection.
+ /// The element and its size, or null if not found.
+ public (Control element, double sizeU)? NullifyElement(int index)
+ {
+ if (_elements is null || _elements.Count == 0)
+ return null;
+
+ var i = index - FirstIndex;
+ if (i < 0 || i >= _elements.Count)
+ return null;
+
+ if (_elements[i] is not Control element)
+ return null;
+
+ var sizeU = _sizes![i];
+ _elements[i] = null;
+ return (element, sizeU);
+ }
+
///
/// Recycles all realized elements.
///
@@ -417,32 +441,147 @@ public void ResetForReuse()
}
///
- /// Validates that is still valid.
+ /// Reconciles the stored element sizes with the elements' current desired sizes and, when
+ /// only content the user has already scrolled past changed size, shifts
+ /// so the item at keeps its position.
+ /// Returns true if any layout-significant change was found.
///
- /// The panel orientation.
+ ///
+ /// Index of the item intersecting the start of the viewport (the item the user is looking
+ /// at), or -1 if unknown. Growth before it must be cancelled out of :
+ /// the anchor sits at StartU + Σ sizes before it , so if that sum grew by
+ /// preDelta , StartU must shrink by the same amount for the anchor to stay put.
+ /// Growth at or after the anchor legitimately pushes later content down and needs no
+ /// compensation.
+ ///
+ /// The accumulated size change of items before the anchor.
+ ///
+ /// Returns an element's size along the panel's layout axis, given the element and its item
+ /// index. It must be the same function the panel used to record the sizes in the first
+ /// place, otherwise every pass would re-detect the difference between the two as a resize.
+ ///
///
- /// If the U size of any element in the realized elements has changed, then the value of
- /// should be considered unstable.
+ /// Changes below are floating-point noise, not
+ /// resizes: they are folded into the stored size and nothing else. Anything larger is a
+ /// real change, however small — a fractional layout scale (125%, 150% DPI) makes the
+ /// layout rounding grid itself sub-pixel, so genuine sub-1px changes do occur.
///
- public void ValidateStartU(Orientation orientation)
+ public bool ValidateStartU(int anchorIndex, Func getSizeU, out double preDelta)
{
+ preDelta = 0;
+
if (_elements is null || _sizes is null || _startUUnstable)
- return;
+ return false;
+
+ var hasSignificantChange = false;
+ var anchorChanged = false;
+ var anchorMeasurePending = false;
+ var otherItemsChanged = false;
+ var otherItemsPendingMeasure = false;
for (var i = 0; i < _elements.Count; ++i)
{
if (_elements[i] is not { } element)
continue;
- var sizeU = orientation == Orientation.Horizontal ?
- element.DesiredSize.Width : element.DesiredSize.Height;
+ var itemIndex = _firstIndex + i;
- if (sizeU != _sizes[i])
+ // Detect partial layout manager state: elements whose data changed
+ // but the layout manager hasn't re-measured them yet.
+ if (!element.IsMeasureValid)
{
- _startUUnstable = true;
- break;
+ if (itemIndex == anchorIndex)
+ anchorMeasurePending = true;
+ else
+ otherItemsPendingMeasure = true;
+ }
+
+ var sizeU = getSizeU(element, itemIndex);
+
+ var diff = sizeU - _sizes[i];
+ if (diff == 0)
+ continue;
+
+ if (!MathUtilities.AreClose(sizeU, _sizes[i], LayoutHelper.LayoutEpsilon))
+ {
+ if (Logger.TryGet(LogEventLevel.Verbose, LogArea.Control) is { } log)
+ {
+ var dc = (element as StyledElement)?.DataContext;
+ log.Log(element,
+ "Item template size changed during layout. " +
+ "This typically means the item template produces non-deterministic sizes " +
+ "(e.g., async image loading, text wrapping). Consider using fixed-size templates. " +
+ "DataContext='{DataContext}', OldSize='{OldSize}', NewSize='{NewSize}', Diff='{Diff}' " +
+ "(#{HashCode} idx={ItemIndex})",
+ dc?.GetType().FullName ?? "(null)", _sizes[i], sizeU, diff,
+ element.GetHashCode(), itemIndex);
+ }
+
+ hasSignificantChange = true;
+
+ if (anchorIndex >= 0 && itemIndex < anchorIndex)
+ {
+ preDelta += diff;
+ otherItemsChanged = true;
+ }
+ else if (itemIndex == anchorIndex)
+ {
+ anchorChanged = true;
+ }
+ else
+ {
+ otherItemsChanged = true;
+ }
}
+
+ // Update stored size so the next pass won't re-detect this change.
+ _sizes[i] = sizeU;
+ }
+
+ if (!hasSignificantChange && !anchorMeasurePending)
+ return false;
+
+ if (anchorMeasurePending ||
+ (anchorChanged && (otherItemsChanged || otherItemsPendingMeasure)))
+ {
+ // Either the anchor hasn't been re-measured yet (partial layout state),
+ // or the anchor changed AND other items also changed or are pending
+ // re-measure (uniform resize scenario). Mark unstable so the layout
+ // re-evaluates positions from scratch.
+ _startUUnstable = true;
}
+ else if (anchorChanged)
+ {
+ // Only the anchor itself changed size and nothing else is affected (e.g. async
+ // content loading on the visible item). Its START position is still correct —
+ // only the items after it shift, which realization handles. Report no change so
+ // the caller does not treat the recorded per-item sizes as stale: the anchor
+ // alternating between a placeholder and a loaded size would otherwise discard
+ // the whole size record on every flip.
+ return false;
+ }
+ else if (!MathUtilities.AreClose(preDelta, 0, LayoutHelper.LayoutEpsilon))
+ {
+ // Only items before the anchor changed (async content loading).
+ // Subtract preDelta from StartU to keep the anchor at its visual position:
+ // anchor_pos = startU + sum_of_sizes_before_anchor
+ // If sizes_before grew by preDelta, decrease startU by the same amount.
+ _startU -= preDelta;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Adjusts StartU to compensate for extent changes outside the realized range.
+ /// This prevents scroll jumping by maintaining the visual position of realized elements.
+ ///
+ public void CompensateStartU(double delta)
+ {
+ if (_startUUnstable || double.IsNaN(_startU))
+ return;
+
+ _startU += delta;
}
}
}
diff --git a/src/Avalonia.Controls/VirtualizingPanel.cs b/src/Avalonia.Controls/VirtualizingPanel.cs
index 13fa61e6016..d64d8a1f4d7 100644
--- a/src/Avalonia.Controls/VirtualizingPanel.cs
+++ b/src/Avalonia.Controls/VirtualizingPanel.cs
@@ -216,7 +216,20 @@ internal void Detach()
Children.Clear();
}
- internal void Refresh() => OnItemsControlItemsChanged(null, CollectionUtils.ResetEventArgs);
+ ///
+ /// Called when the owner requires every container to be
+ /// unrealized and re-realized, e.g. because its ,
+ /// or
+ /// changed.
+ ///
+ ///
+ /// The items themselves have not changed, so this is not a collection change even though
+ /// the default implementation reuses the
+ /// path. A panel that optimizes by keeping
+ /// containers whose items are unchanged must override this and not apply that optimization
+ /// here, otherwise the containers are never re-prepared.
+ ///
+ internal virtual void Refresh() => OnItemsControlItemsChanged(null, CollectionUtils.ResetEventArgs);
private ItemsControl EnsureItemsControl()
{
diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs
index 669e75d81a8..966a0424cff 100644
--- a/src/Avalonia.Controls/VirtualizingStackPanel.cs
+++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs
@@ -3,6 +3,7 @@
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
+using System.Runtime.InteropServices;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Utils;
using Avalonia.Input;
@@ -56,9 +57,26 @@ public class VirtualizingStackPanel : VirtualizingPanel, IScrollSnapPointsInfo
/// Defines the property.
///
public static readonly StyledProperty CacheLengthProperty =
- AvaloniaProperty.Register(nameof(CacheLength), 0.0,
+ AvaloniaProperty.Register(nameof(CacheLength), 0.0,
validate: v => v is >= 0 and <= 2);
+ ///
+ /// Gets or sets whether container warmup is enabled.
+ /// When enabled, containers are pre-created during initialization to improve first-scroll performance.
+ /// Default: false (opt-in).
+ ///
+ public static readonly StyledProperty EnableWarmupProperty =
+ AvaloniaProperty.Register(
+ nameof(EnableWarmup),
+ defaultValue: false);
+
+ ///
+ /// How many containers warmup keeps ready per template key when the item template does not
+ /// specify a size itself (see ). Enough to
+ /// cover the containers in flight while scrolling; the pool grows no further on its own.
+ ///
+ private const int DefaultWarmupPoolSizePerKey = 3;
+
private static readonly AttachedProperty RecycleKeyProperty =
AvaloniaProperty.RegisterAttached("RecycleKey");
@@ -66,27 +84,76 @@ public class VirtualizingStackPanel : VirtualizingPanel, IScrollSnapPointsInfo
private readonly Action _recycleElement;
private readonly Action _recycleElementOnItemRemoved;
private readonly Action _updateElementIndex;
+ private readonly Func _getElementSizeU;
private int _scrollToIndex = -1;
private Control? _scrollToElement;
private bool _isInLayout;
private bool _isWaitingForViewportUpdate;
private double _lastEstimatedElementSizeU = 25;
+
+ // Persistent per-item size model: maps item index -> last measured sizeU.
+ // Upserted whenever a realized element is measured (see EstimateElementSizeU).
+ // The estimate for an un-measured item is the mean of ALL recorded sizes, so it
+ // depends on every item ever measured rather than on which items happen to be
+ // realized right now — scrolling the realized window into a large/small-item
+ // region no longer swings the scalar estimate (and thus the reported extent).
+ // Indices are remapped on structural collection changes so an entry never points
+ // at the wrong item's size. Memory is bounded by the number of distinct item
+ // indices ever measured (no artificial cap; stock has none).
+ private readonly Dictionary _measuredSizes = new();
+
+ // Running sum of _measuredSizes.Values, maintained incrementally by the accessors
+ // below — every mutation of the record adjusts it by the delta, so the record is
+ // never swept (an O(items ever measured) sweep per measure pass would be worse than
+ // stock's O(realized window) average). Together with _measuredSizes.Count it lets
+ // the extent be computed as knownSum + (itemCount - knownCount) * mean: a cumulative
+ // estimate that depends only on what has EVER been measured, not on the current
+ // realized window. That makes the reported extent reproducible when an offset is
+ // revisited. _measuredSizesSumError is the Neumaier compensation term (see
+ // AddToMeasuredSizesSum): read the sum through MeasuredSizesSum, never directly.
+ private double _measuredSizesSum;
+ private double _measuredSizesSumError;
+ internal bool TryGetMeasuredSizeForTesting(int index, out double size) => _measuredSizes.TryGetValue(index, out size);
private RealizedStackElements? _measureElements;
private RealizedStackElements? _realizedElements;
private IScrollAnchorProvider? _scrollAnchorProvider;
private Rect _viewport;
- private Dictionary>? _recyclePool;
+ private Dictionary>? _recyclePool;
+
+ ///
+ /// Exposes the recycle pool for unit testing.
+ ///
+ internal IReadOnlyDictionary>? RecyclePoolForTesting => _recyclePool;
+
private Control? _focusedElement;
private int _focusedIndex = -1;
private Control? _realizingElement;
private int _realizingIndex = -1;
- private double _bufferFactor;
-
+ private double _bufferFactor;
+ private bool _isWarmupComplete = false;
+
+ // Template keys the panel has actually needed a container for, each mapped to an item index
+ // known to use it. Drives container warmup (see NoteEncounteredRecycleKey).
+ private Dictionary? _encounteredRecycleKeys;
+
private bool _hasReachedStart = false;
private bool _hasReachedEnd = false;
+
+ private Rect _lastMeasuredViewport;
+ private bool _suppressScrollIntoView = false; // Suppress ScrollIntoView after Reset
private Rect _lastMeasuredExtendedViewport;
private Rect _lastKnownExtendedViewport;
+ // Index of the first item intersecting the viewport start. Captured before
+ // ValidateStartU so a resize of items *before* the visible area can be compensated
+ // for without moving what the user is looking at.
+ private int _viewportAnchorIndex = -1;
+
+ // Cache for CaptureViewportAnchor to avoid redundant O(n) scans
+ private double _lastCapturedViewportStart = double.NaN;
+
+ // Retained containers for smart reuse during disjunct recycle
+ private Dictionary? _retainedForReuse;
static VirtualizingStackPanel()
{
CacheLengthProperty.Changed.AddClassHandler((x, e) => x.OnCacheLengthChanged(e));
@@ -97,6 +164,7 @@ public VirtualizingStackPanel()
_recycleElement = RecycleElement;
_recycleElementOnItemRemoved = RecycleElementOnItemRemoved;
_updateElementIndex = UpdateElementIndex;
+ _getElementSizeU = GetElementSizeU;
_bufferFactor = Math.Max(0, CacheLength);
EffectiveViewportChanged += OnEffectiveViewportChanged;
@@ -165,6 +233,16 @@ public double CacheLength
set => SetValue(CacheLengthProperty, value);
}
+ ///
+ /// Gets or sets whether container warmup is enabled.
+ /// When enabled, containers are pre-created during initialization to improve first-scroll performance.
+ ///
+ public bool EnableWarmup
+ {
+ get => GetValue(EnableWarmupProperty);
+ set => SetValue(EnableWarmupProperty, value);
+ }
+
///
/// Gets the index of the first realized element, or -1 if no elements are realized.
///
@@ -193,39 +271,78 @@ protected override Size MeasureOverride(Size availableSize)
return default;
var orientation = Orientation;
-
// If we're bringing an item into view, ignore any layout passes until we receive a new
// effective viewport.
if (_isWaitingForViewportUpdate)
+ {
return EstimateDesiredSize(orientation, items.Count);
+ }
_isInLayout = true;
try
{
- _realizedElements?.ValidateStartU(Orientation);
_realizedElements ??= new();
_measureElements ??= new();
- // We need to set the lastEstimatedElementSizeU before calling CalculateDesiredSize()
- _ = EstimateElementSizeU();
+ // Capture viewport anchor BEFORE ValidateStartU so we know which items
+ // are before/after the visible area for scroll position compensation.
+ CaptureViewportAnchor(orientation);
+
+ // Reconcile the stored element sizes with what the elements now desire. When only
+ // content above the anchor changed size, StartU is shifted so the anchor keeps
+ // its position — this is what stops async content (e.g. an image finishing
+ // loading above the viewport) from yanking the scroll position.
+ if (_realizedElements.ValidateStartU(_viewportAnchorIndex, _getElementSizeU, out _) &&
+ double.IsNaN(_realizedElements.StartU))
+ {
+ // StartU went unstable, meaning positions are being re-derived from scratch
+ // after a resize that spans the anchor. The recorded per-item sizes describe
+ // the old layout, so drop them and let the estimate rebuild from the new
+ // measurements (stock likewise adapts instantly to a uniform resize).
+ ClearMeasuredSizes();
+ }
// We handle horizontal and vertical layouts here so X and Y are abstracted to:
// - Horizontal layouts: U = horizontal, V = vertical
// - Vertical layouts: U = vertical, V = horizontal
+ // Note: capture _scrollToIndex before CalculateMeasureViewport/RealizeElements
+ // clears it via GetRealizedElement.
+ var isScrollingToElement = _scrollToIndex >= 0;
var viewport = CalculateMeasureViewport(orientation, items);
+ // Track the extended viewport we're measuring with to prevent redundant invalidations
+ _lastMeasuredViewport = _lastMeasuredExtendedViewport;
+
// If the viewport is disjunct then we can recycle everything.
+ // First, retain containers whose DataContext matches items in the new viewport
+ // so they can be reused without full PrepareItemContainer + Measure overhead.
if (viewport.viewportIsDisjunct)
- _realizedElements.RecycleAllElements(_recycleElement);
+ {
+ var estimatedSize = EstimateElementSizeU();
+ var viewportSize = viewport.viewportUEnd - viewport.viewportUStart;
+ var estimatedCount = estimatedSize > 0
+ ? (int)Math.Ceiling(viewportSize / estimatedSize) + 1
+ : 10;
+ RetainMatchingContainers(items, viewport.anchorIndex,
+ viewport.anchorIndex + estimatedCount);
+ _realizedElements!.RecycleAllElements(_recycleElement);
+ }
// Do the measure, creating/recycling elements as necessary to fill the viewport. Don't
// write to _realizedElements yet, only _measureElements.
RealizeElements(items, availableSize, ref viewport);
+ // Recycle any retained containers that weren't reused during realization
+ RecycleUnusedRetainedContainers();
+
// Now swap the measureElements and realizedElements collection.
(_measureElements, _realizedElements) = (_realizedElements, _measureElements);
- _measureElements.ResetForReuse();
+ _measureElements!.ResetForReuse();
+
+ // Calculate estimate from NEWLY measured elements for contextually-accurate extent calculation.
+ // This eliminates temporal mismatch where old viewport data was used to estimate new viewport.
+ _ = EstimateElementSizeU();
// If there is a focused element is outside the visible viewport (i.e.
// _focusedElement is non-null), ensure it's measured.
@@ -236,6 +353,7 @@ protected override Size MeasureOverride(Size availableSize)
finally
{
_isInLayout = false;
+ _suppressScrollIntoView = false;
}
}
@@ -299,7 +417,6 @@ protected override Size ArrangeOverride(Size finalSize)
finally
{
_isInLayout = false;
-
RaiseEvent(new RoutedEventArgs(Orientation == Orientation.Horizontal ? HorizontalSnapPointsChangedEvent : VerticalSnapPointsChangedEvent));
}
}
@@ -308,6 +425,12 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_scrollAnchorProvider = this.FindAncestorOfType();
+
+ // Schedule warmup after initial render if enabled
+ if (EnableWarmup && !_isWarmupComplete)
+ {
+ Threading.Dispatcher.UIThread.Post(PerformWarmup, Threading.DispatcherPriority.Background);
+ }
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
@@ -316,11 +439,37 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e
_scrollAnchorProvider = null;
}
+ internal override void Refresh()
+ {
+ // A refresh means the ItemTemplate / ItemContainerTheme / DisplayMemberBinding changed:
+ // the collection is untouched, but every container must be re-prepared so the new
+ // template or theme is applied. It must therefore never be treated as a preservable
+ // Reset. Because nothing changed, the Reset path below would find every realized
+ // element still valid at its index and keep it as-is, and the non-preserving branch
+ // would hand matching containers to RetainMatchingContainers — neither calls
+ // PrepareItemContainer. So recycle every realized element up front: the base Reset
+ // handling then sees an empty realized set (no preservation, nothing to retain) and the
+ // next measure re-prepares every container.
+ _realizedElements?.ItemsReset(_recycleElementOnItemRemoved);
+ base.Refresh();
+ }
+
protected override void OnItemsChanged(IReadOnlyList items, NotifyCollectionChangedEventArgs e)
{
+ _lastCapturedViewportStart = double.NaN;
InvalidateMeasure();
- // Always update special elements
+ // Handle async collection loading - trigger warmup when first items become available
+ if (EnableWarmup && !_isWarmupComplete && items.Count > 0 && e.Action == NotifyCollectionChangedAction.Add)
+ {
+ if (_recyclePool == null || _recyclePool.Count == 0)
+ {
+
+ Threading.Dispatcher.UIThread.Post(PerformWarmup, Threading.DispatcherPriority.Background);
+ }
+ }
+
+ // Always update special elements (focused, scroll-to) on collection changes
UpdateSpecialElementsOnItemsChanged(e);
if (_realizedElements is null)
@@ -330,12 +479,18 @@ protected override void OnItemsChanged(IReadOnlyList items, NotifyColle
{
case NotifyCollectionChangedAction.Add:
_realizedElements.ItemsInserted(e.NewStartingIndex, e.NewItems!.Count, _updateElementIndex);
+ RemapMeasuredSizesForInsert(e.NewStartingIndex, e.NewItems!.Count);
break;
case NotifyCollectionChangedAction.Remove:
_realizedElements.ItemsRemoved(e.OldStartingIndex, e.OldItems!.Count, _updateElementIndex, _recycleElementOnItemRemoved);
+ RemapMeasuredSizesForRemove(e.OldStartingIndex, e.OldItems!.Count);
break;
case NotifyCollectionChangedAction.Replace:
_realizedElements.ItemsReplaced(e.OldStartingIndex, e.OldItems!.Count, _recycleElementOnItemRemoved);
+ // The items at these indices are now different objects, so their recorded
+ // sizes are stale — drop them (they'll be re-measured on the next pass).
+ for (var i = 0; i < e.OldItems!.Count; ++i)
+ ForgetMeasuredSize(e.OldStartingIndex + i);
break;
case NotifyCollectionChangedAction.Move:
if (e.OldStartingIndex < 0)
@@ -352,11 +507,161 @@ protected override void OnItemsChanged(IReadOnlyList items, NotifyColle
}
_realizedElements.ItemsInserted(insertIndex, e.NewItems!.Count, _updateElementIndex);
+ // A move shifts an arbitrary index range; rather than track the permutation,
+ // clear the record (conservative but always correct — no entry can point at
+ // the wrong item). Sizes rebuild as items are re-measured.
+ ClearMeasuredSizes();
break;
case NotifyCollectionChangedAction.Reset:
- _realizedElements.ItemsReset(_recycleElementOnItemRemoved);
+ // Try to preserve scroll position during Reset
+ // Strategy: Validate that realized items still exist in the new collection
+ // If they do, keep them realized to maintain scroll stability
+ // If they don't, recycle everything (collection replacement scenario)
+
+ var shouldPreserveRealizedElements = false;
+
+ if (_realizedElements.Count > 0)
+ {
+ // Check whether every realized item still exists at its current index.
+ var preservedCount = 0;
+ var realizedCount = 0;
+ for (var i = 0; i < _realizedElements.Count; i++)
+ {
+ if (_realizedElements.Elements[i] == null)
+ continue;
+
+ realizedCount++;
+
+ var oldIndex = _realizedElements.FirstIndex + i;
+ if (oldIndex >= 0 && oldIndex < items.Count)
+ {
+ // Check if the item at this index is the same object
+ var element = _realizedElements.Elements[i];
+ var dataContext = (element as IDataContextProvider)?.DataContext;
+
+ if (dataContext != null && ReferenceEquals(items[oldIndex], dataContext))
+ {
+ preservedCount++;
+ }
+ }
+ }
+
+ // Preserve realized elements ONLY when EVERY one is still valid at its
+ // current index (the pure append / infinite-scroll case, where all
+ // realized items keep their indices). A partial match is unsafe: when a
+ // mid-list insert or remove is coalesced into a single Reset (e.g. by
+ // DynamicData's Bind reset-threshold), the elements before the edit point
+ // still match while everything at/after it has shifted. A bare-majority
+ // test would then preserve the whole stale mapping, leaving the shifted
+ // items pinned to the wrong containers and rendered at the wrong position.
+ // In that case fall through to the full reset path, which re-realizes
+ // (and reuses matching containers via RetainMatchingContainers) correctly.
+ shouldPreserveRealizedElements = realizedCount > 0 && preservedCount == realizedCount;
+
+ }
+
+ if (shouldPreserveRealizedElements)
+ {
+ // Keep the realized elements — every one is still valid at its index, and
+ // normal realization handles any adjustment. The recorded per-item sizes are
+ // deliberately kept too: nothing about the items realized here changed, so
+ // re-deriving the estimate from scratch would only make the reported extent
+ // move for no reason. Suppress ScrollIntoView so the ListBox does not pull
+ // the scroll position to the selected item.
+ _suppressScrollIntoView = true;
+ }
+ else
+ {
+ // Collection was replaced or reordered - recycle everything.
+ // First, retain containers whose DataContext matches items in the
+ // estimated viewport so they can be reused without full re-prepare.
+
+ if (items.Count > 0 && _realizedElements.Count > 0)
+ {
+ var orientation = Orientation;
+ var vpStart = orientation == Orientation.Horizontal ? _viewport.X : _viewport.Y;
+ var vpEnd = orientation == Orientation.Horizontal ? _viewport.Right : _viewport.Bottom;
+ var estSize = _lastEstimatedElementSizeU;
+ var startIdx = estSize > 0 ? Math.Max(0, (int)(vpStart / estSize)) : 0;
+ var endIdx = estSize > 0
+ ? Math.Min(items.Count, (int)Math.Ceiling(vpEnd / estSize) + 1)
+ : Math.Min(items.Count, 20);
+ RetainMatchingContainers(items, startIdx, endIdx);
+ }
+
+ _realizedElements.ItemsReset(_recycleElementOnItemRemoved);
+
+ // All elements were recycled and item identities/indices are no longer
+ // known — clear the per-item size record so no entry points at a stale item.
+ ClearMeasuredSizes();
+ }
+
+ // WARMUP OPTIMIZATION: After reset, clear only obsolete keys and top-up if needed
+ if (EnableWarmup && _isWarmupComplete && !shouldPreserveRealizedElements && items.Count > 0)
+ {
+ // Clear only containers whose keys are no longer in the new collection
+ ClearObsoleteWarmupContainers();
+
+ // Discover what keys we need now
+ var currentKeys = DiscoverTemplateKeys();
+
+ // Check if we need to warm up any new keys or top-up existing ones
+ bool needsWarmup = false;
+ foreach (var kvp in currentKeys)
+ {
+ var existingCount = _recyclePool?.TryGetValue(kvp.Key, out var pool) == true
+ ? pool.Count
+ : 0;
+
+ if (existingCount < kvp.Value)
+ {
+ needsWarmup = true;
+ break;
+ }
+ }
+
+ if (needsWarmup)
+ {
+
+ _isWarmupComplete = false;
+ Threading.Dispatcher.UIThread.Post(PerformWarmup, Threading.DispatcherPriority.Background);
+ }
+ }
+
break;
}
+
+ // If the collection is now empty, remove any pooled recycle containers from the
+ // visual tree. Containers recycled when the collection is cleared (e.g. ItemsReset
+ // above) are pushed to the recycle pool but kept parented for reuse. Because
+ // MeasureOverride early-returns on an empty collection, the normal measure-time
+ // cleanup never runs, so without this they would linger as invisible "ghost"
+ // children. Recycle any still-realized elements first, then drop the pool.
+ if (items.Count == 0)
+ {
+ if (_realizedElements is { Count: > 0 })
+ _realizedElements.RecycleAllElements(_recycleElement);
+ RemoveRecyclePoolChildren();
+ }
+ }
+
+ ///
+ /// Removes all pooled recycle containers from the visual tree and clears the pool.
+ /// Used when the collection becomes empty to avoid leaving invisible "ghost" children.
+ ///
+ private void RemoveRecyclePoolChildren()
+ {
+ if (_recyclePool is null || _recyclePool.Count == 0)
+ return;
+
+ foreach (var pool in _recyclePool.Values)
+ {
+ for (var i = pool.Count - 1; i >= 0; i--)
+ RemoveInternalChild(pool[i]);
+ pool.Clear();
+ }
+
+ _recyclePool.Clear();
}
private void UpdateSpecialElementsOnItemsChanged(NotifyCollectionChangedEventArgs e)
@@ -612,7 +917,15 @@ protected internal override int IndexFromContainer(Control container)
var items = Items;
if (_isInLayout || index < 0 || index >= items.Count || _realizedElements is null || !IsEffectivelyVisible)
+ {
return null;
+ }
+
+ // Suppress ScrollIntoView temporarily after Reset to prevent viewport jumps
+ if (_suppressScrollIntoView)
+ {
+ return GetRealizedElement(index);
+ }
if (GetRealizedElement(index) is Control element)
{
@@ -657,7 +970,8 @@ protected internal override int IndexFromContainer(Control container)
// - Measure is first done with the old viewport (which will be a no-op, see MeasureOverride)
// - The viewport is then updated by the layout system which invalidates our measure
// - Measure is then done with the new viewport.
- _isWaitingForViewportUpdate = !_viewport.Contains(rect);
+ var viewportContainsItem = _viewport.Contains(rect);
+ _isWaitingForViewportUpdate = !viewportContainsItem;
root.LayoutManager.ExecuteLayoutPass();
// If for some reason the layout system didn't give us a new viewport during the layout, we
@@ -705,10 +1019,22 @@ private MeasureViewport CalculateMeasureViewport(Orientation orientation, IReadO
int anchorIndex;
double anchorU;
- if (_scrollToIndex >= 0 && _scrollToElement is not null)
+ if (_scrollToIndex >= 0)
{
+ // Scroll to specific index (e.g., after Reset to preserve position)
anchorIndex = _scrollToIndex;
- anchorU = orientation == Orientation.Horizontal ? _scrollToElement.Bounds.Left : _scrollToElement.Bounds.Top;
+
+ if (_scrollToElement is not null)
+ {
+ // Use element's actual position if available
+ anchorU = orientation == Orientation.Horizontal ? _scrollToElement.Bounds.Left : _scrollToElement.Bounds.Top;
+ }
+ else
+ {
+ // Estimate position based on index (e.g., after Reset when no elements realized)
+ anchorU = _scrollToIndex * EstimateElementSizeU();
+
+ }
}
else
{
@@ -721,7 +1047,7 @@ private MeasureViewport CalculateMeasureViewport(Orientation orientation, IReadO
}
// Check if the anchor element is not within the currently realized elements.
- var disjunct = anchorIndex < _realizedElements.FirstIndex ||
+ var disjunct = anchorIndex < _realizedElements.FirstIndex ||
anchorIndex > _realizedElements.LastIndex;
return new MeasureViewport
@@ -741,8 +1067,22 @@ private Size CalculateDesiredSize(Orientation orientation, int itemCount, in Mea
if (viewport.lastIndex >= 0)
{
- var remaining = itemCount - viewport.lastIndex - 1;
- sizeU = viewport.realizedEndU + (remaining * _lastEstimatedElementSizeU);
+ // Window-independent extent from the persistent per-item size record:
+ // extent = knownSum + (itemCount - knownCount) * mean
+ // knownSum/knownCount are what has EVER been measured (published by the
+ // EstimateElementSizeU call that precedes this in MeasureOverride), not the
+ // current realized window, so revisiting an offset reproduces the same extent.
+ // Stock instead blends realizedEndU (the current window's accumulated
+ // positions) with the estimate, which swings the reported extent by region.
+ sizeU = CacheBasedExtentU(itemCount);
+
+ // Reconciliation: the realized block is still positioned by the anchor/StartU
+ // logic, whose bottom is realizedEndU. If the mean (dragged down by many small
+ // items elsewhere) put the cache extent below the current block's actual
+ // bottom, the scrollbar couldn't reach the realized content — take the max so
+ // the extent always covers what is on screen. When every item is known this is
+ // a no-op (knownSum == realizedEndU at the bottom edge).
+ sizeU = Math.Max(sizeU, viewport.realizedEndU);
}
return orientation == Orientation.Horizontal ? new(sizeU, sizeV) : new(sizeV, sizeU);
@@ -754,46 +1094,241 @@ private Size EstimateDesiredSize(Orientation orientation, int itemCount)
{
// We have an element to scroll to, so we can estimate the desired size based on the
// element's position and the remaining elements.
- var remaining = itemCount - _scrollToIndex - 1;
- var u = orientation == Orientation.Horizontal ?
+ var u = orientation == Orientation.Horizontal ?
_scrollToElement.Bounds.Right :
_scrollToElement.Bounds.Bottom;
- var sizeU = u + (remaining * _lastEstimatedElementSizeU);
- return orientation == Orientation.Horizontal ?
- new(sizeU, DesiredSize.Height) :
+ // Same cache-based tail as CalculateDesiredSize so the scroll-to-element
+ // extent uses the window-independent mean consistently; reconcile against the
+ // scroll target's actual bottom so the extent always covers it.
+ var sizeU = Math.Max(CacheBasedExtentU(itemCount), u);
+ return orientation == Orientation.Horizontal ?
+ new(sizeU, DesiredSize.Height) :
new(DesiredSize.Width, sizeU);
}
return DesiredSize;
}
+ ///
+ /// Computes the total extent along U from the persistent per-item size record:
+ /// knownSum + (itemCount - knownCount) * mean , where knownSum and
+ /// knownCount are the sum and count of every item ever measured (published by
+ /// ) and mean = knownSum / knownCount . This
+ /// depends only on what has been measured, not on the current realized window, so the
+ /// reported extent is reproducible when an offset is revisited. When every item has
+ /// been measured, itemCount - knownCount == 0 and the extent is exactly the
+ /// true total (knownSum ) — the correct bottom edge. Falls back to the scalar
+ /// estimate when the record is empty.
+ ///
+ private double CacheBasedExtentU(int itemCount)
+ {
+ var knownCount = _measuredSizes.Count;
+ if (knownCount == 0)
+ return itemCount * _lastEstimatedElementSizeU;
+
+ var knownSum = MeasuredSizesSum;
+ var mean = knownSum / knownCount;
+ var unknownCount = itemCount - knownCount;
+ if (unknownCount < 0)
+ unknownCount = 0;
+ return knownSum + (unknownCount * mean);
+ }
+
private double EstimateElementSizeU()
{
if (_realizedElements is null)
return _lastEstimatedElementSizeU;
- var orientation = Orientation;
- var total = 0.0;
- var divisor = 0.0;
-
- // Average the desired size of the realized, measured elements.
- foreach (var element in _realizedElements.Elements)
+ // Upsert every currently-realized, measured element's size into the persistent
+ // per-item size record, keyed by item index. This is the only update point for
+ // the record: the estimate is then the mean over ALL recorded sizes, not just
+ // the elements realized on this pass. For uniform items every recorded size is
+ // equal, so the mean equals that size — identical to stock's realized average
+ // (provable no-op for the uniform/deterministic case).
+ var firstIndex = _realizedElements.FirstIndex;
+ var elements = _realizedElements.Elements;
+ for (var i = 0; i < elements.Count; ++i)
{
- if (element is null || !element.IsMeasureValid)
+ if (elements[i] is not { IsMeasureValid: true } element)
continue;
- var sizeU = orientation == Orientation.Horizontal ?
- element.DesiredSize.Width :
- element.DesiredSize.Height;
- total += sizeU;
- ++divisor;
+ RecordMeasuredSize(firstIndex + i, GetElementSizeU(element, firstIndex + i));
}
- // Check we have enough information on which to base our estimate.
- if (divisor == 0 || total == 0)
+ // Not enough information yet: keep the last estimate (stock's seed until the
+ // first measurement).
+ var knownCount = _measuredSizes.Count;
+ if (knownCount == 0)
+ return _lastEstimatedElementSizeU;
+
+ // The running sum is maintained by the upsert above, so this pass costs
+ // O(realized window) — the record itself is never swept.
+ var total = MeasuredSizesSum;
+
+ // Guard against a degenerate all-zero record (matches stock's total == 0 guard).
+ if (total == 0)
return _lastEstimatedElementSizeU;
- // Store and return the estimate.
- return _lastEstimatedElementSizeU = total / divisor;
+ // Store and return the estimate: the mean of all recorded sizes.
+ return _lastEstimatedElementSizeU = total / knownCount;
+ }
+
+ ///
+ /// The sum of every value in , maintained incrementally.
+ ///
+ private double MeasuredSizesSum => _measuredSizesSum + _measuredSizesSumError;
+
+ ///
+ /// The single upsert point for the persistent per-item size record: records
+ /// for and keeps the running sum in
+ /// agreement by applying only the delta.
+ ///
+ private void RecordMeasuredSize(int index, double size)
+ {
+ if (_measuredSizes.TryGetValue(index, out var previous))
+ {
+ // Re-measuring an unchanged item is the common case and must not touch the
+ // sum: no write means no rounding, so a scrolling session over settled
+ // content accumulates no error at all.
+ if (previous == size)
+ return;
+
+ _measuredSizes[index] = size;
+ AddToMeasuredSizesSum(size - previous);
+ }
+ else
+ {
+ _measuredSizes[index] = size;
+ AddToMeasuredSizesSum(size);
+ }
+ }
+
+ ///
+ /// Drops the recorded size for , keeping the running sum in
+ /// agreement.
+ ///
+ private void ForgetMeasuredSize(int index)
+ {
+ if (!_measuredSizes.Remove(index, out var previous))
+ return;
+
+ if (_measuredSizes.Count == 0)
+ ResetMeasuredSizesSum();
+ else
+ AddToMeasuredSizesSum(-previous);
+ }
+
+ ///
+ /// Drops the whole record (used when the index-to-item mapping is no longer
+ /// trustworthy), keeping the running sum in agreement.
+ ///
+ private void ClearMeasuredSizes()
+ {
+ _measuredSizes.Clear();
+ ResetMeasuredSizesSum();
+ }
+
+ private void ResetMeasuredSizesSum()
+ {
+ _measuredSizesSum = 0;
+ _measuredSizesSumError = 0;
+ }
+
+ ///
+ /// Adds to the running sum using Neumaier compensated
+ /// summation: the rounding error of each accumulation is carried in
+ /// and folded back in by
+ /// , so an incrementally maintained sum stays within one
+ /// rounding of a freshly computed full sum however many updates it has seen. This is
+ /// what makes the reported extent reproducible without periodically re-summing the
+ /// record (which would need a rebuild interval, i.e. a tuning constant).
+ ///
+ private void AddToMeasuredSizesSum(double delta)
+ {
+ var sum = _measuredSizesSum + delta;
+
+ _measuredSizesSumError += Math.Abs(_measuredSizesSum) >= Math.Abs(delta)
+ ? (_measuredSizesSum - sum) + delta
+ : (delta - sum) + _measuredSizesSum;
+
+ _measuredSizesSum = sum;
+ }
+
+ ///
+ /// Remaps the persistent per-item size record after items were
+ /// inserted at : entries at or after the insertion point shift up
+ /// by . The inserted slots are left unrecorded (unknown size).
+ /// Mirrors so an entry never points at
+ /// the wrong item. Remapped in place: an insert at or past the highest recorded index
+ /// — the append case an infinite-scroll list pays on every batch — moves nothing and
+ /// allocates nothing, and a mid-list insert only touches the entries after it.
+ ///
+ private void RemapMeasuredSizesForInsert(int index, int count)
+ {
+ if (count <= 0 || _measuredSizes.Count == 0)
+ return;
+
+ // Snapshot the entries that have to move; the record is enumerated but not
+ // rebuilt, and nothing is allocated when none of them do.
+ List>? shifted = null;
+ foreach (var entry in _measuredSizes)
+ {
+ if (entry.Key >= index)
+ (shifted ??= new List>()).Add(entry);
+ }
+
+ if (shifted is null)
+ return;
+
+ // Remove every moving entry before writing any of them back, so a shifted key can
+ // never overwrite an entry that has not been moved yet (which iterating the
+ // snapshot in dictionary order otherwise would). A pure shift leaves the sum
+ // unchanged.
+ foreach (var entry in shifted)
+ _measuredSizes.Remove(entry.Key);
+ foreach (var entry in shifted)
+ _measuredSizes[entry.Key + count] = entry.Value;
+ }
+
+ ///
+ /// Remaps the persistent per-item size record after items were
+ /// removed at : entries in the removed range are dropped and
+ /// entries after it shift down by . Mirrors
+ /// . Remapped in place, like
+ /// : a remove past the highest recorded index
+ /// moves nothing and allocates nothing.
+ ///
+ private void RemapMeasuredSizesForRemove(int index, int count)
+ {
+ if (count <= 0 || _measuredSizes.Count == 0)
+ return;
+
+ var end = index + count;
+ List>? shifted = null;
+ List? dropped = null;
+ foreach (var entry in _measuredSizes)
+ {
+ if (entry.Key >= end)
+ (shifted ??= new List>()).Add(entry);
+ else if (entry.Key >= index)
+ (dropped ??= new List()).Add(entry.Key);
+ }
+
+ if (dropped is not null)
+ {
+ foreach (var key in dropped)
+ ForgetMeasuredSize(key);
+ }
+
+ if (shifted is null)
+ return;
+
+ // As in the insert case: clear the moving entries first so a shifted key cannot
+ // land on one that has not moved yet. The entries that stay put are all below
+ // index, and every shifted key lands at or above it, so the two never collide.
+ foreach (var entry in shifted)
+ _measuredSizes.Remove(entry.Key);
+ foreach (var entry in shifted)
+ _measuredSizes[entry.Key - count] = entry.Value;
}
private void GetOrEstimateAnchorElementForViewport(
@@ -815,16 +1350,17 @@ private void GetOrEstimateAnchorElementForViewport(
// get the anchor element.
if (_realizedElements?.StartU is { } u && !double.IsNaN(u))
{
- var orientation = Orientation;
-
for (var i = 0; i < _realizedElements.Elements.Count; ++i)
{
if (_realizedElements.Elements[i] is not { } element)
continue;
- var sizeU = orientation == Orientation.Horizontal ?
- element.DesiredSize.Width :
- element.DesiredSize.Height;
+ // Walk the *stored* sizes, not DesiredSize: these are the sizes the elements
+ // were last laid out at, so they describe where things currently are on
+ // screen. DesiredSize may already have moved on (content that has just
+ // settled), and using it here would look for the anchor in a layout that has
+ // not happened yet.
+ var sizeU = _realizedElements.SizeU[i];
var endU = u + sizeU;
if (endU > viewportStartU && u < viewportEndU)
@@ -836,18 +1372,119 @@ private void GetOrEstimateAnchorElementForViewport(
u = endU;
}
+
}
// We don't have any realized elements in the requested viewport, or can't rely on
- // StartU being valid. Estimate the index using only the estimated element size.
+ // StartU being valid. Estimate the index using realized element positions if available.
var estimatedSize = EstimateElementSizeU();
- // Estimate the element at the start of the viewport.
+ // If we have realized elements, use their actual positions to improve estimation accuracy.
+ // This prevents anchor jumps when scrolling with variable-sized items.
+ if (_realizedElements != null && _realizedElements.Count > 0 && _realizedElements.StartU is { } startU && !double.IsNaN(startU))
+ {
+ var firstIndex = _realizedElements.FirstIndex;
+ var lastIndex = _realizedElements.LastIndex;
+
+ // If viewport is before realized elements, extrapolate backward from first element
+ if (viewportStartU < startU)
+ {
+ var distanceBack = startU - viewportStartU;
+ var itemsBack = (int)(distanceBack / estimatedSize);
+ index = Math.Max(0, firstIndex - itemsBack);
+ position = startU - (itemsBack * estimatedSize);
+ return;
+ }
+
+ // If viewport is after realized elements, extrapolate forward from last element
+ var lastElementU = _realizedElements.GetElementU(lastIndex);
+ if (!double.IsNaN(lastElementU))
+ {
+ var lastElementSize = _realizedElements.SizeU[_realizedElements.Count - 1];
+ var lastElementEndU = lastElementU + lastElementSize;
+
+ if (viewportStartU >= lastElementEndU)
+ {
+ var distanceForward = viewportStartU - lastElementEndU;
+ var itemsForward = (int)(distanceForward / estimatedSize);
+ index = Math.Min(lastIndex + 1 + itemsForward, itemCount - 1);
+ position = lastElementEndU + (itemsForward * estimatedSize);
+ return;
+ }
+ }
+ }
+
+ // Fallback: No realized elements or unable to extrapolate, use simple estimation
var startIndex = Math.Min((int)(viewportStartU / estimatedSize), itemCount - 1);
index = startIndex;
position = startIndex * estimatedSize;
}
+ ///
+ /// Captures the index of the item that intersects the start of the viewport — the item
+ /// the user is looking at. uses it to
+ /// tell a resize of already-scrolled-past content (which must be compensated for, so the
+ /// anchor does not move) from a resize at or after the anchor (which legitimately pushes
+ /// later content down).
+ ///
+ private void CaptureViewportAnchor(Orientation orientation)
+ {
+ if (_realizedElements == null || _realizedElements.Count == 0)
+ {
+ _viewportAnchorIndex = -1;
+ return;
+ }
+
+ var viewportStartU = orientation == Orientation.Horizontal ? _viewport.X : _viewport.Y;
+
+ var startU = _realizedElements.StartU;
+
+ // Skip re-capture if viewport hasn't moved significantly AND StartU is stable
+ // AND the cached anchor is still within the realized range.
+ // All three conditions must hold — a stale anchor outside the realized range
+ // would cause ValidateStartU to misclassify all realized items as "before anchor",
+ // producing a massive incorrect preDelta and a visible scroll jump.
+ if (!double.IsNaN(_lastCapturedViewportStart) &&
+ Math.Abs(viewportStartU - _lastCapturedViewportStart) < 1.0 &&
+ _viewportAnchorIndex >= 0 &&
+ !double.IsNaN(startU) &&
+ _viewportAnchorIndex >= _realizedElements.FirstIndex &&
+ _viewportAnchorIndex <= _realizedElements.LastIndex)
+ {
+ return;
+ }
+ _lastCapturedViewportStart = viewportStartU;
+
+ _viewportAnchorIndex = -1;
+
+ if (double.IsNaN(startU))
+ {
+ return;
+ }
+
+ var u = startU;
+
+ // Find first element that intersects viewport start
+ for (var i = 0; i < _realizedElements.Count; i++)
+ {
+ if (_realizedElements.Elements[i] == null)
+ continue;
+
+ var sizeU = _realizedElements.SizeU[i];
+ var elementEndU = u + sizeU;
+ var itemIndex = _realizedElements.FirstIndex + i;
+
+ if (elementEndU > viewportStartU && u <= viewportStartU)
+ {
+ _viewportAnchorIndex = itemIndex;
+ return;
+ }
+
+ u = elementEndU;
+ }
+
+ }
+
private double GetOrEstimateElementU(int index)
{
// Return the position of the existing element if realized.
@@ -888,6 +1525,32 @@ private double GetOrEstimateElementU(int index)
return index * estimatedSize;
}
+ ///
+ /// Test-only seam applied to every size the panel reads off an element, so a test can
+ /// simulate non-deterministic measurement (async image loading, text wrapping) without a
+ /// template that actually behaves that way. Takes the item index and the measured size in
+ /// the layout orientation, and returns the size the panel should use.
+ ///
+ ///
+ /// Deliberately internal and not a protected virtual method: nothing in production
+ /// wants to change a measured size, so this must not become public API.
+ ///
+ internal Func? ElementSizeAdjustmentForTesting { get; set; }
+
+ ///
+ /// The panel's single view of an element's size along the layout axis. Every place that
+ /// records or re-checks a size must go through here: if size *recording* applied
+ /// but size *checking* did not, the two would
+ /// disagree by the adjustment on every pass and each pass would look like a fresh resize.
+ ///
+ private double GetElementSizeU(Control element, int index)
+ {
+ var sizeU = Orientation == Orientation.Horizontal
+ ? element.DesiredSize.Width
+ : element.DesiredSize.Height;
+ return ElementSizeAdjustmentForTesting is { } adjust ? adjust(index, sizeU) : sizeU;
+ }
+
private void RealizeElements(
IReadOnlyList items,
Size availableSize,
@@ -900,7 +1563,7 @@ private void RealizeElements(
var index = viewport.anchorIndex;
var horizontal = Orientation == Orientation.Horizontal;
var u = viewport.anchorU;
-
+
// Reset boundary flags
_hasReachedStart = false;
_hasReachedEnd = false;
@@ -916,10 +1579,11 @@ private void RealizeElements(
_realizingIndex = index;
var e = GetOrCreateElement(items, index);
_realizingElement = e;
-
- e.Measure(availableSize);
-
- var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height;
+
+ if (!e.IsMeasureValid)
+ e.Measure(availableSize);
+
+ var sizeU = GetElementSizeU(e, index);
var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width;
_measureElements!.Add(index, e, u, sizeU);
@@ -930,7 +1594,7 @@ private void RealizeElements(
_realizingIndex = -1;
_realizingElement = null;
} while (u < viewport.viewportUEnd && index < items.Count);
-
+
// Check if we reached the end of the collection
_hasReachedEnd = index >= items.Count;
@@ -948,10 +1612,12 @@ private void RealizeElements(
while (u > viewport.viewportUStart && index >= 0)
{
var e = GetOrCreateElement(items, index);
-
- e.Measure(availableSize);
- var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height;
+
+ if (!e.IsMeasureValid)
+ e.Measure(availableSize);
+ var sizeU = GetElementSizeU(e, index);
var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width;
+
u -= sizeU;
_measureElements!.Add(index, e, u, sizeU);
@@ -962,6 +1628,24 @@ private void RealizeElements(
// Check if we reached the start of the collection
_hasReachedStart = index < 0;
+ // Item 0 sits at u == 0 by definition, so whenever the realized range reaches it the
+ // whole block's position is known exactly: StartU must be 0. Realization walks
+ // backwards from an *estimated* anchor position, so it can arrive at item 0 with a
+ // non-zero u; that is accumulated estimation error, not a real offset, and leaving it
+ // in would either clip item 0 above the viewport or leave a gap above it. Re-basing
+ // the block here also feeds an exact position back into the estimates that follow.
+ if (_hasReachedStart && _measureElements.Count > 0 && _measureElements.FirstIndex == 0)
+ {
+ var firstItemU = _measureElements.StartU;
+
+ if (!MathUtilities.AreClose(firstItemU, 0))
+ {
+ var adjustment = -firstItemU;
+ _measureElements.CompensateStartU(adjustment);
+ viewport.realizedEndU += adjustment;
+ }
+ }
+
// We can now recycle elements before the first element.
_realizedElements.RecycleElementsBefore(index + 1, _recycleElement);
}
@@ -976,10 +1660,42 @@ private Control GetOrCreateElement(IReadOnlyList items, int index)
return realized;
var item = items[index];
- var generator = ItemContainerGenerator!;
+
+ // Check retained containers first — these already have the correct DataContext
+ // and only need a lightweight index update instead of full PrepareItemContainer.
+ if (_retainedForReuse != null && item != null &&
+ _retainedForReuse.TryGetValue(item, out var retained))
+ {
+ _retainedForReuse.Remove(item);
+ var element = retained.element;
+ var oldIndex = retained.oldIndex;
+ if (oldIndex != index)
+ ItemContainerGenerator!.ItemContainerIndexChanged(element, oldIndex, index);
+
+ // Force the reused subtree to re-measure. The container itself may still be
+ // IsMeasureValid==true while a descendant's measure was invalidated (e.g. a
+ // data-bound size changed on the SAME item while it was realized). Avalonia's
+ // InvalidateMeasure does not walk up, so a stale descendant is only honored once
+ // it is actually re-measured — but RealizeElements skips e.Measure when the
+ // container is measure-valid, leaving it arranged at the previous size. Mirror
+ // the recycle-for-different-item path (see GetRecycledElement) so re-realization
+ // via the retained path always re-measures.
+ // Only when a descendant's measure was actually invalidated (e.g. a data-bound
+ // size changed on the SAME item while it was realized). In that case the container
+ // itself is still IsMeasureValid==true and RealizeElements would skip e.Measure,
+ // arranging it at the stale size — so force the whole subtree to re-measure. When
+ // nothing changed the subtree is fully valid and this is a no-op, preserving the
+ // reuse-without-re-measure optimization.
+ if (AnyMeasureInvalidInSubtree(element))
+ InvalidateMeasureRecursive(element);
+ return element;
+ }
+
+ var generator = ItemContainerGenerator!;
if (generator.NeedsContainer(item, index, out var recycleKey))
{
+ NoteEncounteredRecycleKey(recycleKey, index);
return GetRecycledElement(item, index, recycleKey) ??
CreateElement(item, index, recycleKey);
}
@@ -989,6 +1705,33 @@ private Control GetOrCreateElement(IReadOnlyList items, int index)
}
}
+ ///
+ /// Records that the panel needed a container for , remembering
+ /// one item index that uses it so warmup can build more containers of that kind later.
+ ///
+ ///
+ /// This is what makes warmup's pool track the template keys actually in use. An index is
+ /// stored rather than the item itself so the panel never keeps a data item alive; the index
+ /// is re-checked against the current collection at warmup time. When a key turns up that
+ /// warmup has not seen, warmup is scheduled again so the pool grows to cover it — the pool
+ /// therefore follows where the user actually goes, instead of a guess made from the first
+ /// N items of the collection.
+ ///
+ private void NoteEncounteredRecycleKey(object? recycleKey, int index)
+ {
+ if (recycleKey is null)
+ return;
+
+ _encounteredRecycleKeys ??= new();
+ if (_encounteredRecycleKeys.TryAdd(recycleKey, index) && EnableWarmup && _isWarmupComplete)
+ {
+ // A kind of item we have never pooled for. Top the pool up for it, off the layout
+ // pass that discovered it.
+ _isWarmupComplete = false;
+ Threading.Dispatcher.UIThread.Post(PerformWarmup, Threading.DispatcherPriority.Background);
+ }
+ }
+
private Control? GetRealizedElement(int index)
{
return _realizedElements?.GetElement(index);
@@ -1042,17 +1785,80 @@ private Control GetItemAsOwnContainer(object? item, int index)
if (_recyclePool?.TryGetValue(recycleKey, out var recyclePool) == true && recyclePool.Count > 0)
{
- var recycled = recyclePool.Pop();
+ // edge case: The item is already datacontext of a recyclable item
+ var recycleIndex = recyclePool.Count - 1;
+ for (int i = 0; i < recyclePool.Count; i++)
+ {
+ if (recyclePool[i].DataContext == item)
+ {
+ recycleIndex = i;
+ break;
+ }
+ }
+
+ var recycled = recyclePool[recycleIndex];
+ recyclePool.RemoveAt(recycleIndex);
recycled.SetCurrentValue(Visual.IsVisibleProperty, true);
+
+ // Detect whether this pooled container is being reused for a *different* item.
+ // For IVirtualizingDataTemplate the container is not cleared on recycle and the
+ // same child instance is reused, so a container reused for a new item keeps its
+ // previous content's cached layout. Making the container visible invalidates the
+ // container's own measure, but its content subtree is still IsMeasureValid at the
+ // same available size and would short-circuit re-measure — leaving the new item
+ // arranged with the previous item's size (content rendered blank / clipped, or
+ // text not re-wrapped). Force the reused subtree to re-measure in that case.
+ // When reused for the SAME item, the cached layout is still correct — skip the work.
+ var dataContextChanged = !ReferenceEquals(recycled.DataContext, item);
+
generator.PrepareItemContainer(recycled, item, index);
- AddInternalChild(recycled);
generator.ItemContainerPrepared(recycled, item, index);
+
+ if (dataContextChanged)
+ InvalidateMeasureRecursive(recycled);
+
return recycled;
}
return null;
}
+ ///
+ /// Invalidates the measure of and its entire visual subtree.
+ /// Needed when a recycled container is reused for a different item: the container's content
+ /// is not re-created (IVirtualizingDataTemplate reuses the child), so descendants would
+ /// otherwise short-circuit re-measure at the unchanged available size and keep the previous
+ /// item's layout. This forces the new item's data to be measured before the arrange pass.
+ ///
+ private static void InvalidateMeasureRecursive(Visual visual)
+ {
+ if (visual is Layoutable layoutable)
+ layoutable.InvalidateMeasure();
+
+ foreach (var child in visual.GetVisualChildren())
+ InvalidateMeasureRecursive(child);
+ }
+
+ ///
+ /// Returns true if or any descendant has an invalid measure.
+ /// Used by the retained-container reuse path to decide whether a stale descendant (e.g. a
+ /// data-bound size that changed on the same item while it was realized) needs the reused
+ /// subtree to be re-measured. Avalonia's does
+ /// not propagate up, so a still-valid container would otherwise short-circuit re-measure
+ /// and arrange the item at its previous size.
+ ///
+ private static bool AnyMeasureInvalidInSubtree(Visual visual)
+ {
+ if (visual is Layoutable { IsMeasureValid: false })
+ return true;
+
+ foreach (var child in visual.GetVisualChildren())
+ if (AnyMeasureInvalidInSubtree(child))
+ return true;
+
+ return false;
+ }
+
private Control CreateElement(object? item, int index, object? recycleKey)
{
Debug.Assert(ItemContainerGenerator is not null);
@@ -1079,7 +1885,6 @@ private void RecycleElement(Control element, int index)
if (recycleKey is null)
{
- ItemContainerGenerator!.ClearItemContainer(element);
RemoveInternalChild(element);
}
else if (recycleKey == s_itemIsItsOwnContainer)
@@ -1096,33 +1901,6 @@ private void RecycleElement(Control element, int index)
ItemContainerGenerator!.ClearItemContainer(element);
PushToRecyclePool(recycleKey, element);
element.SetCurrentValue(Visual.IsVisibleProperty, false);
- RemoveInternalChild(element);
- }
- }
-
- private void RecycleElementOnItemRemoved(Control element)
- {
- Debug.Assert(ItemContainerGenerator is not null);
-
- _scrollAnchorProvider?.UnregisterAnchorCandidate(element);
-
- var recycleKey = element.GetValue(RecycleKeyProperty);
-
- if (recycleKey is null)
- {
- ItemContainerGenerator!.ClearItemContainer(element);
- RemoveInternalChild(element);
- }
- else if (recycleKey == s_itemIsItsOwnContainer)
- {
- RemoveInternalChild(element);
- }
- else
- {
- ItemContainerGenerator!.ClearItemContainer(element);
- PushToRecyclePool(recycleKey, element);
- element.SetCurrentValue(Visual.IsVisibleProperty, false);
- RemoveInternalChild(element);
}
}
@@ -1135,7 +1913,7 @@ private void RecycleFocusedElement()
_focusedElement = null;
_focusedIndex = -1;
}
-
+
private void RecycleScrollToElement()
{
if (_scrollToElement != null)
@@ -1145,6 +1923,102 @@ private void RecycleScrollToElement()
_scrollToElement = null;
_scrollToIndex = -1;
}
+
+ ///
+ /// Retains containers whose DataContext matches items in the given index range,
+ /// so they can be reused without full PrepareItemContainer + Measure overhead.
+ /// Nullifies matching elements in so that the
+ /// subsequent RecycleAll/ItemsReset skips them.
+ ///
+ private void RetainMatchingContainers(IReadOnlyList items, int startIndex, int endIndex)
+ {
+ if (_realizedElements is null || _realizedElements.Count == 0)
+ return;
+
+ startIndex = Math.Max(0, startIndex);
+ endIndex = Math.Min(endIndex, items.Count);
+
+ if (endIndex <= startIndex)
+ return;
+
+ // Build a set of DataContexts we need in the estimated viewport range
+ var needed = new Dictionary(endIndex - startIndex);
+ for (var i = startIndex; i < endIndex; i++)
+ {
+ var item = items[i];
+ if (item != null && !needed.ContainsKey(item))
+ needed[item] = i;
+ }
+
+ if (needed.Count == 0)
+ return;
+
+ _retainedForReuse ??= new Dictionary();
+ _retainedForReuse.Clear();
+
+ // Walk realized elements, nullify those whose DataContext matches a needed item
+ var firstRealized = _realizedElements.FirstIndex;
+ var lastRealized = _realizedElements.LastIndex;
+
+ for (var i = firstRealized; i <= lastRealized; i++)
+ {
+ var element = _realizedElements.GetElement(i);
+ if (element?.DataContext is not { } dc)
+ continue;
+
+ if (needed.ContainsKey(dc))
+ {
+ var nullified = _realizedElements.NullifyElement(i);
+ if (nullified.HasValue)
+ {
+ // Unregister as anchor candidate so the ScrollViewer doesn't
+ // track stale positions when the element moves to a new index.
+ _scrollAnchorProvider?.UnregisterAnchorCandidate(nullified.Value.element);
+ _retainedForReuse[dc] = (nullified.Value.element, i, nullified.Value.sizeU);
+ }
+ }
+ }
+
+ if (_retainedForReuse.Count == 0)
+ _retainedForReuse = null;
+ }
+
+ ///
+ /// Recycles any retained containers that were not reused during realization.
+ /// Must be called after RealizeElements to avoid orphaned children.
+ ///
+ private void RecycleUnusedRetainedContainers()
+ {
+ if (_retainedForReuse == null)
+ return;
+
+ foreach (var entry in _retainedForReuse)
+ {
+ RecycleElementOnItemRemoved(entry.Value.element);
+ }
+
+ _retainedForReuse = null;
+ }
+
+ private void RecycleElementOnItemRemoved(Control element)
+ {
+ Debug.Assert(ItemContainerGenerator is not null);
+
+ _scrollAnchorProvider?.UnregisterAnchorCandidate(element);
+
+ var recycleKey = element.GetValue(RecycleKeyProperty);
+
+ if (recycleKey is null || recycleKey == s_itemIsItsOwnContainer)
+ {
+ RemoveInternalChild(element);
+ }
+ else
+ {
+ ItemContainerGenerator!.ClearItemContainer(element);
+ PushToRecyclePool(recycleKey, element);
+ element.SetCurrentValue(Visual.IsVisibleProperty, false);
+ }
+ }
private void PushToRecyclePool(object recycleKey, Control element)
{
@@ -1156,7 +2030,13 @@ private void PushToRecyclePool(object recycleKey, Control element)
_recyclePool.Add(recycleKey, pool);
}
- pool.Push(element);
+ // Respect MaxPoolSizePerKey, but only for keys an IVirtualizingDataTemplate handed out.
+ // Containers under DefaultRecycleKey are pooled uncapped, as in stock Avalonia.
+ if (ItemsControl?.GetMaxPoolSizePerKey(recycleKey) is { } maxPoolSize &&
+ pool.Count >= maxPoolSize)
+ return;
+
+ pool.Add(element);
}
private void UpdateElementIndex(Control element, int oldIndex, int newIndex)
@@ -1227,8 +2107,26 @@ private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChanged
var oldExtendedViewportStart = vertical ? _lastMeasuredExtendedViewport.Top : _lastMeasuredExtendedViewport.Left;
var oldExtendedViewportEnd = vertical ? _lastMeasuredExtendedViewport.Bottom : _lastMeasuredExtendedViewport.Right;
+ var newViewport = e.EffectiveViewport.Intersect(new(Bounds.Size));
+
+ // Ignore a collapsed (empty) viewport: it carries no information about where the
+ // user is looking, so it must not be allowed to overwrite the state that does.
+ // A window or page being hidden (navigating to another activity, a picker, an
+ // unselected tab) reports a 0x0 effective viewport. Accepting it would make the
+ // viewport disjunct from every realized element, so the next measure would recycle
+ // them all and re-anchor to index 0 at StartU=0; on return the scroll anchor is gone
+ // and the ScrollViewer clamps the now out-of-range offset into a large scroll jump.
+ // Dropping the update keeps _viewport, the extended viewports and the realized range
+ // intact, so the scroll position survives the round trip unchanged.
+ //
+ // Covered by Collapsing_Viewport_To_Empty_And_Restoring_Preserves_Scroll_Position.
+ if (newViewport.Width <= 0 || newViewport.Height <= 0)
+ {
+ return;
+ }
+
// Update current viewport
- _viewport = e.EffectiveViewport.Intersect(new(Bounds.Size));
+ _viewport = newViewport;
_isWaitingForViewportUpdate = false;
// Calculate buffer sizes based on viewport dimensions
@@ -1259,53 +2157,64 @@ private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChanged
else if (!MathUtilities.AreClose(oldExtendedViewportStart, newExtendedViewportStart) ||
!MathUtilities.AreClose(oldExtendedViewportEnd, newExtendedViewportEnd))
{
- // Check if we're about to scroll into an area where we don't have realized elements
- // This would be the case if we're near the edge of our current extended viewport
- var nearingEdge = false;
+ // For small extended viewport shifts, skip the expensive nearingEdge check
+ var extShiftU = Math.Abs(newExtendedViewportEnd - oldExtendedViewportEnd) +
+ Math.Abs(newExtendedViewportStart - oldExtendedViewportStart);
- if (_realizedElements != null)
+ if (extShiftU < 2.0)
+ {
+ // Tiny shift, not worth measuring
+ }
+ else
{
- var firstRealizedElementU = _realizedElements.StartU;
- var lastRealizedElementU = _realizedElements.StartU;
+ // Check if we're about to scroll into an area where we don't have realized elements
+ // This would be the case if we're near the edge of our current extended viewport
+ var nearingEdge = false;
- for (var i = 0; i < _realizedElements.Count; i++)
+ if (_realizedElements != null)
{
- lastRealizedElementU += _realizedElements.SizeU[i];
- }
+ var firstRealizedElementU = _realizedElements.StartU;
+ var lastRealizedElementU = _realizedElements.StartU;
- // If scrolling up/left and nearing the top/left edge of realized elements
- if (newViewportStart < oldViewportStart &&
- newViewportStart - newExtendedViewportStart < bufferSize)
- {
- // Edge case: We're at item 0 with excess measurement space.
- // Skip re-measuring since we're at the list start and it won't change the result.
- // This prevents redundant Measure-Arrange cycles when at list beginning.
- nearingEdge = !_hasReachedStart;
- }
+ for (var i = 0; i < _realizedElements.Count; i++)
+ {
+ lastRealizedElementU += _realizedElements.SizeU[i];
+ }
+
+ // If scrolling up/left and nearing the top/left edge of realized elements
+ if (newViewportStart < oldViewportStart &&
+ newViewportStart - newExtendedViewportStart < bufferSize)
+ {
+ // Edge case: We're at item 0 with excess measurement space.
+ // Skip re-measuring since we're at the list start and it won't change the result.
+ // This prevents redundant Measure-Arrange cycles when at list beginning.
+ nearingEdge = !_hasReachedStart;
+ }
- // If scrolling down/right and nearing the bottom/right edge of realized elements
- if (newViewportEnd > oldViewportEnd &&
- newExtendedViewportEnd - newViewportEnd < bufferSize)
+ // If scrolling down/right and nearing the bottom/right edge of realized elements
+ if (newViewportEnd > oldViewportEnd &&
+ newExtendedViewportEnd - newViewportEnd < bufferSize)
+ {
+ // Edge case: We're at the last item with excess measurement space.
+ // Skip re-measuring since we're at the list end and it won't change the result.
+ // This prevents redundant Measure-Arrange cycles when at list beginning.
+ nearingEdge = !_hasReachedEnd;
+ }
+ }
+ else
{
- // Edge case: We're at the last item with excess measurement space.
- // Skip re-measuring since we're at the list end and it won't change the result.
- // This prevents redundant Measure-Arrange cycles when at list beginning.
- nearingEdge = !_hasReachedEnd;
+ nearingEdge = true;
}
- }
- else
- {
- nearingEdge = true;
- }
- needsMeasure = nearingEdge;
+ needsMeasure = nearingEdge;
+ }
}
}
// Supplementary check: detect viewport growth after a previous shrink.
- // The main comparison (Cases 1a/1b) uses _extendedViewport which only updates
+ // The main comparison (Cases 1a/1b) uses _lastMeasuredExtendedViewport which only updates
// on measure. When the viewport shrinks (e.g. ComboBox popup during filtering),
- // _extendedViewport stays stale-large, masking subsequent growth. Compare against
+ // _lastMeasuredExtendedViewport stays stale-large, masking subsequent growth. Compare against
// _lastKnownExtendedViewport (always updated) to catch this case.
if (!needsMeasure)
{
@@ -1321,18 +2230,32 @@ private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChanged
if (needsMeasure)
{
+ // Check if we're already measuring with this viewport (or very close to it)
+ // This prevents layout cycles during fast scrolling where viewport shifts slightly
+ // as heterogeneous items are measured
+ if (_isInLayout &&
+ MathUtilities.AreClose(_lastMeasuredViewport.X, extendedViewPort.X) &&
+ MathUtilities.AreClose(_lastMeasuredViewport.Y, extendedViewPort.Y) &&
+ MathUtilities.AreClose(_lastMeasuredViewport.Width, extendedViewPort.Width) &&
+ MathUtilities.AreClose(_lastMeasuredViewport.Height, extendedViewPort.Height))
+ {
+ // We're already measuring with this viewport - don't invalidate again
+ _lastMeasuredExtendedViewport = extendedViewPort;
+ return;
+ }
// Only update the measure viewport when triggering a measure. This keeps the
// wider realization range available for externally-triggered measures (e.g. from
// OnItemsChanged), ensuring enough items are realized.
_lastMeasuredExtendedViewport = extendedViewPort;
InvalidateMeasure();
}
+
}
private void OnItemsControlPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (_focusedElement is not null &&
- e.Property == KeyboardNavigation.TabOnceActiveElementProperty &&
+ e.Property == KeyboardNavigation.TabOnceActiveElementProperty &&
e.GetOldValue() == _focusedElement)
{
// TabOnceActiveElement has moved away from _focusedElement so we can recycle it.
@@ -1340,17 +2263,276 @@ private void OnItemsControlPropertyChanged(object? sender, AvaloniaPropertyChang
_focusedElement = null;
_focusedIndex = -1;
}
+
+ // Handle ItemTemplate changes - invalidate warmup and re-trigger if enabled
+ if (e.Property == ItemsControl.ItemTemplateProperty)
+ {
+ if (EnableWarmup && _isWarmupComplete)
+ {
+
+ ClearWarmupContainers();
+ _isWarmupComplete = false;
+
+ Threading.Dispatcher.UIThread.Post(PerformWarmup, Threading.DispatcherPriority.Background);
+ }
+ }
+ }
+
+ ///
+ /// Clears unused warmup containers from the recycle pool.
+ /// Only removes containers that haven't been used yet (null DataContext and invisible).
+ ///
+ private void ClearWarmupContainers()
+ {
+ if (_recyclePool == null)
+ return;
+
+ int clearedCount = 0;
+
+ foreach (var pool in _recyclePool.Values)
+ {
+ for (int i = pool.Count - 1; i >= 0; i--)
+ {
+ var container = pool[i];
+ if (container.DataContext == null && !container.IsVisible)
+ {
+ RemoveInternalChild(container);
+ pool.RemoveAt(i);
+ clearedCount++;
+ }
+ }
+ }
+
+ }
+
+ ///
+ /// Clears only obsolete warmup containers from the recycle pool.
+ /// Preserves containers whose recycleKey is still active in the current collection.
+ ///
+ private void ClearObsoleteWarmupContainers()
+ {
+ if (_recyclePool == null)
+ return;
+
+ // Get currently needed keys from the new collection
+ var activeKeys = new HashSet(DiscoverTemplateKeys().Keys);
+
+ var keysToRemove = new List();
+ int clearedCount = 0;
+
+ foreach (var kvp in _recyclePool)
+ {
+ var recycleKey = kvp.Key;
+ var pool = kvp.Value;
+
+ // Only clear pools for obsolete keys (not in new collection)
+ if (!activeKeys.Contains(recycleKey))
+ {
+ for (int i = pool.Count - 1; i >= 0; i--)
+ {
+ var container = pool[i];
+ if (container.DataContext == null && !container.IsVisible)
+ {
+ RemoveInternalChild(container);
+ pool.RemoveAt(i);
+ clearedCount++;
+ }
+ }
+
+ if (pool.Count == 0)
+ keysToRemove.Add(recycleKey);
+ }
+ }
+
+ // Remove empty pools
+ foreach (var key in keysToRemove)
+ _recyclePool.Remove(key);
+
}
private void OnCacheLengthChanged(AvaloniaPropertyChangedEventArgs e)
{
var newValue = e.GetNewValue();
_bufferFactor = newValue;
-
+
// Force a recalculation of the extended viewport on the next layout pass
InvalidateMeasure();
}
-
+
+ ///
+ /// The template keys the panel has actually needed a container for so far, mapped to the
+ /// number of containers warmup should keep available for each. Grows as the user reaches
+ /// items of new kinds, so a collection whose kinds are not all present at its start (a
+ /// grouped or sorted list) is covered just as well as one where they are.
+ ///
+ internal Dictionary DiscoverTemplateKeys()
+ {
+ var templateKeys = new Dictionary();
+ var items = Items;
+
+ if (_encounteredRecycleKeys is null || items == null || items.Count == 0)
+ return templateKeys;
+
+ // Forget kinds the collection no longer contains — after the items are replaced, a key
+ // encountered under the old collection is not a kind we should keep containers for.
+ List? vanished = null;
+ foreach (var key in _encounteredRecycleKeys.Keys)
+ {
+ if (FindItemForRecycleKey(key, items) is null)
+ {
+ (vanished ??= new()).Add(key);
+ }
+ else
+ {
+ // How many containers to keep for this kind. The template says so for keys it
+ // handed out (it is the thing that knows how expensive it is to build);
+ // anything under the default key keeps a small pool. Resolved per key rather
+ // than once, because a template only speaks for the keys it opted in with.
+ templateKeys[key] = ItemsControl?.GetMinPoolSizePerKey(key)
+ ?? DefaultWarmupPoolSizePerKey;
+ }
+ }
+
+ if (vanished is not null)
+ {
+ foreach (var key in vanished)
+ _encounteredRecycleKeys.Remove(key);
+ }
+
+ return templateKeys;
+ }
+
+ ///
+ /// Finds an item currently in the collection whose container would use
+ /// , so warmup has something to build a container from.
+ /// Starts at the index where the key was first seen and, because the collection may have
+ /// changed since, falls back to scanning.
+ ///
+ private object? FindItemForRecycleKey(object recycleKey, IReadOnlyList items)
+ {
+ var generator = ItemContainerGenerator;
+ if (generator is null)
+ return null;
+
+ bool Matches(int i) =>
+ items[i] is not null &&
+ generator.NeedsContainer(items[i], i, out var key) &&
+ Equals(key, recycleKey);
+
+ if (_encounteredRecycleKeys!.TryGetValue(recycleKey, out var rememberedIndex) &&
+ rememberedIndex >= 0 && rememberedIndex < items.Count &&
+ Matches(rememberedIndex))
+ {
+ return items[rememberedIndex];
+ }
+
+ for (var i = 0; i < items.Count; ++i)
+ {
+ if (Matches(i))
+ {
+ _encounteredRecycleKeys[recycleKey] = i;
+ return items[i];
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Pre-creates containers with their content for each discovered template type.
+ /// Containers are stored in the recycle pool with their Child controls already attached,
+ /// ready to be reused during scrolling. This eliminates the expensive template instantiation
+ /// cost during the first scroll.
+ ///
+ internal void PerformWarmup()
+ {
+ // Warmup is posted to the dispatcher and runs a turn later, by which time the panel may
+ // have left the visual tree — a page navigated away from, a closed window. It is still
+ // attached to its ItemsControl at that point, so Items and the generator are both live
+ // and the loop below would run to completion; the containers it built would then sit in
+ // Children and the recycle pool for as long as the panel is referenced, having bought
+ // nothing. Warmup moves template instantiation off the first scroll, and a panel nobody
+ // is showing has no first scroll to move it off. Leave _isWarmupComplete alone so a
+ // re-attach posts the work again.
+ if (!IsAttachedToVisualTree)
+ return;
+
+ if (_isWarmupComplete || Items == null || Items.Count == 0)
+ return;
+
+ var templateKeys = DiscoverTemplateKeys();
+
+ if (templateKeys.Count == 0)
+ {
+ _isWarmupComplete = true;
+ return;
+ }
+
+ var items = Items;
+ _recyclePool ??= new Dictionary>();
+
+ var orientation = Orientation;
+ var availableSize = orientation == Orientation.Horizontal
+ ? new Size(double.PositiveInfinity, Bounds.Height > 0 ? Bounds.Height : _lastEstimatedElementSizeU)
+ : new Size(Bounds.Width > 0 ? Bounds.Width : double.PositiveInfinity, double.PositiveInfinity);
+
+ // Containers already realized count towards the target: they will land in the pool when
+ // they are recycled, and the point of the pool is to have containers ready, not to have
+ // idle ones.
+ var realizedPerKey = new Dictionary();
+ if (_realizedElements is { Elements: not null } realizedElements)
+ {
+ foreach (var element in realizedElements.Elements)
+ {
+ if (element?.GetValue(RecycleKeyProperty) is { } key)
+ CollectionsMarshal.GetValueRefOrAddDefault(realizedPerKey, key, out _)++;
+ }
+ }
+
+ foreach (var kvp in templateKeys)
+ {
+ var recycleKey = kvp.Key;
+ var targetCount = kvp.Value;
+
+ var existingCount = _recyclePool.TryGetValue(recycleKey, out var existingPool)
+ ? existingPool.Count
+ : 0;
+ if (realizedPerKey.TryGetValue(recycleKey, out var realizedCount))
+ existingCount += realizedCount;
+
+ var neededCount = Math.Max(0, targetCount - existingCount);
+ if (neededCount == 0)
+ continue;
+
+ // Any item of this kind will do — building a container is about instantiating the
+ // template, and the data is replaced on reuse.
+ if (FindItemForRecycleKey(recycleKey, items) is not { } sampleItem)
+ continue;
+
+ var sampleIndex = _encounteredRecycleKeys![recycleKey];
+
+ for (var i = 0; i < neededCount; i++)
+ {
+ try
+ {
+ // Creates the container *and* its content, which is the expensive part we
+ // are moving off the first scroll. The content is deliberately left
+ // attached: reuse then only rebinds data.
+ var container = CreateElement(sampleItem, sampleIndex, recycleKey);
+ container.Measure(availableSize);
+ PushToRecyclePool(recycleKey, container);
+ container.SetCurrentValue(Visual.IsVisibleProperty, false);
+ }
+ catch
+ {
+ break;
+ }
+ }
+ }
+
+ _isWarmupComplete = true;
+ }
+
///
public IReadOnlyList GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment)
{
diff --git a/src/Markup/Avalonia.Markup.Xaml/Templates/DataTemplate.cs b/src/Markup/Avalonia.Markup.Xaml/Templates/DataTemplate.cs
index b45898d8bd3..7e40bbc0672 100644
--- a/src/Markup/Avalonia.Markup.Xaml/Templates/DataTemplate.cs
+++ b/src/Markup/Avalonia.Markup.Xaml/Templates/DataTemplate.cs
@@ -5,7 +5,7 @@
namespace Avalonia.Markup.Xaml.Templates
{
- public class DataTemplate : IRecyclingDataTemplate, ITypedDataTemplate
+ public class DataTemplate : IRecyclingDataTemplate, ITypedDataTemplate, IVirtualizingDataTemplate
{
[DataType]
public Type? DataType { get; set; }
@@ -14,6 +14,26 @@ public class DataTemplate : IRecyclingDataTemplate, ITypedDataTemplate
[TemplateContent]
public object? Content { get; set; }
+ ///
+ /// Gets or sets whether this template supports content virtualization.
+ /// When true, content controls are recycled based on DataType.
+ /// Default is false for backward compatibility.
+ ///
+ public bool EnableVirtualization { get; set; } = false;
+
+ ///
+ /// Gets or sets the maximum pool size per key for content virtualization.
+ /// Default is 5.
+ ///
+ public int MaxPoolSizePerKey { get; set; } = 5;
+
+ ///
+ /// Gets or sets the minimum number of controls to keep in the recycle pool
+ /// for each key. Default is 2.
+ /// This is only used when warmup is enabled
+ ///
+ public int MinPoolSizePerKey { get; set; } = 2;
+
public bool Match(object? data)
{
if (DataType == null)
@@ -30,7 +50,24 @@ public bool Match(object? data)
public Control? Build(object? data, Control? existing)
{
+ // If virtualizing and recycled control provided, use it
+ if (EnableVirtualization && existing != null)
+ return existing;
+
+ // Otherwise create new from template
return existing ?? TemplateContent.Load(Content)?.Result;
}
+
+ ///
+ /// Gets a key that identifies which recycling pool this data belongs to.
+ ///
+ public object? GetKey(object? data)
+ {
+ if (!EnableVirtualization)
+ return null;
+
+ // Use DataType as the key (all objects of same type share same pool)
+ return DataType ?? data?.GetType();
+ }
}
}
diff --git a/tests/Avalonia.Benchmarks/Controls/ComplexItems.cs b/tests/Avalonia.Benchmarks/Controls/ComplexItems.cs
new file mode 100644
index 00000000000..d19fc9ad9b3
--- /dev/null
+++ b/tests/Avalonia.Benchmarks/Controls/ComplexItems.cs
@@ -0,0 +1,311 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+#nullable enable
+
+namespace Avalonia.Benchmarks.Controls
+{
+ ///
+ /// The four row kinds a heterogeneous form/feed list is made of. Four kinds is the point, not
+ /// decoration: stock pools every container under a single DefaultRecycleKey , so a
+ /// recycled container is routinely handed an item of a *different* kind and has to throw its
+ /// subtree away and build the other one. Type-aware recycle keys are what stop that.
+ ///
+ internal enum RowKind
+ {
+ Header,
+ TextRow,
+ PhotoRow,
+ FormRow,
+ }
+
+ ///
+ /// A row with enough bound properties that re-evaluating them is real work, as in a form
+ /// template. is bound by the template onto the root border, so
+ /// geometry stays predictable while the subtree above it stays expensive.
+ ///
+ internal sealed class ComplexItem : IBenchItem
+ {
+ public ComplexItem(int index, RowKind kind, double height)
+ {
+ Kind = kind;
+ Height = height;
+ Title = "Row " + index;
+ Subtitle = "Secondary line for row " + index;
+ Caption = "Field " + index;
+ Value = "Value " + index;
+ Hint = "hint " + index;
+ Badge = (index % 7).ToString();
+ Detail = "Detail text for row " + index + " spanning a little further";
+ }
+
+ public RowKind Kind { get; }
+
+ public double Height { get; }
+
+ public string Title { get; }
+
+ public string Subtitle { get; }
+
+ public string Caption { get; }
+
+ public string Value { get; }
+
+ public string Hint { get; }
+
+ public string Badge { get; }
+
+ public string Detail { get; }
+ }
+
+ // Public only because it is used as a BenchmarkDotNet [Params] type.
+ public enum TemplateMode
+ {
+ ///
+ /// A plain template that rebuilds its subtree whenever the presenter asks for content —
+ /// stock behaviour, and what a template that does not opt in still gets on this branch.
+ ///
+ Plain,
+
+ ///
+ /// The fork's opt-in: IVirtualizingDataTemplate with a per-row-kind key, so
+ /// containers pool by kind and a reused one keeps its child attached.
+ ///
+ Virtualized,
+ }
+
+ ///
+ /// Builds the row subtrees and the plain (non-opted-in) template over them.
+ ///
+ ///
+ /// Stock API only, so this file can be copied into a merge-base worktree. The opt-in template
+ /// lives in ComplexVirtualizingTemplate.cs , which is fork-only; leave it (and
+ /// ComplexScrollBenchmark.cs ) behind when copying. Nothing else needs editing — the
+ /// opt-in template registers itself through , so with
+ /// that file absent simply reports the one arm that exists.
+ ///
+ internal static class ComplexItems
+ {
+ ///
+ /// Set by the fork-only opt-in template's module initializer. Null at the merge-base, where
+ /// IVirtualizingDataTemplate does not exist.
+ ///
+ ///
+ /// A property rather than a field: with the fork-only file absent nothing assigns it, and a
+ /// field would then be CS0649 — which this repo treats as an error.
+ ///
+ public static Func? VirtualizingTemplateFactory { get; set; }
+
+ public static IReadOnlyList AvailableModes =>
+ VirtualizingTemplateFactory is null
+ ? new[] { TemplateMode.Plain }
+ : new[] { TemplateMode.Plain, TemplateMode.Virtualized };
+
+ public static IDataTemplate TemplateFor(TemplateMode mode, VirtualizationCounters counters) =>
+ mode == TemplateMode.Virtualized
+ ? VirtualizingTemplateFactory!(counters)
+ : CreatePlainTemplate(counters);
+
+ public static VirtualizationHarness CreateHarness(IReadOnlyList items, TemplateMode mode)
+ {
+ var counters = new VirtualizationCounters();
+ return new VirtualizationHarness(items, TemplateFor(mode, counters), counters, viewportWidth: 480);
+ }
+
+ /// Nominal row height per kind — heterogeneous, so the size record has work to do too.
+ private static double HeightFor(RowKind kind) => kind switch
+ {
+ RowKind.Header => 56,
+ RowKind.TextRow => 76,
+ RowKind.PhotoRow => 168,
+ _ => 116,
+ };
+
+ public static List CreateItems(int count)
+ {
+ var items = new List(count);
+ // Fixed seed so both arms of the comparison see byte-identical data.
+ var random = new Random(20259);
+
+ for (var i = 0; i < count; ++i)
+ {
+ // A header every so often, then a shuffle of the three content kinds — a grouped
+ // list whose kinds are not evenly spread, which is also the shape that broke the
+ // old head-sampling warmup.
+ var kind = i % 12 == 0
+ ? RowKind.Header
+ : (RowKind)(1 + random.Next(0, 3));
+
+ items.Add(new ComplexItem(i, kind, HeightFor(kind) + random.Next(0, 9)));
+ }
+
+ return items;
+ }
+
+ ///
+ /// The template a normal Avalonia app writes: one template that switches on the item kind,
+ /// building a fresh subtree every time it is asked. This is the "container virtualization
+ /// does not exist" arm.
+ ///
+ public static IDataTemplate CreatePlainTemplate(VirtualizationCounters counters) =>
+ new FuncDataTemplate((item, _) => Build(item, counters));
+
+ ///
+ /// Builds the subtree for 's kind. Every text and size comes from a
+ /// binding so the tree is correct for whatever item its DataContext later becomes — which
+ /// is precisely what makes it reusable across recycling.
+ ///
+ public static Control Build(ComplexItem? item, VirtualizationCounters counters)
+ {
+ counters.ChildBuilds++;
+
+ var kind = item?.Kind ?? RowKind.TextRow;
+
+ var content = kind switch
+ {
+ RowKind.Header => BuildHeader(counters),
+ RowKind.TextRow => BuildTextRow(counters),
+ RowKind.PhotoRow => BuildPhotoRow(counters),
+ _ => BuildFormRow(counters),
+ };
+
+ return New(counters, new Border
+ {
+ BorderThickness = new Thickness(1),
+ BorderBrush = Brushes.Gainsboro,
+ Background = Brushes.White,
+ Padding = new Thickness(8),
+ Margin = new Thickness(0, 0, 0, 2),
+ [!Layoutable.HeightProperty] = new Binding(nameof(ComplexItem.Height)),
+ Child = content,
+ });
+ }
+
+ private static Control BuildHeader(VirtualizationCounters counters)
+ {
+ var stack = New(counters, new StackPanel { Orientation = Orientation.Vertical, Spacing = 2 });
+
+ stack.Children.Add(Text(counters, nameof(ComplexItem.Title), 16, FontWeight.Bold));
+ stack.Children.Add(Text(counters, nameof(ComplexItem.Subtitle), 11, FontWeight.Normal));
+ stack.Children.Add(New(counters, new Rectangle
+ {
+ Height = 1,
+ Fill = Brushes.Silver,
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ }));
+
+ return stack;
+ }
+
+ private static Control BuildTextRow(VirtualizationCounters counters)
+ {
+ var outer = New(counters, new StackPanel { Orientation = Orientation.Vertical, Spacing = 3 });
+
+ var line = New(counters, new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 });
+ line.Children.Add(Badge(counters));
+ line.Children.Add(Text(counters, nameof(ComplexItem.Caption), 13, FontWeight.SemiBold));
+ line.Children.Add(Text(counters, nameof(ComplexItem.Value), 13, FontWeight.Normal));
+ outer.Children.Add(line);
+
+ outer.Children.Add(Text(counters, nameof(ComplexItem.Detail), 11, FontWeight.Normal));
+ outer.Children.Add(New(counters, new Rectangle { Height = 1, Fill = Brushes.WhiteSmoke }));
+
+ return outer;
+ }
+
+ private static Control BuildPhotoRow(VirtualizationCounters counters)
+ {
+ var row = New(counters, new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 });
+
+ // Stands in for a decoded image: a real one would make this arm slower still, and the
+ // point is the subtree's construction cost, not the decoder's.
+ row.Children.Add(New(counters, new Border
+ {
+ Width = 96,
+ Height = 96,
+ Background = Brushes.LightSteelBlue,
+ CornerRadius = new CornerRadius(4),
+ Child = New(counters, new Rectangle { Fill = Brushes.SteelBlue, Margin = new Thickness(12) }),
+ }));
+
+ var texts = New(counters, new StackPanel { Orientation = Orientation.Vertical, Spacing = 3 });
+ texts.Children.Add(Text(counters, nameof(ComplexItem.Title), 14, FontWeight.SemiBold));
+ texts.Children.Add(Text(counters, nameof(ComplexItem.Subtitle), 11, FontWeight.Normal));
+ texts.Children.Add(Text(counters, nameof(ComplexItem.Detail), 11, FontWeight.Normal));
+
+ var chips = New(counters, new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4 });
+ for (var i = 0; i < 3; ++i)
+ chips.Children.Add(Badge(counters));
+ texts.Children.Add(chips);
+
+ row.Children.Add(texts);
+
+ return row;
+ }
+
+ private static Control BuildFormRow(VirtualizationCounters counters)
+ {
+ var outer = New(counters, new StackPanel { Orientation = Orientation.Vertical, Spacing = 4 });
+
+ outer.Children.Add(Text(counters, nameof(ComplexItem.Caption), 12, FontWeight.SemiBold));
+
+ outer.Children.Add(New(counters, new Border
+ {
+ BorderThickness = new Thickness(1),
+ BorderBrush = Brushes.Silver,
+ Padding = new Thickness(6, 4),
+ Child = Text(counters, nameof(ComplexItem.Value), 13, FontWeight.Normal),
+ }));
+
+ var buttons = New(counters, new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 });
+ for (var i = 0; i < 2; ++i)
+ {
+ buttons.Children.Add(New(counters, new Border
+ {
+ Background = Brushes.WhiteSmoke,
+ CornerRadius = new CornerRadius(3),
+ Padding = new Thickness(8, 3),
+ Child = Text(counters, nameof(ComplexItem.Hint), 11, FontWeight.Normal),
+ }));
+ }
+
+ outer.Children.Add(buttons);
+
+ return outer;
+ }
+
+ private static Border Badge(VirtualizationCounters counters) =>
+ New(counters, new Border
+ {
+ Background = Brushes.Gainsboro,
+ CornerRadius = new CornerRadius(8),
+ Padding = new Thickness(5, 1),
+ Child = Text(counters, nameof(ComplexItem.Badge), 10, FontWeight.Bold),
+ });
+
+ private static TextBlock Text(
+ VirtualizationCounters counters,
+ string property,
+ double fontSize,
+ FontWeight weight) =>
+ New(counters, new TextBlock
+ {
+ FontSize = fontSize,
+ FontWeight = weight,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ [!TextBlock.TextProperty] = new Binding(property),
+ });
+
+ private static T New(VirtualizationCounters counters, T visual) where T : Control
+ {
+ counters.VisualsCreated++;
+ return visual;
+ }
+ }
+}
diff --git a/tests/Avalonia.Benchmarks/Controls/ComplexScrollBenchmark.cs b/tests/Avalonia.Benchmarks/Controls/ComplexScrollBenchmark.cs
new file mode 100644
index 00000000000..7218625ea4f
--- /dev/null
+++ b/tests/Avalonia.Benchmarks/Controls/ComplexScrollBenchmark.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using Avalonia.Controls.Templates;
+using Avalonia.UnitTests;
+using BenchmarkDotNet.Attributes;
+
+#nullable enable
+
+namespace Avalonia.Benchmarks.Controls
+{
+ ///
+ /// Container-level virtualization off vs. on, over a heterogeneous list of complex nested rows.
+ ///
+ ///
+ ///
+ /// This is the comparison the feature is actually for. Over a one-visual template there is
+ /// nothing to save by keeping a child attached — rebuilding it costs a single allocation — so a
+ /// trivial template measures the panel and says nothing about the optimization. These rows are
+ /// 10–20 visuals deep with bound text, which is what a form or feed row really looks like.
+ ///
+ ///
+ /// The Plain arm is the "feature does not exist" side: a template that does not opt in
+ /// takes stock's DefaultRecycleKey path and has its content cleared on recycle, so it
+ /// rebuilds. Running this file at the merge-base too shows that arm really is stock, rather than
+ /// only being claimed to be.
+ ///
+ ///
+ /// Desktop x64 understates this. The saved work is subtree construction, binding setup and text
+ /// layout — all of which cost proportionally more on a phone, which is where this fork's list
+ /// actually runs.
+ ///
+ ///
+ [MemoryDiagnoser]
+ public class ComplexScrollBenchmark : IDisposable
+ {
+ private IDisposable? _app;
+ private List? _items;
+ private VirtualizationHarness? _harness;
+ private double[]? _jumpOffsets;
+
+ [Params(5_000)]
+ public int ItemCount { get; set; }
+
+ ///
+ /// Sourced rather than hard-coded so this file is stock API too: at the merge-base the
+ /// opt-in template is absent and this yields Plain alone, which is exactly the arm a
+ /// baseline run should measure.
+ ///
+ public IEnumerable Modes => ComplexItems.AvailableModes;
+
+ [ParamsSource(nameof(Modes))]
+ public TemplateMode Mode { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _app = UnitTestApplication.Start(TestServices.RealFocus);
+ _items = ComplexItems.CreateItems(ItemCount);
+ _harness = CreateHarness();
+ _jumpOffsets = VirtualizationScenarios.CreateJumpOffsets(_items, _harness.ViewportHeight);
+ }
+
+ internal VirtualizationHarness CreateHarness() => ComplexItems.CreateHarness(_items!, Mode);
+
+ [Benchmark]
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public void ScrollDownAndBack() => VirtualizationScenarios.ScrollDownAndBack(_harness!);
+
+ [Benchmark]
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public void JumpToOffsets() => VirtualizationScenarios.JumpToOffsets(_harness!, _jumpOffsets!);
+
+ public void Dispose()
+ {
+ _app?.Dispose();
+ _app = null;
+ }
+ }
+}
diff --git a/tests/Avalonia.Benchmarks/Controls/ComplexVirtualizingTemplate.cs b/tests/Avalonia.Benchmarks/Controls/ComplexVirtualizingTemplate.cs
new file mode 100644
index 00000000000..a01f59b5984
--- /dev/null
+++ b/tests/Avalonia.Benchmarks/Controls/ComplexVirtualizingTemplate.cs
@@ -0,0 +1,40 @@
+using System.Runtime.CompilerServices;
+using Avalonia.Controls.Templates;
+
+#nullable enable
+
+namespace Avalonia.Benchmarks.Controls
+{
+ ///
+ /// The opt-in arm of the container-virtualization comparison, and a working example of the
+ /// production API: a plain with a
+ /// .
+ ///
+ ///
+ ///
+ /// Fork-only. RecycleKeySelector does not exist at the merge-base, so this file is
+ /// left behind when copying the benchmark directory into a baseline worktree. It registers
+ /// itself through a , so its absence needs no edit
+ /// anywhere else — then reports only the arm that
+ /// exists.
+ ///
+ ///
+ /// The key is , not the item's CLR type. All four row kinds are
+ /// one class, but they are four different subtrees, and a container built for one kind must
+ /// never be handed an item of another — it would keep the wrong tree. This is the case a XAML
+ /// DataTemplate cannot express, because it keys on DataType .
+ ///
+ ///
+ internal static class ComplexVirtualizingTemplate
+ {
+ [ModuleInitializer]
+ internal static void Register() =>
+ ComplexItems.VirtualizingTemplateFactory = counters =>
+ new FuncDataTemplate((item, _) => ComplexItems.Build(item, counters))
+ {
+ RecycleKeySelector = data => (data as ComplexItem)?.Kind,
+ MaxPoolSizePerKey = 8,
+ MinPoolSizePerKey = 2,
+ };
+ }
+}
diff --git a/tests/Avalonia.Benchmarks/Controls/SizeRecordBenchmark.cs b/tests/Avalonia.Benchmarks/Controls/SizeRecordBenchmark.cs
new file mode 100644
index 00000000000..b8ceb2f84b0
--- /dev/null
+++ b/tests/Avalonia.Benchmarks/Controls/SizeRecordBenchmark.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using Avalonia.UnitTests;
+using BenchmarkDotNet.Attributes;
+
+#nullable enable
+
+namespace Avalonia.Benchmarks.Controls
+{
+ ///
+ /// Does a large per-item size record slow a measure pass down?
+ ///
+ ///
+ ///
+ /// The fork's VirtualizingStackPanel keeps one recorded size per item ever measured and
+ /// claims a measure pass stays O(realized window) regardless, because the record is never swept
+ /// — its sum is maintained incrementally at the single upsert site. This benchmark is that
+ /// claim: the same 20 wheel steps, run once on a panel that has only ever seen the head of the
+ /// collection and once on a panel that has already walked the whole thing.
+ ///
+ ///
+ /// A per-pass sweep would show up as Traversed=true costing multiples of
+ /// Traversed=false , growing with . Flat rows mean the record is
+ /// memory only. Stock has no record, so its two rows are flat by construction and give the
+ /// reference shape.
+ ///
+ ///
+ [MemoryDiagnoser]
+ public class SizeRecordBenchmark : IDisposable
+ {
+ private const int WheelSteps = 20;
+
+ private IDisposable? _app;
+ private List? _items;
+ private VirtualizationHarness? _harness;
+
+ [Params(10_000, 100_000)]
+ public int ItemCount { get; set; }
+
+ /// Whether the whole collection was scrolled through before measuring.
+ [Params(false, true)]
+ public bool Traversed { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _app = UnitTestApplication.Start(TestServices.RealFocus);
+ _items = VirtualizationHarness.CreateItems(ItemCount, ItemSizeKind.Variable);
+ _harness = new VirtualizationHarness(_items);
+
+ if (Traversed)
+ VirtualizationScenarios.TraverseEntireCollection(_harness);
+
+ // Measure at the head of the collection either way, so the only difference between the
+ // two rows is how much the panel remembers about the rest of it.
+ _harness.ScrollTo(0);
+ }
+
+ [Benchmark]
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public void SteadyStateWheelScroll()
+ {
+ for (var i = 1; i <= WheelSteps; ++i)
+ _harness!.ScrollTo(i * VirtualizationScenarios.WheelStep);
+
+ for (var i = WheelSteps - 1; i >= 0; --i)
+ _harness!.ScrollTo(i * VirtualizationScenarios.WheelStep);
+ }
+
+ public void Dispose()
+ {
+ _app?.Dispose();
+ _app = null;
+ }
+ }
+}
diff --git a/tests/Avalonia.Benchmarks/Controls/VirtualizationHarness.cs b/tests/Avalonia.Benchmarks/Controls/VirtualizationHarness.cs
new file mode 100644
index 00000000000..4b36c00eb4a
--- /dev/null
+++ b/tests/Avalonia.Benchmarks/Controls/VirtualizationHarness.cs
@@ -0,0 +1,314 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Controls;
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Layout;
+using Avalonia.UnitTests;
+
+#nullable enable
+
+namespace Avalonia.Benchmarks.Controls
+{
+ // Public only because it is used as a BenchmarkDotNet [Params] type on a public field.
+ public enum ItemSizeKind
+ {
+ /// Every item the same height — the case stock's realized-window average is exact for.
+ Uniform,
+
+ /// Deterministic 20..120px spread — the case the per-item size record exists for.
+ Variable,
+ }
+
+ ///
+ /// Anything the harness can lay out. is what the item's template resolves
+ /// to along the scrolling axis, so scroll offsets can be computed from the data rather than from
+ /// whatever the panel currently estimates the extent to be.
+ ///
+ internal interface IBenchItem
+ {
+ double Height { get; }
+ }
+
+ internal sealed class SizedItem : IBenchItem
+ {
+ public SizedItem(int index, double height)
+ {
+ Caption = "Item " + index;
+ Height = height;
+ }
+
+ public string Caption { get; }
+
+ public double Height { get; }
+ }
+
+ internal sealed class VirtualizationCounters
+ {
+ public int Prepares;
+ public int Clears;
+ public int ContainerMeasures;
+ public int LayoutPasses;
+
+ ///
+ /// Times a template actually built a child visual tree. This is the number container-level
+ /// virtualization exists to drive down: keeping container and child together as one
+ /// reusable unit means a recycled container does not rebuild its subtree.
+ ///
+ public int ChildBuilds;
+
+ /// Visuals constructed while building those subtrees.
+ public int VisualsCreated;
+
+ public void Reset() =>
+ Prepares = Clears = ContainerMeasures = LayoutPasses = ChildBuilds = VisualsCreated = 0;
+ }
+
+ ///
+ /// Counts the container lifecycle calls the panel drives. PrepareContainerForItemOverride
+ /// is the expensive one — it is what RetainMatchingContainers claims to avoid on a
+ /// disjunct viewport jump, and the claim has never been measured.
+ ///
+ internal sealed class CountingItemsControl : ItemsControl
+ {
+ public VirtualizationCounters Counters { get; set; } = new();
+
+ // `protected`, not `protected internal`: from outside Avalonia.Controls the internal half
+ // of the base member's accessibility is not visible, so it cannot be repeated here.
+ protected override void PrepareContainerForItemOverride(Control container, object? item, int index)
+ {
+ Counters.Prepares++;
+ base.PrepareContainerForItemOverride(container, item, index);
+ }
+
+ protected override void ClearContainerForItemOverride(Control container)
+ {
+ Counters.Clears++;
+ base.ClearContainerForItemOverride(container);
+ }
+ }
+
+ ///
+ /// A rooted, scrollable, virtualized driven the same way
+ /// VirtualizingStackPanelTests drives one: set , then run
+ /// a layout pass.
+ ///
+ ///
+ /// Deliberately built from stock API only — no EnableVirtualization , no
+ /// IVirtualizingDataTemplate , no warmup — so this file compiles and runs unchanged in a
+ /// worktree at git merge-base master HEAD . That is the only way to get honest before/after
+ /// numbers: run the same benchmark on both, not a toggle inside one build.
+ ///
+ internal sealed class VirtualizationHarness
+ {
+ public VirtualizationHarness(
+ IReadOnlyList items,
+ IDataTemplate? itemTemplate = null,
+ // Templates are built before the harness exists, so one that counts its own child builds
+ // has to be handed the same counter set the harness will report from.
+ VirtualizationCounters? counters = null,
+ double viewportWidth = 400,
+ double viewportHeight = 600,
+ double cacheLength = 0)
+ {
+ Items = items;
+ Counters = counters ??= new VirtualizationCounters();
+
+ Panel = new VirtualizingStackPanel
+ {
+ Orientation = Orientation.Vertical,
+ CacheLength = cacheLength,
+ };
+
+ var presenter = new ItemsPresenter
+ {
+ [~ItemsPresenter.ItemsPanelProperty] = new TemplateBinding(ItemsPresenter.ItemsPanelProperty),
+ };
+
+ Scroll = new ScrollViewer
+ {
+ Name = "PART_ScrollViewer",
+ Content = presenter,
+ Template = ScrollViewerTemplate(),
+ };
+
+ ItemsControl = new CountingItemsControl
+ {
+ Counters = counters,
+ ItemsSource = items,
+ Template = new FuncControlTemplate((_, ns) => Scroll.RegisterInNameScope(ns)),
+ ItemsPanel = new FuncTemplate(() => Panel),
+ // Sizes must come from bindings, not from the item passed to the build function: a
+ // recycled container keeps its child, so the build function runs once per container
+ // while the item behind it changes many times.
+ ItemTemplate = itemTemplate ?? new FuncDataTemplate((_, _) =>
+ {
+ counters.ChildBuilds++;
+ counters.VisualsCreated++;
+ return new MeasureCountingCanvas(counters)
+ {
+ Width = 100,
+ [!Layoutable.HeightProperty] = new Binding(nameof(SizedItem.Height)),
+ };
+ }),
+ };
+
+ Root = new TestRoot(false, ItemsControl)
+ {
+ ClientSize = new Size(viewportWidth, viewportHeight),
+ Renderer = new NullRenderer(),
+ };
+
+ Counters.LayoutPasses++;
+ Root.LayoutManager.ExecuteInitialLayoutPass();
+ }
+
+ public IReadOnlyList Items { get; }
+
+ public VirtualizationCounters Counters { get; }
+
+ public VirtualizingStackPanel Panel { get; }
+
+ public ScrollViewer Scroll { get; }
+
+ public CountingItemsControl ItemsControl { get; }
+
+ public TestRoot Root { get; }
+
+ public double ViewportHeight => Root.ClientSize.Height;
+
+ public void ScrollTo(double offsetY)
+ {
+ Scroll.Offset = new Vector(0, offsetY);
+ Layout();
+ }
+
+ public void Layout()
+ {
+ Counters.LayoutPasses++;
+ Root.LayoutManager.ExecuteLayoutPass();
+ }
+
+ public static List CreateItems(int count, ItemSizeKind kind)
+ {
+ var items = new List(count);
+ // Fixed seed: the same collection on every run and on both branches, so a count
+ // difference is the panel's doing and not the data's.
+ var random = new Random(20259);
+
+ for (var i = 0; i < count; ++i)
+ {
+ var height = kind == ItemSizeKind.Uniform ? 40d : 20d + random.Next(0, 101);
+ items.Add(new SizedItem(i, height));
+ }
+
+ return items;
+ }
+
+ /// The true summed height of , independent of any estimate.
+ public static double TotalHeight(IReadOnlyList items)
+ {
+ var total = 0d;
+
+ for (var i = 0; i < items.Count; ++i)
+ total += items[i].Height;
+
+ return total;
+ }
+
+ private static IControlTemplate ScrollViewerTemplate()
+ {
+ return new FuncControlTemplate((_, ns) =>
+ new ScrollContentPresenter
+ {
+ Name = "PART_ScrollContentPresenter",
+ }.RegisterInNameScope(ns));
+ }
+
+ private sealed class MeasureCountingCanvas : Canvas
+ {
+ private readonly VirtualizationCounters _counters;
+
+ public MeasureCountingCanvas(VirtualizationCounters counters) => _counters = counters;
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ _counters.ContainerMeasures++;
+ return base.MeasureOverride(availableSize);
+ }
+ }
+ }
+
+ ///
+ /// The scroll patterns the benchmarks and the count report share, so a timing figure and a
+ /// container-count figure always describe the same work.
+ ///
+ internal static class VirtualizationScenarios
+ {
+ /// Roughly a mouse-wheel notch.
+ public const double WheelStep = 120;
+
+ public const int WheelSteps = 40;
+
+ public const int JumpCount = 20;
+
+ /// Half a viewport — a PageUp/PageDown, and the overlap case retention targets.
+ public const double PageStep = 300;
+
+ public const int PageSteps = 10;
+
+ ///
+ /// Wheel-scroll down and back up again. The way back is the interesting half: a backwards
+ /// scroll can put the anchor before FirstIndex , which marks the viewport
+ /// disjunct and recycles the whole realized set even though the new window overlaps
+ /// the old one almost completely. That is the case RetainMatchingContainers targets.
+ ///
+ public static void ScrollDownAndBack(VirtualizationHarness harness)
+ {
+ for (var i = 1; i <= WheelSteps; ++i)
+ harness.ScrollTo(i * WheelStep);
+
+ for (var i = WheelSteps - 1; i >= 0; --i)
+ harness.ScrollTo(i * WheelStep);
+ }
+
+ /// Scrollbar-drag style jumps across the whole collection: every one is disjunct.
+ public static void JumpToOffsets(VirtualizationHarness harness, IReadOnlyList offsets)
+ {
+ for (var i = 0; i < offsets.Count; ++i)
+ harness.ScrollTo(offsets[i]);
+ }
+
+ ///
+ /// Jump targets spread over the collection's true height, so the same offsets are
+ /// used whatever the panel currently estimates the extent to be.
+ ///
+ public static double[] CreateJumpOffsets(IReadOnlyList items, double viewportHeight)
+ {
+ var scrollable = Math.Max(0, VirtualizationHarness.TotalHeight(items) - viewportHeight);
+ var offsets = new double[JumpCount];
+ var random = new Random(20993);
+
+ for (var i = 0; i < offsets.Length; ++i)
+ offsets[i] = Math.Round(random.NextDouble() * scrollable);
+
+ return offsets;
+ }
+
+ ///
+ /// Walk the whole collection a viewport at a time, so every item is measured once and the
+ /// per-item size record ends up holding one entry per item.
+ ///
+ public static void TraverseEntireCollection(VirtualizationHarness harness)
+ {
+ var end = Math.Max(0, VirtualizationHarness.TotalHeight(harness.Items) - harness.ViewportHeight);
+ var step = harness.ViewportHeight;
+
+ for (var offset = step; offset < end; offset += step)
+ harness.ScrollTo(offset);
+
+ harness.ScrollTo(end);
+ }
+ }
+}
diff --git a/tests/Avalonia.Benchmarks/Controls/VirtualizationReport.cs b/tests/Avalonia.Benchmarks/Controls/VirtualizationReport.cs
new file mode 100644
index 00000000000..58b73a77a71
--- /dev/null
+++ b/tests/Avalonia.Benchmarks/Controls/VirtualizationReport.cs
@@ -0,0 +1,285 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using Avalonia.UnitTests;
+
+#nullable enable
+
+namespace Avalonia.Benchmarks.Controls
+{
+ ///
+ /// Prints the deterministic half of the virtualization numbers: how many containers the panel
+ /// prepares, clears and measures for a given scroll pattern, and how much memory it retains
+ /// after walking a large collection.
+ ///
+ ///
+ ///
+ /// These are counts, not timings — they do not vary between runs, so BenchmarkDotNet's
+ /// statistical machinery buys nothing and its warmup would pollute the counters. Timings are in
+ /// and ; the
+ /// milliseconds printed here are only there to keep a count next to its rough cost.
+ ///
+ ///
+ /// Run as dotnet Avalonia.Benchmarks.dll --virtualization-report . To get before/after
+ /// figures, run it again in a worktree at git merge-base master HEAD with this directory
+ /// copied in — everything here is stock API. Do not use git stash for the baseline: it
+ /// leaves the branch's commits in place.
+ ///
+ ///
+ internal static class VirtualizationReport
+ {
+ private static readonly int[] ItemCounts = { 1_000, 100_000 };
+
+ public static void Run()
+ {
+ using var app = UnitTestApplication.Start(TestServices.RealFocus);
+
+ Warmup();
+
+ Console.WriteLine("## Container churn");
+ Console.WriteLine();
+ Console.WriteLine("| Items | Sizes | Scenario | Layout passes | Prepares | Clears | Container measures | ms |");
+ Console.WriteLine("|---|---|---|---:|---:|---:|---:|---:|");
+
+ foreach (var itemCount in ItemCounts)
+ {
+ foreach (var sizes in new[] { ItemSizeKind.Uniform, ItemSizeKind.Variable })
+ ReportChurn(itemCount, sizes);
+ }
+
+ Console.WriteLine();
+ Console.WriteLine("## Container-level virtualization, complex heterogeneous rows");
+ Console.WriteLine();
+ Console.WriteLine("`Plain` = template does not opt in, i.e. stock recycling: one shared pool, content");
+ Console.WriteLine("cleared, subtree rebuilt. `Virtualized` = IVirtualizingDataTemplate keyed by row kind.");
+ Console.WriteLine("`Child builds` counts subtree constructions; `Visuals` counts the controls in them.");
+ Console.WriteLine();
+ Console.WriteLine("| Items | Mode | Scenario | Prepares | Child builds | Visuals | ms |");
+ Console.WriteLine("|---|---|---|---:|---:|---:|---:|");
+
+ // Only the Plain arm exists at the merge-base, where the opt-in template does not.
+ foreach (var mode in ComplexItems.AvailableModes)
+ ReportComplex(5_000, mode);
+
+ Console.WriteLine();
+ Console.WriteLine("## Memory retained by a live panel");
+ Console.WriteLine();
+ Console.WriteLine("Managed bytes held after the item collection itself is excluded — the panel, its");
+ Console.WriteLine("containers, its recycle pool and (on this branch) its per-item size record.");
+ Console.WriteLine();
+ Console.WriteLine("| Items | At the head | After a full traversal | Delta | Delta/item |");
+ Console.WriteLine("|---|---:|---:|---:|---:|");
+
+ foreach (var itemCount in ItemCounts)
+ ReportMemory(itemCount);
+ }
+
+ ///
+ /// Drive the whole harness once before reporting anything. The counts do not need it, but
+ /// the milliseconds column does: without it the first row absorbs JIT, static
+ /// initialization and tiered-compilation cost and reads an order of magnitude slower than
+ /// the identical row below it.
+ ///
+ private static void Warmup()
+ {
+ var items = VirtualizationHarness.CreateItems(200, ItemSizeKind.Variable);
+ var harness = new VirtualizationHarness(items);
+
+ VirtualizationScenarios.ScrollDownAndBack(harness);
+ VirtualizationScenarios.JumpToOffsets(
+ harness,
+ VirtualizationScenarios.CreateJumpOffsets(items, harness.ViewportHeight));
+ VirtualizationScenarios.TraverseEntireCollection(harness);
+ }
+
+ private static void ReportChurn(int itemCount, ItemSizeKind sizes)
+ {
+ var items = VirtualizationHarness.CreateItems(itemCount, sizes);
+
+ var clock = Stopwatch.StartNew();
+ var harness = new VirtualizationHarness(items);
+ clock.Stop();
+ WriteRow(itemCount, sizes, "First layout", harness.Counters, clock);
+
+ var jumpOffsets = VirtualizationScenarios.CreateJumpOffsets(items, harness.ViewportHeight);
+
+ harness.Counters.Reset();
+ clock.Restart();
+ for (var i = 1; i <= VirtualizationScenarios.WheelSteps; ++i)
+ harness.ScrollTo(i * VirtualizationScenarios.WheelStep);
+ clock.Stop();
+ WriteRow(itemCount, sizes,
+ $"Wheel down ({VirtualizationScenarios.WheelSteps}x{VirtualizationScenarios.WheelStep:0}px)",
+ harness.Counters, clock);
+
+ // The upward half is where RetainMatchingContainers can act: scrolling back puts the
+ // anchor before FirstIndex, which is what marks a viewport disjunct. Note the measured
+ // result is nothing like "stock re-prepares the whole window on every backwards step" —
+ // it does not; the disjunct branch fires on only a few of these steps. Read the numbers.
+ harness.Counters.Reset();
+ clock.Restart();
+ for (var i = VirtualizationScenarios.WheelSteps - 1; i >= 0; --i)
+ harness.ScrollTo(i * VirtualizationScenarios.WheelStep);
+ clock.Stop();
+ WriteRow(itemCount, sizes,
+ $"Wheel up ({VirtualizationScenarios.WheelSteps}x{VirtualizationScenarios.WheelStep:0}px)",
+ harness.Counters, clock);
+
+ // Half a viewport at a time. Between the wheel (which mostly stays inside the realized
+ // window) and the jumps (which share no items with it), this is the shape retention is
+ // for: a new window that overlaps the old one by about half.
+ harness.ScrollTo(0);
+ harness.Counters.Reset();
+ clock.Restart();
+ for (var i = 1; i <= VirtualizationScenarios.PageSteps; ++i)
+ harness.ScrollTo(i * VirtualizationScenarios.PageStep);
+ clock.Stop();
+ WriteRow(itemCount, sizes,
+ $"Page down ({VirtualizationScenarios.PageSteps}x{VirtualizationScenarios.PageStep:0}px)",
+ harness.Counters, clock);
+
+ harness.Counters.Reset();
+ clock.Restart();
+ for (var i = VirtualizationScenarios.PageSteps - 1; i >= 0; --i)
+ harness.ScrollTo(i * VirtualizationScenarios.PageStep);
+ clock.Stop();
+ WriteRow(itemCount, sizes,
+ $"Page up ({VirtualizationScenarios.PageSteps}x{VirtualizationScenarios.PageStep:0}px)",
+ harness.Counters, clock);
+
+ harness.Counters.Reset();
+ clock.Restart();
+ VirtualizationScenarios.JumpToOffsets(harness, jumpOffsets);
+ clock.Stop();
+ WriteRow(itemCount, sizes, $"Jumps ({jumpOffsets.Length})", harness.Counters, clock);
+ }
+
+ private static void WriteRow(
+ int itemCount,
+ ItemSizeKind sizes,
+ string scenario,
+ VirtualizationCounters counters,
+ Stopwatch clock)
+ {
+ Console.WriteLine(string.Format(
+ CultureInfo.InvariantCulture,
+ "| {0:N0} | {1} | {2} | {3:N0} | {4:N0} | {5:N0} | {6:N0} | {7:F1} |",
+ itemCount,
+ sizes,
+ scenario,
+ counters.LayoutPasses,
+ counters.Prepares,
+ counters.Clears,
+ counters.ContainerMeasures,
+ clock.Elapsed.TotalMilliseconds));
+ }
+
+ private static void ReportComplex(int itemCount, TemplateMode mode)
+ {
+ var items = ComplexItems.CreateItems(itemCount);
+
+ var clock = Stopwatch.StartNew();
+ var harness = ComplexItems.CreateHarness(items, mode);
+ clock.Stop();
+ WriteComplexRow(itemCount, mode, "First layout", harness.Counters, clock);
+
+ var jumpOffsets = VirtualizationScenarios.CreateJumpOffsets(items, harness.ViewportHeight);
+
+ harness.Counters.Reset();
+ clock.Restart();
+ VirtualizationScenarios.ScrollDownAndBack(harness);
+ clock.Stop();
+ WriteComplexRow(itemCount, mode, "Wheel down + up (80 steps)", harness.Counters, clock);
+
+ // Half-viewport paging is where RetainMatchingContainers was seen to act at all, so the
+ // complex arm has to include it: this is where an avoided prepare avoids a whole
+ // subtree rebuild rather than one Canvas.
+ harness.ScrollTo(0);
+ harness.Counters.Reset();
+ clock.Restart();
+ for (var i = 1; i <= VirtualizationScenarios.PageSteps; ++i)
+ harness.ScrollTo(i * VirtualizationScenarios.PageStep);
+ for (var i = VirtualizationScenarios.PageSteps - 1; i >= 0; --i)
+ harness.ScrollTo(i * VirtualizationScenarios.PageStep);
+ clock.Stop();
+ WriteComplexRow(itemCount, mode, "Page down + up (20 steps)", harness.Counters, clock);
+
+ harness.Counters.Reset();
+ clock.Restart();
+ VirtualizationScenarios.JumpToOffsets(harness, jumpOffsets);
+ clock.Stop();
+ WriteComplexRow(itemCount, mode, $"Jumps ({jumpOffsets.Length})", harness.Counters, clock);
+ }
+
+ private static void WriteComplexRow(
+ int itemCount,
+ TemplateMode mode,
+ string scenario,
+ VirtualizationCounters counters,
+ Stopwatch clock)
+ {
+ Console.WriteLine(string.Format(
+ CultureInfo.InvariantCulture,
+ "| {0:N0} | {1} | {2} | {3:N0} | {4:N0} | {5:N0} | {6:F1} |",
+ itemCount,
+ mode,
+ scenario,
+ counters.Prepares,
+ counters.ChildBuilds,
+ counters.VisualsCreated,
+ clock.Elapsed.TotalMilliseconds));
+ }
+
+ private static void ReportMemory(int itemCount)
+ {
+ // Built once and kept alive across both measurements, so the items themselves cancel
+ // out of the delta and what is left is what the panel keeps.
+ var items = VirtualizationHarness.CreateItems(itemCount, ItemSizeKind.Variable);
+
+ var atHead = RetainedBytes(() => new VirtualizationHarness(items));
+ var traversed = RetainedBytes(() =>
+ {
+ var harness = new VirtualizationHarness(items);
+ VirtualizationScenarios.TraverseEntireCollection(harness);
+ return harness;
+ });
+
+ GC.KeepAlive(items);
+
+ var delta = traversed - atHead;
+
+ Console.WriteLine(string.Format(
+ CultureInfo.InvariantCulture,
+ "| {0:N0} | {1:N0} | {2:N0} | {3:N0} | {4:F1} |",
+ itemCount,
+ atHead,
+ traversed,
+ delta,
+ (double)delta / itemCount));
+ }
+
+ private static long RetainedBytes(Func build)
+ {
+ Settle();
+ var before = GC.GetTotalMemory(true);
+
+ var kept = build();
+
+ Settle();
+ var after = GC.GetTotalMemory(true);
+
+ GC.KeepAlive(kept);
+ return after - before;
+
+ static void Settle()
+ {
+ for (var i = 0; i < 3; ++i)
+ {
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Avalonia.Benchmarks/Controls/VirtualizedScrollBenchmark.cs b/tests/Avalonia.Benchmarks/Controls/VirtualizedScrollBenchmark.cs
new file mode 100644
index 00000000000..6b739859559
--- /dev/null
+++ b/tests/Avalonia.Benchmarks/Controls/VirtualizedScrollBenchmark.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using Avalonia.UnitTests;
+using BenchmarkDotNet.Attributes;
+
+#nullable enable
+
+namespace Avalonia.Benchmarks.Controls
+{
+ ///
+ /// Scroll cost for a virtualized ItemsControl : first layout, wheel scrolling down and
+ /// back, and scrollbar-style jumps. Uses stock API only, so the same file can be run in a
+ /// worktree at the merge-base to produce the "vs. stock" half of the comparison.
+ ///
+ [MemoryDiagnoser]
+ public class VirtualizedScrollBenchmark : IDisposable
+ {
+ private IDisposable? _app;
+ private List? _items;
+ private VirtualizationHarness? _harness;
+ private double[]? _jumpOffsets;
+
+ [Params(1_000, 100_000)]
+ public int ItemCount { get; set; }
+
+ [Params(ItemSizeKind.Uniform, ItemSizeKind.Variable)]
+ public ItemSizeKind Sizes { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _app = UnitTestApplication.Start(TestServices.RealFocus);
+ _items = VirtualizationHarness.CreateItems(ItemCount, Sizes);
+ _harness = new VirtualizationHarness(_items);
+ _jumpOffsets = VirtualizationScenarios.CreateJumpOffsets(_items, _harness.ViewportHeight);
+ }
+
+ /// Building the control and running the first layout pass — the startup cost a page pays.
+ [Benchmark]
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public object FirstLayout() => new VirtualizationHarness(_items!);
+
+ ///
+ /// 40 wheel steps down and 40 back. The upward half is where a stock panel treats every
+ /// step as a disjunct viewport and recycles the entire realized set.
+ ///
+ [Benchmark]
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public void ScrollDownAndBack() => VirtualizationScenarios.ScrollDownAndBack(_harness!);
+
+ /// 20 jumps spread across the whole collection.
+ [Benchmark]
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public void JumpToOffsets() => VirtualizationScenarios.JumpToOffsets(_harness!, _jumpOffsets!);
+
+ public void Dispose()
+ {
+ _app?.Dispose();
+ _app = null;
+ }
+ }
+}
diff --git a/tests/Avalonia.Benchmarks/Program.cs b/tests/Avalonia.Benchmarks/Program.cs
index c81225ea6ce..78a93965d28 100644
--- a/tests/Avalonia.Benchmarks/Program.cs
+++ b/tests/Avalonia.Benchmarks/Program.cs
@@ -29,6 +29,14 @@ static void Main(string[] args)
return;
}
+ // Container prepare/clear/measure counts and retained memory for the virtualizing
+ // panel. Deterministic, so it bypasses BDN — see VirtualizationReport.
+ if (args.Contains("--virtualization-report"))
+ {
+ Controls.VirtualizationReport.Run();
+ return;
+ }
+
// Use reflection for a more maintainable way of creating the benchmark switcher,
// Benchmarks are listed in namespace order first (e.g. BenchmarkDotNet.Samples.CPU,
// BenchmarkDotNet.Samples.IL, etc) then by name, so the output is easy to understand
diff --git a/tests/Avalonia.Controls.UnitTests/ContainerVirtualizationTests.cs b/tests/Avalonia.Controls.UnitTests/ContainerVirtualizationTests.cs
new file mode 100644
index 00000000000..74cad948c76
--- /dev/null
+++ b/tests/Avalonia.Controls.UnitTests/ContainerVirtualizationTests.cs
@@ -0,0 +1,963 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Layout;
+using Avalonia.Styling;
+using Avalonia.UnitTests;
+using Avalonia.VisualTree;
+using Xunit;
+
+using XamlDataTemplate = Avalonia.Markup.Xaml.Templates.DataTemplate;
+
+#nullable enable
+
+namespace Avalonia.Controls.UnitTests
+{
+ ///
+ /// Tests for opt-in container-level virtualization: recycle-key selection in
+ /// ItemsControl.NeedsContainer<T> , the MaxPoolSizePerKey gating in
+ /// VirtualizingStackPanel.PushToRecyclePool , the skip-clear in
+ /// ClearContainerForItemOverride , and which templates the ItemsControl resolves
+ /// onto a container at all.
+ ///
+ public class ContainerVirtualizationTests : ScopedTestBase
+ {
+ // ===== (a) A plain XAML DataTemplate must not cap the container pool =====
+
+ [Fact]
+ public void Plain_DataTemplate_Does_Not_Cap_Recycle_Pool()
+ {
+ using var app = App();
+
+ // EnableVirtualization left at its default false: this is the stock, most common case.
+ var template = CanvasTemplate(enableVirtualization: false);
+ var items = CreateItems(200);
+
+ var (panel, _, _, _) = CreateTarget(items, template);
+
+ var recycled = RecycleByShrinkingItems(items, panel, keep: 10);
+
+ Assert.True(recycled > template.MaxPoolSizePerKey,
+ $"Test is not exercising the cap: only {recycled} containers were recycled, " +
+ $"which is not more than MaxPoolSizePerKey ({template.MaxPoolSizePerKey}).");
+
+ // Every recycled container must be in the pool - the pool is uncapped for
+ // DefaultRecycleKey, exactly as in stock Avalonia.
+ Assert.Equal(recycled, PooledCount(panel));
+
+ // And they all share the single stock pool.
+ Assert.Same(TestItemsControl.ExposedDefaultRecycleKey, panel.RecyclePoolForTesting!.Keys.Single());
+ }
+
+ // ===== (b) MaxPoolSizePerKey is still honoured once the template opts in =====
+
+ [Fact]
+ public void MaxPoolSizePerKey_Is_Respected_For_DataTemplate_With_EnableVirtualization()
+ {
+ using var app = App();
+
+ var template = CanvasTemplate(enableVirtualization: true, dataType: typeof(TypeA_Item));
+ template.MaxPoolSizePerKey = 2;
+ var items = CreateItems(200);
+
+ var (panel, _, _, _) = CreateTarget(items, template);
+
+ var recycled = RecycleByShrinkingItems(items, panel, keep: 10);
+ Assert.True(recycled > 2, $"Test is not exercising the cap: only {recycled} recycled.");
+
+ // The key came from the template, so the cap applies.
+ Assert.Equal(typeof(TypeA_Item), panel.RecyclePoolForTesting!.Keys.Single());
+ Assert.Equal(2, PooledCount(panel));
+ }
+
+ ///
+ /// MinPoolSizePerKey is the warmup-depth knob, and the markup it is meant to be
+ /// configured from is a XAML DataTemplate - so it has to be settable there and the
+ /// value has to reach DiscoverTemplateKeys , which is the only thing that reads it.
+ ///
+ [Fact]
+ public void MinPoolSizePerKey_Set_On_A_DataTemplate_Reaches_Warmup()
+ {
+ using var app = App();
+
+ var template = CanvasTemplate(enableVirtualization: true, dataType: typeof(TypeA_Item));
+ template.MinPoolSizePerKey = 7;
+ var items = CreateItems(200);
+
+ var (panel, _, _, _) = CreateTarget(items, template);
+
+ var keys = panel.DiscoverTemplateKeys();
+
+ Assert.True(keys.TryGetValue(typeof(TypeA_Item), out var depth),
+ "The template's key was never encountered, so warmup depth was not resolved at all.");
+
+ // 3 is DefaultWarmupPoolSizePerKey - i.e. what this asserts is that the template was
+ // asked, not that some default happened to match.
+ Assert.Equal(7, depth);
+ }
+
+ // ===== (c) IsEnabled = false is a kill switch back to stock behaviour =====
+
+ [Fact]
+ public void IsEnabled_False_Forces_Default_Recycle_Key_And_Clears_Content()
+ {
+ using var app = App();
+ var original = ContainerVirtualization.IsEnabled;
+
+ try
+ {
+ ContainerVirtualization.IsEnabled = false;
+
+ // A template that opts in and keys per item type - with the kill switch off it must
+ // be ignored entirely.
+ var template = new FuncVirtualizingDataTemplate((_, _) =>
+ new Canvas { Width = 100, Height = 10 });
+
+ var items = new ObservableCollection(
+ Enumerable.Range(0, 200).Select(i => i % 2 == 0
+ ? new TypeA_Item { Name = $"A{i}" }
+ : new TypeB_Item { Name = $"B{i}" }));
+
+ var (panel, _, itemsControl, _) = CreateTarget(items, template);
+
+ // Both item types resolve to the single stock key...
+ var generator = itemsControl.ItemContainerGenerator;
+ Assert.True(generator.NeedsContainer(items[0], 0, out var keyA));
+ Assert.True(generator.NeedsContainer(items[1], 1, out var keyB));
+ Assert.Same(TestItemsControl.ExposedDefaultRecycleKey, keyA);
+ Assert.Same(TestItemsControl.ExposedDefaultRecycleKey, keyB);
+
+ var recycled = RecycleByShrinkingItems(items, panel, keep: 10);
+ Assert.True(recycled > 0, "No container was recycled.");
+
+ // ...so all containers land in one shared pool.
+ Assert.Same(TestItemsControl.ExposedDefaultRecycleKey, panel.RecyclePoolForTesting!.Keys.Single());
+ Assert.NotEmpty(PooledContainers(panel));
+
+ // ...and Content/ContentTemplate are cleared on recycle, as in stock.
+ Assert.All(PooledContainers(panel).Cast(), pooled =>
+ {
+ Assert.False(pooled.IsSet(ContentPresenter.ContentProperty));
+ Assert.False(pooled.IsSet(ContentPresenter.ContentTemplateProperty));
+ Assert.Null(pooled.Content);
+ });
+ }
+ finally
+ {
+ ContainerVirtualization.IsEnabled = original;
+ }
+ }
+
+ // ===== (d) A typed DataTemplate that did not opt in gets its content cleared =====
+
+ [Fact]
+ public void Typed_DataTemplate_Without_Opt_In_Clears_Content_On_Recycle()
+ {
+ using var app = App();
+
+ // with no EnableVirtualization: the
+ // DataType alone must not buy the skip-clear.
+ var template = CanvasTemplate(enableVirtualization: false, dataType: typeof(TypeA_Item));
+ var items = CreateItems(200);
+
+ var (panel, _, _, _) = CreateTarget(items, template);
+
+ var recycled = RecycleByShrinkingItems(items, panel, keep: 10);
+ Assert.True(recycled > 0, "No container was recycled.");
+ Assert.NotEmpty(PooledContainers(panel));
+
+ Assert.All(PooledContainers(panel).Cast(), pooled =>
+ {
+ Assert.False(pooled.IsSet(ContentPresenter.ContentProperty));
+ Assert.False(pooled.IsSet(ContentPresenter.ContentTemplateProperty));
+ Assert.Null(pooled.Content);
+ });
+ }
+
+ // ===== (e) An opted-in template keeps its Child attached across recycling =====
+
+ [Fact]
+ public void Opted_In_Template_Keeps_Child_Attached_Across_Recycling()
+ {
+ using var app = App();
+
+ var template = CanvasTemplate(enableVirtualization: true, dataType: typeof(TypeA_Item));
+ var items = CreateItems(200);
+
+ var (panel, scroll, itemsControl, _) = CreateTarget(items, template, new Size(100, 100));
+
+ var container = Assert.IsType(itemsControl.ContainerFromIndex(0));
+ var child = container.Child;
+ Assert.NotNull(child);
+
+ // Scroll far enough that the container is recycled and then handed back out for a
+ // different item.
+ scroll.Offset = new Vector(0, 1000);
+ Layout(panel);
+
+ var index = itemsControl.IndexFromContainer(container);
+ Assert.True(index > 0, "The container was not recycled and reused for a different item.");
+
+ // The actual feature: no visual-tree mutation, the same child instance is still there.
+ Assert.Same(child, container.Child);
+
+ // ...and again on the way back.
+ scroll.Offset = new Vector(0, 0);
+ Layout(panel);
+
+ Assert.True(itemsControl.IndexFromContainer(container) >= 0, "The container was not reused.");
+ Assert.Same(child, container.Child);
+ }
+
+ // ===== (e2) FuncDataTemplate can opt in too, which is what makes the feature usable
+ // from code rather than only from XAML =====
+
+ ///
+ /// The guard that matters most on this file: backs
+ /// FuncDataTemplate.Default , every ItemTemplate written in code, and much of
+ /// the framework's own templating. If implementing IVirtualizingDataTemplate on it
+ /// turned virtualization on by default, every one of those would silently start skipping
+ /// content clearing and paying the §9 lifecycle trade.
+ ///
+ [Fact]
+ public void FuncDataTemplate_Without_A_Key_Selector_Is_Not_Opted_In()
+ {
+ using var app = App();
+
+ var template = new FuncDataTemplate((_, _) => new Canvas { Width = 100, Height = 10 });
+ var items = CreateItems(200);
+
+ var (panel, _, itemsControl, _) = CreateTarget(items, template);
+
+ Assert.Null(template.RecycleKeySelector);
+ Assert.Null(((IVirtualizingDataTemplate)template).GetKey(items[0]));
+
+ Assert.True(itemsControl.ItemContainerGenerator.NeedsContainer(items[0], 0, out var key));
+ Assert.Same(TestItemsControl.ExposedDefaultRecycleKey, key);
+
+ var recycled = RecycleByShrinkingItems(items, panel, keep: 10);
+ Assert.True(recycled > 0, "No container was recycled.");
+
+ // One shared, uncapped pool and cleared content: stock behaviour, unchanged.
+ Assert.Same(TestItemsControl.ExposedDefaultRecycleKey, panel.RecyclePoolForTesting!.Keys.Single());
+ Assert.Equal(recycled, PooledCount(panel));
+ Assert.All(PooledContainers(panel).Cast(), pooled =>
+ {
+ Assert.False(pooled.IsSet(ContentPresenter.ContentProperty));
+ Assert.Null(pooled.Content);
+ });
+ }
+
+ [Fact]
+ public void FuncDataTemplate_With_A_Key_Selector_Keys_The_Pool_And_Keeps_The_Child()
+ {
+ using var app = App();
+
+ var template = new FuncDataTemplate((_, _) => new Canvas { Width = 100, Height = 10 })
+ {
+ RecycleKeySelector = d => d?.GetType(),
+ };
+
+ var items = CreateItems(200);
+ var (panel, scroll, itemsControl, _) = CreateTarget(items, template, new Size(100, 100));
+
+ Assert.True(itemsControl.ItemContainerGenerator.NeedsContainer(items[0], 0, out var key));
+ Assert.Equal(typeof(TypeA_Item), key);
+
+ var container = Assert.IsType(itemsControl.ContainerFromIndex(0));
+ var child = container.Child;
+ Assert.NotNull(child);
+
+ scroll.Offset = new Vector(0, 1000);
+ Layout(panel);
+
+ Assert.True(itemsControl.IndexFromContainer(container) > 0,
+ "The container was not recycled and reused for a different item.");
+
+ // The feature: the subtree survived recycling rather than being rebuilt.
+ Assert.Same(child, container.Child);
+ }
+
+ ///
+ /// The reason a key *selector* is the opt-in rather than a bool. A XAML
+ /// DataTemplate keys on DataType , so it cannot express "one CLR type, several
+ /// subtree shapes" — which is exactly what a template that branches on a property produces,
+ /// and what a heterogeneous list is made of.
+ ///
+ [Fact]
+ public void FuncDataTemplate_Key_Selector_Can_Key_On_Something_Other_Than_The_Type()
+ {
+ using var app = App();
+
+ var template = new FuncDataTemplate(
+ (item, _) => item?.Kind == "tall"
+ ? new Canvas { Width = 100, Height = 20 }
+ : new Canvas { Width = 100, Height = 10 })
+ {
+ RecycleKeySelector = d => ((KindedItem)d!).Kind,
+ };
+
+ var items = new ObservableCollection(
+ Enumerable.Range(0, 200).Select(i => new KindedItem(i % 2 == 0 ? "tall" : "short")));
+
+ var (panel, _, itemsControl, _) = CreateTarget(items, template);
+
+ // Same CLR type, two pools — which DataType keying could not have produced.
+ Assert.True(itemsControl.ItemContainerGenerator.NeedsContainer(items[0], 0, out var tall));
+ Assert.True(itemsControl.ItemContainerGenerator.NeedsContainer(items[1], 1, out var @short));
+ Assert.Equal("tall", tall);
+ Assert.Equal("short", @short);
+
+ RecycleByShrinkingItems(items, panel, keep: 10);
+
+ Assert.Equal(
+ new[] { "short", "tall" },
+ panel.RecyclePoolForTesting!.Keys.Select(k => (string)k).OrderBy(k => k).ToArray());
+ }
+
+ [Fact]
+ public void FuncDataTemplate_Key_Selector_Returning_Null_Falls_Back_To_Stock_For_That_Item()
+ {
+ using var app = App();
+
+ // Opted in for "tall" only: the other kind must behave exactly as an un-opted-in item.
+ var template = new FuncDataTemplate((_, _) => new Canvas { Width = 100, Height = 10 })
+ {
+ RecycleKeySelector = d => ((KindedItem)d!).Kind == "tall" ? "tall" : null,
+ };
+
+ var items = new ObservableCollection(
+ Enumerable.Range(0, 200).Select(i => new KindedItem(i % 2 == 0 ? "tall" : "short")));
+
+ var (_, _, itemsControl, _) = CreateTarget(items, template);
+
+ Assert.True(itemsControl.ItemContainerGenerator.NeedsContainer(items[0], 0, out var tall));
+ Assert.True(itemsControl.ItemContainerGenerator.NeedsContainer(items[1], 1, out var @short));
+ Assert.Equal("tall", tall);
+ Assert.Same(TestItemsControl.ExposedDefaultRecycleKey, @short);
+ }
+
+ [Fact]
+ public void FuncDataTemplate_MaxPoolSizePerKey_Is_Respected_Once_Opted_In()
+ {
+ using var app = App();
+
+ var template = new FuncDataTemplate((_, _) => new Canvas { Width = 100, Height = 10 })
+ {
+ RecycleKeySelector = d => d?.GetType(),
+ MaxPoolSizePerKey = 2,
+ };
+
+ var items = CreateItems(200);
+ var (panel, _, _, _) = CreateTarget(items, template);
+
+ var recycled = RecycleByShrinkingItems(items, panel, keep: 10);
+ Assert.True(recycled > 2, $"Test is not exercising the cap: only {recycled} recycled.");
+
+ Assert.Equal(typeof(TypeA_Item), panel.RecyclePoolForTesting!.Keys.Single());
+ Assert.Equal(2, PooledCount(panel));
+ }
+
+ // ===== (f) `item is T` must win over the virtualization branch =====
+
+ [Fact]
+ public void Item_That_Is_Its_Own_Container_Is_Not_Wrapped_When_Virtualization_Enabled()
+ {
+ using var app = App();
+
+ // GetKey returns a non-null key for every item, including Controls - so if the
+ // `item is T` check were not first, these items would be wrapped.
+ var template = new FuncVirtualizingDataTemplate((_, _) =>
+ new Canvas { Width = 100, Height = 10 });
+
+ var items = new ObservableCollection(
+ Enumerable.Range(0, 20).Select(_ => (object)new Canvas { Width = 100, Height = 10 }));
+
+ var (panel, _, itemsControl, _) = CreateTarget(items, template);
+
+ var generator = itemsControl.ItemContainerGenerator;
+ Assert.False(generator.NeedsContainer(items[0], 0, out var recycleKey));
+ Assert.Null(recycleKey);
+
+ // The item itself is the container - not a ContentPresenter wrapping it.
+ Assert.Same(items[0], itemsControl.ContainerFromIndex(0));
+ }
+
+ // ===== (g) A recycled container reused for a different item picks up the new item =====
+
+ [Fact]
+ public void Recycled_Container_Reused_For_Different_Item_Gets_New_Content()
+ {
+ using var app = App();
+
+ var template = CanvasTemplate(enableVirtualization: true, dataType: typeof(TypeA_Item));
+ var items = CreateItems(200);
+
+ var (panel, scroll, itemsControl, _) = CreateTarget(items, template, new Size(100, 100));
+
+ var container = Assert.IsType(itemsControl.ContainerFromIndex(0));
+ Assert.Same(items[0], container.Content);
+
+ // Recycle it and bring it back for a different item. Content is never cleared on the
+ // skip-clear path, so preparation has to overwrite the stale value.
+ scroll.Offset = new Vector(0, 1000);
+ Layout(panel);
+
+ var index = itemsControl.IndexFromContainer(container);
+ Assert.True(index > 0, "The container was not reused for a different item.");
+ Assert.Same(items[index], container.Content);
+ Assert.NotSame(items[0], container.Content);
+ }
+
+ // ===== (h) A DataTemplates-collection template is resolved by the presenter =====
+
+ [Fact]
+ public void DataTemplates_Collection_Template_Is_Never_Copied_Onto_The_Container()
+ {
+ using var app = App();
+
+ var items = CreateItems(200);
+ var canvasTemplate = new FuncDataTemplate((_, _) => new Canvas { Width = 100, Height = 10 });
+
+ // No ItemTemplate: the template can only be found by walking up to the DataTemplates
+ // collection, which is the ContentPresenter's job - the ItemsControl hands out
+ // ContentTemplate only from ItemTemplate / DisplayMemberBinding, as in stock Avalonia.
+ var (panel, scroll, itemsControl, _) = CreateTarget(
+ items,
+ itemTemplate: null,
+ clientSize: new Size(100, 100),
+ configure: ic => ic.DataTemplates.Add(canvasTemplate));
+
+ // Re-seating the same template and re-preparing containers that are already in the tree
+ // is what used to make the resolution succeed and stamp ContentTemplate on the
+ // container - the one state in which the ItemsControl and the presenter could disagree
+ // about which template an item uses.
+ itemsControl.DataTemplates.Clear();
+ itemsControl.DataTemplates.Add(canvasTemplate);
+ scroll.Offset = new Vector(0, 500);
+ Layout(panel);
+
+ var realized = panel.GetRealizedContainers()!.Cast().ToList();
+ Assert.NotEmpty(realized);
+ Assert.All(realized, c =>
+ {
+ Assert.False(c.IsSet(ContentPresenter.ContentTemplateProperty));
+ // The item still displays through the collection's template, resolved by the
+ // presenter itself.
+ Assert.IsType(c.Child);
+ });
+ }
+
+ [Fact]
+ public void Template_Swapped_In_DataTemplates_At_Runtime_Is_Picked_Up()
+ {
+ using var app = App();
+
+ var items = CreateItems(200);
+
+ var (panel, scroll, itemsControl, _) = CreateTarget(
+ items,
+ itemTemplate: null,
+ clientSize: new Size(100, 100),
+ configure: ic => ic.DataTemplates.Add(
+ new FuncDataTemplate((_, _) => new Canvas { Width = 100, Height = 10 })));
+
+ Assert.All(
+ panel.GetRealizedContainers()!.Cast(),
+ c => Assert.IsType(c.Child));
+
+ // Swap the template at runtime. Nothing memoizes the resolution per item type, so
+ // containers realized from here on must show the new template.
+ itemsControl.DataTemplates.Clear();
+ itemsControl.DataTemplates.Add(
+ new FuncDataTemplate((_, _) => new Border { Width = 100, Height = 10 }));
+
+ scroll.Offset = new Vector(0, 1000);
+ Layout(panel);
+
+ var prepared = panel.GetRealizedContainers()!.Cast().ToList();
+ Assert.NotEmpty(prepared);
+ Assert.All(prepared, c => Assert.IsType(c.Child));
+ }
+
+ [Fact]
+ public void ItemTemplate_Is_Still_Applied_To_The_Container()
+ {
+ using var app = App();
+
+ // The counterpart to the two tests above: the template the ItemsControl *does* resolve
+ // still lands on the container, so recycling has a ContentTemplate to key its
+ // skip-clear decision on.
+ var template = CanvasTemplate(enableVirtualization: true, dataType: typeof(TypeA_Item));
+ var items = CreateItems(200);
+
+ var (panel, _, _, _) = CreateTarget(items, template, new Size(100, 100));
+
+ var realized = panel.GetRealizedContainers()!.Cast().ToList();
+ Assert.NotEmpty(realized);
+ Assert.All(realized, c => Assert.Same(template, c.ContentTemplate));
+ }
+
+ [Fact]
+ public void DataTemplates_Collection_Does_Not_Cause_Repeated_Measures()
+ {
+ using var app = App();
+
+ // The deleted per-type template cache was justified as "critical for avoiding layout
+ // cycles when using DataTemplates collections". Resolving the template in the presenter
+ // on every realization must not turn into a measure feedback loop: a settled panel
+ // stays settled, and a scroll costs on the order of one measure per realized child.
+ var measures = new MeasureCounter();
+ var items = CreateItems(200);
+
+ var (panel, scroll, _, _) = CreateTarget(
+ items,
+ itemTemplate: null,
+ clientSize: new Size(100, 100),
+ configure: ic => ic.DataTemplates.Add(
+ new FuncDataTemplate((_, _) => new CountingCanvas(measures))));
+
+ var realizedCount = panel.GetRealizedContainers()!.Count();
+ Assert.True(realizedCount > 0, "Nothing was realized.");
+
+ // Settled: another pass at the same offset measures nothing.
+ var afterInitial = measures.Count;
+ Layout(panel);
+ Assert.Equal(afterInitial, measures.Count);
+
+ measures.Count = 0;
+ scroll.Offset = new Vector(0, 500);
+ Layout(panel);
+
+ Assert.True(measures.Count <= realizedCount * 3,
+ $"One scroll realizing ~{realizedCount} children cost {measures.Count} child measures; " +
+ $"the template resolution is feeding back into layout.");
+
+ // ...and it settles again.
+ var afterScroll = measures.Count;
+ Layout(panel);
+ Assert.Equal(afterScroll, measures.Count);
+ }
+
+ // ===== (i) Keying and pool capping agree for a DisplayMemberBinding template =====
+
+ [Fact]
+ public void DisplayMemberBinding_Template_Keys_And_Caps_Consistently()
+ {
+ using var app = App();
+
+ // Non-empty names: the synthesised TextBlock is what gives the container its width, and
+ // a zero-width viewport stops the panel realizing.
+ var items = new ObservableCollection(
+ Enumerable.Range(0, 200).Select(i => (object)new TypeA_Item { Name = $"Item {i}" }));
+
+ // DisplayMemberBinding synthesises the effective item template. It is a plain
+ // FuncDataTemplate, so it never opts in - and because keying and capping both resolve
+ // through GetEffectiveItemTemplate() they must agree on that: stock key, no cap.
+ var (panel, _, itemsControl, _) = CreateTarget(
+ items,
+ itemTemplate: null,
+ configure: ic => ic.DisplayMemberBinding = new Binding(nameof(TypeA_Item.Name)),
+ // The synthesised template is a TextBlock, which measures to nothing in a test
+ // environment with no text shaping - pin the container height so a predictable
+ // number of items is realized.
+ styles: new[]
+ {
+ new Style(x => x.OfType())
+ {
+ Setters = { new Setter(Layoutable.HeightProperty, 10.0) },
+ },
+ });
+
+ Assert.True(itemsControl.ItemContainerGenerator.NeedsContainer(items[0], 0, out var key));
+ Assert.Same(TestItemsControl.ExposedDefaultRecycleKey, key);
+
+ var recycled = RecycleByShrinkingItems(items, panel, keep: 10);
+ Assert.True(recycled > 5, $"Test is not exercising the cap: only {recycled} recycled.");
+
+ // Uncapped, because the key is not one an IVirtualizingDataTemplate handed out.
+ Assert.Same(TestItemsControl.ExposedDefaultRecycleKey, panel.RecyclePoolForTesting!.Keys.Single());
+ Assert.Equal(recycled, PooledCount(panel));
+ }
+
+ // ===== (j) Nested virtualization: the retained Child is itself a virtualizing list =====
+
+ [Fact]
+ public void Nested_Virtualized_List_Retargets_When_Outer_Container_Is_Recycled()
+ {
+ using var app = App();
+
+ var (panel, scroll, itemsControl, items) = CreateNestedTarget();
+
+ var container = Assert.IsType(itemsControl.ContainerFromIndex(0));
+ var inner = Assert.IsType(container.Child);
+
+ var first = (OuterItem)items[0];
+ Assert.Same(first.Children, inner.ItemsSource);
+
+ var innerItems = RealizedInnerItems(inner);
+ Assert.NotEmpty(innerItems);
+ Assert.True(innerItems.Count < first.Children.Count,
+ $"The inner list is not virtualizing: {innerItems.Count} of {first.Children.Count} realized.");
+ Assert.All(innerItems, x => Assert.Contains(x, first.Children));
+
+ // Recycle the outer container and have it handed back out for a different outer item.
+ scroll.Offset = new Vector(0, 1000);
+ Layout(panel);
+
+ var index = itemsControl.IndexFromContainer(container);
+ Assert.True(index > 0, "The outer container was not reused for a different item.");
+
+ // The whole point of the feature: the inner list survives the recycle as the same
+ // instance, and is simply re-pointed at the new outer item's children.
+ Assert.Same(inner, container.Child);
+
+ var reused = (OuterItem)items[index];
+ Assert.Same(reused.Children, inner.ItemsSource);
+
+ var reusedInnerItems = RealizedInnerItems(inner);
+ Assert.NotEmpty(reusedInnerItems);
+
+ // No inner container is left carrying the previous outer item's data...
+ Assert.All(reusedInnerItems, x => Assert.Contains(x, reused.Children));
+ Assert.Empty(reusedInnerItems.Intersect(first.Children));
+
+ // ...and none is realized twice.
+ Assert.Equal(reusedInnerItems.Count, reusedInnerItems.Distinct().Count());
+ var containers = inner.GetRealizedContainers().ToList();
+ Assert.Equal(containers.Count, containers.Distinct().Count());
+ }
+
+ [Fact]
+ public void Nested_Inner_Lists_Are_Never_Shared_Between_Outer_Containers()
+ {
+ using var app = App();
+
+ var (panel, scroll, itemsControl, items) = CreateNestedTarget();
+
+ scroll.Offset = new Vector(0, 1000);
+ Layout(panel);
+
+ var outerContainers = panel.GetRealizedContainers()!.Cast().ToList();
+ Assert.True(outerContainers.Count > 1, "Not enough outer containers realized to compare.");
+
+ var innerLists = new List();
+ var innerContainers = new List();
+
+ foreach (var outerContainer in outerContainers)
+ {
+ var index = itemsControl.IndexFromContainer(outerContainer);
+ Assert.True(index >= 0, "A realized outer container has no index.");
+
+ var inner = Assert.IsType(outerContainer.Child);
+ innerLists.Add(inner);
+ innerContainers.AddRange(inner.GetRealizedContainers());
+
+ // Each inner list shows only the children of the outer item its container
+ // currently holds - no cross-contamination from the item it held before.
+ var owner = (OuterItem)items[index];
+ Assert.Same(owner.Children, inner.ItemsSource);
+ Assert.All(RealizedInnerItems(inner), x => Assert.Contains(x, owner.Children));
+ }
+
+ // Two outer containers never end up sharing one retained child...
+ Assert.Equal(innerLists.Count, innerLists.Distinct().Count());
+
+ // ...and the per-key inner pools never hand the same inner container to two lists.
+ Assert.Equal(innerContainers.Count, innerContainers.Distinct().Count());
+ }
+
+ [Fact]
+ public void Nested_Virtualization_Survives_An_Outer_Scroll_Roundtrip()
+ {
+ using var app = App();
+
+ var (panel, scroll, itemsControl, items) = CreateNestedTarget();
+
+ var container = Assert.IsType(itemsControl.ContainerFromIndex(0));
+ var inner = Assert.IsType(container.Child);
+ var first = (OuterItem)items[0];
+
+ scroll.Offset = new Vector(0, 1000);
+ Layout(panel);
+ scroll.Offset = new Vector(0, 0);
+ Layout(panel);
+
+ var backAtTop = Assert.IsType(itemsControl.ContainerFromIndex(0));
+ var innerBackAtTop = Assert.IsType(backAtTop.Child);
+
+ Assert.Same(first, backAtTop.Content);
+ Assert.Same(first.Children, innerBackAtTop.ItemsSource);
+
+ var innerItems = RealizedInnerItems(innerBackAtTop);
+ Assert.NotEmpty(innerItems);
+ Assert.All(innerItems, x => Assert.Contains(x, first.Children));
+ Assert.Equal(innerItems.Count, innerItems.Distinct().Count());
+
+ // The container that was scrolled away and back is still driving its own inner list.
+ Assert.Same(inner, container.Child);
+ }
+
+ // ===== helpers =====
+
+ private static IDisposable App() => UnitTestApplication.Start(TestServices.RealFocus);
+
+ private static void Layout(Control target) => target.GetLayoutManager()?.ExecuteLayoutPass();
+
+ private static ObservableCollection CreateItems(int count) where T : new() =>
+ new(Enumerable.Range(0, count).Select(_ => (object)new T()));
+
+ ///
+ /// A real XAML . Its Content is deferred content, which
+ /// XAML compilation normally produces; a Func<IServiceProvider?, object?> is the
+ /// shape TemplateContent.Load accepts, so the template can be built in code without a
+ /// XAML compile step.
+ ///
+ private static XamlDataTemplate CanvasTemplate(bool enableVirtualization, Type? dataType = null) =>
+ new()
+ {
+ DataType = dataType,
+ EnableVirtualization = enableVirtualization,
+ Content = (Func)(_ =>
+ new TemplateResult(new Canvas { Width = 100, Height = 10 }, new NameScope())),
+ };
+
+ ///
+ /// Shrinks the collection so that fewer containers are needed than are currently realized.
+ /// The surplus is recycled without being immediately handed back out, which is what makes the
+ /// pool observable at the end of a layout pass - during a scroll the panel drains the pool in
+ /// the same pass it filled it. Returns how many containers were recycled.
+ ///
+ private static int RecycleByShrinkingItems(
+ ObservableCollection items,
+ VirtualizingStackPanel panel,
+ int keep)
+ {
+ var before = panel.GetRealizedContainers()!.Count();
+ Assert.True(before > keep, $"Only {before} containers were realized, expected more than {keep}.");
+
+ for (var i = items.Count - 1; i >= keep; i--)
+ items.RemoveAt(i);
+
+ Layout(panel);
+
+ return before - panel.GetRealizedContainers()!.Count();
+ }
+
+ private static int PooledCount(VirtualizingStackPanel panel) =>
+ panel.RecyclePoolForTesting?.Values.Sum(x => x.Count) ?? 0;
+
+ private static IEnumerable PooledContainers(VirtualizingStackPanel panel) =>
+ panel.RecyclePoolForTesting?.Values.SelectMany(x => x) ?? Enumerable.Empty();
+
+ private static (VirtualizingStackPanel panel, ScrollViewer scroll, ItemsControl itemsControl, TestRoot root)
+ CreateTarget(
+ IEnumerable items,
+ IDataTemplate? itemTemplate,
+ Size? clientSize = null,
+ Action? configure = null,
+ IEnumerable