Skip to content

Commit b7d25af

Browse files
fix(treeview): typed TreeView<T> with viewBuilder; obsolete ContentElement (#447)
WinUI node-mode TreeView stringifies the node and cannot host a live UIElement in TreeViewNode.Content (verified empirically on native ARM64), so per-node `TreeViewNodeData.ContentElement` always rendered blank. Adds a typed, data-driven `TreeView<T>` (TemplatedTreeViewElement<T>) — the hierarchical peer of ListView<T>: per-node `viewBuilder` (the ItemTemplate equivalent), `childrenSelector` for hierarchy, `keySelector` for identity. OnItemInvoked/OnExpanding hand the developer's own T back. Heterogeneous nodes + per-shape templates fall out of a switch in the viewBuilder. Hosting follows WinUI's own mechanism: each node's view is mounted once and kept on an attached property; the internal TreeViewList's ContainerContentChanging hosts it on realize and releases it (Content=null) on recycle. The host is deferred to a low-priority dispatch and re-validates the container still belongs to the node, so it never perturbs the synchronous expand pass (which otherwise re-prepared the expanding node with a stale IsExpanded and snapped it shut). Expansion is uncontrolled — the selector is only pushed when its value changes, never clobbering user toggles. `TreeViewNodeData.ContentElement` is marked [Obsolete] pointing to TreeView<T>; the legacy path stays functional (warning-suppressed) for back-compat. Samples: DataTemplateDemo §4 migrated to TreeView<T> (heterogeneous pet groups vs. animals); gallery TreeViewPage gains a file-explorer card (folders/docs/images via per-shape templates) and switches source snippets to raw-string literals so they render multi-line. Tests: TemplatedTreeViewFixtures (render, events resolve T, keyed update, expansion-not-clobbered, collapse/expand cycle, expand-collapsed-node, value-type T) + WinUITreeViewNativeProbe documenting native node-mode behavior. Full self-test suite green (1075 fixtures). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c14d172 commit b7d25af

11 files changed

Lines changed: 1101 additions & 48 deletions

File tree

samples/Reactor.TestApp/Demos/DataTemplateDemo.cs

Lines changed: 54 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,40 @@ class DataTemplateDemo : Component
1414
{
1515
record Animal(int Id, string Name, string Species, string Emoji);
1616

17+
// Heterogeneous tree model: group rows and animal rows are distinct C#
18+
// shapes, so the TreeView<T> viewBuilder renders each differently and the
19+
// childrenSelector reads hierarchy off the shape (see section 4).
20+
abstract record PetNode : IReactorKeyed { public abstract string Key { get; } }
21+
sealed record PetGroup(string Label, string Glyph, IReadOnlyList<PetNode> Items) : PetNode
22+
{
23+
public override string Key => "group:" + Label;
24+
}
25+
sealed record PetLeaf(Animal Animal) : PetNode
26+
{
27+
public override string Key => "animal:" + Animal.Id;
28+
}
29+
30+
static string EmojiFor(string species) => species switch
31+
{
32+
"Cat" => "\U0001F431",
33+
"Dog" => "\U0001F436",
34+
"Rabbit" => "\U0001F430",
35+
"Hamster" => "\U0001F439",
36+
"Parrot" => "\U0001F99C",
37+
_ => "\U0001F43E",
38+
};
39+
40+
static IReadOnlyList<PetNode> BuildPetTree(IReadOnlyList<Animal> animals)
41+
{
42+
var groups = new[] { "Cat", "Dog", "Rabbit", "Hamster", "Parrot" }
43+
.Where(species => animals.Any(a => a.Species == species))
44+
.Select(species => (PetNode)new PetGroup(species, EmojiFor(species),
45+
animals.Where(a => a.Species == species)
46+
.Select(a => (PetNode)new PetLeaf(a)).ToList()))
47+
.ToList();
48+
return [new PetGroup("All Pets", "\U0001F3E0", groups)];
49+
}
50+
1751
static readonly List<Animal> AllAnimals =
1852
[
1953
new(1, "Luna", "Cat", "\U0001F431"),
@@ -44,7 +78,7 @@ public override Element Render()
4478

4579
return ScrollView(VStack(16,
4680
Heading("DataTemplate Demo"),
47-
TextBlock("Typed ListView<T>, GridView<T>, FlipView<T> with viewBuilder, plus TreeView ContentElement."),
81+
TextBlock("Typed ListView<T>, GridView<T>, FlipView<T>, and TreeView<T> — all data-driven via a viewBuilder."),
4882

4983
// Filter + add/remove controls
5084
HStack(12,
@@ -154,48 +188,26 @@ public override Element Render()
154188

155189
TextBlock($"Showing {flipIndex + 1} of {filtered.Count}").Foreground(SecondaryText),
156190

157-
// 4. TreeView with ContentElement
158-
SubHeading("4. TreeView with ContentElement"),
159-
TextBlock("Tree nodes render custom Reactor elements instead of plain text."),
191+
// 4. TreeView<T> — heterogeneous nodes from the C# data shape
192+
SubHeading("4. TreeView<T> (heterogeneous nodes)"),
193+
TextBlock("Hierarchy (childrenSelector) and per-node template (viewBuilder) both come from the data shape — group rows and animal rows render differently."),
160194
Border(
161-
TreeView(
162-
new TreeViewNodeData("Pets") { IsExpanded = true,
163-
ContentElement = HStack(8,
164-
TextBlock("\U0001F3E0").FontSize(16),
165-
TextBlock("All Pets").SemiBold()
166-
),
167-
Children = new[] { "Cat", "Dog", "Rabbit", "Hamster", "Parrot" }
168-
.Where(species => filtered.Any(a => a.Species == species))
169-
.Select(species => new TreeViewNodeData(species)
170-
{
171-
IsExpanded = true,
172-
ContentElement = HStack(8,
173-
TextBlock(species switch
174-
{
175-
"Cat" => "\U0001F431",
176-
"Dog" => "\U0001F436",
177-
"Rabbit" => "\U0001F430",
178-
"Hamster" => "\U0001F439",
179-
"Parrot" => "\U0001F99C",
180-
_ => "\U0001F43E"
181-
}),
182-
TextBlock(species).SemiBold(),
183-
TextBlock($"({filtered.Count(a => a.Species == species)})").Foreground(TertiaryText)
184-
),
185-
Children = filtered
186-
.Where(a => a.Species == species)
187-
.Select(a => new TreeViewNodeData(a.Name)
188-
{
189-
ContentElement = HStack(8,
190-
TextBlock(a.Emoji),
191-
TextBlock(a.Name),
192-
Caption($"#{a.Id}").Foreground(TertiaryText)
193-
)
194-
}).ToArray()
195-
}).ToArray()
196-
}
197-
)
198-
).CornerRadius(8).Height(300)
195+
TreeView<PetNode>(
196+
items: BuildPetTree(filtered),
197+
childrenSelector: n => n is PetGroup g ? g.Items : null,
198+
viewBuilder: n => n switch
199+
{
200+
PetGroup g => HStack(8,
201+
TextBlock(g.Glyph).FontSize(16),
202+
TextBlock(g.Label).SemiBold(),
203+
Caption($"({g.Items.Count})").Foreground(TertiaryText)),
204+
PetLeaf l => HStack(8,
205+
TextBlock(l.Animal.Emoji),
206+
TextBlock(l.Animal.Name),
207+
Caption($"#{l.Animal.Id}").Foreground(TertiaryText)),
208+
_ => TextBlock(n.Key),
209+
}) with { IsExpanded = _ => true }
210+
).CornerRadius(8).Height(300).Margin(5)
199211
));
200212
}
201213
}

samples/ReactorGallery/ControlPages/Collections/TreeViewPage.cs

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,34 @@ namespace WinUIGalleryReactor;
1010

1111
class TreeViewPage : Component
1212
{
13+
// Heterogeneous node model: folders, documents and images are distinct C#
14+
// shapes. TreeView<T> reads hierarchy off the shape (childrenSelector) and
15+
// picks a per-node template by pattern-matching the shape (viewBuilder) —
16+
// the WinUI ItemTemplateSelector pattern, expressed in C#.
17+
abstract record FsEntry(string Name) : IReactorKeyed { public string Key => Name; }
18+
sealed record FsFolder(string Name, FsEntry[] Items) : FsEntry(Name);
19+
sealed record FsDoc(string Name, string Size) : FsEntry(Name);
20+
sealed record FsImage(string Name, string Dimensions) : FsEntry(Name);
21+
22+
static readonly FsEntry[] SampleTree =
23+
[
24+
new FsFolder("Documents",
25+
[
26+
new FsFolder("Work",
27+
[
28+
new FsDoc("Report.docx", "18 KB"),
29+
new FsDoc("Slides.pptx", "2.1 MB"),
30+
]),
31+
new FsDoc("Budget.xlsx", "44 KB"),
32+
]),
33+
new FsFolder("Pictures",
34+
[
35+
new FsImage("Beach.jpg", "4032 x 3024"),
36+
new FsImage("Mountain.png", "1920 x 1080"),
37+
]),
38+
new FsDoc("readme.txt", "1 KB"),
39+
];
40+
1341
public override Element Render()
1442
{
1543
return ScrollView(
@@ -31,7 +59,15 @@ public override Element Render()
3159
TreeNode("Family")),
3260
TreeNode("Music")
3361
).Height(300),
34-
@"TreeView(\n TreeNode(""Documents"",\n TreeNode(""Work"",\n TreeNode(""Report.docx""),\n TreeNode(""Slides.pptx""))),\n TreeNode(""Pictures"", ...)\n)"),
62+
"""
63+
TreeView(
64+
TreeNode("Documents",
65+
TreeNode("Work",
66+
TreeNode("Report.docx"),
67+
TreeNode("Slides.pptx"))),
68+
TreeNode("Pictures", ...),
69+
TreeNode("Music"))
70+
"""),
3571

