diff --git a/src/Avalonia.Controls/Utils/RealizedStackElements.cs b/src/Avalonia.Controls/Utils/RealizedStackElements.cs
index 18cba5b123..b8f088f090 100644
--- a/src/Avalonia.Controls/Utils/RealizedStackElements.cs
+++ b/src/Avalonia.Controls/Utils/RealizedStackElements.cs
@@ -281,13 +281,13 @@ namespace Avalonia.Controls.Utils
// elements after the insertion point.
var elementCount = _elements.Count;
var start = Math.Max(realizedIndex, 0);
- var newIndex = realizedIndex + count;
for (var i = start; i < elementCount; ++i)
{
- if (_elements[i] is Control element)
- updateElementIndex(element, newIndex - count, newIndex);
- ++newIndex;
+ if (_elements[i] is not Control element)
+ continue;
+ var oldIndex = i + first;
+ updateElementIndex(element, oldIndex, oldIndex + count);
}
if (realizedIndex < 0)
@@ -341,7 +341,7 @@ namespace Avalonia.Controls.Utils
for (var i = 0; i < _elements.Count; ++i)
{
if (_elements[i] is Control element)
- updateElementIndex(element, newIndex - count, newIndex);
+ updateElementIndex(element, newIndex + count, newIndex);
++newIndex;
}
}
@@ -384,6 +384,37 @@ namespace Avalonia.Controls.Utils
}
}
+ ///
+ /// Updates the elements in response to items being replaced in the source collection.
+ ///
+ /// The index in the source collection of the remove.
+ /// The number of items removed.
+ /// A method used to recycle elements.
+ public void ItemsReplaced(int index, int count, Action recycleElement)
+ {
+ if (index < 0)
+ throw new ArgumentOutOfRangeException(nameof(index));
+ if (_elements is null || _elements.Count == 0)
+ return;
+
+ // Get the index within the realized _elements collection.
+ var startIndex = index - FirstIndex;
+ var endIndex = Math.Min(startIndex + count, Count);
+
+ if (startIndex >= 0 && endIndex > startIndex)
+ {
+ for (var i = startIndex; i < endIndex; ++i)
+ {
+ if (_elements[i] is { } element)
+ {
+ recycleElement(element);
+ _elements[i] = null;
+ _sizes![i] = double.NaN;
+ }
+ }
+ }
+ }
+
///
/// Recycles all elements in response to the source collection being reset.
///
diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs
index d1fb4b8269..ac4826be1e 100644
--- a/src/Avalonia.Controls/VirtualizingStackPanel.cs
+++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs
@@ -253,6 +253,8 @@ namespace Avalonia.Controls
_realizedElements.ItemsRemoved(e.OldStartingIndex, e.OldItems!.Count, _updateElementIndex, _recycleElementOnItemRemoved);
break;
case NotifyCollectionChangedAction.Replace:
+ _realizedElements.ItemsReplaced(e.OldStartingIndex, e.OldItems!.Count, _recycleElementOnItemRemoved);
+ break;
case NotifyCollectionChangedAction.Move:
_realizedElements.ItemsRemoved(e.OldStartingIndex, e.OldItems!.Count, _updateElementIndex, _recycleElementOnItemRemoved);
_realizedElements.ItemsInserted(e.NewStartingIndex, e.NewItems!.Count, _updateElementIndex);
diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs
index b61131a6ee..bed8379d9c 100644
--- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs
@@ -611,6 +611,125 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(10, raised);
}
+ [Fact]
+ public void ContainerIndexChanged_Is_Raised_On_Insert()
+ {
+ using var app = App();
+ var (target, scroll, itemsControl) = CreateTarget();
+ var items = (IList)itemsControl.ItemsSource!;
+ var raised = 0;
+ var index = 1;
+
+ itemsControl.ContainerIndexChanged += (s, e) =>
+ {
+ ++raised;
+ Assert.Equal(index, e.OldIndex);
+ Assert.Equal(++index, e.NewIndex);
+ };
+
+ items.Insert(index, "new");
+
+ Assert.Equal(9, raised);
+ }
+
+ [Fact]
+ public void ContainerIndexChanged_Is_Raised_When_Item_Inserted_Before_Realized_Elements()
+ {
+ using var app = App();
+ var (target, scroll, itemsControl) = CreateTarget();
+ var items = (IList)itemsControl.ItemsSource!;
+ var raised = 0;
+ var index = 20;
+
+ itemsControl.ContainerIndexChanged += (s, e) =>
+ {
+ ++raised;
+ Assert.Equal(index, e.OldIndex);
+ Assert.Equal(++index, e.NewIndex);
+ };
+
+ scroll.Offset = new Vector(0, 200);
+ Layout(target);
+
+ items.Insert(10, "new");
+
+ Assert.Equal(10, raised);
+ }
+
+ [Fact]
+ public void ContainerIndexChanged_Is_Raised_On_Remove()
+ {
+ using var app = App();
+ var (target, scroll, itemsControl) = CreateTarget();
+ var items = (IList)itemsControl.ItemsSource!;
+ var raised = 0;
+ var index = 1;
+
+ itemsControl.ContainerIndexChanged += (s, e) =>
+ {
+ ++raised;
+ Assert.Equal(index + 1, e.OldIndex);
+ Assert.Equal(index++, e.NewIndex);
+ };
+
+ items.RemoveAt(index);
+
+ Assert.Equal(8, raised);
+ }
+
+ [Fact]
+ public void ContainerIndexChanged_Is_Raised_When_Item_Removed_Before_Realized_Elements()
+ {
+ using var app = App();
+ var (target, scroll, itemsControl) = CreateTarget();
+ var items = (IList)itemsControl.ItemsSource!;
+ var raised = 0;
+ var index = 20;
+
+ itemsControl.ContainerIndexChanged += (s, e) =>
+ {
+ Assert.Equal(index, e.OldIndex);
+ Assert.Equal(index - 1, e.NewIndex);
+ ++index;
+ ++raised;
+ };
+
+ scroll.Offset = new Vector(0, 200);
+ Layout(target);
+
+ items.RemoveAt(10);
+
+ Assert.Equal(10, raised);
+ }
+
+ [Fact]
+ public void Fires_Correct_Container_Lifecycle_Events_On_Replace()
+ {
+ using var app = App();
+ var (target, scroll, itemsControl) = CreateTarget();
+ var items = (IList)itemsControl.ItemsSource!;
+ var events = new List();
+
+ itemsControl.ContainerPrepared += (s, e) => events.Add($"Prepared #{e.Container.GetHashCode()} = {e.Index}");
+ itemsControl.ContainerClearing += (s, e) => events.Add($"Clearing #{e.Container.GetHashCode()}");
+ itemsControl.ContainerIndexChanged += (s, e) => events.Add($"IndexChanged #{e.Container.GetHashCode()} {e.OldIndex} -> {e.NewIndex}");
+
+ var toReplace = target.GetRealizedElements().ElementAt(2)!;
+ items[2] = "New Item";
+
+ Assert.Equal(
+ new[] { $"Clearing #{toReplace.GetHashCode()}" },
+ events);
+ events.Clear();
+
+ itemsControl.UpdateLayout();
+
+ Assert.Equal(
+ new[] { $"Prepared #{toReplace.GetHashCode()} = 2" },
+ events);
+ events.Clear();
+ }
+
[Fact]
public void Scrolling_Down_With_Larger_Element_Does_Not_Cause_Jump_And_Arrives_At_End()
{