Browse Source

Added SelectionModel changed args.

`SelectionModel` as ported from WinUI has no information about what changed in a `SelectionChanged` event. This adds that information along with unit tests.
pull/3470/head
Steven Kirk 6 years ago
parent
commit
7548dc9c2e
  1. 191
      src/Avalonia.Controls/SelectionModel.cs
  2. 144
      src/Avalonia.Controls/SelectionModelChangeSet.cs
  3. 45
      src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs
  4. 150
      src/Avalonia.Controls/SelectionNode.cs
  5. 417
      tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs

191
src/Avalonia.Controls/SelectionModel.cs

@ -6,6 +6,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq;
using Avalonia.Controls.Utils; using Avalonia.Controls.Utils;
#nullable enable #nullable enable
@ -19,7 +20,6 @@ namespace Avalonia.Controls
private IReadOnlyList<IndexPath>? _selectedIndicesCached; private IReadOnlyList<IndexPath>? _selectedIndicesCached;
private IReadOnlyList<object?>? _selectedItemsCached; private IReadOnlyList<object?>? _selectedItemsCached;
private SelectionModelChildrenRequestedEventArgs? _childrenRequestedEventArgs; private SelectionModelChildrenRequestedEventArgs? _childrenRequestedEventArgs;
private SelectionModelSelectionChangedEventArgs? _selectionChangedEventArgs;
public event EventHandler<SelectionModelChildrenRequestedEventArgs>? ChildrenRequested; public event EventHandler<SelectionModelChildrenRequestedEventArgs>? ChildrenRequested;
public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangedEventHandler? PropertyChanged;
@ -36,9 +36,12 @@ namespace Avalonia.Controls
get => _rootNode?.Source; get => _rootNode?.Source;
set set
{ {
ClearSelection(resetAnchor: true, raiseSelectionChanged: false); using (var operation = new Operation(this))
{
ClearSelection(resetAnchor: true);
}
_rootNode.Source = value; _rootNode.Source = value;
OnSelectionChanged();
RaisePropertyChanged("Source"); RaisePropertyChanged("Source");
} }
} }
@ -55,12 +58,13 @@ namespace Avalonia.Controls
if (value && selectedIndices != null && selectedIndices.Count > 0) if (value && selectedIndices != null && selectedIndices.Count > 0)
{ {
using var operation = new Operation(this);
// We want to be single select, so make sure there is only // We want to be single select, so make sure there is only
// one selected item. // one selected item.
var firstSelectionIndexPath = selectedIndices[0]; var firstSelectionIndexPath = selectedIndices[0];
ClearSelection(resetAnchor: true, raiseSelectionChanged: false); ClearSelection(resetAnchor: true);
SelectWithPathImpl(firstSelectionIndexPath, select: true, raiseSelectionChanged: false); SelectWithPathImpl(firstSelectionIndexPath, select: true);
// Setting SelectedIndex will raise SelectionChanged event.
SelectedIndex = firstSelectionIndexPath; SelectedIndex = firstSelectionIndexPath;
} }
@ -131,9 +135,9 @@ namespace Avalonia.Controls
if (!isSelected.HasValue || !isSelected.Value) if (!isSelected.HasValue || !isSelected.Value)
{ {
ClearSelection(resetAnchor: true, raiseSelectionChanged: false); using var operation = new Operation(this);
SelectWithPathImpl(value, select: true, raiseSelectionChanged: false); ClearSelection(resetAnchor: true);
OnSelectionChanged(); SelectWithPathImpl(value, select: true);
} }
} }
} }
@ -289,7 +293,7 @@ namespace Avalonia.Controls
public void Dispose() public void Dispose()
{ {
ClearSelection(resetAnchor: false, raiseSelectionChanged: false); ClearSelection(resetAnchor: false);
_rootNode?.Dispose(); _rootNode?.Dispose();
_selectedIndicesCached = null; _selectedIndicesCached = null;
_selectedItemsCached = null; _selectedItemsCached = null;
@ -299,17 +303,41 @@ namespace Avalonia.Controls
public void SetAnchorIndex(int groupIndex, int index) => AnchorIndex = new IndexPath(groupIndex, index); public void SetAnchorIndex(int groupIndex, int index) => AnchorIndex = new IndexPath(groupIndex, index);
public void Select(int index) => SelectImpl(index, select: true); public void Select(int index)
{
using var operation = new Operation(this);
SelectImpl(index, select: true);
}
public void Select(int groupIndex, int itemIndex) => SelectWithGroupImpl(groupIndex, itemIndex, select: true); public void Select(int groupIndex, int itemIndex)
{
using var operation = new Operation(this);
SelectWithGroupImpl(groupIndex, itemIndex, select: true);
}
public void SelectAt(IndexPath index) => SelectWithPathImpl(index, select: true, raiseSelectionChanged: true); public void SelectAt(IndexPath index)
{
using var operation = new Operation(this);
SelectWithPathImpl(index, select: true);
}
public void Deselect(int index) => SelectImpl(index, select: false); public void Deselect(int index)
{
using var operation = new Operation(this);
SelectImpl(index, select: false);
}
public void Deselect(int groupIndex, int itemIndex) => SelectWithGroupImpl(groupIndex, itemIndex, select: false); public void Deselect(int groupIndex, int itemIndex)
{
using var operation = new Operation(this);
SelectWithGroupImpl(groupIndex, itemIndex, select: false);
}
public void DeselectAt(IndexPath index) => SelectWithPathImpl(index, select: false, raiseSelectionChanged: true); public void DeselectAt(IndexPath index)
{
using var operation = new Operation(this);
SelectWithPathImpl(index, select: false);
}
public bool? IsSelected(int index) public bool? IsSelected(int index)
{ {
@ -383,46 +411,56 @@ namespace Avalonia.Controls
public void SelectRangeFromAnchor(int index) public void SelectRangeFromAnchor(int index)
{ {
using var operation = new Operation(this);
SelectRangeFromAnchorImpl(index, select: true); SelectRangeFromAnchorImpl(index, select: true);
} }
public void SelectRangeFromAnchor(int endGroupIndex, int endItemIndex) public void SelectRangeFromAnchor(int endGroupIndex, int endItemIndex)
{ {
using var operation = new Operation(this);
SelectRangeFromAnchorWithGroupImpl(endGroupIndex, endItemIndex, select: true); SelectRangeFromAnchorWithGroupImpl(endGroupIndex, endItemIndex, select: true);
} }
public void SelectRangeFromAnchorTo(IndexPath index) public void SelectRangeFromAnchorTo(IndexPath index)
{ {
using var operation = new Operation(this);
SelectRangeImpl(AnchorIndex, index, select: true); SelectRangeImpl(AnchorIndex, index, select: true);
} }
public void DeselectRangeFromAnchor(int index) public void DeselectRangeFromAnchor(int index)
{ {
using var operation = new Operation(this);
SelectRangeFromAnchorImpl(index, select: false); SelectRangeFromAnchorImpl(index, select: false);
} }
public void DeselectRangeFromAnchor(int endGroupIndex, int endItemIndex) public void DeselectRangeFromAnchor(int endGroupIndex, int endItemIndex)
{ {
using var operation = new Operation(this);
SelectRangeFromAnchorWithGroupImpl(endGroupIndex, endItemIndex, false /* select */); SelectRangeFromAnchorWithGroupImpl(endGroupIndex, endItemIndex, false /* select */);
} }
public void DeselectRangeFromAnchorTo(IndexPath index) public void DeselectRangeFromAnchorTo(IndexPath index)
{ {
using var operation = new Operation(this);
SelectRangeImpl(AnchorIndex, index, select: false); SelectRangeImpl(AnchorIndex, index, select: false);
} }
public void SelectRange(IndexPath start, IndexPath end) public void SelectRange(IndexPath start, IndexPath end)
{ {
using var operation = new Operation(this);
SelectRangeImpl(start, end, select: true); SelectRangeImpl(start, end, select: true);
} }
public void DeselectRange(IndexPath start, IndexPath end) public void DeselectRange(IndexPath start, IndexPath end)
{ {
using var operation = new Operation(this);
SelectRangeImpl(start, end, select: false); SelectRangeImpl(start, end, select: false);
} }
public void SelectAll() public void SelectAll()
{ {
using var operation = new Operation(this);
SelectionTreeHelper.Traverse( SelectionTreeHelper.Traverse(
_rootNode, _rootNode,
realizeChildren: true, realizeChildren: true,
@ -433,13 +471,12 @@ namespace Avalonia.Controls
info.Node.SelectAll(); info.Node.SelectAll();
} }
}); });
OnSelectionChanged();
} }
public void ClearSelection() public void ClearSelection()
{ {
ClearSelection(resetAnchor: true, raiseSelectionChanged: true); using var operation = new Operation(this);
ClearSelection(resetAnchor: true);
} }
protected void OnPropertyChanged(string propertyName) protected void OnPropertyChanged(string propertyName)
@ -452,9 +489,15 @@ namespace Avalonia.Controls
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
} }
public void OnSelectionInvalidatedDueToCollectionChange() public void OnSelectionInvalidatedDueToCollectionChange(
IEnumerable<object>? removedItems)
{ {
OnSelectionChanged(); var e = new SelectionModelSelectionChangedEventArgs(
Enumerable.Empty<IndexPath>(),
Enumerable.Empty<IndexPath>(),
removedItems ?? Enumerable.Empty<object>(),
Enumerable.Empty<object>());
OnSelectionChanged(e);
} }
internal object? ResolvePath(object data, SelectionNode sourceNode) internal object? ResolvePath(object data, SelectionNode sourceNode)
@ -496,7 +539,7 @@ namespace Avalonia.Controls
return resolved; return resolved;
} }
private void ClearSelection(bool resetAnchor, bool raiseSelectionChanged) private void ClearSelection(bool resetAnchor)
{ {
SelectionTreeHelper.Traverse( SelectionTreeHelper.Traverse(
_rootNode, _rootNode,
@ -507,27 +550,17 @@ namespace Avalonia.Controls
{ {
AnchorIndex = default; AnchorIndex = default;
} }
if (raiseSelectionChanged)
{
OnSelectionChanged();
}
} }
private void OnSelectionChanged() private void OnSelectionChanged(SelectionModelSelectionChangedEventArgs? e = null)
{ {
_selectedIndicesCached = null; _selectedIndicesCached = null;
_selectedItemsCached = null; _selectedItemsCached = null;
// Raise SelectionChanged event // Raise SelectionChanged event
if (SelectionChanged != null) if (e != null)
{ {
if (_selectionChangedEventArgs == null) SelectionChanged?.Invoke(this, e);
{
_selectionChangedEventArgs = new SelectionModelSelectionChangedEventArgs();
}
SelectionChanged(this, _selectionChangedEventArgs);
} }
RaisePropertyChanged(nameof(SelectedIndex)); RaisePropertyChanged(nameof(SelectedIndex));
@ -544,7 +577,7 @@ namespace Avalonia.Controls
{ {
if (_singleSelect) if (_singleSelect)
{ {
ClearSelection(resetAnchor: true, raiseSelectionChanged: false); ClearSelection(resetAnchor: true);
} }
var selected = _rootNode.Select(index, select); var selected = _rootNode.Select(index, select);
@ -553,15 +586,13 @@ namespace Avalonia.Controls
{ {
AnchorIndex = new IndexPath(index); AnchorIndex = new IndexPath(index);
} }
OnSelectionChanged();
} }
private void SelectWithGroupImpl(int groupIndex, int itemIndex, bool select) private void SelectWithGroupImpl(int groupIndex, int itemIndex, bool select)
{ {
if (_singleSelect) if (_singleSelect)
{ {
ClearSelection(resetAnchor: true, raiseSelectionChanged: false); ClearSelection(resetAnchor: true);
} }
var childNode = _rootNode.GetAt(groupIndex, realizeChild: true); var childNode = _rootNode.GetAt(groupIndex, realizeChild: true);
@ -571,17 +602,15 @@ namespace Avalonia.Controls
{ {
AnchorIndex = new IndexPath(groupIndex, itemIndex); AnchorIndex = new IndexPath(groupIndex, itemIndex);
} }
OnSelectionChanged();
} }
private void SelectWithPathImpl(IndexPath index, bool select, bool raiseSelectionChanged) private void SelectWithPathImpl(IndexPath index, bool select)
{ {
bool selected = false; bool selected = false;
if (_singleSelect) if (_singleSelect)
{ {
ClearSelection(resetAnchor: true, raiseSelectionChanged: false); ClearSelection(resetAnchor: true);
} }
SelectionTreeHelper.TraverseIndexPath( SelectionTreeHelper.TraverseIndexPath(
@ -601,11 +630,6 @@ namespace Avalonia.Controls
{ {
AnchorIndex = index; AnchorIndex = index;
} }
if (raiseSelectionChanged)
{
OnSelectionChanged();
}
} }
private void SelectRangeFromAnchorImpl(int index, bool select) private void SelectRangeFromAnchorImpl(int index, bool select)
@ -618,12 +642,7 @@ namespace Avalonia.Controls
anchorIndex = anchor.GetAt(0); anchorIndex = anchor.GetAt(0);
} }
bool selected = _rootNode.SelectRange(new IndexRange(anchorIndex, index), select); _rootNode.SelectRange(new IndexRange(anchorIndex, index), select);
if (selected)
{
OnSelectionChanged();
}
} }
private void SelectRangeFromAnchorWithGroupImpl(int endGroupIndex, int endItemIndex, bool select) private void SelectRangeFromAnchorWithGroupImpl(int endGroupIndex, int endItemIndex, bool select)
@ -650,18 +669,12 @@ namespace Avalonia.Controls
endItemIndex = temp; endItemIndex = temp;
} }
var selected = false;
for (int groupIdx = startGroupIndex; groupIdx <= endGroupIndex; groupIdx++) for (int groupIdx = startGroupIndex; groupIdx <= endGroupIndex; groupIdx++)
{ {
var groupNode = _rootNode.GetAt(groupIdx, realizeChild: true)!; var groupNode = _rootNode.GetAt(groupIdx, realizeChild: true)!;
int startIndex = groupIdx == startGroupIndex ? startItemIndex : 0; int startIndex = groupIdx == startGroupIndex ? startItemIndex : 0;
int endIndex = groupIdx == endGroupIndex ? endItemIndex : groupNode.DataCount - 1; int endIndex = groupIdx == endGroupIndex ? endItemIndex : groupNode.DataCount - 1;
selected |= groupNode.SelectRange(new IndexRange(startIndex, endIndex), select); groupNode.SelectRange(new IndexRange(startIndex, endIndex), select);
}
if (selected)
{
OnSelectionChanged();
} }
} }
@ -691,8 +704,55 @@ namespace Avalonia.Controls
info.ParentNode!.Select(info.Path.GetAt(info.Path.GetSize() - 1), select); info.ParentNode!.Select(info.Path.GetAt(info.Path.GetSize() - 1), select);
} }
}); });
}
private void BeginOperation()
{
if (SelectionChanged != null)
{
_rootNode.BeginOperation();
}
}
private void EndOperation()
{
static IEnumerable<T>? Concat<T>(IEnumerable<T>? a, IEnumerable<T> b)
{
return a == null ? b : a.Concat(b);
}
OnSelectionChanged(); SelectionModelSelectionChangedEventArgs? e = null;
if (SelectionChanged != null)
{
IEnumerable<IndexPath>? selectedIndices = null;
IEnumerable<IndexPath>? deselectedIndices = null;
IEnumerable<object>? selectedItems = null;
IEnumerable<object>? deselectedItems = null;
foreach (var changes in _rootNode.EndOperation())
{
if (changes.HasChanges)
{
selectedIndices = Concat(selectedIndices, changes.SelectedIndices);
deselectedIndices = Concat(deselectedIndices, changes.DeselectedIndices);
selectedItems = Concat(selectedItems, changes.SelectedItems);
deselectedItems = Concat(deselectedItems, changes.DeselectedItems);
}
}
if (selectedIndices != null || deselectedIndices != null ||
selectedItems != null || deselectedItems != null)
{
e = new SelectionModelSelectionChangedEventArgs(
deselectedIndices ?? Enumerable.Empty<IndexPath>(),
selectedIndices ?? Enumerable.Empty<IndexPath>(),
deselectedItems ?? Enumerable.Empty<object>(),
selectedItems ?? Enumerable.Empty<object>());
}
}
OnSelectionChanged(e);
} }
internal class SelectedItemInfo internal class SelectedItemInfo
@ -706,5 +766,12 @@ namespace Avalonia.Controls
public SelectionNode Node { get; } public SelectionNode Node { get; }
public IndexPath Path { get; } public IndexPath Path { get; }
} }
private struct Operation : IDisposable
{
private readonly SelectionModel _manager;
public Operation(SelectionModel manager) => (_manager = manager).BeginOperation();
public void Dispose() => _manager.EndOperation();
}
} }
} }