3672
SampleCard("Deeply Nested TreeView",
3773
TreeView(
@@ -44,7 +80,46 @@ public override Element Render()
4480
TreeNode("Level 1B",
4581
TreeNode("Level 2C")))
4682
).Height(250),
47-
@"TreeView(\n TreeNode(""Root"",\n TreeNode(""Level 1A"",\n TreeNode(""Level 2A"",\n TreeNode(""Level 3A""))))\n)")
83+
"""
84+
TreeView(
85+
TreeNode("Root",
86+
TreeNode("Level 1A",
87+
TreeNode("Level 2A",
88+
TreeNode("Level 3A")))))
89+
"""),
90+
91+
SampleCard("Heterogeneous nodes & custom templates (TreeView<T>)",
92+
(TreeView<FsEntry>(
93+
items: SampleTree,
94+
childrenSelector: e => e is FsFolder f ? f.Items : null,
95+
viewBuilder: e => e switch
96+
{
97+
FsFolder fo => HStack(8,
98+
TextBlock("\U0001F4C1"),
99+
TextBlock(fo.Name).SemiBold(),
100+
Caption($"{fo.Items.Length} items")),
101+
FsImage im => HStack(8,
102+
TextBlock("\U0001F5BC"),
103+
TextBlock(im.Name),
104+
Caption(im.Dimensions)),
105+
FsDoc d => HStack(8,
106+
TextBlock("\U0001F4C4"),
107+
TextBlock(d.Name),
108+
Caption(d.Size)),
109+
_ => TextBlock(e.Name),
110+
}) with { IsExpanded = e => e is FsFolder })
111+
.Height(320),
112+
"""
113+
TreeView<FsEntry>(
114+
items: tree,
115+
childrenSelector: e => e is FsFolder f ? f.Items : null,
116+
viewBuilder: e => e switch
117+
{
118+
FsFolder fo => HStack(Text("📁"), Text(fo.Name).SemiBold(), Caption($"{fo.Items.Length} items")),
119+
FsImage im => HStack(Text("🖼"), Text(im.Name), Caption(im.Dimensions)),
120+
FsDoc d => HStack(Text("📄"), Text(d.Name), Caption(d.Size)),
121+
}) with { IsExpanded = e => e is FsFolder }
122+
""")
48123
).Margin(36, 24, 36, 36)
49124
);
50125
}

