From 0c894e20d3ccba4dac511195a51b36e00612cd69 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 17 Feb 2020 11:05:02 +0100 Subject: [PATCH] Added SelectedItemsSync. To sync between an `ISelectionModel` and a `SelectedItems` collection. --- .../Utils/SelectedItemsSync.cs | 226 ++++++++++++++++++ .../Utils/SelectedItemsSyncTests.cs | 210 ++++++++++++++++ 2 files changed, 436 insertions(+) create mode 100644 src/Avalonia.Controls/Utils/SelectedItemsSync.cs create mode 100644 tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs diff --git a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs new file mode 100644 index 0000000000..3d6c88cd99 --- /dev/null +++ b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections; +using System.Collections.Specialized; +using System.Linq; +using Avalonia.Collections; + +#nullable enable + +namespace Avalonia.Controls.Utils +{ + /// + /// Synchronizes an with a list of SelectedItems. + /// + internal class SelectedItemsSync + { + private IList? _items; + private bool _updatingItems; + private bool _updatingModel; + + public SelectedItemsSync(ISelectionModel model) + { + Model = model; + } + + public ISelectionModel Model { get; private set; } + + public IList GetOrCreateItems() + { + if (_items == null) + { + var items = new AvaloniaList(Model.SelectedItems); + items.CollectionChanged += ItemsCollectionChanged; + Model.SelectionChanged += SelectionModelSelectionChanged; + _items = items; + } + + return _items; + } + + public void SetItems(IList items) + { + items = items ?? throw new ArgumentNullException(nameof(items)); + + if (items.IsFixedSize) + { + throw new NotSupportedException( + "Cannot assign fixed size selection to SelectedItems."); + } + + if (_items is INotifyCollectionChanged incc) + { + incc.CollectionChanged -= ItemsCollectionChanged; + } + + if (_items == null) + { + Model.SelectionChanged += SelectionModelSelectionChanged; + } + + try + { + _updatingModel = true; + _items = items; + + using (Model.Update()) + { + Model.ClearSelection(); + Add(items); + } + + if (_items is INotifyCollectionChanged incc2) + { + incc2.CollectionChanged += ItemsCollectionChanged; + } + } + finally + { + _updatingModel = false; + } + } + + public void SetModel(ISelectionModel model) + { + model = model ?? throw new ArgumentNullException(nameof(model)); + + if (_items != null) + { + Model.SelectionChanged -= SelectionModelSelectionChanged; + Model = model; + Model.SelectionChanged += SelectionModelSelectionChanged; + + try + { + _updatingItems = true; + _items.Clear(); + + foreach (var i in model.SelectedItems) + { + _items.Add(i); + } + } + finally + { + _updatingItems = false; + } + } + } + + private void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (_updatingItems) + { + return; + } + + if (_items == null) + { + throw new AvaloniaInternalException("CollectionChanged raised but we don't have items."); + } + + void Remove() + { + foreach (var i in e.OldItems) + { + var index = IndexOf(Model.Source, i); + + if (index != -1) + { + Model.Deselect(index); + } + } + } + + try + { + using var operation = Model.Update(); + + _updatingModel = true; + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + Add(e.NewItems); + break; + case NotifyCollectionChangedAction.Remove: + Remove(); + break; + case NotifyCollectionChangedAction.Replace: + Remove(); + Add(e.NewItems); + break; + case NotifyCollectionChangedAction.Reset: + Model.ClearSelection(); + Add(_items); + break; + } + } + finally + { + _updatingModel = false; + } + } + + private void Add(IList newItems) + { + foreach (var i in newItems) + { + var index = IndexOf(Model.Source, i); + + if (index != -1) + { + Model.Select(index); + } + } + } + + private void SelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) + { + if (_updatingModel) + { + return; + } + + if (_items == null) + { + throw new AvaloniaInternalException("SelectionModelChanged raised but we don't have items."); + } + + try + { + var deselected = e.DeselectedItems.ToList(); + var selected = e.SelectedItems.ToList(); + + _updatingItems = true; + + foreach (var i in deselected) + { + _items.Remove(i); + } + + foreach (var i in selected) + { + _items.Add(i); + } + } + finally + { + _updatingItems = false; + } + } + + private static int IndexOf(object source, object item) + { + if (source is IList l) + { + return l.IndexOf(item); + } + else if (source is ItemsSourceView v) + { + return v.IndexOf(item); + } + + return -1; + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs new file mode 100644 index 0000000000..917f422557 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using Avalonia.Collections; +using Avalonia.Controls.Utils; +using Xunit; + +namespace Avalonia.Controls.UnitTests.Utils +{ + public class SelectedItemsSyncTests + { + [Fact] + public void Initial_Items_Are_From_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + Assert.Equal(new[] { "bar", "baz" }, items); + } + + [Fact] + public void Selecting_On_Model_Adds_Item() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + target.Model.Select(0); + + Assert.Equal(new[] { "bar", "baz", "foo" }, items); + } + + [Fact] + public void Selecting_Duplicate_On_Model_Adds_Item() + { + var target = CreateTarget(new[] { "foo", "bar", "baz", "foo", "bar", "baz" }); + var items = target.GetOrCreateItems(); + + target.Model.Select(4); + + Assert.Equal(new[] { "bar", "baz", "bar" }, items); + } + + [Fact] + public void Deselecting_On_Model_Removes_Item() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + target.Model.Deselect(1); + + Assert.Equal(new[] { "baz" }, items); + } + + [Fact] + public void Deselecting_Duplicate_On_Model_Removes_Item() + { + var target = CreateTarget(new[] { "foo", "bar", "baz", "foo", "bar", "baz" }); + var items = target.GetOrCreateItems(); + + target.Model.Select(4); + target.Model.Deselect(4); + + Assert.Equal(new[] { "baz", "bar" }, items); + } + + [Fact] + public void Reassigning_Model_Resets_Items() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + var newModel = new SelectionModel { Source = target.Model.Source }; + newModel.Select(0); + newModel.Select(1); + + target.SetModel(newModel); + + Assert.Equal(new[] { "foo", "bar" }, items); + } + + [Fact] + public void Reassigning_Model_Tracks_New_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + var newModel = new SelectionModel { Source = target.Model.Source }; + target.SetModel(newModel); + + newModel.Select(0); + newModel.Select(1); + + Assert.Equal(new[] { "foo", "bar" }, items); + } + + [Fact] + public void Adding_To_Items_Selects_On_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + items.Add("foo"); + + Assert.Equal( + new[] { new IndexPath(0), new IndexPath(1), new IndexPath(2) }, + target.Model.SelectedIndices); + Assert.Equal(new[] { "bar", "baz", "foo" }, items); + } + + [Fact] + public void Removing_From_Items_Deselects_On_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + items.Remove("baz"); + + Assert.Equal(new[] { new IndexPath(1) }, target.Model.SelectedIndices); + Assert.Equal(new[] { "bar" }, items); + } + + [Fact] + public void Replacing_Item_Updates_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + items[0] = "foo"; + + Assert.Equal(new[] { new IndexPath(0), new IndexPath(2) }, target.Model.SelectedIndices); + Assert.Equal(new[] { "foo", "baz" }, items); + } + + [Fact] + public void Clearing_Items_Updates_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + items.Clear(); + + Assert.Empty(target.Model.SelectedIndices); + } + + [Fact] + public void Setting_Items_Updates_Model() + { + var target = CreateTarget(); + var oldItems = target.GetOrCreateItems(); + + var newItems = new AvaloniaList { "foo", "baz" }; + target.SetItems(newItems); + + Assert.Equal(new[] { new IndexPath(0), new IndexPath(2) }, target.Model.SelectedIndices); + Assert.Same(newItems, target.GetOrCreateItems()); + Assert.NotSame(oldItems, target.GetOrCreateItems()); + Assert.Equal(new[] { "foo", "baz" }, newItems); + } + + [Fact] + public void Setting_Items_Subscribes_To_Model() + { + var target = CreateTarget(); + var items = new AvaloniaList { "foo", "baz" }; + + target.SetItems(items); + target.Model.Select(1); + + Assert.Equal(new[] { "foo", "baz", "bar" }, items); + } + + [Fact] + public void Handles_Null_Model_Source() + { + var model = new SelectionModel(); + model.Select(1); + + var target = new SelectedItemsSync(model); + var items = target.GetOrCreateItems(); + + Assert.Empty(items); + + model.Select(2); + model.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(new[] { "bar", "baz" }, items); + } + + [Fact] + public void Does_Not_Accept_Fixed_Size_Items() + { + var target = CreateTarget(); + + Assert.Throws(() => + target.SetItems(new[] { "foo", "bar", "baz" })); + } + + private static SelectedItemsSync CreateTarget( + IEnumerable items = null) + { + items ??= new[] { "foo", "bar", "baz" }; + + var model = new SelectionModel { Source = items }; + model.SelectRange(new IndexPath(1), new IndexPath(2)); + + var target = new SelectedItemsSync(model); + return target; + } + } +}