From 3f6e982be88403f790d38a0d83e5f064c32dda0f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 1 Feb 2020 23:43:24 +0100 Subject: [PATCH] Added SelectionModel.RetainSelectionOnReset. --- .../Repeater/ItemsSourceView.cs | 2 + src/Avalonia.Controls/SelectionModel.cs | 7 +- src/Avalonia.Controls/SelectionNode.cs | 104 +++++++++++++-- .../SelectionModelTests.cs | 120 ++++++++++++++++++ 4 files changed, 224 insertions(+), 9 deletions(-) diff --git a/src/Avalonia.Controls/Repeater/ItemsSourceView.cs b/src/Avalonia.Controls/Repeater/ItemsSourceView.cs index 02ead7ef36..ecf8abc13f 100644 --- a/src/Avalonia.Controls/Repeater/ItemsSourceView.cs +++ b/src/Avalonia.Controls/Repeater/ItemsSourceView.cs @@ -96,6 +96,8 @@ namespace Avalonia.Controls /// the item. public object GetAt(int index) => _inner[index]; + public int IndexOf(object item) => _inner.IndexOf(item); + /// /// Retrieves the index of the item that has the specified unique identifier (key). /// diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index c8d2c5cc9e..e5f79fa40f 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -73,6 +73,11 @@ namespace Avalonia.Controls } } + public bool RetainSelectionOnReset + { + get => _rootNode.RetainSelectionOnReset; + set => _rootNode.RetainSelectionOnReset = value; + } public IndexPath AnchorIndex { @@ -497,7 +502,7 @@ namespace Avalonia.Controls } public void OnSelectionInvalidatedDueToCollectionChange( - IReadOnlyList? removedItems) + IReadOnlyList? removedItems) { var e = new SelectionModelSelectionChangedEventArgs(null, null, removedItems, null); OnSelectionChanged(e); diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index 85db801100..a8ad634d35 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -32,6 +32,8 @@ namespace Avalonia.Controls private SelectionNodeOperation? _operation; private object? _source; private bool _selectedIndicesCacheIsValid; + private bool _retainSelectionOnReset; + private List? _selectedItems; public SelectionNode(SelectionModel manager, SelectionNode? parent) { @@ -41,6 +43,40 @@ namespace Avalonia.Controls public int AnchorIndex { get; set; } = -1; + public bool RetainSelectionOnReset + { + get => _retainSelectionOnReset; + set + { + if (_retainSelectionOnReset != value) + { + _retainSelectionOnReset = value; + + if (_retainSelectionOnReset) + { + _selectedItems = new List(); + + foreach (var i in SelectedIndices) + { + _selectedItems.Add(ItemsSourceView!.GetAt(i)); + } + } + else + { + _selectedItems = null; + } + + foreach (var child in _childrenNodes) + { + if (child != null) + { + child.RetainSelectionOnReset = value; + } + } + } + } + } + public object? Source { get => _source; @@ -414,6 +450,14 @@ namespace Avalonia.Controls { _operation?.Selected(selected); + if (_selectedItems != null) + { + for (var i = addRange.Begin; i <= addRange.End; ++i) + { + _selectedItems.Add(ItemsSourceView!.GetAt(i)); + } + } + if (raiseOnSelectionChanged) { OnSelectionChanged(); @@ -431,6 +475,14 @@ namespace Avalonia.Controls { _operation?.Deselected(removed); + if (_selectedItems != null) + { + for (var i = removeRange.Begin; i <= removeRange.End; ++i) + { + _selectedItems.Remove(ItemsSourceView!.GetAt(i)); + } + } + if (raiseOnSelectionChanged) { OnSelectionChanged(); @@ -448,6 +500,7 @@ namespace Avalonia.Controls OnSelectionChanged(); } + _selectedItems?.Clear(); SelectedCount = 0; AnchorIndex = -1; @@ -492,7 +545,7 @@ namespace Avalonia.Controls private void OnSourceListChanged(object dataSource, NotifyCollectionChangedEventArgs args) { bool selectionInvalidated = false; - List? removed = null; + List? removed = null; switch (args.Action) { @@ -509,11 +562,19 @@ namespace Avalonia.Controls } case NotifyCollectionChangedAction.Reset: - { - ClearSelection(); - selectionInvalidated = true; - break; - } + { + if (_selectedItems == null) + { + ClearSelection(); + } + else + { + removed = RecreateSelectionFromSelectedItems(); + } + + selectionInvalidated = true; + break; + } case NotifyCollectionChangedAction.Replace: { @@ -606,10 +667,10 @@ namespace Avalonia.Controls return selectionInvalidated; } - private (bool, List) OnItemsRemoved(int index, IList items) + private (bool, List) OnItemsRemoved(int index, IList items) { var selectionInvalidated = false; - var removed = new List(); + var removed = new List(); var count = items.Count; // Remove the items from the selection for leaf @@ -804,6 +865,33 @@ namespace Avalonia.Controls return selectionState; } + private List RecreateSelectionFromSelectedItems() + { + var removed = new List(); + + _selected.Clear(); + SelectedCount = 0; + + for (var i = 0; i < _selectedItems!.Count; ++i) + { + var item = _selectedItems[i]; + var index = ItemsSourceView!.IndexOf(item); + + if (index != -1) + { + IndexRange.Add(_selected, new IndexRange(index, index)); + ++SelectedCount; + } + else + { + removed.Add(item); + _selectedItems.RemoveAt(i--); + } + } + + return removed; + } + public enum SelectionState { Selected, diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index b9149bf42b..b3a5e0959f 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -7,6 +7,7 @@ using System; using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Collections.Specialized; using System.Linq; using Avalonia.Collections; using Avalonia.Diagnostics; @@ -1392,6 +1393,107 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(2, raised); } + [Fact] + public void RetainSelectionOnReset_Retains_Selection_On_Reset() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; + + target.SelectRange(new IndexPath(1), new IndexPath(2)); + data.Reset(); + + Assert.Equal(new[] { new IndexPath(1), new IndexPath(2) }, target.SelectedIndices); + Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems); + } + + [Fact] + public void RetainSelectionOnReset_Retains_Correct_Selection_After_Remove() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; + + target.SelectRange(new IndexPath(1), new IndexPath(2)); + target.Deselect(2); + data.Reset(); + + Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); + Assert.Equal(new[] { "bar", }, target.SelectedItems); + } + + [Fact] + public void RetainSelectionOnReset_Retains_No_Selection_After_Clear() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; + + target.SelectRange(new IndexPath(1), new IndexPath(2)); + target.ClearSelection(); + data.Reset(); + + Assert.Empty(target.SelectedIndices); + Assert.Empty(target.SelectedItems); + } + + [Fact] + public void RetainSelectionOnReset_Retains_Correct_Selection_After_Two_Resets() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; + + target.SelectRange(new IndexPath(1), new IndexPath(2)); + data.Reset(new[] { "foo", "bar" }); + data.Reset(new[] { "foo", "bar", "baz" }); + + Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); + Assert.Equal(new[] { "bar", }, target.SelectedItems); + } + + [Fact] + public void RetainSelectionOnReset_Raises_Empty_SelectionChanged_On_Reset_With_No_Changes() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; + var raised = 0; + + target.SelectRange(new IndexPath(1), new IndexPath(2)); + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + data.Reset(); + + Assert.Equal(1, raised); + } + + [Fact] + public void RetainSelectionOnReset_Raises_SelectionChanged_On_Reset_With_Removed_Items() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; + var raised = 0; + + target.SelectRange(new IndexPath(1), new IndexPath(2)); + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + data.Reset(new[] { "foo", "baz" }); + + Assert.Equal(1, raised); + } + private int GetSubscriberCount(AvaloniaList list) { return ((INotifyCollectionChangedDebug)list).GetCollectionChangedSubscribers()?.Length ?? 0; @@ -1743,6 +1845,24 @@ namespace Avalonia.Controls.UnitTests public LogWrapper(ITestOutputHelper output) => _output = output; public void Comment(string s) => _output.WriteLine(s); } + + private class ResettingList : List, INotifyCollectionChanged + { + public event NotifyCollectionChangedEventHandler CollectionChanged; + + public void Reset(IEnumerable items = null) + { + if (items != null) + { + Clear(); + AddRange(items); + } + + CollectionChanged?.Invoke( + this, + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + } } class CustomSelectionModel : SelectionModel