src/Reactor/Core/Element.cs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,17 @@ internal static bool OwnPropsEqual(Element a, Element b)
701701
&& ta.GetIsItemClickEnabled() == tb.GetIsItemClickEnabled()
702702
&& !ta.HasSetters && !tb.HasSetters,
703703

704+
// Typed TreeView<T>: Items/selectors/ViewBuilder are factory inputs
705+
// (they drive child reconcile, not parent control properties), so
706+
// own-prop equality compares only the WinUI properties the update
707+
// path writes back — same rationale as the templated collections.
708+
(TemplatedTreeViewElementBase ta, TemplatedTreeViewElementBase tb) =>
709+
ta.SelectionMode == tb.SelectionMode
710+
&& ta.CanDragItems == tb.CanDragItems
711+
&& ta.AllowDrop == tb.AllowDrop
712+
&& ta.CanReorderItems == tb.CanReorderItems
713+
&& ReferenceEquals(ta.SettersErased, tb.SettersErased),
714+
704715
// Lazy (virtualized) stacks: same rationale — Items/ViewBuilder
705716
// are factory inputs, not control properties.
706717
(LazyStackElementBase la, LazyStackElementBase lb) =>
@@ -1736,6 +1747,13 @@ public record TreeViewNodeData(string Content, TreeViewNodeData[]? Children = nu
17361747
/// Optional Reactor element to render as the node's visual content.
17371748
/// When null, a TextBlock showing Content is rendered.
17381749
/// </summary>
1750+
[Obsolete(
1751+
"Pre-mounting an element into a node renders blank in WinUI node-mode TreeView " +
1752+
"(issue #447): the default ContentPresenter stringifies the node and cannot host a " +
1753+
"live UIElement, and per-node events don't resolve. Use the typed " +
1754+
"TreeView<T>(items, keySelector, childrenSelector, viewBuilder) overload, which renders " +
1755+
"each node through a viewBuilder (like WinUI's ItemTemplate) and hands your own T back " +
1756+
"from OnItemInvoked/OnExpanding.")]
17391757
public Element? ContentElement { get; init; }
17401758
}
17411759

