Browse Source

fix: raise SelectionChanged event on collection Reset when items are deselected

When a collection bound to a SelectingItemsControl (e.g. ListBox) was
cleared via NotifyCollectionChangedAction.Reset, the SelectionChanged
event was not raised despite the selection being lost.

The root cause was in InternalSelectionModel.OnSourceReset: the base
SelectionModel.OnSourceReset() directly reset _selectedIndex to -1
before any Operation could capture the old selection state. The
subsequent SyncFromSelectedItems created an Operation that saw no
change (old and new both -1), so CommitOperation never fired
SelectionChanged.

The fix snapshots _writableSelectedItems before sync, diffs against
the post-sync state to find items that were actually lost (not merely
re-selected at a new index after reorder), and injects them as
DeselectedItems on the pending Operation — following the same pattern
used by OnSelectionRemoved for individual item removals.

Fixes #20897
pull/20942/head
Nathan Nguyen 5 days ago
parent
commit
ecab00d212
  1. 41
      src/Avalonia.Controls/Selection/InternalSelectionModel.cs
  2. 101
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs

41
src/Avalonia.Controls/Selection/InternalSelectionModel.cs

@ -264,7 +264,46 @@ namespace Avalonia.Controls.Selection
}
}
private void OnSourceReset(object? sender, EventArgs e) => SyncFromSelectedItems();
private void OnSourceReset(object? sender, EventArgs e)
{
// Snapshot the selected items before sync clears them. The source is already
// modified, but _writableSelectedItems still holds the previous selection.
List<object?>? previousItems = null;
if (_writableSelectedItems?.Count > 0)
{
previousItems = new List<object?>(_writableSelectedItems.Count);
foreach (var item in _writableSelectedItems)
previousItems.Add(item);
}
SyncFromSelectedItems();
if (previousItems?.Count > 0)
{
// Diff: only report items that were actually lost, not re-selected.
// A Reset may reorder without losing items (e.g. sort).
var currentItems = new HashSet<object?>();
if (_writableSelectedItems != null)
{
foreach (var item in _writableSelectedItems)
currentItems.Add(item);
}
var deselectedItems = new List<object?>();
foreach (var item in previousItems)
{
if (!currentItems.Contains(item))
deselectedItems.Add(item);
}
if (deselectedItems.Count > 0)
{
using var update = BatchUpdate();
update.Operation.DeselectedItems = deselectedItems;
}
}
}
private void OnSelectedItemsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{

101
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs

@ -856,6 +856,107 @@ namespace Avalonia.Controls.UnitTests.Primitives
Assert.Equal(-1, target.SelectedIndex);
}
[Fact]
public void Resetting_Items_Collection_Should_Raise_SelectionChanged()
{
// Issue #20897: Clearing a collection bound to a ListBox with a selected item
// should raise SelectionChanged with the previously selected item in RemovedItems.
var items = new ObservableCollection<Item>
{
new Item(),
new Item(),
new Item(),
};
var target = new SelectingItemsControl
{
ItemsSource = items,
Template = Template(),
};
Prepare(target);
target.SelectedIndex = 1;
var selectedItem = items[1];
SelectionChangedEventArgs? receivedArgs = null;
target.SelectionChanged += (_, args) => receivedArgs = args;
items.Clear();
Assert.Null(target.SelectedItem);
Assert.Equal(-1, target.SelectedIndex);
Assert.NotNull(receivedArgs);
Assert.Empty(receivedArgs.AddedItems);
Assert.Equal(new[] { selectedItem }, receivedArgs.RemovedItems);
}
[Fact]
public void Resetting_Items_To_Empty_With_Multiple_Selection_Should_Raise_SelectionChanged()
{
// Issue #20897: Same bug with multiple selection mode.
var items = new ObservableCollection<Item>
{
new Item(),
new Item(),
new Item(),
};
var target = new TestSelector
{
ItemsSource = items,
Template = Template(),
SelectionMode = SelectionMode.Multiple,
};
Prepare(target);
target.SelectedIndex = 0;
target.Selection.Select(2);
var selected0 = items[0];
var selected2 = items[2];
SelectionChangedEventArgs? receivedArgs = null;
target.SelectionChanged += (_, args) => receivedArgs = args;
items.Clear();
Assert.Null(target.SelectedItem);
Assert.Equal(-1, target.SelectedIndex);
Assert.NotNull(receivedArgs);
Assert.Empty(receivedArgs.AddedItems);
Assert.Equal(2, receivedArgs.RemovedItems.Count);
Assert.Contains(selected0, receivedArgs.RemovedItems.Cast<object>());
Assert.Contains(selected2, receivedArgs.RemovedItems.Cast<object>());
}
[Fact]
public void Resetting_Items_With_Preserved_Selection_Should_Not_Report_Deselection()
{
// A Reset that reorders items without losing the selected item should
// report no deselected items — the selection was preserved, not lost.
var items = new ResettingCollection(3);
var target = new SelectingItemsControl
{
ItemsSource = items,
Template = Template(),
};
target.ApplyTemplate();
target.SelectedIndex = 1;
SelectionChangedEventArgs? receivedArgs = null;
target.SelectionChanged += (_, args) => receivedArgs = args;
// Reorder: "Item1" (the selected item) moves but stays in the collection.
items.Reset(new[] { "Item2", "Item0", "Item1" });
Assert.Equal("Item1", target.SelectedItem);
Assert.NotNull(receivedArgs);
Assert.Empty(receivedArgs.RemovedItems);
}
[Fact]
public void Raising_IsSelectedChanged_On_Item_Should_Update_Selection()
{

Loading…
Cancel
Save