From 5e83c603c4f6cc2cd8bce8d129e4f6efb16ebd68 Mon Sep 17 00:00:00 2001 From: Alexander Marek Date: Mon, 17 Aug 2026 07:51:11 +0200 Subject: [PATCH] #20259 - VirtualizingStackPanel: window-independent extent, opt-in container virtualization Three separable things, all in VirtualizingStackPanel and the ItemsControl plumbing behind it. 1. The extent estimate no longer depends on which items happen to be realized. A persistent per-item size record replaces the average over the realized window, so revisiting an offset reports the same extent, and once every item has been measured the extent is the exact total. The running sum is maintained incrementally at the single upsert site with Neumaier compensation, so a measure pass stays O(realized window) as in stock. Every fork-added tuning constant that used to damp the oscillation goes with it: EMA smoothing, extent freezing and boundary clamping, the layout cycle breaker, estimate caching, the sub-pixel resize threshold, warmup head sampling. 2. Reset preservation now requires every realized element to still validate, rather than a bare majority. A mid-list insert or remove coalesced into a single Reset used to preserve a stale mapping, so shifted items rendered under the wrong containers. 3. Container-level virtualization, opt-in per template. A template that hands out a recycle key through IVirtualizingDataTemplate, which means EnableVirtualization in XAML or RecycleKeySelector on a FuncDataTemplate, has its container and its child recycled as one unit instead of the child subtree being torn down and rebuilt on every scroll. Over heterogeneous complex rows that is ~3.4x faster scrolling and 83% less allocated, at identical container prepare counts. A template that does not opt in keeps stock behaviour, and ContainerVirtualization.IsEnabled is a kill switch rather than the opt-in. Supporting changes: constant-free anchor compensation in ValidateStartU, RetainMatchingContainers for disjunct scrolls, opt-in warmup that grows the pool off encountered keys, a guard against 0x0 effective viewports, and ContentPresenter batch updates so Content and ContentTemplate land together. VirtualizingPanel.Refresh becomes virtual so a template or theme change reaches already-realized containers. Note that an opted-in container is hidden and kept in Panel.Children rather than torn down, so Loaded/Unloaded and AttachedToVisualTree/ DetachedFromVisualTree fire once for the first item a container displayed and not per item. Templates that need per-item work should use DataContextChanged. Tests: VirtualizingStackPanelTests grows to 153 methods over 310 cases, with new ContainerVirtualizationTests and ContentPresenterTests_BatchUpdate. Benchmarks in tests/Avalonia.Benchmarks/Controls cover scroll and jump timings, deterministic container counts, and what the size record costs in memory and per pass. Sample: ListBoxComplexLayoutPage in ControlCatalog. --- .../Converter/MarkdownToInlinesConverter.cs | 178 + .../Pages/FieldTemplateSelector.cs | 106 + .../Pages/ListBoxComplexLayoutPage.xaml | 406 ++ .../Pages/ListBoxComplexLayoutPage.xaml.cs | 110 + samples/ControlCatalog/Pages/ListBoxPage.xaml | 13 +- .../ControlCatalog/Pages/ListBoxPage.xaml.cs | 6 + .../ListBoxComplexLayoutPageViewModel.cs | 579 ++ .../MainWindowViewModel_PageList.cs | 1 + src/Avalonia.Controls/ItemsControl.cs | 160 +- .../Presenters/ContentPresenter.cs | 53 +- .../Templates/FuncDataTemplate.cs | 50 +- .../Templates/IVirtualizingDataTemplate.cs | 34 + .../Utils/RealizedStackElements.cs | 161 +- src/Avalonia.Controls/VirtualizingPanel.cs | 15 +- .../VirtualizingStackPanel.cs | 1434 +++- .../Templates/DataTemplate.cs | 39 +- .../Controls/ComplexItems.cs | 311 + .../Controls/ComplexScrollBenchmark.cs | 80 + .../Controls/ComplexVirtualizingTemplate.cs | 40 + .../Controls/SizeRecordBenchmark.cs | 77 + .../Controls/VirtualizationHarness.cs | 314 + .../Controls/VirtualizationReport.cs | 285 + .../Controls/VirtualizedScrollBenchmark.cs | 63 + tests/Avalonia.Benchmarks/Program.cs | 8 + .../ContainerVirtualizationTests.cs | 963 +++ .../ItemsControlTests.cs | 23 + .../ListBoxVirtualizationIssueTests.cs | 10 +- .../ContentPresenterTests_BatchUpdate.cs | 246 + .../VirtualizingStackPanelTests.cs | 5988 ++++++++++++++--- 29 files changed, 10623 insertions(+), 1130 deletions(-) create mode 100644 samples/ControlCatalog/Converter/MarkdownToInlinesConverter.cs create mode 100644 samples/ControlCatalog/Pages/FieldTemplateSelector.cs create mode 100644 samples/ControlCatalog/Pages/ListBoxComplexLayoutPage.xaml create mode 100644 samples/ControlCatalog/Pages/ListBoxComplexLayoutPage.xaml.cs create mode 100644 samples/ControlCatalog/ViewModels/ListBoxComplexLayoutPageViewModel.cs create mode 100644 src/Avalonia.Controls/Templates/IVirtualizingDataTemplate.cs create mode 100644 tests/Avalonia.Benchmarks/Controls/ComplexItems.cs create mode 100644 tests/Avalonia.Benchmarks/Controls/ComplexScrollBenchmark.cs create mode 100644 tests/Avalonia.Benchmarks/Controls/ComplexVirtualizingTemplate.cs create mode 100644 tests/Avalonia.Benchmarks/Controls/SizeRecordBenchmark.cs create mode 100644 tests/Avalonia.Benchmarks/Controls/VirtualizationHarness.cs create mode 100644 tests/Avalonia.Benchmarks/Controls/VirtualizationReport.cs create mode 100644 tests/Avalonia.Benchmarks/Controls/VirtualizedScrollBenchmark.cs create mode 100644 tests/Avalonia.Controls.UnitTests/ContainerVirtualizationTests.cs create mode 100644 tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_BatchUpdate.cs 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +