diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index 2cf8d941ca..b2a90229f4 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -10,6 +10,7 @@ using Avalonia.Controls.Generators; using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Input.Platform; +using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Threading; using Avalonia.VisualTree; @@ -60,7 +61,8 @@ namespace Avalonia.Controls /// static TreeView() { - // HACK: Needed or SelectedItem property will not be found in Release build. + SelectingItemsControl.IsSelectedChangedEvent.AddClassHandler((x, e) => + x.ContainerSelectionChanged(e)); } /// @@ -430,9 +432,8 @@ namespace Avalonia.Controls private void MarkItemSelected(object item, bool selected) { - var container = TreeContainerFromItem(item)!; - - MarkContainerSelected(container, selected); + if (TreeContainerFromItem(item) is Control container) + MarkContainerSelected(container, selected); } private void SelectedItemsAdded(IList items) @@ -487,15 +488,32 @@ namespace Avalonia.Controls protected internal override Control CreateContainerForItemOverride() => new TreeViewItem(); protected internal override bool IsItemItsOwnContainerOverride(Control item) => item is TreeViewItem; - protected internal override void PrepareContainerForItemOverride(Control container, object? item, int index) + protected internal override void ContainerForItemPreparedOverride(Control container, object? item, int index) { - base.PrepareContainerForItemOverride(container, item, index); + base.ContainerForItemPreparedOverride(container, item, index); - if (item == SelectedItem) + // Once the container has been full prepared and added to the tree, any bindings from + // styles or item container themes are guaranteed to be applied. + if (!container.IsSet(SelectingItemsControl.IsSelectedProperty)) { - MarkContainerSelected(container, true); - if (AutoScrollToSelectedItem) - Dispatcher.UIThread.Post(container.BringIntoView); + // The IsSelected property is not set on the container: update the container + // selection based on the current selection as understood by this control. + MarkContainerSelected(container, SelectedItems.Contains(item)); + } + else + { + // The IsSelected property is set on the container: there is a style or item + // container theme which has bound the IsSelected property. Update our selection + // based on the selection state of the container. + var containerIsSelected = SelectingItemsControl.GetIsSelected(container); + + if (containerIsSelected != SelectedItems.Contains(item)) + { + if (containerIsSelected) + SelectedItems.Add(item); + else + SelectedItems.Remove(item); + } } } @@ -863,27 +881,44 @@ namespace Avalonia.Controls } /// - /// Sets a container's 'selected' class or . + /// Called when a container raises the + /// . /// - /// The container. - /// Whether the control is selected - private void MarkContainerSelected(Control? container, bool selected) + /// The event. + private void ContainerSelectionChanged(RoutedEventArgs e) { - if (container == null) + if (e.Source is TreeViewItem container && + container.TreeViewOwner == this && + TreeItemFromContainer(container) is object item) { - return; - } + var containerIsSelected = SelectingItemsControl.GetIsSelected(container); + var ourIsSelected = SelectedItems.Contains(item); - if (container is ISelectable selectable) - { - selectable.IsSelected = selected; + if (containerIsSelected != ourIsSelected) + { + if (containerIsSelected) + SelectedItems.Add(item); + else + SelectedItems.Remove(item); + } } - else + + if (e.Source != this) { - ((IPseudoClasses)container.Classes).Set(":selected", selected); + e.Handled = true; } } + /// + /// Sets a container's 'selected' class or . + /// + /// The container. + /// Whether the control is selected + private void MarkContainerSelected(Control container, bool selected) + { + container.SetCurrentValue(SelectingItemsControl.IsSelectedProperty, selected); + } + /// /// Makes a list of objects equal another (though doesn't preserve order). /// diff --git a/src/Avalonia.Controls/TreeViewItem.cs b/src/Avalonia.Controls/TreeViewItem.cs index 1bea605dc3..806d7e320b 100644 --- a/src/Avalonia.Controls/TreeViewItem.cs +++ b/src/Avalonia.Controls/TreeViewItem.cs @@ -105,6 +105,11 @@ namespace Avalonia.Controls EnsureTreeView().PrepareContainerForItemOverride(container, item, index); } + protected internal override void ContainerForItemPreparedOverride(Control container, object? item, int index) + { + EnsureTreeView().ContainerForItemPreparedOverride(container, item, index); + } + /// protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e) { diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index 3ca70f96cc..b2cf51629a 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -5,6 +5,7 @@ using System.ComponentModel; using System.Linq; using Avalonia.Collections; using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Data.Core; @@ -219,7 +220,7 @@ namespace Avalonia.Controls.UnitTests Assert.True(fromContainer.IsSelected); ClickContainer(toContainer, KeyModifiers.Shift); - AssertChildrenSelected(target, rootNode); + AssertAllChildContainersSelected(target, rootNode); } [Fact] @@ -238,7 +239,7 @@ namespace Avalonia.Controls.UnitTests Assert.True(fromContainer.IsSelected); ClickContainer(toContainer, KeyModifiers.Shift); - AssertChildrenSelected(target, rootNode); + AssertAllChildContainersSelected(target, rootNode); } [Fact] @@ -255,7 +256,7 @@ namespace Avalonia.Controls.UnitTests ClickContainer(fromContainer, KeyModifiers.None); ClickContainer(toContainer, KeyModifiers.Shift); - AssertChildrenSelected(target, rootNode); + AssertAllChildContainersSelected(target, rootNode); ClickContainer(fromContainer, KeyModifiers.None); Assert.True(fromContainer.IsSelected); @@ -975,7 +976,7 @@ namespace Avalonia.Controls.UnitTests target.RaiseEvent(keyEvent); - AssertChildrenSelected(target, rootNode); + AssertAllChildContainersSelected(target, rootNode); } [Fact] @@ -1005,7 +1006,7 @@ namespace Avalonia.Controls.UnitTests target.RaiseEvent(keyEvent); - AssertChildrenSelected(target, rootNode); + AssertAllChildContainersSelected(target, rootNode); } [Fact] @@ -1035,7 +1036,7 @@ namespace Avalonia.Controls.UnitTests target.RaiseEvent(keyEvent); - AssertChildrenSelected(target, rootNode); + AssertAllChildContainersSelected(target, rootNode); } [Fact] @@ -1047,7 +1048,7 @@ namespace Avalonia.Controls.UnitTests target.SelectAll(); - AssertChildrenSelected(target, data[0]); + AssertAllChildContainersSelected(target, data[0]); Assert.Equal(5, target.SelectedItems.Count); _mouse.Click(target.Presenter!.Panel!.Children[0], MouseButton.Right); @@ -1259,6 +1260,87 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void Can_Bind_Initial_Selected_State_Via_ItemContainerTheme() + { + using var app = Start(); + var data = CreateTestTreeData(); + var selected = new[] { data[0], data[0].Children[1] }; + + foreach (var node in selected) + node.IsSelected = true; + + var itemTheme = new ControlTheme(typeof(TreeViewItem)) + { + BasedOn = CreateTreeViewItemControlTheme(), + Setters = + { + new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")), + } + }; + + var target = CreateTarget(data: data, itemContainerTheme: itemTheme); + + AssertDataSelection(data, selected); + AssertContainerSelection(target, selected); + Assert.Equal(selected[0], target.SelectedItem); + Assert.Equal(selected, target.SelectedItems); + } + + [Fact] + public void Can_Bind_Initial_Selected_State_Via_Style() + { + using var app = Start(); + var data = CreateTestTreeData(); + var selected = new[] { data[0], data[0].Children[1] }; + + foreach (var node in selected) + node.IsSelected = true; + + var style = new Style(x => x.OfType()) + { + Setters = + { + new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")), + } + }; + + var target = CreateTarget(data: data, styles: new[] { style }); + + AssertDataSelection(data, selected); + AssertContainerSelection(target, selected); + Assert.Equal(selected[0], target.SelectedItem); + Assert.Equal(selected, target.SelectedItems); + } + + [Fact] + public void Selection_State_Is_Updated_Via_IsSelected_Binding() + { + using var app = Start(); + var data = CreateTestTreeData(); + var selected = new[] { data[0], data[0].Children[1] }; + + selected[0].IsSelected = true; + + var itemTheme = new ControlTheme(typeof(TreeViewItem)) + { + BasedOn = CreateTreeViewItemControlTheme(), + Setters = + { + new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")), + } + }; + + var target = CreateTarget(data: data, itemContainerTheme: itemTheme); + + selected[1].IsSelected = true; + + AssertDataSelection(data, selected); + AssertContainerSelection(target, selected); + Assert.Equal(selected[0], target.SelectedItem); + Assert.Equal(selected, target.SelectedItems); + } + private static TreeView CreateTarget(Optional?> data = default, bool expandAll = true, ControlTheme? itemContainerTheme = null, @@ -1465,17 +1547,61 @@ namespace Avalonia.Controls.UnitTests _mouse.Click(container, modifiers: modifiers); } - private void AssertChildrenSelected(TreeView treeView, Node rootNode) + private void AssertContainerSelection(TreeView treeView, params Node[] expected) { - Assert.NotNull(rootNode.Children); + static void Evaluate(Control container, HashSet remaining) + { + var treeViewItem = Assert.IsType(container); + var node = (Node)container.DataContext!; - foreach (var child in rootNode.Children) + Assert.Equal(remaining.Contains(node), treeViewItem.IsSelected); + remaining.Remove(node); + + foreach (var child in treeViewItem.GetRealizedContainers()) + { + Evaluate(child, remaining); + } + } + + var remaining = expected.ToHashSet(); + foreach (var container in treeView.GetRealizedContainers()) + Evaluate(container, remaining); + Assert.Empty(remaining); + } + + private void AssertAllChildContainersSelected(TreeView treeView, Node node) + { + Assert.NotNull(node.Children); + + foreach (var child in node.Children) { var container = Assert.IsType(treeView.TreeContainerFromItem(child)); Assert.True(container.IsSelected); } } + private void AssertDataSelection(IEnumerable data, params Node[] expected) + { + static void Evaluate(Node rootNode, HashSet remaining) + { + Assert.Equal(remaining.Contains(rootNode), rootNode.IsSelected); + remaining.Remove(rootNode); + + if (rootNode.Children is null) + return; + + foreach (var child in rootNode.Children) + { + Evaluate(child, remaining); + } + } + + var remaining = expected.ToHashSet(); + foreach (var node in data) + Evaluate(node, remaining); + Assert.Empty(remaining); + } + private IDisposable Start() { return UnitTestApplication.Start( @@ -1492,6 +1618,7 @@ namespace Avalonia.Controls.UnitTests private class Node : NotifyingBase { private IAvaloniaList _children = new AvaloniaList(); + private bool _isSelected; public string? Value { get; set; } @@ -1504,6 +1631,21 @@ namespace Avalonia.Controls.UnitTests RaisePropertyChanged(nameof(Children)); } } + + public bool IsSelected + { + get => _isSelected; + set + { + if (_isSelected != value) + { + _isSelected = value; + RaisePropertyChanged(); + } + } + } + + public override string ToString() => Value ?? string.Empty; } private class TestTreeDataTemplate : ITreeDataTemplate