Skip to content

Commit 5e83c60

Browse files
committed
#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.
1 parent 6ef2d0b commit 5e83c60

29 files changed

Lines changed: 10623 additions & 1130 deletions
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Globalization;
4+
using Avalonia.Controls.Documents;
5+
using Avalonia.Data.Converters;
6+
using Avalonia.Media;
7+
8+
namespace ControlCatalog.Converter
9+
{
10+
/// <summary>
11+
/// Turns a small subset of markdown into <see cref="Inline"/>s for a TextBlock: ATX headings,
12+
/// bullet and numbered lists, <c>**bold**</c>, <c>*italic*</c> and <c>`code`</c>.
13+
///
14+
/// Deliberately not a markdown library. The point for this sample is that the *height* of the
15+
/// row is a function of the text and of how it wraps, so the virtualizing panel cannot know it
16+
/// without measuring — the same situation any rich-text row puts it in.
17+
/// </summary>
18+
public class MarkdownToInlinesConverter : IValueConverter
19+
{
20+
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
21+
{
22+
if (value is not string markdown || string.IsNullOrWhiteSpace(markdown))
23+
return new InlineCollection();
24+
25+
var inlines = new InlineCollection();
26+
var lines = markdown.Replace("\r\n", "\n").Split('\n');
27+
var firstBlock = true;
28+
29+
foreach (var rawLine in lines)
30+
{
31+
var line = rawLine.Trim();
32+
33+
if (line.Length == 0)
34+
continue;
35+
36+
if (!firstBlock)
37+
inlines.Add(new LineBreak());
38+
firstBlock = false;
39+
40+
if (TryTakePrefix(line, "### ", out var h3))
41+
{
42+
AddSpans(inlines, h3, FontWeight.SemiBold, 1.05);
43+
}
44+
else if (TryTakePrefix(line, "## ", out var h2))
45+
{
46+
AddSpans(inlines, h2, FontWeight.Bold, 1.15);
47+
}
48+
else if (TryTakePrefix(line, "# ", out var h1))
49+
{
50+
AddSpans(inlines, h1, FontWeight.Bold, 1.3);
51+
}
52+
else if (TryTakePrefix(line, "- ", out var bullet) || TryTakePrefix(line, "* ", out bullet))
53+
{
54+
inlines.Add(new Run(" • "));
55+
AddSpans(inlines, bullet, FontWeight.Normal, 1.0);
56+
}
57+
else if (TryTakeOrderedPrefix(line, out var number, out var ordered))
58+
{
59+
inlines.Add(new Run($" {number}. "));
60+
AddSpans(inlines, ordered, FontWeight.Normal, 1.0);
61+
}
62+
else
63+
{
64+
AddSpans(inlines, line, FontWeight.Normal, 1.0);
65+
}
66+
}
67+
68+
return inlines;
69+
}
70+
71+
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
72+
=> throw new NotSupportedException();
73+
74+
private static bool TryTakePrefix(string line, string prefix, out string rest)
75+
{
76+
if (line.StartsWith(prefix, StringComparison.Ordinal))
77+
{
78+
rest = line.Substring(prefix.Length);
79+
return true;
80+
}
81+
82+
rest = line;
83+
return false;
84+
}
85+
86+
private static bool TryTakeOrderedPrefix(string line, out int number, out string rest)
87+
{
88+
var dot = line.IndexOf(". ", StringComparison.Ordinal);
89+
if (dot > 0 && int.TryParse(line.Substring(0, dot), out number))
90+
{
91+
rest = line.Substring(dot + 2);
92+
return true;
93+
}
94+
95+
number = 0;
96+
rest = line;
97+
return false;
98+
}
99+
100+
/// <summary>
101+
/// Splits inline markup into runs. <paramref name="baseWeight"/> and
102+
/// <paramref name="sizeFactor"/> carry the enclosing block's styling (a heading stays bold
103+
/// and larger even where it contains emphasis).
104+
/// </summary>
105+
private static void AddSpans(InlineCollection inlines, string text, FontWeight baseWeight, double sizeFactor)
106+
{
107+
foreach (var (span, kind) in SplitSpans(text))
108+
{
109+
var run = new Run(span);
110+
111+
if (sizeFactor != 1.0)
112+
run.FontSize = 14 * sizeFactor;
113+
114+
run.FontWeight = kind == SpanKind.Bold ? FontWeight.Bold : baseWeight;
115+
116+
if (kind == SpanKind.Italic)
117+
run.FontStyle = FontStyle.Italic;
118+
119+
if (kind == SpanKind.Code)
120+
{
121+
run.FontFamily = new FontFamily("Consolas, Menlo, monospace");
122+
run.Background = new SolidColorBrush(Color.FromArgb(28, 128, 128, 128));
123+
}
124+
125+
inlines.Add(run);
126+
}
127+
}
128+
129+
private enum SpanKind { Plain, Bold, Italic, Code }
130+
131+
/// <summary>
132+
/// Single pass over the line, splitting on the three inline markers. Unclosed markers are
133+
/// treated as literal text rather than swallowing the rest of the line.
134+
/// </summary>
135+
private static IEnumerable<(string Text, SpanKind Kind)> SplitSpans(string text)
136+
{
137+
var i = 0;
138+
var plainStart = 0;
139+
140+
while (i < text.Length)
141+
{
142+
var (marker, kind) = text[i] switch
143+
{
144+
'*' when i + 1 < text.Length && text[i + 1] == '*' => ("**", SpanKind.Bold),
145+
'*' => ("*", SpanKind.Italic),
146+
'`' => ("`", SpanKind.Code),
147+
_ => (null, SpanKind.Plain),
148+
};
149+
150+
if (marker is null)
151+
{
152+
i++;
153+
continue;
154+
}
155+
156+
var contentStart = i + marker.Length;
157+
var close = text.IndexOf(marker, contentStart, StringComparison.Ordinal);
158+
159+
if (close < 0 || close == contentStart)
160+
{
161+
i += marker.Length;
162+
continue;
163+
}
164+
165+
if (i > plainStart)
166+
yield return (text.Substring(plainStart, i - plainStart), SpanKind.Plain);
167+
168+
yield return (text.Substring(contentStart, close - contentStart), kind);
169+
170+
i = close + marker.Length;
171+
plainStart = i;
172+
}
173+
174+
if (plainStart < text.Length)
175+
yield return (text.Substring(plainStart), SpanKind.Plain);
176+
}
177+
}
178+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
using System.Collections.Generic;
2+
using Avalonia.Controls;
3+
using Avalonia.Controls.Templates;
4+
using Avalonia.Metadata;
5+
6+
namespace ControlCatalog.Pages
7+
{
8+
/// <summary>
9+
/// Picks a row template by the row's type, and tells the virtualizing panel how to pool the
10+
/// controls it builds.
11+
///
12+
/// A flat list of heterogeneous rows cannot use one template, and a plain
13+
/// <c>ItemsControl.DataTemplates</c> collection would recycle nothing: every scroll step would
14+
/// rebuild a row's whole control tree. Implementing <see cref="IVirtualizingDataTemplate"/>
15+
/// instead gives the panel a recycle key per row kind, so a container built for one image row
16+
/// is reused for the next image row and only its bindings change.
17+
/// </summary>
18+
/// <remarks>
19+
/// Templates are supplied as content, so the layouts stay in XAML next to the page:
20+
/// <code>
21+
/// &lt;pages:FieldTemplateSelector x:Key="FieldTemplates"&gt;
22+
/// &lt;DataTemplate DataType="vm:HeadlineItem"&gt;…&lt;/DataTemplate&gt;
23+
/// &lt;DataTemplate DataType="vm:TextFieldItem"&gt;…&lt;/DataTemplate&gt;
24+
/// &lt;/pages:FieldTemplateSelector&gt;
25+
/// </code>
26+
/// </remarks>
27+
public class FieldTemplateSelector : IVirtualizingDataTemplate
28+
{
29+
[Content]
30+
public List<IDataTemplate> Templates { get; } = new();
31+
32+
/// <summary>
33+
/// Whether a recycled container may keep the control tree it already has.
34+
///
35+
/// Turn it off to see what a plain <see cref="IDataTemplate"/> costs. A template that is not
36+
/// an <see cref="IRecyclingDataTemplate"/> — or that ignores the control handed back to it,
37+
/// which is what this flag simulates — makes <c>ContentPresenter</c> throw the row's control
38+
/// tree away and build a new one every time a container is reused for a different row. The
39+
/// container pooling still happens; it is the contents that get rebuilt.
40+
/// </summary>
41+
public bool RecycleContent { get; set; } = true;
42+
43+
/// <summary>
44+
/// How many row control trees have been built from scratch. With recycling on this settles
45+
/// at roughly the number of containers in play; with it off it climbs for as long as you
46+
/// keep scrolling.
47+
/// </summary>
48+
public int Builds { get; private set; }
49+
50+
public void ResetBuilds() => Builds = 0;
51+
52+
/// <summary>Upper bound on idle containers kept per row kind.</summary>
53+
public int MaxPoolSizePerKey { get; set; } = 6;
54+
55+
/// <summary>
56+
/// How many containers warmup pre-builds per row kind. Warmup grows the pool off the row
57+
/// kinds the panel has actually met, so a kind that first appears deep in the list (the
58+
/// markdown and image rows here) is covered when the user reaches it rather than only if it
59+
/// happened to occur near the top.
60+
/// </summary>
61+
public int MinPoolSizePerKey { get; set; } = 3;
62+
63+
/// <summary>
64+
/// The recycle key. Row kind is the right granularity: two image rows have the same control
65+
/// tree and differ only in their data, whereas an image row and a number field have nothing
66+
/// in common and must never be swapped for one another.
67+
/// </summary>
68+
public object? GetKey(object? data) => data?.GetType();
69+
70+
public bool Match(object? data) => FindTemplate(data) is not null;
71+
72+
public Control? Build(object? data)
73+
{
74+
var built = FindTemplate(data)?.Build(data);
75+
if (built is not null)
76+
Builds++;
77+
return built;
78+
}
79+
80+
public Control? Build(object? data, Control? existing)
81+
{
82+
// The control tree handed back is already the right one for this row kind (the panel
83+
// only offers a container whose recycle key matches), so keep it and let the
84+
// DataContext change drive the update. Rebuilding here is what a plain IDataTemplate
85+
// effectively does, and it is the expensive path — see RecycleContent.
86+
if (existing is not null && RecycleContent)
87+
return existing;
88+
89+
return Build(data);
90+
}
91+
92+
private IDataTemplate? FindTemplate(object? data)
93+
{
94+
if (data is null)
95+
return null;
96+
97+
foreach (var template in Templates)
98+
{
99+
if (template.Match(data))
100+
return template;
101+
}
102+
103+
return null;
104+
}
105+
}
106+
}

0 commit comments

Comments
 (0)