Skip to content

Commit 3ecac96

Browse files
fix(treeview): typed data-driven TreeView<T> with per-container hosting (#447)
A node-mode WinUI TreeView stringifies TreeViewNode.Content and cannot host a pre-built UIElement, so TreeViewNodeData.ContentElement rendered blank rows (#447). Add a typed, data-driven TreeView<T> — the hierarchical peer of ListView<T> — that renders each node from a data -> Element viewBuilder (the WinUI ItemTemplate equivalent), with heterogeneous nodes handled by a switch in the viewBuilder (the ItemTemplateSelector pattern). Hosting mirrors the typed ListView<T>: the ItemTemplate is an empty ContentControl shell and each node view is mounted imperatively into the realized container via the internal TreeViewList's ContainerContentChanging (fresh mount on realize, unmount on recycle). This keeps expand/collapse robust under container recycling — fixing the "every other expand/collapse blanks the first child row(s)" regression that the earlier declarative {Binding Content} approach suffered (a recycled container retained the element's visual parent). node.Content holds the data item; the ItemInvoked and Expanding trampolines read T back from it. TreeViewNodeData.ContentElement is marked [Obsolete] pointing at TreeView<T>; the legacy path stays functional (CS0618 suppressed at internal use sites). - Element.cs: TemplatedTreeViewElementBase (object-erased base) + TemplatedTreeViewElement<T>; OwnPropsEqual arm. - Dsl.cs: TreeView<T> factory overloads (explicit + IReactorKeyed). - Reconciler: empty-shell ItemTemplate, ContainerContentChanging hosting on the internal TreeViewList, keyed in-place node diff that reconciles only realized containers, full-tree unmount walk. - Samples: DataTemplateDemo section 4 migrated to a discriminated PetNode model. - Tests: TemplatedTreeViewFixtures (TTV_) — render, heterogeneous templates, keyed update, expand/collapse cycles, events, value-type T, unmount. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 70b4245 commit 3ecac96

9 files changed

Lines changed: 1017 additions & 39 deletions

File tree

samples/Reactor.TestApp/Demos/DataTemplateDemo.cs

Lines changed: 66 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public override Element Render()
4444

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

4949
// Filter + add/remove controls
5050
HStack(12,
@@ -154,48 +154,75 @@ public override Element Render()
154154

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

157-
// 4. TreeView with ContentElement
158-
SubHeading("4. TreeView with ContentElement"),
159-
TextBlock("Tree nodes render custom Reactor elements instead of plain text."),
157+
// 4. Typed TreeView<T> — hierarchical peer of ListView<T>
158+
SubHeading("4. Typed TreeView<T> with viewBuilder"),
159+
TextBlock("Heterogeneous nodes render distinct templates via a switch in the viewBuilder (the ItemTemplateSelector pattern)."),
160160
Border(
161-
TreeView(
162-
new TreeViewNodeData("Pets") { IsExpanded = true,
163-
ContentElement = HStack(8,
161+
TreeView(BuildPetTree(filtered),
162+
keySelector: n => n.Key,
163+
childrenSelector: n => n.Children.Length > 0 ? n.Children : null,
164+
viewBuilder: n => n switch
165+
{
166+
PetRoot => HStack(8,
164167
TextBlock("\U0001F3E0").FontSize(16),
165168
TextBlock("All Pets").SemiBold()
166169
),
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)
170+
PetSpecies s => HStack(8,
171+
TextBlock(s.Emoji),
172+
TextBlock(s.Species).SemiBold(),
173+
TextBlock($"({s.Count})").Foreground(TertiaryText)
174+
),
175+
PetLeaf l => HStack(8,
176+
TextBlock(l.Animal.Emoji),
177+
TextBlock(l.Animal.Name),
178+
Caption($"#{l.Animal.Id}").Foreground(TertiaryText)
179+
),
180+
_ => TextBlock("?")
181+
})
182+
// Expand every group; leaves have no children to expand.
183+
with { IsExpanded = n => n is not PetLeaf }
184+
).CornerRadius(8).Height(300).Margin(10)
199185
));
200186
}
187+
188+
// ── §4 typed-tree model: a discriminated pet hierarchy ────────────────
189+
// (root group → species groups → animal leaves). Distinct record shapes
190+
// drive distinct per-node templates in the viewBuilder switch above.
191+
abstract record PetNode(string Key)
192+
{
193+
public PetNode[] Children { get; init; } = [];
194+
}
195+
record PetRoot(string Key) : PetNode(Key);
196+
record PetSpecies(string Key, string Species, string Emoji, int Count) : PetNode(Key);
197+
record PetLeaf(string Key, Animal Animal) : PetNode(Key);
198+
199+
static string EmojiForSpecies(string species) => species switch
200+
{
201+
"Cat" => "\U0001F431",
202+
"Dog" => "\U0001F436",
203+
"Rabbit" => "\U0001F430",
204+
"Hamster" => "\U0001F439",
205+
"Parrot" => "\U0001F99C",
206+
_ => "\U0001F43E"
207+
};
208+
209+
static PetNode[] BuildPetTree(List<Animal> animals)
210+
{
211+
var speciesGroups = new[] { "Cat", "Dog", "Rabbit", "Hamster", "Parrot" }
212+
.Where(species => animals.Any(a => a.Species == species))
213+
.Select(species => (PetNode)new PetSpecies(
214+
$"species:{species}",
215+
species,
216+
EmojiForSpecies(species),
217+
animals.Count(a => a.Species == species))
218+
{
219+
Children = animals
220+
.Where(a => a.Species == species)
221+
.Select(a => (PetNode)new PetLeaf($"animal:{a.Id}", a))
222+
.ToArray()
223+
})
224+
.ToArray();
225+
226+
return [new PetRoot("root") { Children = speciesGroups }];
227+
}
201228
}

src/Reactor/Core/Element.cs

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

704+
// Templated hierarchical TreeView — same rationale as the
705+
// templated lists above: Items/selectors/ViewBuilder are factory
706+
// inputs that drive child reconcile, not parent-control props.
707+
(TemplatedTreeViewElementBase tta, TemplatedTreeViewElementBase ttb) =>
708+
tta.GetSelectionMode() == ttb.GetSelectionMode()
709+
&& tta.GetCanDragItems() == ttb.GetCanDragItems()
710+
&& tta.GetAllowDrop() == ttb.GetAllowDrop()
711+
&& tta.GetCanReorderItems() == ttb.GetCanReorderItems()
712+
&& !tta.HasSetters && !ttb.HasSetters,
713+
704714
// Lazy (virtualized) stacks: same rationale — Items/ViewBuilder
705715
// are factory inputs, not control properties.
706716
(LazyStackElementBase la, LazyStackElementBase lb) =>
@@ -1736,6 +1746,17 @@ public record TreeViewNodeData(string Content, TreeViewNodeData[]? Children = nu
17361746
/// Optional Reactor element to render as the node's visual content.
17371747
/// When null, a TextBlock showing Content is rendered.
17381748
/// </summary>
1749+
/// <remarks>
1750+
/// Deprecated. WinUI's node-mode <c>TreeView</c> stringifies node content
1751+
/// and cannot host a pre-built <c>UIElement</c>, so rich per-node visuals
1752+
/// must come from a template (a <c>data → Element</c> function), never an
1753+
/// element instance. Use the typed, data-driven
1754+
/// <c>UI.TreeView&lt;T&gt;(items, keySelector, childrenSelector, viewBuilder)</c>
1755+
/// — the hierarchical peer of <c>ListView&lt;T&gt;</c> — instead. The legacy
1756+
/// path stays functional for back-compat but renders blank under
1757+
/// virtualization recycling.
1758+
/// </remarks>
1759+
[Obsolete("Use the typed UI.TreeView<T>(items, keySelector, childrenSelector, viewBuilder) overload (the hierarchical peer of ListView<T>); a pre-built Element cannot be hosted in a node-mode TreeViewNode. See issue #447.")]
17391760
public Element? ContentElement { get; init; }
17401761
}
17411762

@@ -3192,6 +3213,122 @@ public override void ApplyControlSetters(object control) =>
31923213
internal override bool HasSetters => Setters.Length > 0;
31933214
}
31943215

