From c4afd15d900f639994d479f20bebd18f8f83c0b5 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 17 Feb 2020 12:29:43 +0100 Subject: [PATCH] Use SelectionModel in TreeView. --- .../ControlCatalog/Pages/TreeViewPage.xaml | 2 +- .../ControlCatalog/Pages/TreeViewPage.xaml.cs | 17 +- src/Avalonia.Controls/TreeView.cs | 663 ++++++++---------- .../TreeViewTests.cs | 17 +- 4 files changed, 315 insertions(+), 384 deletions(-) diff --git a/samples/ControlCatalog/Pages/TreeViewPage.xaml b/samples/ControlCatalog/Pages/TreeViewPage.xaml index 3a81e2ed02..c9e3fafb6d 100644 --- a/samples/ControlCatalog/Pages/TreeViewPage.xaml +++ b/samples/ControlCatalog/Pages/TreeViewPage.xaml @@ -10,7 +10,7 @@ HorizontalAlignment="Center" Spacing="16"> - + diff --git a/samples/ControlCatalog/Pages/TreeViewPage.xaml.cs b/samples/ControlCatalog/Pages/TreeViewPage.xaml.cs index 1f35f05f1d..5893796b8b 100644 --- a/samples/ControlCatalog/Pages/TreeViewPage.xaml.cs +++ b/samples/ControlCatalog/Pages/TreeViewPage.xaml.cs @@ -28,21 +28,22 @@ namespace ControlCatalog.Pages { Node root = new Node(); Items = root.Children; - SelectedItems = new ObservableCollection(); + Selection = new SelectionModel(); AddItemCommand = ReactiveCommand.Create(() => { - Node parentItem = SelectedItems.Count > 0 ? SelectedItems[0] : root; + Node parentItem = Selection.SelectedItems.Count > 0 ? + (Node)Selection.SelectedItems[0] : root; parentItem.AddNewItem(); }); RemoveItemCommand = ReactiveCommand.Create(() => { - while (SelectedItems.Count > 0) + while (Selection.SelectedItems.Count > 0) { - Node lastItem = SelectedItems[0]; + Node lastItem = (Node)Selection.SelectedItems[0]; RecursiveRemove(Items, lastItem); - SelectedItems.Remove(lastItem); + Selection.DeselectAt(Selection.SelectedIndices[0]); } bool RecursiveRemove(ObservableCollection items, Node selectedItem) @@ -67,7 +68,7 @@ namespace ControlCatalog.Pages public ObservableCollection Items { get; } - public ObservableCollection SelectedItems { get; } + public SelectionModel Selection { get; } public ReactiveCommand AddItemCommand { get; } @@ -78,7 +79,7 @@ namespace ControlCatalog.Pages get => _selectionMode; set { - SelectedItems.Clear(); + Selection.ClearSelection(); this.RaiseAndSetIfChanged(ref _selectionMode, value); } } @@ -109,7 +110,7 @@ namespace ControlCatalog.Pages public override string ToString() => Header; - private Node CreateNewNode() => new Node {Header = $"Item {_counter++}"}; + private Node CreateNewNode() => new Node { Header = $"Item {_counter++}" }; } } } diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index 3a7ad97763..19d378404e 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -5,11 +5,12 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Collections.Specialized; +using System.ComponentModel; using System.Linq; -using Avalonia.Collections; using Avalonia.Controls.Generators; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Utils; +using Avalonia.Data; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; @@ -45,15 +46,29 @@ namespace Avalonia.Controls o => o.SelectedItems, (o, v) => o.SelectedItems = v); + /// + /// Defines the property. + /// + public static readonly DirectProperty SelectionProperty = + SelectingItemsControl.SelectionProperty.AddOwner( + o => o.Selection, + (o, v) => o.Selection = v); + /// /// Defines the property. /// public static readonly StyledProperty SelectionModeProperty = ListBox.SelectionModeProperty.AddOwner(); - private static readonly IList Empty = Array.Empty(); + /// + /// Defines the property. + /// + public static RoutedEvent SelectionChangedEvent = + SelectingItemsControl.SelectionChangedEvent; + private object _selectedItem; - private IList _selectedItems; + private ISelectionModel _selection; + private readonly SelectedItemsSync _selectedItems; /// /// Initializes static members of the class. @@ -63,6 +78,13 @@ namespace Avalonia.Controls // HACK: Needed or SelectedItem property will not be found in Release build. } + public TreeView() + { + // Setting Selection to null causes a default SelectionModel to be created. + Selection = null; + _selectedItems = new SelectedItemsSync(Selection); + } + /// /// Occurs when the control's selection changes. /// @@ -87,8 +109,6 @@ namespace Avalonia.Controls set => SetValue(AutoScrollToSelectedItemProperty, value); } - private bool _syncingSelectedItems; - /// /// Gets or sets the selection mode. /// @@ -98,61 +118,102 @@ namespace Avalonia.Controls set => SetValue(SelectionModeProperty, value); } + /// + /// Gets or sets the selected item. + /// /// /// Gets or sets the selected item. /// public object SelectedItem { - get => _selectedItem; - set - { - var selectedItems = SelectedItems; - - SetAndRaise(SelectedItemProperty, ref _selectedItem, value); + get => Selection.SelectedItem; + set => Selection.SelectedIndex = IndexFromItem(value); + } - if (value != null) - { - if (selectedItems.Count != 1 || selectedItems[0] != value) - { - _syncingSelectedItems = true; - SelectSingleItem(value); - _syncingSelectedItems = false; - } - } - else if (SelectedItems.Count > 0) - { - SelectedItems.Clear(); - } - } + /// + /// Gets or sets the selected items. + /// + protected IList SelectedItems + { + get => _selectedItems.GetOrCreateItems(); + set => _selectedItems.SetItems(value); } /// - /// Gets the selected items. + /// Gets or sets a model holding the current selection. /// - public IList SelectedItems + public ISelectionModel Selection { - get + get => _selection; + set { - if (_selectedItems == null) + value ??= new SelectionModel { - _selectedItems = new AvaloniaList(); - SubscribeToSelectedItems(); - } - - return _selectedItems; - } + SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple), + AutoSelect = SelectionMode.HasFlagCustom(SelectionMode.AlwaysSelected), + RetainSelectionOnReset = true, + }; - set - { - if (value?.IsFixedSize == true || value?.IsReadOnly == true) + if (_selection != value) { - throw new NotSupportedException( - "Cannot use a fixed size or read-only collection as SelectedItems."); - } + if (value == null) + { + throw new ArgumentNullException(nameof(value), "Cannot set Selection to null."); + } + else if (value.Source != null && value.Source != Items) + { + throw new ArgumentException("Selection has invalid Source."); + } + + List oldSelection = null; + + if (_selection != null) + { + oldSelection = Selection.SelectedItems.ToList(); + _selection.PropertyChanged -= OnSelectionModelPropertyChanged; + _selection.SelectionChanged -= OnSelectionModelSelectionChanged; + _selection.ChildrenRequested -= OnSelectionModelChildrenRequested; + MarkContainersUnselected(); + } + + _selection = value; + + if (_selection != null) + { + _selection.Source = Items; + _selection.PropertyChanged += OnSelectionModelPropertyChanged; + _selection.SelectionChanged += OnSelectionModelSelectionChanged; + _selection.ChildrenRequested += OnSelectionModelChildrenRequested; - UnsubscribeFromSelectedItems(); - _selectedItems = value ?? new AvaloniaList(); - SubscribeToSelectedItems(); + if (_selection.SingleSelect) + { + SelectionMode &= ~SelectionMode.Multiple; + } + else + { + SelectionMode |= SelectionMode.Multiple; + } + + if (_selection.AutoSelect) + { + SelectionMode |= SelectionMode.AlwaysSelected; + } + else + { + SelectionMode &= ~SelectionMode.AlwaysSelected; + } + + UpdateContainerSelection(); + + var selectedItem = SelectedItem; + + if (_selectedItem != selectedItem) + { + RaisePropertyChanged(SelectedItemProperty, _selectedItem, selectedItem); + _selectedItem = selectedItem; + } + } + } } } @@ -185,186 +246,12 @@ namespace Avalonia.Controls /// Note that this method only selects nodes currently visible due to their parent nodes /// being expanded: it does not expand nodes. /// - public void SelectAll() - { - SynchronizeItems(SelectedItems, ItemContainerGenerator.Index.Items); - } + public void SelectAll() => Selection.SelectAll(); /// /// Deselects all items in the . /// - public void UnselectAll() - { - SelectedItems.Clear(); - } - - /// - /// Subscribes to the CollectionChanged event, if any. - /// - private void SubscribeToSelectedItems() - { - if (_selectedItems is INotifyCollectionChanged incc) - { - incc.CollectionChanged += SelectedItemsCollectionChanged; - } - - SelectedItemsCollectionChanged( - _selectedItems, - new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - } - - private void SelectSingleItem(object item) - { - SelectedItems.Clear(); - SelectedItems.Add(item); - } - - /// - /// Called when the CollectionChanged event is raised. - /// - /// The event sender. - /// The event args. - private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) - { - IList added = null; - IList removed = null; - - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - - SelectedItemsAdded(e.NewItems.Cast().ToArray()); - - if (AutoScrollToSelectedItem) - { - var container = (TreeViewItem)ItemContainerGenerator.Index.ContainerFromItem(e.NewItems[0]); - - container?.BringIntoView(); - } - - added = e.NewItems; - - break; - case NotifyCollectionChangedAction.Remove: - - if (!_syncingSelectedItems) - { - if (SelectedItems.Count == 0) - { - SelectedItem = null; - } - else - { - var selectedIndex = SelectedItems.IndexOf(_selectedItem); - - if (selectedIndex == -1) - { - var old = _selectedItem; - _selectedItem = SelectedItems[0]; - - RaisePropertyChanged(SelectedItemProperty, old, _selectedItem); - } - } - } - - foreach (var item in e.OldItems) - { - MarkItemSelected(item, false); - } - - removed = e.OldItems; - - break; - case NotifyCollectionChangedAction.Reset: - - foreach (IControl container in ItemContainerGenerator.Index.Containers) - { - MarkContainerSelected(container, false); - } - - if (SelectedItems.Count > 0) - { - SelectedItemsAdded(SelectedItems); - - added = SelectedItems; - } - else if (!_syncingSelectedItems) - { - SelectedItem = null; - } - - break; - case NotifyCollectionChangedAction.Replace: - - foreach (var item in e.OldItems) - { - MarkItemSelected(item, false); - } - - foreach (var item in e.NewItems) - { - MarkItemSelected(item, true); - } - - if (SelectedItem != SelectedItems[0] && !_syncingSelectedItems) - { - var oldItem = SelectedItem; - var item = SelectedItems[0]; - _selectedItem = item; - RaisePropertyChanged(SelectedItemProperty, oldItem, item); - } - - added = e.NewItems; - removed = e.OldItems; - - break; - } - - if (added?.Count > 0 || removed?.Count > 0) - { - var changed = new SelectionChangedEventArgs( - SelectingItemsControl.SelectionChangedEvent, - removed ?? Empty, - added ?? Empty); - RaiseEvent(changed); - } - } - - private void MarkItemSelected(object item, bool selected) - { - var container = ItemContainerGenerator.Index.ContainerFromItem(item); - - MarkContainerSelected(container, selected); - } - - private void SelectedItemsAdded(IList items) - { - if (items.Count == 0) - { - return; - } - - foreach (object item in items) - { - MarkItemSelected(item, true); - } - - if (SelectedItem == null && !_syncingSelectedItems) - { - SetAndRaise(SelectedItemProperty, ref _selectedItem, items[0]); - } - } - - /// - /// Unsubscribes from the CollectionChanged event, if any. - /// - private void UnsubscribeFromSelectedItems() - { - if (_selectedItems is INotifyCollectionChanged incc) - { - incc.CollectionChanged -= SelectedItemsCollectionChanged; - } - } + public void UnselectAll() => Selection.ClearSelection(); (bool handled, IInputElement next) ICustomKeyboardNavigation.GetNext(IInputElement element, NavigationDirection direction) @@ -448,6 +335,72 @@ namespace Avalonia.Controls } } + /// + /// Called when is raised. + /// + /// The sender. + /// The event args. + private void OnSelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SelectionModel.AnchorIndex) && AutoScrollToSelectedItem) + { + var container = ContainerFromIndex(Selection.AnchorIndex); + + if (container != null) + { + container.BringIntoView(); + } + } + } + + /// + /// Called when is raised. + /// + /// The sender. + /// The event args. + private void OnSelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) + { + void Mark(IndexPath index, bool selected) + { + var container = ContainerFromIndex(index); + + if (container != null) + { + MarkContainerSelected(container, selected); + } + } + + foreach (var i in e.SelectedIndices) + { + Mark(i, true); + } + + foreach (var i in e.DeselectedIndices) + { + Mark(i, false); + } + + var newSelectedItem = SelectedItem; + + if (newSelectedItem != _selectedItem) + { + RaisePropertyChanged(SelectedItemProperty, _selectedItem, newSelectedItem); + _selectedItem = newSelectedItem; + } + + var ev = new SelectionChangedEventArgs( + SelectionChangedEvent, + e.DeselectedItems.ToList(), + e.SelectedItems.ToList()); + RaiseEvent(ev); + } + + private void OnSelectionModelChildrenRequested(object sender, SelectionModelChildrenRequestedEventArgs e) + { + var container = ItemContainerGenerator.Index.ContainerFromItem(e.Source) as ItemsControl; + e.Children = container?.Items as IEnumerable; + } + private TreeViewItem GetContainerInDirection( TreeViewItem from, NavigationDirection direction, @@ -501,6 +454,12 @@ namespace Avalonia.Controls return result; } + protected override void ItemsChanged(AvaloniaPropertyChangedEventArgs e) + { + Selection.Source = Items; + base.ItemsChanged(e); + } + /// protected override void OnPointerPressed(PointerPressedEventArgs e) { @@ -522,6 +481,18 @@ namespace Avalonia.Controls } } + protected override void OnPropertyChanged(AvaloniaProperty property, Optional oldValue, BindingValue newValue, BindingPriority priority) + { + base.OnPropertyChanged(property, oldValue, newValue, priority); + + if (property == SelectionModeProperty) + { + var mode = newValue.GetValueOrDefault(); + Selection.SingleSelect = !mode.HasFlagCustom(SelectionMode.Multiple); + Selection.AutoSelect = mode.HasFlagCustom(SelectionMode.AlwaysSelected); + } + } + /// /// Updates the selection for an item based on user interaction. /// @@ -537,9 +508,9 @@ namespace Avalonia.Controls bool toggleModifier = false, bool rightButton = false) { - var item = ItemContainerGenerator.Index.ItemFromContainer(container); + var index = IndexFromContainer((TreeViewItem)container); - if (item == null) + if (index.GetSize() == 0) { return; } @@ -556,41 +527,48 @@ namespace Avalonia.Controls var multi = (mode & SelectionMode.Multiple) != 0; var range = multi && selectedContainer != null && rangeModifier; - if (rightButton) + if (!select) + { + Selection.DeselectAt(index); + } + else if (rightButton) { - if (!SelectedItems.Contains(item)) + if (!Selection.IsSelectedAt(index)) { - SelectSingleItem(item); + Selection.SelectedIndex = index; } } else if (!toggle && !range) { - SelectSingleItem(item); + Selection.SelectedIndex = index; } else if (multi && range) { - SynchronizeItems( - SelectedItems, - GetItemsInRange(selectedContainer as TreeViewItem, container as TreeViewItem)); + using var operation = Selection.Update(); + var anchor = Selection.AnchorIndex; + + if (anchor.GetSize() == 0) + { + anchor = new IndexPath(0); + } + + Selection.ClearSelection(); + Selection.AnchorIndex = anchor; + Selection.SelectRangeFromAnchorTo(index); } else { - var i = SelectedItems.IndexOf(item); - - if (i != -1) + if (Selection.IsSelectedAt(index)) { - SelectedItems.Remove(item); + Selection.DeselectAt(index); + } + else if (multi) + { + Selection.SelectAt(index); } else { - if (multi) - { - SelectedItems.Add(item); - } - else - { - SelectedItem = item; - } + Selection.SelectedIndex = index; } } } @@ -613,117 +591,6 @@ namespace Avalonia.Controls } } - /// - /// Find which node is first in hierarchy. - /// - /// Search root. - /// Nodes to find. - /// Node to find. - /// Found first node. - private static TreeViewItem FindFirstNode(TreeView treeView, TreeViewItem nodeA, TreeViewItem nodeB) - { - return FindInContainers(treeView.ItemContainerGenerator, nodeA, nodeB); - } - - private static TreeViewItem FindInContainers(ITreeItemContainerGenerator containerGenerator, - TreeViewItem nodeA, - TreeViewItem nodeB) - { - IEnumerable containers = containerGenerator.Containers; - - foreach (ItemContainerInfo container in containers) - { - TreeViewItem node = FindFirstNode(container.ContainerControl as TreeViewItem, nodeA, nodeB); - - if (node != null) - { - return node; - } - } - - return null; - } - - private static TreeViewItem FindFirstNode(TreeViewItem node, TreeViewItem nodeA, TreeViewItem nodeB) - { - if (node == null) - { - return null; - } - - TreeViewItem match = node == nodeA ? nodeA : node == nodeB ? nodeB : null; - - if (match != null) - { - return match; - } - - return FindInContainers(node.ItemContainerGenerator, nodeA, nodeB); - } - - /// - /// Returns all items that belong to containers between and . - /// The range is inclusive. - /// - /// From container. - /// To container. - private List GetItemsInRange(TreeViewItem from, TreeViewItem to) - { - var items = new List(); - - if (from == null || to == null) - { - return items; - } - - TreeViewItem firstItem = FindFirstNode(this, from, to); - - if (firstItem == null) - { - return items; - } - - bool wasReversed = false; - - if (firstItem == to) - { - var temp = from; - - from = to; - to = temp; - - wasReversed = true; - } - - TreeViewItem node = from; - - while (node != to) - { - var item = ItemContainerGenerator.Index.ItemFromContainer(node); - - if (item != null) - { - items.Add(item); - } - - node = GetContainerInDirection(node, NavigationDirection.Down, true); - } - - var toItem = ItemContainerGenerator.Index.ItemFromContainer(to); - - if (toItem != null) - { - items.Add(toItem); - } - - if (wasReversed) - { - items.Reverse(); - } - - return items; - } - /// /// Updates the selection based on an event that may have originated in a container that /// belongs to the control. @@ -829,26 +696,90 @@ namespace Avalonia.Controls } } - /// - /// Makes a list of objects equal another (though doesn't preserve order). - /// - /// The items collection. - /// The desired items. - private static void SynchronizeItems(IList items, IEnumerable desired) + private void MarkContainersUnselected() { - var list = items.Cast().ToList(); - var toRemove = list.Except(desired).ToList(); - var toAdd = desired.Except(list).ToList(); + foreach (var container in ItemContainerGenerator.Index.Containers) + { + MarkContainerSelected(container, false); + } + } + + private void UpdateContainerSelection() + { + var index = ItemContainerGenerator.Index; + + foreach (var container in index.Containers) + { + var i = IndexFromContainer((TreeViewItem)container); + + MarkContainerSelected( + container, + Selection.IsSelectedAt(i) != false); + } + } + + private static IndexPath IndexFromContainer(TreeViewItem container) + { + var result = new List(); + + while (true) + { + if (container.Level == 0) + { + var treeView = container.FindAncestorOfType(); + + if (treeView == null) + { + return default; + } + + result.Add(treeView.ItemContainerGenerator.IndexFromContainer(container)); + result.Reverse(); + return new IndexPath(result); + } + else + { + var parent = container.FindAncestorOfType(); + + if (parent == null) + { + return default; + } + + result.Add(parent.ItemContainerGenerator.IndexFromContainer(container)); + container = parent; + } + } + } + + private IndexPath IndexFromItem(object item) + { + var container = ItemContainerGenerator.Index.ContainerFromItem(item) as TreeViewItem; - foreach (var i in toRemove) + if (container != null) { - items.Remove(i); + return IndexFromContainer(container); } - foreach (var i in toAdd) + return default; + } + + private TreeViewItem ContainerFromIndex(IndexPath index) + { + TreeViewItem treeViewItem = null; + + for (var i = 0; i < index.GetSize(); ++i) { - items.Add(i); + var generator = treeViewItem?.ItemContainerGenerator ?? ItemContainerGenerator; + treeViewItem = generator.ContainerFromIndex(index.GetAt(i)) as TreeViewItem; + + if (treeViewItem == null) + { + return null; + } } + + return treeViewItem; } } } diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index a2892be5c2..1ce1f8cc4c 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -10,7 +10,6 @@ using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Data.Core; -using Avalonia.Diagnostics; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; @@ -244,12 +243,12 @@ namespace Avalonia.Controls.UnitTests ClickContainer(item2Container, InputModifiers.Control); Assert.True(item2Container.IsSelected); - Assert.Equal(new[] {item1, item2}, target.SelectedItems.OfType()); + Assert.Equal(new[] {item1, item2}, target.Selection.SelectedItems.OfType()); ClickContainer(item1Container, InputModifiers.Control); Assert.False(item1Container.IsSelected); - Assert.DoesNotContain(item1, target.SelectedItems.OfType()); + Assert.DoesNotContain(item1, target.Selection.SelectedItems.OfType()); } [Fact] @@ -749,11 +748,11 @@ namespace Avalonia.Controls.UnitTests target.SelectAll(); AssertChildrenSelected(target, tree[0]); - Assert.Equal(5, target.SelectedItems.Count); + Assert.Equal(5, target.Selection.SelectedItems.Count); _mouse.Click((Interactive)target.Presenter.Panel.Children[0], MouseButton.Right); - Assert.Equal(5, target.SelectedItems.Count); + Assert.Equal(5, target.Selection.SelectedItems.Count); } [Fact] @@ -785,11 +784,11 @@ namespace Avalonia.Controls.UnitTests ClickContainer(fromContainer, InputModifiers.None); ClickContainer(toContainer, InputModifiers.Shift); - Assert.Equal(2, target.SelectedItems.Count); + Assert.Equal(2, target.Selection.SelectedItems.Count); _mouse.Click(thenContainer, MouseButton.Right); - Assert.Equal(1, target.SelectedItems.Count); + Assert.Equal(1, target.Selection.SelectedItems.Count); } [Fact] @@ -819,7 +818,7 @@ namespace Avalonia.Controls.UnitTests _mouse.Click(fromContainer); _mouse.Click(toContainer, MouseButton.Right, modifiers: InputModifiers.Shift); - Assert.Equal(1, target.SelectedItems.Count); + Assert.Equal(1, target.Selection.SelectedItems.Count); } [Fact] @@ -849,7 +848,7 @@ namespace Avalonia.Controls.UnitTests _mouse.Click(fromContainer); _mouse.Click(toContainer, MouseButton.Right, modifiers: InputModifiers.Control); - Assert.Equal(1, target.SelectedItems.Count); + Assert.Equal(1, target.Selection.SelectedItems.Count); } [Fact]