diff --git a/src/Avalonia.Base/Collections/AvaloniaDictionaryExtensions.cs b/src/Avalonia.Base/Collections/AvaloniaDictionaryExtensions.cs index 8c731c188f..dfd62259e4 100644 --- a/src/Avalonia.Base/Collections/AvaloniaDictionaryExtensions.cs +++ b/src/Avalonia.Base/Collections/AvaloniaDictionaryExtensions.cs @@ -70,11 +70,6 @@ namespace Avalonia.Collections case NotifyCollectionChangedAction.Move: case NotifyCollectionChangedAction.Replace: Remove(e.OldItems!); - int newIndex = e.NewStartingIndex; - if(newIndex > e.OldStartingIndex) - { - newIndex -= e.OldItems!.Count; - } Add(e.NewItems!); break; diff --git a/src/Avalonia.Base/Collections/AvaloniaList.cs b/src/Avalonia.Base/Collections/AvaloniaList.cs index 84a77810da..324e33e5af 100644 --- a/src/Avalonia.Base/Collections/AvaloniaList.cs +++ b/src/Avalonia.Base/Collections/AvaloniaList.cs @@ -466,7 +466,7 @@ namespace Avalonia.Collections if (newIndex > oldIndex) { - modifiedNewIndex -= count; + modifiedNewIndex -= count - 1; } _inner.InsertRange(modifiedNewIndex, items); diff --git a/src/Avalonia.Base/Collections/AvaloniaListExtensions.cs b/src/Avalonia.Base/Collections/AvaloniaListExtensions.cs index 2178577eb7..244788c63e 100644 --- a/src/Avalonia.Base/Collections/AvaloniaListExtensions.cs +++ b/src/Avalonia.Base/Collections/AvaloniaListExtensions.cs @@ -99,15 +99,30 @@ namespace Avalonia.Collections break; case NotifyCollectionChangedAction.Move: - case NotifyCollectionChangedAction.Replace: + if (e.OldStartingIndex < 0) + { + goto case NotifyCollectionChangedAction.Reset; + } + Remove(e.OldStartingIndex, e.OldItems!); - int newIndex = e.NewStartingIndex; + var newIndex = e.NewStartingIndex; + if(newIndex > e.OldStartingIndex) { - newIndex -= e.OldItems!.Count; + newIndex -= e.OldItems!.Count - 1; } + Add(newIndex, e.NewItems!); break; + case NotifyCollectionChangedAction.Replace: + if (e.OldStartingIndex < 0) + { + goto case NotifyCollectionChangedAction.Reset; + } + + Remove(e.OldStartingIndex, e.OldItems!); + Add(e.NewStartingIndex, e.NewItems!); + break; case NotifyCollectionChangedAction.Remove: Remove(e.OldStartingIndex, e.OldItems!); diff --git a/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs b/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs index 4027cd252f..b0e2af2f3a 100644 --- a/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs +++ b/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs @@ -93,10 +93,30 @@ namespace Avalonia.Controls.Presenters Remove(e.OldStartingIndex, e.OldItems!.Count); break; case NotifyCollectionChangedAction.Replace: - case NotifyCollectionChangedAction.Move: + if (e.OldStartingIndex < 0) + { + goto case NotifyCollectionChangedAction.Reset; + } + Remove(e.OldStartingIndex, e.OldItems!.Count); Add(e.NewStartingIndex, e.NewItems!); break; + case NotifyCollectionChangedAction.Move: + if (e.OldStartingIndex < 0) + { + goto case NotifyCollectionChangedAction.Reset; + } + + Remove(e.OldStartingIndex, e.OldItems!.Count); + var insertIndex = e.NewStartingIndex; + + if (e.NewStartingIndex > e.OldStartingIndex) + { + insertIndex -= e.OldItems.Count - 1; + } + + Add(insertIndex, e.NewItems!); + break; case NotifyCollectionChangedAction.Reset: ClearItemsControlLogicalChildren(); children.Clear(); diff --git a/src/Avalonia.Controls/Selection/SelectionNodeBase.cs b/src/Avalonia.Controls/Selection/SelectionNodeBase.cs index caeff61f07..c50f77830f 100644 --- a/src/Avalonia.Controls/Selection/SelectionNodeBase.cs +++ b/src/Avalonia.Controls/Selection/SelectionNodeBase.cs @@ -139,13 +139,38 @@ namespace Avalonia.Controls.Selection break; } case NotifyCollectionChangedAction.Replace: - case NotifyCollectionChangedAction.Move: { + if (e.OldStartingIndex < 0) + { + goto case NotifyCollectionChangedAction.Reset; + } + var removeChange = OnItemsRemoved(e.OldStartingIndex, e.OldItems!); var addChange = OnItemsAdded(e.NewStartingIndex, e.NewItems!); shiftIndex = removeChange.ShiftIndex; shiftDelta = removeChange.ShiftDelta + addChange.ShiftDelta; removed = removeChange.RemovedItems; + break; + } + case NotifyCollectionChangedAction.Move: + { + if (e.OldStartingIndex < 0) + { + goto case NotifyCollectionChangedAction.Reset; + } + + var removeChange = OnItemsRemoved(e.OldStartingIndex, e.OldItems!); + var insertIndex = e.NewStartingIndex; + + if (e.NewStartingIndex > e.OldStartingIndex) + { + insertIndex -= e.OldItems!.Count - 1; + } + + var addChange = OnItemsAdded(insertIndex, e.NewItems!); + shiftIndex = removeChange.ShiftIndex; + shiftDelta = removeChange.ShiftDelta + addChange.ShiftDelta; + removed = removeChange.RemovedItems; } break; case NotifyCollectionChangedAction.Reset: diff --git a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs index d10ae124c1..8b67f01fb1 100644 --- a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs +++ b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs @@ -236,10 +236,30 @@ namespace Avalonia.Controls Remove(e.OldStartingIndex, e.OldItems!.Count); break; case NotifyCollectionChangedAction.Replace: - case NotifyCollectionChangedAction.Move: + if (e.OldStartingIndex < 0) + { + goto case NotifyCollectionChangedAction.Reset; + } + Remove(e.OldStartingIndex, e.OldItems!.Count); Add(e.NewStartingIndex, e.NewItems!.Count); break; + case NotifyCollectionChangedAction.Move: + if (e.OldStartingIndex < 0) + { + goto case NotifyCollectionChangedAction.Reset; + } + + Remove(e.OldStartingIndex, e.OldItems!.Count); + var insertIndex = e.NewStartingIndex; + + if (e.NewStartingIndex > e.OldStartingIndex) + { + insertIndex -= e.OldItems.Count - 1; + } + + Add(insertIndex, e.NewItems!.Count); + break; case NotifyCollectionChangedAction.Reset: if (_realized is not null) { diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index b3d61a9b64..173358bc4c 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -272,8 +272,20 @@ namespace Avalonia.Controls _realizedElements.ItemsReplaced(e.OldStartingIndex, e.OldItems!.Count, _recycleElementOnItemRemoved); break; case NotifyCollectionChangedAction.Move: + if (e.OldStartingIndex < 0) + { + goto case NotifyCollectionChangedAction.Reset; + } + _realizedElements.ItemsRemoved(e.OldStartingIndex, e.OldItems!.Count, _updateElementIndex, _recycleElementOnItemRemoved); - _realizedElements.ItemsInserted(e.NewStartingIndex, e.NewItems!.Count, _updateElementIndex); + var insertIndex = e.NewStartingIndex; + + if (e.NewStartingIndex > e.OldStartingIndex) + { + insertIndex -= e.OldItems.Count - 1; + } + + _realizedElements.ItemsInserted(insertIndex, e.NewItems!.Count, _updateElementIndex); break; case NotifyCollectionChangedAction.Reset: _realizedElements.ItemsReset(_recycleElementOnItemRemoved); diff --git a/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListTests.cs b/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListTests.cs index b59f272b0c..82a9885ddc 100644 --- a/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListTests.cs +++ b/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListTests.cs @@ -52,6 +52,22 @@ namespace Avalonia.Base.UnitTests.Collections Assert.Throws(() => target.InsertRange(1, new List() { 1 })); } + [Fact] + public void Move_Should_Move_One_Item() + { + AvaloniaList target = [1, 2, 3]; + + AssertEvent(target, () => target.Move(0, 1), [ + new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Move, + new[] { 1 }, + 1, + 0) + ]); + + Assert.Equal(new[] { 2, 1, 3 }, target); + } + [Fact] public void Move_Should_Update_Collection() { @@ -75,9 +91,9 @@ namespace Avalonia.Base.UnitTests.Collections [Fact] public void MoveRange_Can_Move_To_End() { - var target = new AvaloniaList(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }); + AvaloniaList target = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; - target.MoveRange(0, 5, 10); + target.MoveRange(0, 5, 9); Assert.Equal(new[] { 6, 7, 8, 9, 10, 1, 2, 3, 4, 5 }, target); } @@ -85,25 +101,35 @@ namespace Avalonia.Base.UnitTests.Collections [Fact] public void MoveRange_Raises_Correct_CollectionChanged_Event() { - var target = new AvaloniaList(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }); - var raised = false; + AvaloniaList target = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; - target.CollectionChanged += (s, e) => - { - Assert.Equal(NotifyCollectionChangedAction.Move, e.Action); - Assert.Equal(0, e.OldStartingIndex); - Assert.Equal(10, e.NewStartingIndex); - Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }, e.OldItems); - Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }, e.NewItems); - raised = true; - }; + AssertEvent(target, () => target.MoveRange(0, 9, 9), [ + new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Move, + new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }, + 9, + 0) + ]); - target.MoveRange(0, 9, 10); - - Assert.True(raised); Assert.Equal(new[] { 10, 1, 2, 3, 4, 5, 6, 7, 8, 9 }, target); } + [Fact] + public void MoveRange_Should_Move_One_Item() + { + AvaloniaList target = [1, 2, 3]; + + AssertEvent(target, () => target.MoveRange(0, 1, 1), [ + new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Move, + new[] { 1 }, + 1, + 0) + ]); + + Assert.Equal(new[] { 2, 1, 3 }, target); + } + [Fact] public void Adding_Item_Should_Raise_CollectionChanged() { @@ -526,5 +552,48 @@ namespace Avalonia.Base.UnitTests.Collections Assert.Equal(0, raised); } + + /// + /// Assert that emits when performing . + /// + /// The event source. + /// The action to perform. + /// The expected events. + private static void AssertEvent(INotifyCollectionChanged items, Action action, NotifyCollectionChangedEventArgs[] expectedEvents) + { + var callCount = 0; + items.CollectionChanged += OnCollectionChanged; + + Assert.Multiple(() => + { + try + { + action(); + } + finally + { + items.CollectionChanged -= OnCollectionChanged; + } + + Assert.Equal(expectedEvents.Length, callCount); + }); + + return; + + void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs actualEvent) + { + Assert.Multiple(() => + { + Assert.True(callCount < expectedEvents.Length); + Assert.Equal(expectedEvents[callCount].Action, actualEvent.Action); + Assert.Equal(expectedEvents[callCount].NewItems, actualEvent.NewItems); + Assert.Equal(expectedEvents[callCount].NewStartingIndex, actualEvent.NewStartingIndex); + Assert.Equal(expectedEvents[callCount].OldItems, actualEvent.OldItems); + Assert.Equal(expectedEvents[callCount].OldStartingIndex, actualEvent.OldStartingIndex); + }); + + ++callCount; + } + } } } diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests.cs index ca98991c4b..24aff3b566 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using Avalonia.Collections; using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; using Avalonia.UnitTests; @@ -91,6 +92,20 @@ namespace Avalonia.Controls.UnitTests.Presenters AssertContainers(panel, items); } + [Fact] + public void Updates_Container_For_Moved_Range_Of_Items() + { + using var app = Start(); + AvaloniaList items = ["foo", "bar", "baz"]; + var (target, _, root) = CreateTarget(items); + var panel = Assert.IsType(target.Panel); + + items.MoveRange(0, 2, 2); + root.LayoutManager.ExecuteLayoutPass(); + + AssertContainers(panel, items); + } + [Fact] public void Updates_Containers_For_Replaced_Items() { diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs index 4f3b2f588f..b636c69488 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs @@ -1,9 +1,11 @@ using System; using System.Collections; using System.Collections.ObjectModel; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia.Animation; +using Avalonia.Collections; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; @@ -110,6 +112,28 @@ namespace Avalonia.Controls.UnitTests Assert.Equal("bar", container.Content); } + [Fact] + public void Handles_Moved_Item_Range() + { + using var app = Start(); + AvaloniaList items = ["foo", "bar", "baz", "qux", "quux"]; + var (target, carousel) = CreateTarget(items); + var container = Assert.IsType(target.Children[0]); + + carousel.SelectedIndex = 3; + Layout(target); + items.MoveRange(0, 2, 4); + Layout(target); + + Assert.Multiple(() => + { + Assert.Single(target.Children); + Assert.Same(container, target.Children[0]); + Assert.Equal("qux", container.Content); + Assert.Equal(1, carousel.SelectedIndex); + }); + } + public class Transitions { [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs index ba7765dabb..0b33239687 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -204,6 +204,68 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(elements, target.GetRealizedElements()); } + [Fact] + public void Updates_Elements_On_Item_Moved() + { + // Arrange + + using var app = App(); + + var actualItems = new AvaloniaList(Enumerable + .Range(0, 100) + .Select(x => $"Item {x}")); + + var (target, _, itemsControl) = CreateTarget(items: actualItems); + + var expectedRealizedElementContents = new[] { 1, 2, 0, 3, 4, 5, 6, 7, 8, 9 } + .Select(x => $"Item {x}"); + + // Act + + actualItems.Move(0, 2); + Layout(target); + + // Assert + + var actualRealizedElementContents = target + .GetRealizedElements() + .Cast() + .Select(x => x.Content); + + Assert.Equivalent(expectedRealizedElementContents, actualRealizedElementContents); + } + + [Fact] + public void Updates_Elements_On_Item_Range_Moved() + { + // Arrange + + using var app = App(); + + var actualItems = new AvaloniaList(Enumerable + .Range(0, 100) + .Select(x => $"Item {x}")); + + var (target, _, itemsControl) = CreateTarget(items: actualItems); + + var expectedRealizedElementContents = new[] { 2, 0, 1, 3, 4, 5, 6, 7, 8, 9 } + .Select(x => $"Item {x}"); + + // Act + + actualItems.MoveRange(0, 2, 3); + Layout(target); + + // Assert + + var actualRealizedElementContents = target + .GetRealizedElements() + .Cast() + .Select(x => x.Content); + + Assert.Equivalent(expectedRealizedElementContents, actualRealizedElementContents); + } + [Fact] public void Updates_Elements_On_Item_Remove() {