3216+
// ════════════════════════════════════════════════════════════════════════
3217+
// Templated (data-driven) hierarchical TreeView
3218+
// ════════════════════════════════════════════════════════════════════════
3219+
3220+
/// <summary>
3221+
/// Abstract non-generic base for the typed, data-driven <c>TreeView</c>.
3222+
/// Non-generic so the reconciler can match a single type in its switch
3223+
/// expression (same type-erasure pattern as <see cref="TemplatedListElementBase"/>).
3224+
///
3225+
/// <para>This is the hierarchical peer of <see cref="TemplatedListViewElement{T}"/>:
3226+
/// the developer supplies their own data items, a key selector, a children
3227+
/// selector (the hierarchy), and a <c>viewBuilder</c> (<c>data → Element</c>,
3228+
/// the WinUI <c>ItemTemplate</c> equivalent). It exists because WinUI's
3229+
/// node-mode <c>TreeView</c> stringifies <c>TreeViewNode.Content</c> and
3230+
/// cannot host a pre-built <c>UIElement</c> — rich per-node visuals must come
3231+
/// from a template, never an element instance (the root cause of issue #447).</para>
3232+
///
3233+
/// <para>The base exposes object-erased accessors; the generic leaf casts back
3234+
/// to <c>T</c>. Reference-type <c>T</c> flows through the covariant
3235+
/// <see cref="IReadOnlyList{T}"/> → <c>IReadOnlyList&lt;object&gt;</c>
3236+
/// conversion; value-type <c>T</c> is boxed once via the leaf's projection
3237+
/// helper.</para>
3238+
/// </summary>
3239+
public abstract record TemplatedTreeViewElementBase : Element
3240+
{
3241+
/// <summary>The root data items (object-erased), in document order.</summary>
3242+
public abstract IReadOnlyList<object> GetRoots();
3243+
/// <summary>The children of <paramref name="item"/>, or null for a leaf.</summary>
3244+
public abstract IReadOnlyList<object>? GetChildren(object item);
3245+
/// <summary>The stable identity string for <paramref name="item"/> (the keyed-diff key).</summary>
3246+
public abstract string GetKey(object item);
3247+
/// <summary>Builds the per-node view (the <c>ItemTemplate</c> equivalent).</summary>
3248+
public abstract Element BuildView(object item);
3249+
/// <summary>Whether <paramref name="item"/>'s node should start expanded.</summary>
3250+
public abstract bool GetIsExpanded(object item);
3251+
/// <summary>Dispatches <c>OnItemInvoked</c> with the developer's own <c>T</c>.</summary>
3252+
public abstract void InvokeItemInvoked(object item);
3253+
/// <summary>Dispatches <c>OnExpanding</c> with the developer's own <c>T</c>.</summary>
3254+
public abstract void InvokeExpanding(object item);
3255+
3256+
public abstract TreeViewSelectionMode GetSelectionMode();
3257+
public abstract bool GetCanDragItems();
3258+
public abstract bool GetAllowDrop();
3259+
public abstract bool GetCanReorderItems();
3260+
public abstract void ApplyControlSetters(object control);
3261+
3262+
/// <summary>
3263+
/// True when programmatic setter actions (.Set(...)) are attached. Used by
3264+
/// <see cref="Element.OwnPropsEqual"/> to suppress the reconcile-highlight
3265+
/// short-circuit (same rationale as <see cref="TemplatedListElementBase.HasSetters"/>).
3266+
/// </summary>
3267+
internal virtual bool HasSetters => false;
3268+
}
3269+
3270+
/// <summary>
3271+
/// Typed, data-driven <c>TreeView</c>. The hierarchical peer of
3272+
/// <see cref="TemplatedListViewElement{T}"/>. See
3273+
/// <see cref="TemplatedTreeViewElementBase"/>.
3274+
/// </summary>
3275+
public record TemplatedTreeViewElement<T>(
3276+
IReadOnlyList<T> Items,
3277+
Func<T, string> KeySelector,
3278+
Func<T, IReadOnlyList<T>?> ChildrenSelector,
3279+
Func<T, Element> ViewBuilder
3280+
) : TemplatedTreeViewElementBase
3281+
{
3282+
/// <summary>Invoked with the developer's <c>T</c> when a node is clicked/invoked.</summary>
3283+
public Action<T>? OnItemInvoked { get; init; }
3284+
/// <summary>Invoked with the developer's <c>T</c> just before a node expands.</summary>
3285+
public Action<T>? OnExpanding { get; init; }
3286+
/// <summary>Per-item initial-expansion selector. Defaults to collapsed.</summary>
3287+
public Func<T, bool>? IsExpanded { get; init; }
3288+
public TreeViewSelectionMode SelectionMode { get; init; } = TreeViewSelectionMode.Single;
3289+
public bool CanDragItems { get; init; }
3290+
public bool AllowDrop { get; init; }
3291+
public bool CanReorderItems { get; init; }
3292+
internal Action<WinUI.TreeView>[] Setters { get; init; } = [];
3293+
3294+
public override IReadOnlyList<object> GetRoots() => Project(Items);
3295+
public override IReadOnlyList<object>? GetChildren(object item)
3296+
{
3297+
var children = ChildrenSelector((T)item);
3298+
return children is null ? null : Project(children);
3299+
}
3300+
public override string GetKey(object item) => KeySelector((T)item);
3301+
public override Element BuildView(object item) => ViewBuilder((T)item);
3302+
public override bool GetIsExpanded(object item) => IsExpanded?.Invoke((T)item) ?? false;
3303+
public override void InvokeItemInvoked(object item) => OnItemInvoked?.Invoke((T)item);
3304+
public override void InvokeExpanding(object item) => OnExpanding?.Invoke((T)item);
3305+
3306+
public override TreeViewSelectionMode GetSelectionMode() => SelectionMode;
3307+
public override bool GetCanDragItems() => CanDragItems;
3308+
public override bool GetAllowDrop() => AllowDrop;
3309+
public override bool GetCanReorderItems() => CanReorderItems;
3310+
public override void ApplyControlSetters(object control) =>
3311+
Reconciler.ApplySetters(Setters, (WinUI.TreeView)control);
3312+
3313+
internal override bool HasCallbacks => OnItemInvoked is not null || OnExpanding is not null;
3314+
internal override bool HasSetters => Setters.Length > 0;
3315+
3316+
/// <summary>
3317+
/// Object-erases the source list. Reference-type <c>T</c> reuses the same
3318+
/// instance through covariance (no copy); value-type <c>T</c> is boxed into
3319+
/// a fresh <c>object[]</c>. Identity-stable mapping back to <c>T</c> is via
3320+
/// <see cref="GetKey"/> (a string), not object reference, so the per-call
3321+
/// boxing of value types is harmless.
3322+
/// </summary>
3323+
private static IReadOnlyList<object> Project(IReadOnlyList<T> source)
3324+
{
3325+
if (source is IReadOnlyList<object> covariant) return covariant;
3326+
var boxed = new object[source.Count];
3327+
for (int i = 0; i < source.Count; i++) boxed[i] = source[i]!;
3328+
return boxed;
3329+
}
3330+
}
3331+
31953332
// ════════════════════════════════════════════════════════════════════════
31963333
// Virtualized collection elements (backed by ItemsRepeater)
31973334
// ════════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)