144
src/Avalonia.Controls/SelectionModelChangeSet.cs

@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.Linq;
#nullable enable
namespace Avalonia.Controls
{
internal class SelectionModelChangeSet
{
private SelectionNode _owner;
private List<IndexRange>? _selected;
private List<IndexRange>? _deselected;
public SelectionModelChangeSet(SelectionNode owner) => _owner = owner;
public bool IsTracking { get; private set; }
public bool HasChanges => _selected?.Count > 0 || _deselected?.Count > 0;
public IEnumerable<IndexPath> SelectedIndices => EnumerateIndices(_selected);
public IEnumerable<IndexPath> DeselectedIndices => EnumerateIndices(_deselected);
public IEnumerable<object> SelectedItems => EnumerateItems(_selected);
public IEnumerable<object> DeselectedItems => EnumerateItems(_deselected);
public void BeginOperation()
{
if (IsTracking)
{
throw new AvaloniaInternalException("SelectionModel change operation already in progress.");
}
IsTracking = true;
_selected?.Clear();
_deselected?.Clear();
}
public void EndOperation() => IsTracking = false;
public void Selected(IndexRange range)
{
if (!IsTracking)
{
return;
}
Add(range, ref _selected, _deselected);
}
public void Selected(IEnumerable<IndexRange> ranges)
{
if (!IsTracking)
{
return;
}
foreach (var range in ranges)
{
Selected(range);
}
}
public void Deselected(IndexRange range)
{
if (!IsTracking)
{
return;
}
Add(range, ref _deselected, _selected);
}
public void Deselected(IEnumerable<IndexRange> ranges)
{
if (!IsTracking)
{
return;
}
foreach (var range in ranges)
{
Deselected(range);
}
}
private static void Add(
IndexRange range,
ref List<IndexRange>? add,
List<IndexRange>? remove)
{
if (remove != null)
{
var removed = new List<IndexRange>();
IndexRange.Remove(remove, range, removed);
var selected = IndexRange.Subtract(range, removed);
if (selected.Any())
{
add ??= new List<IndexRange>();
foreach (var r in selected)
{
IndexRange.Add(add, r);
}
}
}
else
{
add ??= new List<IndexRange>();
IndexRange.Add(add, range);
}
}
private IEnumerable<IndexPath> EnumerateIndices(IEnumerable<IndexRange>? ranges)
{
var path = _owner.IndexPath;
if (ranges != null)
{
foreach (var range in ranges)
{
for (var i = range.Begin; i <= range.End; ++i)
{
yield return path.CloneWithChildIndex(i);
}
}
}
}
private IEnumerable<object> EnumerateItems(IEnumerable<IndexRange>? ranges)
{
var items = _owner.ItemsSourceView;
if (ranges != null && items != null)
{
foreach (var range in ranges)
{
for (var i = range.Begin; i <= range.End; ++i)
{
yield return items.GetAt(i);
}
}
}
}
}
}

