Browse Source

Merge pull request #10685 from AvaloniaUI/feature/9497-container-lifecycle-events

Add container lifecycle events to ItemsControl.
Dan Walmsley 2 years ago
parent
commit
3f46326948

+ 20 - 0
src/Avalonia.Controls/ContainerClearingEventArgs.cs

@@ -0,0 +1,20 @@
+using System;
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Provides data for the <see cref="ItemsControl.ContainerClearing"/> event.
+    /// </summary>
+    public class ContainerClearingEventArgs : EventArgs
+    {
+        public ContainerClearingEventArgs(Control container)
+        {
+            Container = container;
+        }
+
+        /// <summary>
+        /// Gets the prepared container.
+        /// </summary>
+        public Control Container { get; }
+    }
+}

+ 32 - 0
src/Avalonia.Controls/ContainerIndexChangedEventArgs.cs

@@ -0,0 +1,32 @@
+using System;
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Provides data for the <see cref="ItemsControl.ContainerIndexChanged"/> event.
+    /// </summary>
+    public class ContainerIndexChangedEventArgs : EventArgs
+    {
+        public ContainerIndexChangedEventArgs(Control container, int oldIndex, int newIndex)
+        {
+            Container = container;
+            OldIndex = oldIndex;
+            NewIndex = newIndex;
+        }
+
+        /// <summary>
+        /// Get the container for which the index changed.
+        /// </summary>
+        public Control Container { get; }
+
+        /// <summary>
+        /// Gets the index of the container after the change.
+        /// </summary>
+        public int NewIndex { get; }
+
+        /// <summary>
+        /// Gets the index of the container before the change.
+        /// </summary>
+        public int OldIndex { get; }
+    }
+}

+ 26 - 0
src/Avalonia.Controls/ContainerPreparedEventArgs.cs

@@ -0,0 +1,26 @@
+using System;
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Provides data for the <see cref="ItemsControl.ContainerPrepared"/> event.
+    /// </summary>
+    public class ContainerPreparedEventArgs : EventArgs
+    {
+        public ContainerPreparedEventArgs(Control container, int index)
+        {
+            Container = container;
+            Index = index;
+        }
+
+        /// <summary>
+        /// Gets the prepared container.
+        /// </summary>
+        public Control Container { get; }
+
+        /// <summary>
+        /// Gets the index of the item the container was prepared for.
+        /// </summary>
+        public int Index { get; }
+    }
+}

+ 31 - 0
src/Avalonia.Controls/ItemsControl.cs

@@ -274,6 +274,34 @@ namespace Avalonia.Controls
             remove => _childIndexChanged -= value;
         }
 
+        /// <summary>
+        /// Occurs each time a container is prepared for use.
+        /// </summary>
+        /// <remarks>
+        /// The prepared element might be newly created or an existing container that is being re-
+        /// used.
+        /// </remarks>
+        public event EventHandler<ContainerPreparedEventArgs>? ContainerPrepared;
+
+        /// <summary>
+        /// Occurs for each realized container when the index for the item it represents has changed.
+        /// </summary>
+        /// <remarks>
+        /// 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.
+        /// </remarks>
+        public event EventHandler<ContainerIndexChangedEventArgs>? ContainerIndexChanged;
+
+        /// <summary>
+        /// Occurs each time a container is cleared.
+        /// </summary>
+        /// <remarks>
+        /// 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.
+        /// </remarks>
+        public event EventHandler<ContainerClearingEventArgs>? ContainerClearing;
+
         /// <inheritdoc />
         public event EventHandler<RoutedEventArgs> 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)

+ 1 - 2
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);

+ 1 - 1
src/Avalonia.Controls/VirtualizingStackPanel.cs

@@ -1079,7 +1079,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)
                     {

+ 130 - 0
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<InvalidOperationException>(() => target.DisplayMemberBinding = new Binding("Length"));
         }
 
+        [Fact]
+        public void ContainerPrepared_Is_Raised_For_Each_Item_Container_On_Layout()
+        {
+            var target = new ItemsControl
+            {
+                Template = GetTemplate(),
+                Items = { "Foo", "Bar", "Baz" },
+            };
+
+            var result = new List<Control>();
+            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_Raised_For_Each_ItemsSource_Container_On_Layout()
+        {
+            var target = new ItemsControl
+            {
+                Template = GetTemplate(),
+                ItemsSource = new[] { "Foo", "Bar", "Baz" },
+            };
+
+            var result = new List<Control>();
+            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_Raised_For_Added_Item()
+        {
+            var target = new ItemsControl
+            {
+                Template = GetTemplate(),
+                Items = { "Foo", "Bar", "Baz" },
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            var result = new List<Control>();
+
+            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_Raised_When_Item_Added()
+        {
+            var target = new ItemsControl
+            {
+                Template = GetTemplate(),
+                Items = { "Foo", "Bar", "Baz" },
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            var result = new List<Control>();
+            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_Raised_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)

+ 140 - 0
tests/Avalonia.Controls.UnitTests/ListBoxTests.cs

@@ -785,6 +785,146 @@ namespace Avalonia.Controls.UnitTests
             }
         }
 
+        [Fact]
+        public void ContainerPrepared_Is_Raised_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<Control>();
+            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_Raised_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<Control>();
+            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_Raised_For_Added_Item()
+        {
+            using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
+
+            var data = new AvaloniaList<string> { "Foo", "Bar", "Baz" };
+            var target = new ListBox
+            {
+                Template = ListBoxTemplate(),
+                ItemsSource = data,
+            };
+
+            Prepare(target);
+
+            var result = new List<Control>();
+
+            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_Raised_When_Item_Added()
+        {
+            using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
+
+            var data = new AvaloniaList<string> { "Foo", "Bar", "Baz" };
+            var target = new ListBox
+            {
+                Template = ListBoxTemplate(),
+                ItemsSource = data,
+            };
+
+            Prepare(target);
+
+            var result = new List<Control>();
+            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_Raised_When_Item_Removed()
+        {
+            using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
+
+            var data = new AvaloniaList<string> { "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<string>, INotifyCollectionChanged
         {
             public ResettingCollection(int itemCount)

+ 30 - 0
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<int> GetRealizedIndexes(VirtualizingStackPanel target, ItemsControl itemsControl)
         {
             return target.GetRealizedElements()