Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 54 additions & 42 deletions samples/Reactor.TestApp/Demos/DataTemplateDemo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,40 @@ class DataTemplateDemo : Component
{
record Animal(int Id, string Name, string Species, string Emoji);

// Heterogeneous tree model: group rows and animal rows are distinct C#
// shapes, so the TreeView<T> viewBuilder renders each differently and the
// childrenSelector reads hierarchy off the shape (see section 4).
abstract record PetNode : IReactorKeyed { public abstract string Key { get; } }
sealed record PetGroup(string Label, string Glyph, IReadOnlyList<PetNode> Items) : PetNode
{
public override string Key => "group:" + Label;
}
sealed record PetLeaf(Animal Animal) : PetNode
{
public override string Key => "animal:" + Animal.Id;
}

static string EmojiFor(string species) => species switch
{
"Cat" => "\U0001F431",
"Dog" => "\U0001F436",
"Rabbit" => "\U0001F430",
"Hamster" => "\U0001F439",
"Parrot" => "\U0001F99C",
_ => "\U0001F43E",
};

static IReadOnlyList<PetNode> BuildPetTree(IReadOnlyList<Animal> animals)
{
var groups = new[] { "Cat", "Dog", "Rabbit", "Hamster", "Parrot" }
.Where(species => animals.Any(a => a.Species == species))
.Select(species => (PetNode)new PetGroup(species, EmojiFor(species),
animals.Where(a => a.Species == species)
.Select(a => (PetNode)new PetLeaf(a)).ToList()))
.ToList();
return [new PetGroup("All Pets", "\U0001F3E0", groups)];
}

static readonly List<Animal> AllAnimals =
[
new(1, "Luna", "Cat", "\U0001F431"),
Expand Down Expand Up @@ -44,7 +78,7 @@ public override Element Render()

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

// Filter + add/remove controls
HStack(12,
Expand Down Expand Up @@ -154,48 +188,26 @@ public override Element Render()

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

// 4. TreeView with ContentElement
SubHeading("4. TreeView with ContentElement"),
TextBlock("Tree nodes render custom Reactor elements instead of plain text."),
// 4. TreeView<T> — heterogeneous nodes from the C# data shape
SubHeading("4. TreeView<T> (heterogeneous nodes)"),
TextBlock("Hierarchy (childrenSelector) and per-node template (viewBuilder) both come from the data shape — group rows and animal rows render differently."),
Border(
TreeView(
new TreeViewNodeData("Pets") { IsExpanded = true,
ContentElement = HStack(8,
TextBlock("\U0001F3E0").FontSize(16),
TextBlock("All Pets").SemiBold()
),
Children = new[] { "Cat", "Dog", "Rabbit", "Hamster", "Parrot" }
.Where(species => filtered.Any(a => a.Species == species))
.Select(species => new TreeViewNodeData(species)
{
IsExpanded = true,
ContentElement = HStack(8,
TextBlock(species switch
{
"Cat" => "\U0001F431",
"Dog" => "\U0001F436",
"Rabbit" => "\U0001F430",
"Hamster" => "\U0001F439",
"Parrot" => "\U0001F99C",
_ => "\U0001F43E"
}),
TextBlock(species).SemiBold(),
TextBlock($"({filtered.Count(a => a.Species == species)})").Foreground(TertiaryText)
),
Children = filtered
.Where(a => a.Species == species)
.Select(a => new TreeViewNodeData(a.Name)
{
ContentElement = HStack(8,
TextBlock(a.Emoji),
TextBlock(a.Name),
Caption($"#{a.Id}").Foreground(TertiaryText)
)
}).ToArray()
}).ToArray()
}
)
).CornerRadius(8).Height(300)
TreeView<PetNode>(
items: BuildPetTree(filtered),
childrenSelector: n => n is PetGroup g ? g.Items : null,
viewBuilder: n => n switch
{
PetGroup g => HStack(8,
TextBlock(g.Glyph).FontSize(16),
TextBlock(g.Label).SemiBold(),
Caption($"({g.Items.Count})").Foreground(TertiaryText)),
PetLeaf l => HStack(8,
TextBlock(l.Animal.Emoji),
TextBlock(l.Animal.Name),
Caption($"#{l.Animal.Id}").Foreground(TertiaryText)),
_ => TextBlock(n.Key),
}) with { IsExpanded = _ => true }
).CornerRadius(8).Height(300).Margin(5)
));
}
}
79 changes: 77 additions & 2 deletions samples/ReactorGallery/ControlPages/Collections/TreeViewPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,34 @@ namespace WinUIGalleryReactor;

