diff --git a/src/Avalonia.Controls/Selection/InternalSelectionModel.cs b/src/Avalonia.Controls/Selection/InternalSelectionModel.cs index 455700af1f..1b95236bd8 100644 --- a/src/Avalonia.Controls/Selection/InternalSelectionModel.cs +++ b/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? previousItems = null; + + if (_writableSelectedItems?.Count > 0) + { + previousItems = new List(_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(); + if (_writableSelectedItems != null) + { + foreach (var item in _writableSelectedItems) + currentItems.Add(item); + } + + var deselectedItems = new List(); + 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) { diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index 3c36b7ff74..35f0d79286 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/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 + { + 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 + { + 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()); + Assert.Contains(selected2, receivedArgs.RemovedItems.Cast()); + } + + [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() {