45
src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs

@ -4,6 +4,7 @@
// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
using System; using System;
using System.Collections.Generic;
#nullable enable #nullable enable
@ -11,5 +12,49 @@ namespace Avalonia.Controls
{ {
public class SelectionModelSelectionChangedEventArgs : EventArgs public class SelectionModelSelectionChangedEventArgs : EventArgs
{ {
private readonly IEnumerable<IndexPath> _selectedIndicesSource;
private readonly IEnumerable<IndexPath> _deselectedIndicesSource;
private readonly IEnumerable<object> _selectedItemsSource;
private readonly IEnumerable<object> _deselectedItemsSource;
private List<IndexPath>? _selectedIndices;
private List<IndexPath>? _deselectedIndices;
private List<object>? _selectedItems;
private List<object>? _deselectedItems;
public SelectionModelSelectionChangedEventArgs(
IEnumerable<IndexPath> deselectedIndices,
IEnumerable<IndexPath> selectedIndices,
IEnumerable<object> deselectedItems,
IEnumerable<object> selectedItems)
{
_selectedIndicesSource = selectedIndices;
_deselectedIndicesSource = deselectedIndices;
_selectedItemsSource = selectedItems;
_deselectedItemsSource = deselectedItems;
}
/// <summary>
/// Gets the indices of the items that were added to the selection.
/// </summary>
public IReadOnlyList<IndexPath> SelectedIndices =>
_selectedIndices ?? (_selectedIndices = new List<IndexPath>(_selectedIndicesSource));
/// <summary>
/// Gets the indices of the items that were removed from the selection.
/// </summary>
public IReadOnlyList<IndexPath> DeselectedIndices =>
_deselectedIndices ?? (_deselectedIndices = new List<IndexPath>(_deselectedIndicesSource));
/// <summary>
/// Gets the items that were added to the selection.
/// </summary>
public IReadOnlyList<object> SelectedItems =>
_selectedItems ?? (_selectedItems = new List<object>(_selectedItemsSource));
/// <summary>
/// Gets the items that were removed from the selection.
/// </summary>
public IReadOnlyList<object> DeselectedItems =>
_deselectedItems ?? (_deselectedItems = new List<object>(_deselectedItemsSource));
} }
} }