class TreeViewPage : Component
{
// Heterogeneous node model: folders, documents and images are distinct C#
// shapes. TreeView<T> reads hierarchy off the shape (childrenSelector) and
// picks a per-node template by pattern-matching the shape (viewBuilder) —
// the WinUI ItemTemplateSelector pattern, expressed in C#.
abstract record FsEntry(string Name) : IReactorKeyed { public string Key => Name; }
sealed record FsFolder(string Name, FsEntry[] Items) : FsEntry(Name);
sealed record FsDoc(string Name, string Size) : FsEntry(Name);
sealed record FsImage(string Name, string Dimensions) : FsEntry(Name);

static readonly FsEntry[] SampleTree =
[
new FsFolder("Documents",
[
new FsFolder("Work",
[
new FsDoc("Report.docx", "18 KB"),
new FsDoc("Slides.pptx", "2.1 MB"),
]),
new FsDoc("Budget.xlsx", "44 KB"),
]),
new FsFolder("Pictures",
[
new FsImage("Beach.jpg", "4032 x 3024"),
new FsImage("Mountain.png", "1920 x 1080"),
]),
new FsDoc("readme.txt", "1 KB"),
];

public override Element Render()
{
return ScrollView(
Expand All @@ -31,7 +59,15 @@ public override Element Render()
TreeNode("Family")),
TreeNode("Music")
).Height(300),
@"TreeView(\n TreeNode(""Documents"",\n TreeNode(""Work"",\n TreeNode(""Report.docx""),\n TreeNode(""Slides.pptx""))),\n TreeNode(""Pictures"", ...)\n)"),
"""
TreeView(
TreeNode("Documents",
TreeNode("Work",
TreeNode("Report.docx"),
TreeNode("Slides.pptx"))),
TreeNode("Pictures", ...),
TreeNode("Music"))
"""),

SampleCard("Deeply Nested TreeView",
TreeView(
Expand All @@ -44,7 +80,46 @@ public override Element Render()
TreeNode("Level 1B",
TreeNode("Level 2C")))
).Height(250),
@"TreeView(\n TreeNode(""Root"",\n TreeNode(""Level 1A"",\n TreeNode(""Level 2A"",\n TreeNode(""Level 3A""))))\n)")
"""
TreeView(
TreeNode("Root",
TreeNode("Level 1A",
TreeNode("Level 2A",
TreeNode("Level 3A")))))
"""),

SampleCard("Heterogeneous nodes & custom templates (TreeView<T>)",
(TreeView<FsEntry>(
items: SampleTree,
childrenSelector: e => e is FsFolder f ? f.Items : null,
viewBuilder: e => e switch
{
FsFolder fo => HStack(8,
TextBlock("\U0001F4C1"),
TextBlock(fo.Name).SemiBold(),
Caption($"{fo.Items.Length} items")),
FsImage im => HStack(8,
TextBlock("\U0001F5BC"),
TextBlock(im.Name),
Caption(im.Dimensions)),
FsDoc d => HStack(8,
TextBlock("\U0001F4C4"),
TextBlock(d.Name),
Caption(d.Size)),
_ => TextBlock(e.Name),
}) with { IsExpanded = e => e is FsFolder })
.Height(320),
"""
TreeView<FsEntry>(
items: tree,
childrenSelector: e => e is FsFolder f ? f.Items : null,
viewBuilder: e => e switch
{
FsFolder fo => HStack(Text("📁"), Text(fo.Name).SemiBold(), Caption($"{fo.Items.Length} items")),
FsImage im => HStack(Text("🖼"), Text(im.Name), Caption(im.Dimensions)),
FsDoc d => HStack(Text("📄"), Text(d.Name), Caption(d.Size)),
}) with { IsExpanded = e => e is FsFolder }
""")
).Margin(36, 24, 36, 36)
);
}
Expand Down
105 changes: 105 additions & 0 deletions src/Reactor/Core/Element.cs
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,17 @@ internal static bool OwnPropsEqual(Element a, Element b)
&& ta.GetIsItemClickEnabled() == tb.GetIsItemClickEnabled()
&& !ta.HasSetters && !tb.HasSetters,

// Typed TreeView<T>: Items/selectors/ViewBuilder are factory inputs
// (they drive child reconcile, not parent control properties), so
// own-prop equality compares only the WinUI properties the update
// path writes back — same rationale as the templated collections.
(TemplatedTreeViewElementBase ta, TemplatedTreeViewElementBase tb) =>
ta.SelectionMode == tb.SelectionMode
&& ta.CanDragItems == tb.CanDragItems
&& ta.AllowDrop == tb.AllowDrop
&& ta.CanReorderItems == tb.CanReorderItems
&& ReferenceEquals(ta.SettersErased, tb.SettersErased),

// Lazy (virtualized) stacks: same rationale — Items/ViewBuilder
// are factory inputs, not control properties.
(LazyStackElementBase la, LazyStackElementBase lb) =>
Expand Down Expand Up @@ -1736,6 +1747,13 @@ public record TreeViewNodeData(string Content, TreeViewNodeData[]? Children = nu
/// Optional Reactor element to render as the node's visual content.
/// When null, a TextBlock showing Content is rendered.
/// </summary>
[Obsolete(
"Pre-mounting an element into a node renders blank in WinUI node-mode TreeView " +
"(issue #447): the default ContentPresenter stringifies the node and cannot host a " +
"live UIElement, and per-node events don't resolve. Use the typed " +
"TreeView<T>(items, keySelector, childrenSelector, viewBuilder) overload, which renders " +
"each node through a viewBuilder (like WinUI's ItemTemplate) and hands your own T back " +
"from OnItemInvoked/OnExpanding.")]
public Element? ContentElement { get; init; }
}

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

