From 791ea8beef1cd3aad574ff5d1f42299d57f17f34 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 9 Mar 2023 16:13:40 +0100 Subject: [PATCH 1/4] Added container lifecycle events. Seems we weren't calling `ClearItemContainer` for `ItemIsOwnContainer` items in `PanelContainerGenerator`. Fixed that too. --- .../ContainerClearingEventArgs.cs | 20 +++ .../ContainerIndexChangedEventArgs.cs | 32 +++++ .../ContainerPreparedEventArgs.cs | 26 ++++ src/Avalonia.Controls/ItemsControl.cs | 31 +++++ .../Presenters/PanelContainerGenerator.cs | 3 +- .../ItemsControlTests.cs | 130 ++++++++++++++++++ 6 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 src/Avalonia.Controls/ContainerClearingEventArgs.cs create mode 100644 src/Avalonia.Controls/ContainerIndexChangedEventArgs.cs create mode 100644 src/Avalonia.Controls/ContainerPreparedEventArgs.cs diff --git a/src/Avalonia.Controls/ContainerClearingEventArgs.cs b/src/Avalonia.Controls/ContainerClearingEventArgs.cs new file mode 100644 index 0000000000..fb0c5c4bf8 --- /dev/null +++ b/src/Avalonia.Controls/ContainerClearingEventArgs.cs @@ -0,0 +1,20 @@ +using System; + +namespace Avalonia.Controls +{ + /// + /// Provides data for the event. + /// + public class ContainerClearingEventArgs : EventArgs + { + public ContainerClearingEventArgs(Control container) + { + Container = container; + } + + /// + /// Gets the prepared container. + /// + public Control Container { get; } + } +} diff --git a/src/Avalonia.Controls/ContainerIndexChangedEventArgs.cs b/src/Avalonia.Controls/ContainerIndexChangedEventArgs.cs new file mode 100644 index 0000000000..80536e742c --- /dev/null +++ b/src/Avalonia.Controls/ContainerIndexChangedEventArgs.cs @@ -0,0 +1,32 @@ +using System; + +namespace Avalonia.Controls +{ + /// + /// Provides data for the event. + /// + public class ContainerIndexChangedEventArgs : EventArgs + { + public ContainerIndexChangedEventArgs(Control container, int oldIndex, int newIndex) + { + Container = container; + OldIndex = oldIndex; + NewIndex = newIndex; + } + + /// + /// Get the container for which the index changed. + /// + public Control Container { get; } + + /// + /// Gets the index of the container after the change. + /// + public int NewIndex { get; } + + /// + /// Gets the index of the container before the change. + /// + public int OldIndex { get; } + } +} diff --git a/src/Avalonia.Controls/ContainerPreparedEventArgs.cs b/src/Avalonia.Controls/ContainerPreparedEventArgs.cs new file mode 100644 index 0000000000..1acb653bd1 --- /dev/null +++ b/src/Avalonia.Controls/ContainerPreparedEventArgs.cs @@ -0,0 +1,26 @@ +using System; + +namespace Avalonia.Controls +{ + /// + /// Provides data for the event. + /// + public class ContainerPreparedEventArgs : EventArgs + { + public ContainerPreparedEventArgs(Control container, int index) + { + Container = container; + Index = index; + } + + /// + /// Gets the prepared container. + /// + public Control Container { get; } + + /// + /// Gets the index of the item the container was prepared for. + /// + public int Index { get; } + } +} diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 09ae159047..4460ff0486 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -274,6 +274,34 @@ namespace Avalonia.Controls remove => _childIndexChanged -= value; } + /// + /// Occurs each time a container is prepared for use. + /// + /// + /// The prepared element might be newly created or an existing container that is being re- + /// used. + /// + public event EventHandler? ContainerPrepared; + + /// + /// Occurs for each realized container when the index for the item it represents has changed. + /// + /// + /// This event is raised for each realized container where the index for the item it + /// represents has changed. For example, when another item is added or removed in the data + /// source, the index for items that come after in the ordering will be impacted. + /// + public event EventHandler? ContainerIndexChanged; + + /// + /// Occurs each time a container is cleared. + /// + /// + /// This event is raised immediately each time an container is cleared, such as when it + /// falls outside the range of realized items or the corresponding item is removed. + /// + public event EventHandler? ContainerClearing; + /// public event EventHandler HorizontalSnapPointsChanged { @@ -649,18 +677,21 @@ namespace Avalonia.Controls { _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(container, index)); _scrollViewer?.RegisterAnchorCandidate(container); + ContainerPrepared?.Invoke(this, new(container, index)); } internal void ItemContainerIndexChanged(Control container, int oldIndex, int newIndex) { ContainerIndexChangedOverride(container, oldIndex, newIndex); _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(container, newIndex)); + ContainerIndexChanged?.Invoke(this, new(container, oldIndex, newIndex)); } internal void ClearItemContainer(Control container) { _scrollViewer?.UnregisterAnchorCandidate(container); ClearContainerForItemOverride(container); + ContainerClearing?.Invoke(this, new(container)); } private void AddControlItemsToLogicalChildren(IEnumerable? items) diff --git a/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs b/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs index 1cf0202772..796ee8433a 100644 --- a/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs +++ b/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs @@ -69,8 +69,7 @@ namespace Avalonia.Controls.Presenters var c = children[index + i]; if (!c.IsSet(ItemIsOwnContainerProperty)) itemsControl.RemoveLogicalChild(children[i + index]); - else - generator.ClearItemContainer(c); + generator.ClearItemContainer(c); } children.RemoveRange(index, count); diff --git a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs index 10a7b0e540..afbc66ef40 100644 --- a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.Linq; @@ -831,6 +832,135 @@ namespace Avalonia.Controls.UnitTests Assert.Throws(() => target.DisplayMemberBinding = new Binding("Length")); } + [Fact] + public void ContainerPrepared_Is_Called_For_Each_Item_Container_On_Layout() + { + var target = new ItemsControl + { + Template = GetTemplate(), + Items = { "Foo", "Bar", "Baz" }, + }; + + var result = new List(); + var index = 0; + + target.ContainerPrepared += (s, e) => + { + Assert.Equal(index++, e.Index); + result.Add(e.Container); + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + Assert.Equal(3, result.Count); + Assert.Equal(target.GetRealizedContainers(), result); + } + + [Fact] + public void ContainerPrepared_Is_Called_For_Each_ItemsSource_Container_On_Layout() + { + var target = new ItemsControl + { + Template = GetTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + }; + + var result = new List(); + var index = 0; + + target.ContainerPrepared += (s, e) => + { + Assert.Equal(index++, e.Index); + result.Add(e.Container); + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + Assert.Equal(3, result.Count); + Assert.Equal(target.GetRealizedContainers(), result); + } + + [Fact] + public void ContainerPrepared_Is_Called_For_Added_Item() + { + var target = new ItemsControl + { + Template = GetTemplate(), + Items = { "Foo", "Bar", "Baz" }, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + var result = new List(); + + target.ContainerPrepared += (s, e) => + { + Assert.Equal(3, e.Index); + result.Add(e.Container); + }; + + target.Items.Add("Qux"); + + Assert.Equal(1, result.Count); + } + + [Fact] + public void ContainerIndexChanged_Is_Called_When_Item_Added() + { + var target = new ItemsControl + { + Template = GetTemplate(), + Items = { "Foo", "Bar", "Baz" }, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + var result = new List(); + var index = 1; + + target.ContainerIndexChanged += (s, e) => + { + Assert.Equal(index++, e.OldIndex); + Assert.Equal(index, e.NewIndex); + result.Add(e.Container); + }; + + target.Items.Insert(1, "Qux"); + + Assert.Equal(2, result.Count); + Assert.Equal(target.GetRealizedContainers().Skip(2), result); + } + + [Fact] + public void ContainerClearing_Is_Called_When_Item_Removed() + { + var target = new ItemsControl + { + Template = GetTemplate(), + Items = { "Foo", "Bar", "Baz" }, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + var expected = target.ContainerFromIndex(1); + var raised = 0; + + target.ContainerClearing += (s, e) => + { + Assert.Same(expected, e.Container); + ++raised; + }; + + target.Items.RemoveAt(1); + + Assert.Equal(1, raised); + } + private class Item { public Item(string value) From ce2cd52964cf247d7e9d645995fd2c4e677737d7 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 9 Mar 2023 16:24:13 +0100 Subject: [PATCH 2/4] Add lifecycle event tests with virtualization. And fix index error in VirtualizingStackPanel. --- .../VirtualizingStackPanel.cs | 2 +- .../ListBoxTests.cs | 140 ++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index 4970a333a5..977f8a1e8c 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -1078,7 +1078,7 @@ namespace Avalonia.Controls // elements after the insertion point. var elementCount = _elements.Count; var start = Math.Max(realizedIndex, 0); - var newIndex = first + count; + var newIndex = realizedIndex + count; for (var i = start; i < elementCount; ++i) { diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs index d2b75a81ca..72476e34e9 100644 --- a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs @@ -785,6 +785,146 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void ContainerPrepared_Is_Called_For_Each_Item_Container_On_Layout() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var target = new ListBox + { + Template = ListBoxTemplate(), + Items = { "Foo", "Bar", "Baz" }, + }; + + var result = new List(); + var index = 0; + + target.ContainerPrepared += (s, e) => + { + Assert.Equal(index++, e.Index); + result.Add(e.Container); + }; + + Prepare(target); + + Assert.Equal(3, result.Count); + Assert.Equal(target.GetRealizedContainers(), result); + } + + [Fact] + public void ContainerPrepared_Is_Called_For_Each_ItemsSource_Container_On_Layout() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var target = new ListBox + { + Template = ListBoxTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + }; + + var result = new List(); + var index = 0; + + target.ContainerPrepared += (s, e) => + { + Assert.Equal(index++, e.Index); + result.Add(e.Container); + }; + + Prepare(target); + + Assert.Equal(3, result.Count); + Assert.Equal(target.GetRealizedContainers(), result); + } + + [Fact] + public void ContainerPrepared_Is_Called_For_Added_Item() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var data = new AvaloniaList { "Foo", "Bar", "Baz" }; + var target = new ListBox + { + Template = ListBoxTemplate(), + ItemsSource = data, + }; + + Prepare(target); + + var result = new List(); + + target.ContainerPrepared += (s, e) => + { + Assert.Equal(3, e.Index); + result.Add(e.Container); + }; + + data.Add("Qux"); + Layout(target); + + Assert.Equal(1, result.Count); + } + + [Fact] + public void ContainerIndexChanged_Is_Called_When_Item_Added() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var data = new AvaloniaList { "Foo", "Bar", "Baz" }; + var target = new ListBox + { + Template = ListBoxTemplate(), + ItemsSource = data, + }; + + Prepare(target); + + var result = new List(); + var index = 1; + + target.ContainerIndexChanged += (s, e) => + { + Assert.Equal(index++, e.OldIndex); + Assert.Equal(index, e.NewIndex); + result.Add(e.Container); + }; + + data.Insert(1, "Qux"); + Layout(target); + + Assert.Equal(2, result.Count); + Assert.Equal(target.GetRealizedContainers().Skip(2), result); + } + + [Fact] + public void ContainerClearing_Is_Called_When_Item_Removed() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var data = new AvaloniaList { "Foo", "Bar", "Baz" }; + var target = new ListBox + { + Template = ListBoxTemplate(), + ItemsSource = data, + }; + + Prepare(target); + + var expected = target.ContainerFromIndex(1); + var raised = 0; + + target.ContainerClearing += (s, e) => + { + Assert.Same(expected, e.Container); + ++raised; + }; + + data.RemoveAt(1); + Layout(target); + + Assert.Equal(1, raised); + } + private class ResettingCollection : List, INotifyCollectionChanged { public ResettingCollection(int itemCount) From 9d012fbb6b70c9125db9e21066ae04f863ff3b0b Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 9 Mar 2023 16:25:50 +0100 Subject: [PATCH 3/4] Fix terminology. --- tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs | 10 +++++----- tests/Avalonia.Controls.UnitTests/ListBoxTests.cs | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs index afbc66ef40..41aaa7b670 100644 --- a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs @@ -833,7 +833,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void ContainerPrepared_Is_Called_For_Each_Item_Container_On_Layout() + public void ContainerPrepared_Is_Raised_For_Each_Item_Container_On_Layout() { var target = new ItemsControl { @@ -858,7 +858,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void ContainerPrepared_Is_Called_For_Each_ItemsSource_Container_On_Layout() + public void ContainerPrepared_Is_Raised_For_Each_ItemsSource_Container_On_Layout() { var target = new ItemsControl { @@ -883,7 +883,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void ContainerPrepared_Is_Called_For_Added_Item() + public void ContainerPrepared_Is_Raised_For_Added_Item() { var target = new ItemsControl { @@ -908,7 +908,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void ContainerIndexChanged_Is_Called_When_Item_Added() + public void ContainerIndexChanged_Is_Raised_When_Item_Added() { var target = new ItemsControl { @@ -936,7 +936,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void ContainerClearing_Is_Called_When_Item_Removed() + public void ContainerClearing_Is_Raised_When_Item_Removed() { var target = new ItemsControl { diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs index 72476e34e9..f42185a59c 100644 --- a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs @@ -786,7 +786,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void ContainerPrepared_Is_Called_For_Each_Item_Container_On_Layout() + public void ContainerPrepared_Is_Raised_For_Each_Item_Container_On_Layout() { using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); @@ -812,7 +812,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void ContainerPrepared_Is_Called_For_Each_ItemsSource_Container_On_Layout() + public void ContainerPrepared_Is_Raised_For_Each_ItemsSource_Container_On_Layout() { using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); @@ -838,7 +838,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void ContainerPrepared_Is_Called_For_Added_Item() + public void ContainerPrepared_Is_Raised_For_Added_Item() { using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); @@ -866,7 +866,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void ContainerIndexChanged_Is_Called_When_Item_Added() + public void ContainerIndexChanged_Is_Raised_When_Item_Added() { using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); @@ -897,7 +897,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void ContainerClearing_Is_Called_When_Item_Removed() + public void ContainerClearing_Is_Raised_When_Item_Removed() { using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); From 7dbe277653ad777ab5ca1c06c1d154114c0eeda5 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 9 Mar 2023 16:28:21 +0100 Subject: [PATCH 4/4] More lifecycle event tests with virtualization. --- .../VirtualizingStackPanelTests.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs index 2c86ca045e..42b419bbcc 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -406,6 +406,36 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void ContainerPrepared_Is_Raised_When_Scrolling() + { + using var app = App(); + var (target, scroll, itemsControl) = CreateTarget(); + var raised = 0; + + itemsControl.ContainerPrepared += (s, e) => ++raised; + + scroll.Offset = new Vector(0, 200); + Layout(target); + + Assert.Equal(10, raised); + } + + [Fact] + public void ContainerClearing_Is_Raised_When_Scrolling() + { + using var app = App(); + var (target, scroll, itemsControl) = CreateTarget(); + var raised = 0; + + itemsControl.ContainerClearing += (s, e) => ++raised; + + scroll.Offset = new Vector(0, 200); + Layout(target); + + Assert.Equal(10, raised); + } + private static IReadOnlyList GetRealizedIndexes(VirtualizingStackPanel target, ItemsControl itemsControl) { return target.GetRealizedElements()