@@ -2814,6 +2832,93 @@ TreeViewNodeData[] Nodes
28142832
internal override bool HasCallbacks => OnItemInvoked is not null || OnExpanding is not null;
28152833
}
28162834

2835+
/// <summary>
2836+
/// Non-generic base for the typed, data-driven <see cref="TemplatedTreeViewElement{T}"/>,
2837+
/// kept non-generic so the reconciler matches a single type in its mount/update
2838+
/// switch (same erasure pattern as <see cref="TemplatedListElementBase"/>).
2839+
///
2840+
/// <para>Maps to WinUI's native node-mode <c>TreeView</c>: the reconciler builds a
2841+
/// <c>TreeViewNode</c> tree from <see cref="GetRoots"/>/<see cref="GetChildren"/> and
2842+
/// renders each node through the per-node <see cref="BuildView"/> (a real
2843+
/// DataTemplate-style factory) hosted by the <c>{Binding Content}</c> ContentControl
2844+
/// template — rather than pre-mounting a concrete element into <c>Content</c> (which
2845+
/// renders blank, issue #447). The originating <c>T</c> rides on an attached property
2846+
/// of each node so <see cref="InvokeItemInvoked"/>/<see cref="InvokeExpanding"/> hand
2847+
/// the developer's own model back.</para>
2848+
/// </summary>
2849+
public abstract record TemplatedTreeViewElementBase : Element
2850+
{
2851+
/// <summary>Root data items, erased to <c>object</c> for the reconciler.</summary>
2852+
internal abstract IReadOnlyList<object> GetRoots();
2853+
/// <summary>Children of <paramref name="item"/>, or null for a leaf.</summary>
2854+
internal abstract IReadOnlyList<object>? GetChildren(object item);
2855+
/// <summary>Stable identity for keyed reconcile + event payloads.</summary>
2856+
internal abstract string GetKey(object item);
2857+
/// <summary>The per-node view (the "template/renderer") for <paramref name="item"/>.</summary>
2858+
internal abstract Element BuildView(object item);
2859+
/// <summary>Initial expansion state for <paramref name="item"/>.</summary>
2860+
internal abstract bool GetIsExpanded(object item);
2861+
internal abstract void InvokeItemInvoked(object item);
2862+
internal abstract void InvokeExpanding(object item);
2863+
internal abstract bool HasItemInvoked { get; }
2864+
internal abstract bool HasExpanding { get; }
2865+
2866+
public TreeViewSelectionMode SelectionMode { get; init; } = TreeViewSelectionMode.Single;
2867+
public bool CanDragItems { get; init; }
2868+
public bool AllowDrop { get; init; }
2869+
public bool CanReorderItems { get; init; }
2870+
internal abstract Action<WinUI.TreeView>[] SettersErased { get; }
2871+
internal override bool HasCallbacks => HasItemInvoked || HasExpanding;
2872+
}
2873+
2874+
/// <summary>
2875+
/// Typed, data-driven TreeView — the hierarchical peer of
2876+
/// <see cref="TemplatedListViewElement{T}"/>. Each node's visual is produced by
2877+
/// <see cref="ViewBuilder"/> (WinUI <c>ItemTemplate</c> equivalent); hierarchy comes
2878+
/// from <see cref="ChildrenSelector"/>; identity from <see cref="KeySelector"/>.
2879+
/// Construct via the <c>TreeView&lt;T&gt;</c> factory.
2880+
/// </summary>
2881+
public record TemplatedTreeViewElement<T>(
2882+
IReadOnlyList<T> Items,
2883+
Func<T, string> KeySelector,
2884+
Func<T, IReadOnlyList<T>?> ChildrenSelector,
2885+
Func<T, Element> ViewBuilder
2886+
) : TemplatedTreeViewElementBase
2887+
{
2888+
/// <summary>Fires with the developer's own <c>T</c> when a node is invoked.</summary>
2889+
public Action<T>? OnItemInvoked { get; init; }
2890+
/// <summary>Fires with the developer's own <c>T</c> when a node begins expanding.</summary>
2891+
public Action<T>? OnExpanding { get; init; }
2892+
/// <summary>Per-item initial expansion; defaults to collapsed (WinUI default).</summary>
2893+
public Func<T, bool>? IsExpanded { get; init; }
2894+
internal Action<WinUI.TreeView>[] Setters { get; init; } = [];
2895+
2896+
internal override IReadOnlyList<object> GetRoots() => Project(Items);
2897+
internal override IReadOnlyList<object>? GetChildren(object item)
2898+
{
2899+
var ch = ChildrenSelector((T)item);
2900+
return ch is null ? null : Project(ch);
2901+
}
2902+
internal override string GetKey(object item) => KeySelector((T)item);
2903+
internal override Element BuildView(object item) => ViewBuilder((T)item);
2904+
internal override bool GetIsExpanded(object item) => IsExpanded?.Invoke((T)item) ?? false;
2905+
internal override void InvokeItemInvoked(object item) => OnItemInvoked?.Invoke((T)item);
2906+
internal override void InvokeExpanding(object item) => OnExpanding?.Invoke((T)item);
2907+
internal override bool HasItemInvoked => OnItemInvoked is not null;
2908+
internal override bool HasExpanding => OnExpanding is not null;
2909+
internal override Action<WinUI.TreeView>[] SettersErased => Setters;
2910+
2911+
// Reference-typed T flows through covariant IReadOnlyList<object> with no
2912+
// allocation; value-typed T is boxed once per item into a backing array.
2913+
private static IReadOnlyList<object> Project(IReadOnlyList<T> src)
2914+
{
2915+
if (src is IReadOnlyList<object> already) return already;
2916+
var arr = new object[src.Count];
2917+
for (int i = 0; i < src.Count; i++) arr[i] = src[i]!;
2918+
return arr;
2919+
}
2920+
}
2921+
28172922
public record FlipViewElement(
28182923
Element[] Items
28192924
) : Element

0 commit comments

Comments
 (0)