From 60011155734cabd13d9aaf2736b39f805ea4f262 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 17 Feb 2020 12:01:37 +0100 Subject: [PATCH] Use SelectionModel in SelectingItemsControl. --- samples/BindingDemo/MainWindow.xaml | 4 +- .../ViewModels/MainWindowViewModel.cs | 5 +- samples/ControlCatalog/Pages/ListBoxPage.xaml | 2 +- samples/VirtualizationDemo/MainWindow.xaml | 2 +- .../ViewModels/MainWindowViewModel.cs | 14 +- src/Avalonia.Controls/ComboBox.cs | 4 +- src/Avalonia.Controls/ListBox.cs | 19 +- .../Primitives/SelectingItemsControl.cs | 834 +++++------------- src/Avalonia.Controls/SelectionModel.cs | 3 +- .../Utils/SelectedItemsSync.cs | 5 +- .../CarouselTests.cs | 6 +- .../Primitives/SelectingItemsControlTests.cs | 31 +- .../SelectingItemsControlTests_AutoSelect.cs | 4 +- .../SelectingItemsControlTests_Multiple.cs | 211 ++++- .../Primitives/TabStripTests.cs | 9 +- .../TabControlTests.cs | 6 +- .../Utils/SelectedItemsSyncTests.cs | 13 + 17 files changed, 509 insertions(+), 663 deletions(-) diff --git a/samples/BindingDemo/MainWindow.xaml b/samples/BindingDemo/MainWindow.xaml index b57a9a0a9e..26a62ebca6 100644 --- a/samples/BindingDemo/MainWindow.xaml +++ b/samples/BindingDemo/MainWindow.xaml @@ -74,11 +74,11 @@ - + - + diff --git a/samples/BindingDemo/ViewModels/MainWindowViewModel.cs b/samples/BindingDemo/ViewModels/MainWindowViewModel.cs index 22d01e0765..a66038ff3e 100644 --- a/samples/BindingDemo/ViewModels/MainWindowViewModel.cs +++ b/samples/BindingDemo/ViewModels/MainWindowViewModel.cs @@ -6,6 +6,7 @@ using System.Reactive.Linq; using System.Threading.Tasks; using System.Threading; using ReactiveUI; +using Avalonia.Controls; namespace BindingDemo.ViewModels { @@ -27,7 +28,7 @@ namespace BindingDemo.ViewModels Detail = "Item " + x + " details", })); - SelectedItems = new ObservableCollection(); + Selection = new SelectionModel(); ShuffleItems = ReactiveCommand.Create(() => { @@ -56,7 +57,7 @@ namespace BindingDemo.ViewModels } public ObservableCollection Items { get; } - public ObservableCollection SelectedItems { get; } + public SelectionModel Selection { get; } public ReactiveCommand ShuffleItems { get; } public string BooleanString diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index b1b3112e60..47b4ce7151 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -10,7 +10,7 @@ HorizontalAlignment="Center" Spacing="16"> - + diff --git a/samples/VirtualizationDemo/MainWindow.xaml b/samples/VirtualizationDemo/MainWindow.xaml index 12137cd03d..4bd657bf93 100644 --- a/samples/VirtualizationDemo/MainWindow.xaml +++ b/samples/VirtualizationDemo/MainWindow.xaml @@ -45,7 +45,7 @@ SelectedItems { get; } - = new AvaloniaList(); + public SelectionModel Selection { get; } = new SelectionModel(); public AvaloniaList Items { @@ -141,9 +140,9 @@ namespace VirtualizationDemo.ViewModels { var index = Items.Count; - if (SelectedItems.Count > 0) + if (Selection.SelectedIndices.Count > 0) { - index = Items.IndexOf(SelectedItems[0]); + index = Selection.SelectedIndex.GetAt(0); } Items.Insert(index, new ItemViewModel(_newItemIndex++, NewItemString)); @@ -151,9 +150,9 @@ namespace VirtualizationDemo.ViewModels private void Remove() { - if (SelectedItems.Count > 0) + if (Selection.SelectedItems.Count > 0) { - Items.RemoveAll(SelectedItems); + Items.RemoveAll(Selection.SelectedItems.Cast().ToList()); } } @@ -167,8 +166,7 @@ namespace VirtualizationDemo.ViewModels private void SelectItem(int index) { - SelectedItems.Clear(); - SelectedItems.Add(Items[index]); + Selection.SelectedIndex = new IndexPath(index); } } } diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index c2cf20b32d..0722802962 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/src/Avalonia.Controls/ComboBox.cs @@ -289,9 +289,9 @@ namespace Avalonia.Controls { var container = ItemContainerGenerator.ContainerFromIndex(selectedIndex); - if (container == null && SelectedItems.Count > 0) + if (container == null && SelectedIndex != -1) { - ScrollIntoView(SelectedItems[0]); + ScrollIntoView(Selection.SelectedItem); container = ItemContainerGenerator.ContainerFromIndex(selectedIndex); } diff --git a/src/Avalonia.Controls/ListBox.cs b/src/Avalonia.Controls/ListBox.cs index 4966e669ed..a15aedd621 100644 --- a/src/Avalonia.Controls/ListBox.cs +++ b/src/Avalonia.Controls/ListBox.cs @@ -34,6 +34,12 @@ namespace Avalonia.Controls public static readonly new DirectProperty SelectedItemsProperty = SelectingItemsControl.SelectedItemsProperty; + /// + /// Defines the property. + /// + public static readonly new DirectProperty SelectionProperty = + SelectingItemsControl.SelectionProperty; + /// /// Defines the property. /// @@ -73,6 +79,15 @@ namespace Avalonia.Controls set => base.SelectedItems = value; } + /// + /// Gets or sets a model holding the current selection. + /// + public new ISelectionModel Selection + { + get => base.Selection; + set => base.Selection = value; + } + /// /// Gets or sets the selection mode. /// @@ -98,12 +113,12 @@ namespace Avalonia.Controls /// /// Selects all items in the . /// - public new void SelectAll() => base.SelectAll(); + public void SelectAll() => Selection.SelectAll(); /// /// Deselects all items in the . /// - public new void UnselectAll() => base.UnselectAll(); + public void UnselectAll() => Selection.ClearSelection(); /// protected override IItemContainerGenerator CreateItemContainerGenerator() diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index 6bc4e71508..b1a4379cae 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -5,15 +5,15 @@ using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; +using System.ComponentModel; using System.Diagnostics; using System.Linq; -using Avalonia.Collections; using Avalonia.Controls.Generators; +using Avalonia.Controls.Utils; using Avalonia.Data; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; -using Avalonia.Logging; using Avalonia.VisualTree; namespace Avalonia.Controls.Primitives @@ -26,9 +26,9 @@ namespace Avalonia.Controls.Primitives /// provides a base class for s /// that maintain a selection (single or multiple). By default only its /// and properties are visible; the - /// current multiple selection together with the - /// properties are protected, however a derived class can expose - /// these if it wishes to support multiple selection. + /// current multiple and together with the + /// and properties are protected, however a derived class can + /// expose these if it wishes to support multiple selection. /// /// /// maintains a selection respecting the current @@ -77,6 +77,15 @@ namespace Avalonia.Controls.Primitives o => o.SelectedItems, (o, v) => o.SelectedItems = v); + /// + /// Defines the property. + /// + public static readonly DirectProperty SelectionProperty = + AvaloniaProperty.RegisterDirect( + nameof(Selection), + o => o.Selection, + (o, v) => o.Selection = v); + /// /// Defines the property. /// @@ -103,17 +112,22 @@ namespace Avalonia.Controls.Primitives RoutingStrategies.Bubble); private static readonly IList Empty = Array.Empty(); - - private readonly Selection _selection = new Selection(); + private readonly SelectedItemsSync _selectedItems; + private ISelectionModel _selection; private int _selectedIndex = -1; private object _selectedItem; - private IList _selectedItems; private bool _ignoreContainerSelectionChanged; - private bool _syncingSelectedItems; private int _updateCount; private int _updateSelectedIndex; private object _updateSelectedItem; + public SelectingItemsControl() + { + // Setting Selection to null causes a default SelectionModel to be created. + Selection = null; + _selectedItems = new SelectedItemsSync(Selection); + } + /// /// Initializes static members of the class. /// @@ -145,17 +159,15 @@ namespace Avalonia.Controls.Primitives /// public int SelectedIndex { - get - { - return _selectedIndex; - } - + get => Selection.SelectedIndex != default ? Selection.SelectedIndex.GetAt(0) : -1; set { if (_updateCount == 0) { - var effective = (value >= 0 && value < ItemCount) ? value : -1; - UpdateSelectedItem(effective); + if (value != SelectedIndex) + { + Selection.SelectedIndex = new IndexPath(value); + } } else { @@ -170,16 +182,12 @@ namespace Avalonia.Controls.Primitives /// public object SelectedItem { - get - { - return _selectedItem; - } - + get => Selection.SelectedItem; set { if (_updateCount == 0) { - UpdateSelectedItem(IndexOf(Items, value)); + SelectedIndex = IndexOf(Items, value); } else { @@ -190,32 +198,110 @@ namespace Avalonia.Controls.Primitives } /// - /// Gets the selected items. + /// Gets or sets the selected items. /// protected IList SelectedItems { - get - { - if (_selectedItems == null) - { - _selectedItems = new AvaloniaList(); - SubscribeToSelectedItems(); - } - - return _selectedItems; - } + get => _selectedItems.GetOrCreateItems(); + set => _selectedItems.SetItems(value); + } + /// + /// Gets or sets a model holding the current selection. + /// + protected ISelectionModel Selection + { + get => _selection; set { - if (value?.IsFixedSize == true || value?.IsReadOnly == true) + value ??= new SelectionModel { - throw new NotSupportedException( - "Cannot use a fixed size or read-only collection as SelectedItems."); - } + SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple), + AutoSelect = SelectionMode.HasFlagCustom(SelectionMode.AlwaysSelected), + RetainSelectionOnReset = true, + }; + + if (_selection != value) + { + 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; + MarkContainersUnselected(); + } + + _selection = value; - UnsubscribeFromSelectedItems(); - _selectedItems = value ?? new AvaloniaList(); - SubscribeToSelectedItems(); + if (oldSelection?.Count > 0) + { + RaiseEvent(new SelectionChangedEventArgs( + SelectionChangedEvent, + oldSelection, + Array.Empty())); + } + + if (_selection != null) + { + _selection.Source = Items; + _selection.PropertyChanged += OnSelectionModelPropertyChanged; + _selection.SelectionChanged += OnSelectionModelSelectionChanged; + + if (_selection.SingleSelect) + { + SelectionMode &= ~SelectionMode.Multiple; + } + else + { + SelectionMode |= SelectionMode.Multiple; + } + + if (_selection.AutoSelect) + { + SelectionMode |= SelectionMode.AlwaysSelected; + } + else + { + SelectionMode &= ~SelectionMode.AlwaysSelected; + } + + UpdateContainerSelection(); + + var selectedIndex = SelectedIndex; + var selectedItem = SelectedItem; + + if (_selectedIndex != selectedIndex) + { + RaisePropertyChanged(SelectedIndexProperty, _selectedIndex, selectedIndex); + _selectedIndex = selectedIndex; + } + + if (_selectedItem != selectedItem) + { + RaisePropertyChanged(SelectedItemProperty, _selectedItem, selectedItem); + _selectedItem = selectedItem; + } + + if (selectedIndex != -1) + { + RaiseEvent(new SelectionChangedEventArgs( + SelectionChangedEvent, + Array.Empty(), + Selection.SelectedItems.ToList())); + } + } + } } } @@ -285,81 +371,18 @@ namespace Avalonia.Controls.Primitives /// protected override void ItemsChanged(AvaloniaPropertyChangedEventArgs e) { - base.ItemsChanged(e); - if (_updateCount == 0) { - var newIndex = -1; - - if (SelectedIndex != -1) - { - newIndex = IndexOf((IEnumerable)e.NewValue, SelectedItem); - } - - if (AlwaysSelected && Items != null && Items.Cast().Any()) - { - newIndex = 0; - } - - SelectedIndex = newIndex; + Selection.Source = e.NewValue; } + + base.ItemsChanged(e); } /// protected override void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { - if (_updateCount > 0) - { - base.ItemsCollectionChanged(sender, e); - return; - } - - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - _selection.ItemsInserted(e.NewStartingIndex, e.NewItems.Count); - break; - case NotifyCollectionChangedAction.Remove: - _selection.ItemsRemoved(e.OldStartingIndex, e.OldItems.Count); - break; - } - base.ItemsCollectionChanged(sender, e); - - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - if (AlwaysSelected && SelectedIndex == -1) - { - SelectedIndex = 0; - } - else - { - UpdateSelectedItem(_selection.First(), false); - } - - break; - - case NotifyCollectionChangedAction.Remove: - UpdateSelectedItem(_selection.First(), false); - ResetSelectedItems(); - break; - - case NotifyCollectionChangedAction.Replace: - UpdateSelectedItem(SelectedIndex, false); - ResetSelectedItems(); - break; - - case NotifyCollectionChangedAction.Move: - case NotifyCollectionChangedAction.Reset: - SelectedIndex = IndexOf(Items, SelectedItem); - - if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0) - { - SelectedIndex = 0; - } - break; - } } /// @@ -367,36 +390,18 @@ namespace Avalonia.Controls.Primitives { base.OnContainersMaterialized(e); - var resetSelectedItems = false; - foreach (var container in e.Containers) { if ((container.ContainerControl as ISelectable)?.IsSelected == true) { - if (SelectionMode.HasFlag(SelectionMode.Multiple)) - { - if (_selection.Add(container.Index)) - { - resetSelectedItems = true; - } - } - else - { - SelectedIndex = container.Index; - } - + Selection.Select(container.Index); MarkContainerSelected(container.ContainerControl, true); } - else if (_selection.Contains(container.Index)) + else if (Selection.IsSelected(container.Index) == true) { MarkContainerSelected(container.ContainerControl, true); } } - - if (resetSelectedItems) - { - ResetSelectedItems(); - } } /// @@ -425,7 +430,7 @@ namespace Avalonia.Controls.Primitives { if (i.ContainerControl != null && i.Item != null) { - bool selected = _selection.Contains(i.Index); + bool selected = Selection.IsSelected(i.Index) == true; MarkContainerSelected(i.ContainerControl, selected); } } @@ -447,6 +452,18 @@ namespace Avalonia.Controls.Primitives InternalEndInit(); } + 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); + } + } + protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); @@ -461,7 +478,7 @@ namespace Avalonia.Controls.Primitives (((SelectionMode & SelectionMode.Multiple) != 0) || (SelectionMode & SelectionMode.Toggle) != 0)) { - SelectAll(); + Selection.SelectAll(); e.Handled = true; } } @@ -503,36 +520,6 @@ namespace Avalonia.Controls.Primitives return false; } - /// - /// Selects all items in the control. - /// - protected void SelectAll() - { - UpdateSelectedItems(() => - { - _selection.Clear(); - - for (var i = 0; i < ItemCount; ++i) - { - _selection.Add(i); - } - - UpdateSelectedItem(0, false); - - foreach (var container in ItemContainerGenerator.Containers) - { - MarkItemSelected(container.Index, true); - } - - ResetSelectedItems(); - }); - } - - /// - /// Deselects all items in the control. - /// - protected void UnselectAll() => UpdateSelectedItem(-1); - /// /// Updates the selection for an item based on user interaction. /// @@ -559,63 +546,35 @@ namespace Avalonia.Controls.Primitives if (rightButton) { - if (!_selection.Contains(index)) + if (Selection.IsSelected(index) == false) { - UpdateSelectedItem(index); + SelectedIndex = index; } } else if (range) { - UpdateSelectedItems(() => - { - var start = SelectedIndex != -1 ? SelectedIndex : 0; - var step = start < index ? 1 : -1; - - _selection.Clear(); + using var operation = Selection.Update(); + var anchor = Selection.AnchorIndex; - for (var i = start; i != index; i += step) - { - _selection.Add(i); - } - - _selection.Add(index); - - var first = Math.Min(start, index); - var last = Math.Max(start, index); - - foreach (var container in ItemContainerGenerator.Containers) - { - MarkItemSelected( - container.Index, - container.Index >= first && container.Index <= last); - } + if (anchor.GetSize() == 0) + { + anchor = new IndexPath(0); + } - ResetSelectedItems(); - }); + Selection.ClearSelection(); + Selection.AnchorIndex = anchor; + Selection.SelectRangeFromAnchor(index); } else if (multi && toggle) { - UpdateSelectedItems(() => + if (Selection.IsSelected(index) == true) { - if (!_selection.Contains(index)) - { - _selection.Add(index); - MarkItemSelected(index, true); - SelectedItems.Add(ElementAt(Items, index)); - } - else - { - _selection.Remove(index); - MarkItemSelected(index, false); - - if (index == _selectedIndex) - { - UpdateSelectedItem(_selection.First(), false); - } - - SelectedItems.Remove(ElementAt(Items, index)); - } - }); + Selection.Deselect(index); + } + else + { + Selection.Select(index); + } } else if (toggle) { @@ -623,7 +582,9 @@ namespace Avalonia.Controls.Primitives } else { - UpdateSelectedItem(index); + using var operation = Selection.Update(); + Selection.ClearSelection(); + Selection.Select(index); } if (Presenter?.Panel != null) @@ -696,25 +657,71 @@ namespace Avalonia.Controls.Primitives } /// - /// Gets a range of items from an IEnumerable. + /// Called when is raised. /// - /// The items. - /// The index of the first item. - /// The index of the last item. - /// The items. - private static List GetRange(IEnumerable items, int first, int last) + /// The sender. + /// The event args. + private void OnSelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e) { - var list = (items as IList) ?? items.Cast().ToList(); - var step = first > last ? -1 : 1; - var result = new List(); + if (e.PropertyName == nameof(SelectionModel.AnchorIndex) && AutoScrollToSelectedItem) + { + var index = Selection.AnchorIndex.GetSize() > 0 ? Selection.AnchorIndex.GetAt(0) : -1; + var item = index != -1 ? ElementAt(Items, index) : null; + + if (item != null) + { + ScrollIntoView(item); + } + } + } + + /// + /// Called when is raised. + /// + /// The sender. + /// The event args. + private void OnSelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) + { + void Mark(int index, bool selected) + { + var container = ItemContainerGenerator.ContainerFromIndex(index); + + if (container != null) + { + MarkContainerSelected(container, selected); + } + } + + foreach (var i in e.SelectedIndices) + { + Mark(i.GetAt(0), true); + } - for (int i = first; i != last; i += step) + foreach (var i in e.DeselectedIndices) { - result.Add(list[i]); + Mark(i.GetAt(0), false); } - result.Add(list[last]); - return result; + var newSelectedIndex = SelectedIndex; + var newSelectedItem = SelectedItem; + + if (newSelectedIndex != _selectedIndex) + { + RaisePropertyChanged(SelectedIndexProperty, _selectedIndex, newSelectedIndex); + _selectedIndex = newSelectedIndex; + } + + if (newSelectedItem != _selectedItem) + { + RaisePropertyChanged(SelectedItemProperty, _selectedItem, newSelectedItem); + _selectedItem = newSelectedItem; + } + + var ev = new SelectionChangedEventArgs( + SelectionChangedEvent, + e.DeselectedItems.ToList(), + e.SelectedItems.ToList()); + RaiseEvent(ev); } /// @@ -794,301 +801,43 @@ namespace Avalonia.Controls.Primitives } } - /// - /// Sets an item container's 'selected' class or . - /// - /// The index of the item. - /// Whether the item should be selected or deselected. - private void MarkItemSelected(int index, bool selected) + private void MarkContainersUnselected() { - var container = ItemContainerGenerator?.ContainerFromIndex(index); - - if (container != null) + foreach (var container in ItemContainerGenerator.Containers) { - MarkContainerSelected(container, selected); + MarkContainerSelected(container.ContainerControl, false); } } - /// - /// Sets an item container's 'selected' class or . - /// - /// The item. - /// Whether the item should be selected or deselected. - private int MarkItemSelected(object item, bool selected) + private void UpdateContainerSelection() { - var index = IndexOf(Items, item); - - if (index != -1) - { - MarkItemSelected(index, selected); - } - - return index; - } - - private void ResetSelectedItems() - { - UpdateSelectedItems(() => - { - SelectedItems.Clear(); - - foreach (var i in _selection) - { - SelectedItems.Add(ElementAt(Items, i)); - } - }); - } - - /// - /// Called when the CollectionChanged event is raised. - /// - /// The event sender. - /// The event args. - private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) - { - if (_syncingSelectedItems) - { - return; - } - - void Add(IList newItems, IList addedItems = null) - { - foreach (var item in newItems) - { - var index = MarkItemSelected(item, true); - - if (index != -1 && _selection.Add(index) && addedItems != null) - { - addedItems.Add(item); - } - } - } - - void UpdateSelection() - { - if ((SelectedIndex != -1 && !_selection.Contains(SelectedIndex)) || - (SelectedIndex == -1 && _selection.HasItems)) - { - _selectedIndex = _selection.First(); - _selectedItem = ElementAt(Items, _selectedIndex); - RaisePropertyChanged(SelectedIndexProperty, -1, _selectedIndex, BindingPriority.LocalValue); - RaisePropertyChanged(SelectedItemProperty, null, _selectedItem, BindingPriority.LocalValue); - } - } - - IList added = null; - IList removed = null; - - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - { - Add(e.NewItems); - UpdateSelection(); - added = e.NewItems; - } - - break; - - case NotifyCollectionChangedAction.Remove: - if (SelectedItems.Count == 0) - { - SelectedIndex = -1; - } - - foreach (var item in e.OldItems) - { - var index = MarkItemSelected(item, false); - _selection.Remove(index); - } - - removed = e.OldItems; - break; - - case NotifyCollectionChangedAction.Replace: - throw new NotSupportedException("Replacing items in a SelectedItems collection is not supported."); - - case NotifyCollectionChangedAction.Move: - throw new NotSupportedException("Moving items in a SelectedItems collection is not supported."); - - case NotifyCollectionChangedAction.Reset: - { - removed = new List(); - added = new List(); - - foreach (var index in _selection.ToList()) - { - var item = ElementAt(Items, index); - - if (!SelectedItems.Contains(item)) - { - MarkItemSelected(index, false); - removed.Add(item); - _selection.Remove(index); - } - } - - Add(SelectedItems, added); - UpdateSelection(); - } - - break; - } - - if (added?.Count > 0 || removed?.Count > 0) - { - var changed = new SelectionChangedEventArgs( - SelectionChangedEvent, - removed ?? Empty, - added ?? Empty); - RaiseEvent(changed); - } - } - - /// - /// Subscribes to the CollectionChanged event, if any. - /// - private void SubscribeToSelectedItems() - { - var incc = _selectedItems as INotifyCollectionChanged; - - if (incc != null) - { - incc.CollectionChanged += SelectedItemsCollectionChanged; - } - - SelectedItemsCollectionChanged( - _selectedItems, - new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - } - - /// - /// Unsubscribes from the CollectionChanged event, if any. - /// - private void UnsubscribeFromSelectedItems() - { - var incc = _selectedItems as INotifyCollectionChanged; - - if (incc != null) + foreach (var container in ItemContainerGenerator.Containers) { - incc.CollectionChanged -= SelectedItemsCollectionChanged; + MarkContainerSelected( + container.ContainerControl, + Selection.IsSelected(container.Index) != false); } } /// - /// Updates the selection due to a change to or - /// . + /// Sets an item container's 'selected' class or . /// - /// The new selected index. - /// Whether to clear existing selection. - private void UpdateSelectedItem(int index, bool clear = true) + /// The index of the item. + /// Whether the item should be selected or deselected. + private void MarkItemSelected(int index, bool selected) { - var oldIndex = _selectedIndex; - var oldItem = _selectedItem; - - if (index == -1 && AlwaysSelected) - { - index = Math.Min(SelectedIndex, ItemCount - 1); - } - - var item = ElementAt(Items, index); - var itemChanged = !Equals(item, oldItem); - var added = -1; - HashSet removed = null; - - _selectedIndex = index; - _selectedItem = item; - - if (oldIndex != index || itemChanged || _selection.HasMultiple) - { - if (clear) - { - removed = _selection.Clear(); - } - - if (index != -1) - { - if (_selection.Add(index)) - { - added = index; - } - - if (removed?.Contains(index) == true) - { - removed.Remove(index); - added = -1; - } - } - - if (removed != null) - { - foreach (var i in removed) - { - MarkItemSelected(i, false); - } - } - - MarkItemSelected(index, true); - - RaisePropertyChanged( - SelectedIndexProperty, - oldIndex, - index); - } - - if (itemChanged) - { - RaisePropertyChanged( - SelectedItemProperty, - oldItem, - item); - } - - if (removed != null && index != -1) - { - removed.Remove(index); - } - - if (added != -1 || removed?.Count > 0) - { - ResetSelectedItems(); - - var e = new SelectionChangedEventArgs( - SelectionChangedEvent, - removed?.Select(x => ElementAt(Items, x)).ToArray() ?? Array.Empty(), - added != -1 ? new[] { ElementAt(Items, added) } : Array.Empty()); - RaiseEvent(e); - } - - if (AutoScrollToSelectedItem && _selectedIndex != -1) - { - ScrollIntoView(_selectedItem); - } - } + var container = ItemContainerGenerator?.ContainerFromIndex(index); - private void UpdateSelectedItems(Action action) - { - try - { - _syncingSelectedItems = true; - action(); - } - catch (Exception ex) - { - Logger.TryGet(LogEventLevel.Error)?.Log( - LogArea.Property, - this, - "Error thrown updating SelectedItems: {Error}", - ex); - } - finally + if (container != null) { - _syncingSelectedItems = false; + MarkContainerSelected(container, selected); } } private void UpdateFinished() { + Selection.Source = Items; + if (_updateSelectedItem != null) { SelectedItem = _updateSelectedItem; @@ -1133,104 +882,5 @@ namespace Avalonia.Controls.Primitives UpdateFinished(); } } - - private class Selection : IEnumerable - { - private readonly List _list = new List(); - private HashSet _set = new HashSet(); - - public bool HasItems => _set.Count > 0; - public bool HasMultiple => _set.Count > 1; - - public bool Add(int index) - { - if (index == -1) - { - throw new ArgumentException("Invalid index", "index"); - } - - if (_set.Add(index)) - { - _list.Add(index); - return true; - } - - return false; - } - - public bool Remove(int index) - { - if (_set.Remove(index)) - { - _list.RemoveAll(x => x == index); - return true; - } - - return false; - } - - public HashSet Clear() - { - var result = _set; - _list.Clear(); - _set = new HashSet(); - return result; - } - - public void ItemsInserted(int index, int count) - { - _set = new HashSet(); - - for (var i = 0; i < _list.Count; ++i) - { - var ix = _list[i]; - - if (ix >= index) - { - var newIndex = ix + count; - _list[i] = newIndex; - _set.Add(newIndex); - } - else - { - _set.Add(ix); - } - } - } - - public void ItemsRemoved(int index, int count) - { - var last = (index + count) - 1; - - _set = new HashSet(); - - for (var i = 0; i < _list.Count; ++i) - { - var ix = _list[i]; - - if (ix >= index && ix <= last) - { - _list.RemoveAt(i--); - } - else if (ix > last) - { - var newIndex = ix - count; - _list[i] = newIndex; - _set.Add(newIndex); - } - else - { - _set.Add(ix); - } - } - } - - public bool Contains(int index) => _set.Contains(index); - - public int First() => HasItems ? _list[0] : -1; - - public IEnumerator GetEnumerator() => _set.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - } } } diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 0531174454..5eb2a2da0a 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -176,11 +176,12 @@ namespace Avalonia.Controls { var isSelected = IsSelectedWithPartialAt(value); - if (!isSelected.HasValue || !isSelected.Value) + if (!IsSelectedAt(value) || SelectedItems.Count > 1) { using var operation = new Operation(this); ClearSelection(resetAnchor: true); SelectWithPathImpl(value, select: true); + ApplyAutoSelect(); } } } diff --git a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs index 3d6c88cd99..c127771990 100644 --- a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs +++ b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs @@ -19,6 +19,7 @@ namespace Avalonia.Controls.Utils public SelectedItemsSync(ISelectionModel model) { + model = model ?? throw new ArgumentNullException(nameof(model)); Model = model; } @@ -37,9 +38,9 @@ namespace Avalonia.Controls.Utils return _items; } - public void SetItems(IList items) + public void SetItems(IList? items) { - items = items ?? throw new ArgumentNullException(nameof(items)); + items ??= new AvaloniaList(); if (items.IsFixedSize) { diff --git a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs index b16ac6bb8e..8de762a99b 100644 --- a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs +++ b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs @@ -275,7 +275,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void Selected_Item_Changes_To_NextAvailable_Item_If_SelectedItem_Is_Removed_From_Middle() + public void Selected_Item_Changes_To_First_Item_If_SelectedItem_Is_Removed_From_Middle() { var items = new ObservableCollection { @@ -298,8 +298,8 @@ namespace Avalonia.Controls.UnitTests items.RemoveAt(1); - Assert.Equal(1, target.SelectedIndex); - Assert.Equal("FooBar", target.SelectedItem); + Assert.Equal(0, target.SelectedIndex); + Assert.Equal("Foo", target.SelectedItem); } private Control CreateTemplate(Carousel control, INameScope scope) diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index 8c16dd0f70..f384fcc128 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -554,33 +554,6 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(new[] { removed }, receivedArgs.RemovedItems); } - [Fact] - public void Moving_Selected_Item_Should_Update_Selection() - { - var items = new AvaloniaList - { - new Item(), - new Item(), - }; - - var target = new SelectingItemsControl - { - Items = items, - Template = Template(), - }; - - target.ApplyTemplate(); - target.SelectedIndex = 0; - - Assert.Equal(items[0], target.SelectedItem); - Assert.Equal(0, target.SelectedIndex); - - items.Move(0, 1); - - Assert.Equal(items[1], target.SelectedItem); - Assert.Equal(1, target.SelectedIndex); - } - [Fact] public void Resetting_Items_Collection_Should_Clear_Selection() { @@ -1101,8 +1074,8 @@ namespace Avalonia.Controls.UnitTests.Primitives items[1] = "Qux"; - Assert.Equal(1, target.SelectedIndex); - Assert.Equal("Qux", target.SelectedItem); + Assert.Equal(-1, target.SelectedIndex); + Assert.Null(target.SelectedItem); } [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs index a7010c521b..eb6b10fb44 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs @@ -78,8 +78,8 @@ namespace Avalonia.Controls.UnitTests.Primitives target.SelectedIndex = 2; items.RemoveAt(2); - Assert.Equal(2, target.SelectedIndex); - Assert.Equal("qux", target.SelectedItem); + Assert.Equal(0, target.SelectedIndex); + Assert.Equal("foo", target.SelectedItem); } [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index 3a8c98983f..ed5c94517a 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -73,8 +73,6 @@ namespace Avalonia.Controls.UnitTests.Primitives [Fact] public void Assigning_Multiple_SelectedItems_Should_Set_SelectedIndex() { - // Note that we don't need SelectionMode = Multiple here. Multiple selections can always - // be made in code. var target = new TestSelector { Items = new[] { "foo", "bar", "baz" }, @@ -340,7 +338,6 @@ namespace Avalonia.Controls.UnitTests.Primitives "qiz", "lol", }, - SelectionMode = SelectionMode.Multiple, Template = Template(), }; @@ -373,7 +370,7 @@ namespace Avalonia.Controls.UnitTests.Primitives target.SelectedIndex = 3; target.SelectRange(1); - Assert.Equal(new[] { "qux", "baz", "bar" }, target.SelectedItems.Cast().ToList()); + Assert.Equal(new[] { "bar", "baz", "qux" }, target.SelectedItems.Cast().ToList()); } [Fact] @@ -1117,7 +1114,7 @@ namespace Avalonia.Controls.UnitTests.Primitives target.SelectAll(); items[1] = "Qux"; - Assert.Equal(new[] { "Foo", "Qux", "Baz" }, target.SelectedItems); + Assert.Equal(new[] { "Foo", "Baz" }, target.SelectedItems); } [Fact] @@ -1255,6 +1252,195 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(1, target.SelectedItems.Count); } + [Fact] + public void Adding_To_Selection_Should_Set_SelectedIndex() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Selection.Select(1); + + Assert.Equal(1, target.SelectedIndex); + } + + [Fact] + public void Assigning_Null_To_Selection_Should_Create_New_SelectionModel() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + var oldSelection = target.Selection; + + target.Selection = null; + + Assert.NotNull(target.Selection); + Assert.NotSame(oldSelection, target.Selection); + } + + [Fact] + public void Assigning_SelectionModel_With_Different_Source_To_Selection_Should_Fail() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + var selection = new SelectionModel { Source = new[] { "baz" } }; + Assert.Throws(() => target.Selection = selection); + } + + [Fact] + public void Assigning_SelectionModel_With_Null_Source_To_Selection_Should_Set_Source() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + var selection = new SelectionModel(); + target.Selection = selection; + + Assert.Same(target.Items, selection.Source); + } + + [Fact] + public void Assigning_Single_Selected_Item_To_Selection_Should_Set_SelectedIndex() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + var selection = new SelectionModel { Source = target.Items }; + selection.Select(1); + target.Selection = selection; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { "bar" }, target.Selection.SelectedItems); + Assert.Equal(new[] { 1 }, SelectedContainers(target)); + } + + [Fact] + public void Assigning_Multiple_Selected_Items_To_Selection_Should_Set_SelectedIndex() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar", "baz" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + var selection = new SelectionModel { Source = target.Items }; + selection.SelectRange(new IndexPath(0), new IndexPath(2)); + target.Selection = selection; + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { "foo", "bar", "baz" }, target.Selection.SelectedItems); + Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target)); + } + + [Fact] + public void Reassigning_Selection_Should_Clear_Selection() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Selection.Select(1); + target.Selection = new SelectionModel(); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Null(target.SelectedItem); + } + + [Fact] + public void Assigning_Selection_Should_Set_Item_IsSelected() + { + var items = new[] + { + new ListBoxItem(), + new ListBoxItem(), + new ListBoxItem(), + }; + + var target = new TestSelector + { + Items = items, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + var selection = new SelectionModel { Source = items }; + selection.SelectRange(new IndexPath(0), new IndexPath(1)); + target.Selection = selection; + + Assert.True(items[0].IsSelected); + Assert.True(items[1].IsSelected); + Assert.False(items[2].IsSelected); + } + + [Fact] + public void Assigning_Selection_Should_Raise_SelectionChanged() + { + var items = new[] { "foo", "bar", "baz" }; + + var target = new TestSelector + { + Items = items, + Template = Template(), + SelectedItem = "bar", + }; + + var raised = 0; + + target.SelectionChanged += (s, e) => + { + if (raised == 0) + { + Assert.Empty(e.AddedItems.Cast()); + Assert.Equal(new[] { "bar" }, e.RemovedItems.Cast()); + } + else + { + Assert.Equal(new[] { "foo", "baz" }, e.AddedItems.Cast()); + Assert.Empty(e.RemovedItems.Cast()); + } + + ++raised; + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + + var selection = new SelectionModel { Source = items }; + selection.Select(0); + selection.Select(2); + target.Selection = selection; + + Assert.Equal(2, raised); + } + private IEnumerable SelectedContainers(SelectingItemsControl target) { return target.Presenter.Panel.Children @@ -1278,20 +1464,31 @@ namespace Avalonia.Controls.UnitTests.Primitives public static readonly new AvaloniaProperty SelectedItemsProperty = SelectingItemsControl.SelectedItemsProperty; + public TestSelector() + { + SelectionMode = SelectionMode.Multiple; + } + public new IList SelectedItems { get { return base.SelectedItems; } set { base.SelectedItems = value; } } + public new ISelectionModel Selection + { + get => base.Selection; + set => base.Selection = value; + } + public new SelectionMode SelectionMode { get { return base.SelectionMode; } set { base.SelectionMode = value; } } - public new void SelectAll() => base.SelectAll(); - public new void UnselectAll() => base.UnselectAll(); + public void SelectAll() => Selection.SelectAll(); + public void UnselectAll() => Selection.ClearSelection(); public void SelectRange(int index) => UpdateSelection(index, true, true); public void Toggle(int index) => UpdateSelection(index, true, false, true); } diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/TabStripTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/TabStripTests.cs index b4570ec229..707723f809 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/TabStripTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/TabStripTests.cs @@ -70,7 +70,7 @@ namespace Avalonia.Controls.UnitTests.Primitives } [Fact] - public void Removing_Selected_Should_Select_Next() + public void Removing_Selected_Should_Select_First() { var items = new ObservableCollection() { @@ -99,10 +99,9 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Same(items[1], target.SelectedItem); items.RemoveAt(1); - // Assert for former element [2] now [1] == "3rd" - Assert.Equal(1, target.SelectedIndex); - Assert.Same(items[1], target.SelectedItem); - Assert.Same("3rd", ((TabItem)target.SelectedItem).Name); + Assert.Equal(0, target.SelectedIndex); + Assert.Same(items[0], target.SelectedItem); + Assert.Same("first", ((TabItem)target.SelectedItem).Name); } private Control CreateTabStripTemplate(TabStrip parent, INameScope scope) diff --git a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs index a9e86d71ee..d6d5428434 100644 --- a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs @@ -95,7 +95,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void Removal_Should_Set_Next_Tab() + public void Removal_Should_Set_First_Tab() { var collection = new ObservableCollection() { @@ -126,11 +126,9 @@ namespace Avalonia.Controls.UnitTests target.SelectedItem = collection[1]; collection.RemoveAt(1); - // compare with former [2] now [1] == "3rd" - Assert.Same(collection[1], target.SelectedItem); + Assert.Same(collection[0], target.SelectedItem); } - [Fact] public void TabItem_Templates_Should_Be_Set_Before_TabItem_ApplyTemplate() { diff --git a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs index 917f422557..3ab5950974 100644 --- a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs @@ -169,6 +169,19 @@ namespace Avalonia.Controls.UnitTests.Utils Assert.Equal(new[] { "foo", "baz", "bar" }, items); } + [Fact] + public void Setting_Items_To_Null_Creates_Empty_Items() + { + var target = CreateTarget(); + var oldItems = target.GetOrCreateItems(); + + target.SetItems(null); + + var newItems = Assert.IsType>(target.GetOrCreateItems()); + + Assert.NotSame(oldItems, newItems); + } + [Fact] public void Handles_Null_Model_Source() {