150
src/Avalonia.Controls/SelectionNode.cs

@ -7,6 +7,7 @@ using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Linq;
#nullable enable #nullable enable
@ -28,6 +29,7 @@ namespace Avalonia.Controls
private readonly SelectionNode? _parent; private readonly SelectionNode? _parent;
private readonly List<IndexRange> _selected = new List<IndexRange>(); private readonly List<IndexRange> _selected = new List<IndexRange>();
private readonly List<int> _selectedIndicesCached = new List<int>(); private readonly List<int> _selectedIndicesCached = new List<int>();
private SelectionModelChangeSet? _changes;
private object? _source; private object? _source;
private bool _selectedIndicesCacheIsValid; private bool _selectedIndicesCacheIsValid;
@ -134,6 +136,11 @@ namespace Avalonia.Controls
{ {
child = new SelectionNode(_manager, parent: this); child = new SelectionNode(_manager, parent: this);
child.Source = resolvedChild; child.Source = resolvedChild;
if (_changes?.IsTracking == true)
{
child.BeginOperation();
}
} }
else else
{ {
@ -276,12 +283,50 @@ namespace Avalonia.Controls
} }
} }
public IEnumerable<object> SelectedItems
{
get => SelectedIndices.Select(x => ItemsSourceView!.GetAt(x));
}
public void Dispose() public void Dispose()
{ {
ItemsSourceView?.Dispose(); ItemsSourceView?.Dispose();
UnhookCollectionChangedHandler(); UnhookCollectionChangedHandler();
} }
public void BeginOperation()
{
_changes ??= new SelectionModelChangeSet(this);
_changes.BeginOperation();
for (var i = 0; i < _childrenNodes.Count; ++i)
{
_childrenNodes[i]?.BeginOperation();
}
}
public IEnumerable<SelectionModelChangeSet> EndOperation()
{
if (_changes != null)
{
_changes.EndOperation();
yield return _changes;
for (var i = 0; i < _childrenNodes.Count; ++i)
{
var child = _childrenNodes[i];
if (child != null)
{
foreach (var changes in child.EndOperation())
{
yield return changes;
}
}
}
}
}
public bool Select(int index, bool select) public bool Select(int index, bool select)
{ {
return Select(index, select, raiseOnSelectionChanged: true); return Select(index, select, raiseOnSelectionChanged: true);
@ -349,21 +394,13 @@ namespace Avalonia.Controls
private void AddRange(IndexRange addRange, bool raiseOnSelectionChanged) private void AddRange(IndexRange addRange, bool raiseOnSelectionChanged)
{ {
// TODO: Check for duplicates (Task 14107720) var selected = new List<IndexRange>();
// TODO: Optimize by merging adjacent ranges (Task 14107720)
var oldCount = SelectedCount;
for (int i = addRange.Begin; i <= addRange.End; i++) SelectedCount += IndexRange.Add(_selected, addRange, selected);
{
if (!IsSelected(i))
{
SelectedCount++;
}
}
if (oldCount != SelectedCount) if (selected.Count > 0)
{ {
_selected.Add(addRange); _changes?.Selected(selected);
if (raiseOnSelectionChanged) if (raiseOnSelectionChanged)
{ {
@ -374,71 +411,17 @@ namespace Avalonia.Controls
private void RemoveRange(IndexRange removeRange, bool raiseOnSelectionChanged) private void RemoveRange(IndexRange removeRange, bool raiseOnSelectionChanged)
{ {
int oldCount = SelectedCount; var removed = new List<IndexRange>();
// TODO: Prevent overlap of Ranges in _selected (Task 14107720) SelectedCount -= IndexRange.Remove(_selected, removeRange, removed);
for (int i = removeRange.Begin; i <= removeRange.End; i++)
{
if (IsSelected(i))
{
SelectedCount--;
}
}
if (oldCount != SelectedCount) if (removed.Count > 0)
{ {
// Build up a both a list of Ranges to remove and ranges to add _changes?.Deselected(removed);
var toRemove = new List<IndexRange>();
var toAdd = new List<IndexRange>();
foreach (var range in _selected)
{
// If this range intersects the remove range, we have to do something
if (removeRange.Intersects(range))
{
// Intersection with the beginning of the range
// Anything to the left of the point (exclusive) stays
// Anything to the right of the point (inclusive) gets clipped
if (range.Contains(removeRange.Begin - 1))
{
range.Split(removeRange.Begin - 1, out var before, out _);
toAdd.Add(before);
}
// Intersection with the end of the range if (raiseOnSelectionChanged)
// Anything to the left of the point (inclusive) gets clipped
// Anything to the right of the point (exclusive) stays
if (range.Contains(removeRange.End))
{
if (range.Split(removeRange.End, out _, out var after))
{
toAdd.Add(after);
}
}
// Remove this Range from the collection
// New ranges will be added for any remaining subsections
toRemove.Add(range);
}
}
bool change = ((toRemove.Count > 0) || (toAdd.Count > 0));
if (change)
{ {
// Remove tagged ranges OnSelectionChanged();
foreach (var remove in toRemove)
{
_selected.Remove(remove);
}
// Add new ranges
_selected.AddRange(toAdd);
if (raiseOnSelectionChanged)
{
OnSelectionChanged();
}
} }
} }
} }
@ -448,6 +431,7 @@ namespace Avalonia.Controls
// Deselect all items // Deselect all items
if (_selected.Count > 0) if (_selected.Count > 0)
{ {
_changes?.Deselected(_selected);
_selected.Clear(); _selected.Clear();
OnSelectionChanged(); OnSelectionChanged();
} }
@ -496,6 +480,7 @@ namespace Avalonia.Controls
private void OnSourceListChanged(object dataSource, NotifyCollectionChangedEventArgs args) private void OnSourceListChanged(object dataSource, NotifyCollectionChangedEventArgs args)
{ {
bool selectionInvalidated = false; bool selectionInvalidated = false;
IList<object>? removed = null;
switch (args.Action) switch (args.Action)
{ {
@ -507,7 +492,7 @@ namespace Avalonia.Controls
case NotifyCollectionChangedAction.Remove: case NotifyCollectionChangedAction.Remove:
{ {
selectionInvalidated = OnItemsRemoved(args.OldStartingIndex, args.OldItems.Count); (selectionInvalidated, removed) = OnItemsRemoved(args.OldStartingIndex, args.OldItems);
break; break;
} }
@ -520,7 +505,7 @@ namespace Avalonia.Controls
case NotifyCollectionChangedAction.Replace: case NotifyCollectionChangedAction.Replace:
{ {
selectionInvalidated = OnItemsRemoved(args.OldStartingIndex, args.OldItems.Count); (selectionInvalidated, removed) = OnItemsRemoved(args.OldStartingIndex, args.OldItems);
selectionInvalidated |= OnItemsAdded(args.NewStartingIndex, args.NewItems.Count); selectionInvalidated |= OnItemsAdded(args.NewStartingIndex, args.NewItems.Count);
break; break;
} }
@ -529,7 +514,7 @@ namespace Avalonia.Controls
if (selectionInvalidated) if (selectionInvalidated)
{ {
OnSelectionChanged(); OnSelectionChanged();
_manager.OnSelectionInvalidatedDueToCollectionChange(); _manager.OnSelectionInvalidatedDueToCollectionChange(removed);
} }
} }
@ -609,21 +594,23 @@ namespace Avalonia.Controls
return selectionInvalidated; return selectionInvalidated;
} }
private bool OnItemsRemoved(int index, int count) private (bool, IList<object>) OnItemsRemoved(int index, IList items)
{ {
bool selectionInvalidated = false; var selectionInvalidated = false;
var removed = new List<object>();
var count = items.Count;
// Remove the items from the selection for leaf // Remove the items from the selection for leaf
if (ItemsSourceView!.Count > 0) if (ItemsSourceView!.Count > 0)
{ {
bool isSelected = false; bool isSelected = false;
for (int i = index; i <= index + count - 1; i++) for (int i = 0; i <= count - 1; i++)
{ {
if (IsSelected(i)) if (IsSelected(index + i))
{ {
isSelected = true; isSelected = true;
break; removed.Add(items[i]);
} }
} }
@ -654,6 +641,7 @@ namespace Avalonia.Controls
{ {
if (_childrenNodes[index] != null) if (_childrenNodes[index] != null)
{ {
removed.AddRange(_childrenNodes[index]!.SelectedItems);
RealizedChildrenNodeCount--; RealizedChildrenNodeCount--;
} }
_childrenNodes.RemoveAt(index); _childrenNodes.RemoveAt(index);
@ -696,7 +684,7 @@ namespace Avalonia.Controls
} }
} }
return selectionInvalidated; return (selectionInvalidated, removed);
} }
private void OnSelectionChanged() private void OnSelectionChanged()

417
tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs

@ -930,6 +930,423 @@ namespace Avalonia.Controls.UnitTests
}); });
} }
[Fact]
public void Selecting_Item_Raises_SelectionChanged()
{
var target = new SelectionModel();
var raised = 0;
target.Source = Enumerable.Range(0, 10).ToList();
target.SelectionChanged += (s, e) =>
{
Assert.Empty(e.DeselectedIndices);
Assert.Empty(e.DeselectedItems);
Assert.Equal(new[] { new IndexPath(4) }, e.SelectedIndices);
Assert.Equal(new object[] { 4 }, e.SelectedItems);
++raised;
};
target.Select(4);
Assert.Equal(1, raised);
}
[Fact]
public void Selecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged()
{
var target = new SelectionModel();
var raised = 0;
target.Source = Enumerable.Range(0, 10).ToList();
target.Select(4);
target.SelectionChanged += (s, e) => ++raised;
target.Select(4);
Assert.Equal(0, raised);
}
[Fact]
public void SingleSelecting_Item_Raises_SelectionChanged()
{
var target = new SelectionModel { SingleSelect = true };
var raised = 0;
target.Source = Enumerable.Range(0, 10).ToList();
target.Select(3);
target.SelectionChanged += (s, e) =>
{
Assert.Equal(new[] { new IndexPath(3) }, e.DeselectedIndices);
Assert.Equal(new object[] { 3 }, e.DeselectedItems);
Assert.Equal(new[] { new IndexPath(4) }, e.SelectedIndices);
Assert.Equal(new object[] { 4 }, e.SelectedItems);
++raised;
};
target.Select(4);
Assert.Equal(1, raised);
}
[Fact]
public void SingleSelecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged()
{
var target = new SelectionModel { SingleSelect = true };
var raised = 0;
target.Source = Enumerable.Range(0, 10).ToList();
target.Select(4);
target.SelectionChanged += (s, e) => ++raised;
target.Select(4);
Assert.Equal(0, raised);
}
[Fact]
public void Selecting_Item_With_Group_Raises_SelectionChanged()
{
var target = new SelectionModel();
var raised = 0;
target.Source = CreateNestedData(1, 2, 3);
target.SelectionChanged += (s, e) =>
{
Assert.Empty(e.DeselectedIndices);
Assert.Empty(e.DeselectedItems);
Assert.Equal(new[] { new IndexPath(1, 1) }, e.SelectedIndices);
Assert.Equal(new object[] { 4 }, e.SelectedItems);
++raised;
};
target.Select(1, 1);
Assert.Equal(1, raised);
}
[Fact]
public void SelectAt_Raises_SelectionChanged()
{
var target = new SelectionModel();
var raised = 0;
target.Source = CreateNestedData(1, 2, 3);
target.SelectionChanged += (s, e) =>
{
Assert.Empty(e.DeselectedIndices);
Assert.Empty(e.DeselectedItems);
Assert.Equal(new[] { new IndexPath(1, 1) }, e.SelectedIndices);
Assert.Equal(new object[] { 4 }, e.SelectedItems);
++raised;
};
target.SelectAt(new IndexPath(1, 1));
Assert.Equal(1, raised);
}
[Fact]
public void SelectAll_Raises_SelectionChanged()
{
var target = new SelectionModel { SingleSelect = true };
var raised = 0;
target.Source = Enumerable.Range(0, 10).ToList();
target.SelectionChanged += (s, e) =>
{
var expected = Enumerable.Range(0, 10);
Assert.Empty(e.DeselectedIndices);
Assert.Empty(e.DeselectedItems);
Assert.Equal(expected.Select(x => new IndexPath(x)), e.SelectedIndices);
Assert.Equal(expected, e.SelectedItems.Cast<int>());
++raised;
};
target.SelectAll();
Assert.Equal(1, raised);
}
[Fact]
public void SelectAll_With_Already_Selected_Items_Raises_SelectionChanged()
{
var target = new SelectionModel { SingleSelect = true };
var raised = 0;
target.Source = Enumerable.Range(0, 10).ToList();
target.Select(4);
target.SelectionChanged += (s, e) =>
{
var expected = Enumerable.Range(0, 10).Except(new[] { 4 });
Assert.Empty(e.DeselectedIndices);
Assert.Empty(e.DeselectedItems);
Assert.Equal(expected.Select(x => new IndexPath(x)), e.SelectedIndices);
Assert.Equal(expected, e.SelectedItems.Cast<int>());
++raised;
};
target.SelectAll();
Assert.Equal(1, raised);
}
[Fact]
public void SelectRangeFromAnchor_Raises_SelectionChanged()
{
var target = new SelectionModel();
var raised = 0;
target.Source = Enumerable.Range(0, 10).ToList();
target.SelectionChanged += (s, e) =>
{
var expected = Enumerable.Range(4, 3);
Assert.Empty(e.DeselectedIndices);
Assert.Empty(e.DeselectedItems);
Assert.Equal(expected.Select(x => new IndexPath(x)), e.SelectedIndices);
Assert.Equal(expected, e.SelectedItems.Cast<int>());
++raised;
};
target.AnchorIndex = new IndexPath(4);
target.SelectRangeFromAnchor(6);
Assert.Equal(1, raised);
}
[Fact]
public void SelectRangeFromAnchor_With_Group_Raises_SelectionChanged()
{
var target = new SelectionModel();
var raised = 0;
target.Source = CreateNestedData(1, 2, 10);
target.SelectionChanged += (s, e) =>
{
var expected = Enumerable.Range(11, 6);
Assert.Empty(e.DeselectedIndices);
Assert.Empty(e.DeselectedItems);
Assert.Equal(expected.Select(x => new IndexPath(x / 10, x % 10)), e.SelectedIndices);
Assert.Equal(expected, e.SelectedItems.Cast<int>());
++raised;
};
target.AnchorIndex = new IndexPath(1, 1);
target.SelectRangeFromAnchor(1, 6);
Assert.Equal(1, raised);
}
[Fact]
public void SelectRangeFromAnchorTo_Raises_SelectionChanged()
{
var target = new SelectionModel();
var raised = 0;
target.Source = CreateNestedData(1, 2, 10);
target.SelectionChanged += (s, e) =>
{
var expected = Enumerable.Range(11, 6);
Assert.Empty(e.DeselectedIndices);
Assert.Empty(e.DeselectedItems);
Assert.Equal(expected.Select(x => new IndexPath(x / 10, x % 10)), e.SelectedIndices);
Assert.Equal(expected, e.SelectedItems.Cast<int>());
++raised;
};
target.AnchorIndex = new IndexPath(1, 1);
target.SelectRangeFromAnchorTo(new IndexPath(1, 6));
Assert.Equal(1, raised);
}
[Fact]
public void ClearSelection_Raises_SelectionChanged()
{
var target = new SelectionModel();
var raised = 0;
target.Source = Enumerable.Range(0, 10).ToList();
target.Select(4);
target.Select(5);
target.SelectionChanged += (s, e) =>
{
var expected = Enumerable.Range(4, 2);
Assert.Equal(expected.Select(x => new IndexPath(x)), e.DeselectedIndices);
Assert.Equal(expected, e.DeselectedItems.Cast<int>());
Assert.Empty(e.SelectedIndices);
Assert.Empty(e.SelectedItems);
++raised;
};
target.ClearSelection();
Assert.Equal(1, raised);
}
[Fact]
public void Changing_Source_Raises_SelectionChanged()
{
var target = new SelectionModel();
var raised = 0;
target.Source = Enumerable.Range(0, 10).ToList();
target.Select(4);
target.Select(5);
target.SelectionChanged += (s, e) =>
{
var expected = Enumerable.Range(4, 2);
Assert.Equal(expected.Select(x => new IndexPath(x)), e.DeselectedIndices);
Assert.Equal(expected, e.DeselectedItems.Cast<int>());
Assert.Empty(e.SelectedIndices);
Assert.Empty(e.SelectedItems);
++raised;
};
target.Source = Enumerable.Range(20, 10).ToList();
Assert.Equal(1, raised);
}
[Fact]
public void Setting_SelectedIndex_Raises_SelectionChanged()
{
var target = new SelectionModel();
var raised = 0;
target.Source = Enumerable.Range(0, 10).ToList();
target.Select(4);
target.Select(5);
target.SelectionChanged += (s, e) =>
{
Assert.Equal(new[] { new IndexPath(4), new IndexPath(5) }, e.DeselectedIndices);
Assert.Equal(new object[] { 4, 5 }, e.DeselectedItems);
Assert.Equal(new[] { new IndexPath(6) }, e.SelectedIndices);
Assert.Equal(new object[] { 6 }, e.SelectedItems);
++raised;
};
target.SelectedIndex = new IndexPath(6);
Assert.Equal(1, raised);
}
[Fact]
public void Removing_Selected_Item_Raises_SelectionChanged()
{
var target = new SelectionModel();
var data = new ObservableCollection<int>(Enumerable.Range(0, 10));
var raised = 0;
target.Source = data;
target.Select(4);
target.Select(5);
target.SelectionChanged += (s, e) =>
{
Assert.Empty(e.DeselectedIndices);
Assert.Equal(new object[] { 4 }, e.DeselectedItems);
Assert.Empty(e.SelectedIndices);
Assert.Empty(e.SelectedItems);
++raised;
};
data.Remove(4);
Assert.Equal(1, raised);
}
[Fact]
public void Removing_Selected_Child_Item_Raises_SelectionChanged()
{
var target = new SelectionModel();
var data = CreateNestedData(1, 2, 3);
var raised = 0;
target.Source = data;
target.SelectRange(new IndexPath(0), new IndexPath(1, 1));
target.SelectionChanged += (s, e) =>
{
Assert.Empty(e.DeselectedIndices);
Assert.Equal(new object[] { 1}, e.DeselectedItems);
Assert.Empty(e.SelectedIndices);
Assert.Empty(e.SelectedItems);
++raised;
};
((AvaloniaList<object>)data[0]).RemoveAt(1);
Assert.Equal(1, raised);
}
[Fact]
public void Removing_Selected_Item_With_Children_Raises_SelectionChanged()
{
var target = new SelectionModel();
var data = CreateNestedData(1, 2, 3);
var raised = 0;
target.Source = data;
target.SelectRange(new IndexPath(0), new IndexPath(1, 1));
target.SelectionChanged += (s, e) =>
{
Assert.Empty(e.DeselectedIndices);
Assert.Equal(new object[] { 0, 1, 2 }, e.DeselectedItems);
Assert.Empty(e.SelectedIndices);
Assert.Empty(e.SelectedItems);
++raised;
};
data.RemoveAt(0);
Assert.Equal(1, raised);
}
[Fact]
public void Removing_Unselected_Item_Before_Selected_Item_Raises_SelectionChanged()
{
var target = new SelectionModel();
var data = new ObservableCollection<int>(Enumerable.Range(0, 10));
var raised = 0;
target.Source = data;
target.Select(8);
target.SelectionChanged += (s, e) =>
{
Assert.Empty(e.DeselectedIndices);
Assert.Empty(e.DeselectedItems);
Assert.Empty(e.SelectedIndices);
Assert.Empty(e.SelectedItems);
++raised;
};
data.Remove(6);
Assert.Equal(1, raised);
}
[Fact]
public void Removing_Unselected_Item_After_Selected_Item_Doesnt_Raise_SelectionChanged()
{
var target = new SelectionModel();
var data = new ObservableCollection<int>(Enumerable.Range(0, 10));
var raised = 0;
target.Source = data;
target.Select(4);
target.SelectionChanged += (s, e) => ++raised;
data.Remove(6);
Assert.Equal(0, raised);
}
[Fact] [Fact]
public void Disposing_Unhooks_CollectionChanged_Handlers() public void Disposing_Unhooks_CollectionChanged_Handlers()
{ {

Loading…
Cancel
Save