/// <summary>
/// Non-generic base for the typed, data-driven <see cref="TemplatedTreeViewElement{T}"/>,
/// kept non-generic so the reconciler matches a single type in its mount/update
/// switch (same erasure pattern as <see cref="TemplatedListElementBase"/>).
///
/// <para>Maps to WinUI's native node-mode <c>TreeView</c>: the reconciler builds a
/// <c>TreeViewNode</c> tree from <see cref="GetRoots"/>/<see cref="GetChildren"/> and
/// renders each node through the per-node <see cref="BuildView"/> (a real
/// DataTemplate-style factory) hosted by the <c>{Binding Content}</c> ContentControl
/// template — rather than pre-mounting a concrete element into <c>Content</c> (which
/// renders blank, issue #447). The originating <c>T</c> rides on an attached property
/// of each node so <see cref="InvokeItemInvoked"/>/<see cref="InvokeExpanding"/> hand
/// the developer's own model back.</para>
/// </summary>
public abstract record TemplatedTreeViewElementBase : Element
{
/// <summary>Root data items, erased to <c>object</c> for the reconciler.</summary>
internal abstract IReadOnlyList<object> GetRoots();
/// <summary>Children of <paramref name="item"/>, or null for a leaf.</summary>
internal abstract IReadOnlyList<object>? GetChildren(object item);
/// <summary>Stable identity for keyed reconcile + event payloads.</summary>
internal abstract string GetKey(object item);
/// <summary>The per-node view (the "template/renderer") for <paramref name="item"/>.</summary>
internal abstract Element BuildView(object item);
/// <summary>Initial expansion state for <paramref name="item"/>.</summary>
internal abstract bool GetIsExpanded(object item);
internal abstract void InvokeItemInvoked(object item);
internal abstract void InvokeExpanding(object item);
internal abstract bool HasItemInvoked { get; }
internal abstract bool HasExpanding { get; }

public TreeViewSelectionMode SelectionMode { get; init; } = TreeViewSelectionMode.Single;
public bool CanDragItems { get; init; }
public bool AllowDrop { get; init; }
public bool CanReorderItems { get; init; }
internal abstract Action<WinUI.TreeView>[] SettersErased { get; }
internal override bool HasCallbacks => HasItemInvoked || HasExpanding;
}

/// <summary>
/// Typed, data-driven TreeView — the hierarchical peer of
/// <see cref="TemplatedListViewElement{T}"/>. Each node's visual is produced by
/// <see cref="ViewBuilder"/> (WinUI <c>ItemTemplate</c> equivalent); hierarchy comes
/// from <see cref="ChildrenSelector"/>; identity from <see cref="KeySelector"/>.
/// Construct via the <c>TreeView&lt;T&gt;</c> factory.
/// </summary>
public record TemplatedTreeViewElement<T>(
IReadOnlyList<T> Items,
Func<T, string> KeySelector,
Func<T, IReadOnlyList<T>?> ChildrenSelector,
Func<T, Element> ViewBuilder
) : TemplatedTreeViewElementBase
{
/// <summary>Fires with the developer's own <c>T</c> when a node is invoked.</summary>
public Action<T>? OnItemInvoked { get; init; }
/// <summary>Fires with the developer's own <c>T</c> when a node begins expanding.</summary>
public Action<T>? OnExpanding { get; init; }
/// <summary>Per-item initial expansion; defaults to collapsed (WinUI default).</summary>
public Func<T, bool>? IsExpanded { get; init; }
internal Action<WinUI.TreeView>[] Setters { get; init; } = [];

internal override IReadOnlyList<object> GetRoots() => Project(Items);
internal override IReadOnlyList<object>? GetChildren(object item)
{
var ch = ChildrenSelector((T)item);
return ch is null ? null : Project(ch);
}
internal override string GetKey(object item) => KeySelector((T)item);
internal override Element BuildView(object item) => ViewBuilder((T)item);
internal override bool GetIsExpanded(object item) => IsExpanded?.Invoke((T)item) ?? false;
internal override void InvokeItemInvoked(object item) => OnItemInvoked?.Invoke((T)item);
internal override void InvokeExpanding(object item) => OnExpanding?.Invoke((T)item);
internal override bool HasItemInvoked => OnItemInvoked is not null;
internal override bool HasExpanding => OnExpanding is not null;
internal override Action<WinUI.TreeView>[] SettersErased => Setters;

// Reference-typed T flows through covariant IReadOnlyList<object> with no
// allocation; value-typed T is boxed once per item into a backing array.
private static IReadOnlyList<object> Project(IReadOnlyList<T> src)
{
if (src is IReadOnlyList<object> already) return already;
var arr = new object[src.Count];
for (int i = 0; i < src.Count; i++) arr[i] = src[i]!;
return arr;
}
}

public record FlipViewElement(
Element[] Items
) : Element
Expand Down
Loading
Loading