Browse Source

Merge pull request #10055 from AvaloniaUI/fixes/9997-nth-last-child-itemscontrol

Fix nth-last-child styles on virtualizing layouts
Max Katz 2 năm trước cách đây
mục cha
commit
bd5865fca9

+ 1 - 1
samples/ControlCatalog/Pages/ListBoxPage.xaml

@@ -10,7 +10,7 @@
         <Setter Property="FontWeight" Value="Bold" />
       </Style>
       <Style Selector="ListBox ListBoxItem:nth-last-child(5n+4)">
-        <Setter Property="Foreground" Value="Blue" />
+        <Setter Property="Background" Value="Blue" />
         <Setter Property="FontWeight" Value="Bold" />
       </Style>
     </DockPanel.Styles>

+ 60 - 9
src/Avalonia.Base/LogicalTree/ChildIndexChangedEventArgs.cs

@@ -1,28 +1,79 @@
-#nullable enable
-using System;
+using System;
+
+#nullable enable
 
 namespace Avalonia.LogicalTree
 {
+    /// <summary>
+    /// Describes the action that caused a <see cref="IChildIndexProvider.ChildIndexChanged"/> event.
+    /// </summary>
+    public enum ChildIndexChangedAction
+    {
+        /// <summary>
+        /// The index of a single child changed.
+        /// </summary>
+        ChildIndexChanged,
+
+        /// <summary>
+        /// The index of multiple children changed and all children should be re-evaluated.
+        /// </summary>
+        ChildIndexesReset,
+
+        /// <summary>
+        /// The total number of children changed.
+        /// </summary>
+        TotalCountChanged,
+    }
+
     /// <summary>
     /// Event args for <see cref="IChildIndexProvider.ChildIndexChanged"/> event.
     /// </summary>
     public class ChildIndexChangedEventArgs : EventArgs
     {
-        public static new ChildIndexChangedEventArgs Empty { get; } = new ChildIndexChangedEventArgs();
-
-        private ChildIndexChangedEventArgs()
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ChildIndexChangedEventArgs"/> class with
+        /// an action of <see cref="ChildIndexChangedAction.ChildIndexChanged"/>.
+        /// </summary>
+        /// <param name="child">The child whose index was changed.</param>
+        /// <param name="index">The new index of the child.</param>
+        public ChildIndexChangedEventArgs(ILogical child, int index)
         {
+            Action = ChildIndexChangedAction.ChildIndexChanged;
+            Child = child;
+            Index = index;
         }
 
-        public ChildIndexChangedEventArgs(ILogical child)
+        private ChildIndexChangedEventArgs(ChildIndexChangedAction action)
         {
-            Child = child;
+            Action = action;
+            Index = -1;
         }
 
         /// <summary>
-        /// Logical child which index was changed.
-        /// If null, all children should be reset.
+        /// Gets the type of change action that ocurred on the list control.
+        /// </summary>
+        public ChildIndexChangedAction Action { get; }
+
+        /// <summary>
+        /// Gets the logical child whose index was changed or null if all children should be re-evaluated.
         /// </summary>
         public ILogical? Child { get; }
+
+        /// <summary>
+        /// Gets the new index of <see cref="Child"/> or -1 if all children should be re-evaluated.
+        /// </summary>
+        public int Index { get; }
+
+        /// <summary>
+        /// Gets an instance of the <see cref="ChildIndexChangedEventArgs"/> with an action of
+        /// <see cref="ChildIndexChangedAction.ChildIndexesReset"/>.
+        /// </summary>
+        public static ChildIndexChangedEventArgs ChildIndexesReset { get; } = new(ChildIndexChangedAction.ChildIndexesReset);
+
+        /// <summary>
+        /// Gets an instance of the <see cref="ChildIndexChangedEventArgs"/> with an action of
+        /// <see cref="ChildIndexChangedAction.TotalCountChanged"/>.
+        /// </summary>
+        public static ChildIndexChangedEventArgs TotalCountChanged { get; } = new(ChildIndexChangedAction.TotalCountChanged);
     }
 }

+ 1 - 1
src/Avalonia.Base/LogicalTree/IChildIndexProvider.cs

@@ -25,7 +25,7 @@ namespace Avalonia.LogicalTree
         bool TryGetTotalCount(out int count);
 
         /// <summary>
-        /// Notifies subscriber when child's index or total count was changed.
+        /// Notifies subscriber when a child's index was changed.
         /// </summary>
         event EventHandler<ChildIndexChangedEventArgs>? ChildIndexChanged;
     }

+ 38 - 9
src/Avalonia.Base/Styling/Activators/NthChildActivator.cs

@@ -1,4 +1,5 @@
 #nullable enable
+using System;
 using Avalonia.LogicalTree;
 
 namespace Avalonia.Styling.Activators
@@ -13,6 +14,7 @@ namespace Avalonia.Styling.Activators
         private readonly int _step;
         private readonly int _offset;
         private readonly bool _reversed;
+        private int _index = -1;
 
         public NthChildActivator(
             ILogical control,
@@ -28,24 +30,51 @@ namespace Avalonia.Styling.Activators
 
         protected override bool EvaluateIsActive()
         {
-            return NthChildSelector.Evaluate(_control, _provider, _step, _offset, _reversed).IsMatch;
+            var index = _index >= 0 ? _index : _provider.GetChildIndex(_control);
+            return NthChildSelector.Evaluate(index, _provider, _step, _offset, _reversed).IsMatch;
         }
 
-        protected override void Initialize() => _provider.ChildIndexChanged += ChildIndexChanged;
-        protected override void Deinitialize() => _provider.ChildIndexChanged -= ChildIndexChanged;
+        protected override void Initialize()
+        {
+            _provider.ChildIndexChanged += ChildIndexChanged;
+        }
+
+        protected override void Deinitialize()
+        {
+            _provider.ChildIndexChanged -= ChildIndexChanged;
+        }
 
         private void ChildIndexChanged(object? sender, ChildIndexChangedEventArgs e)
         {
             // Run matching again if:
-            // 1. Selector is reversed, so other item insertion/deletion might affect total count without changing subscribed item index.
-            // 2. e.Child is null, when all children indices were changed.
-            // 3. Subscribed child index was changed.
-            if (_reversed
-                || e.Child is null
-                || e.Child == _control)
+            // 1. Subscribed child index was changed
+            // 2. Child indexes were reset
+            // 3. We're a reversed (nth-last-child) selector and total count has changed
+            if ((e.Child == _control || e.Action == ChildIndexChangedAction.ChildIndexesReset) ||
+                (_reversed && e.Action == ChildIndexChangedAction.TotalCountChanged))
             {
+                // We're using the _index field to pass the index of the child to EvaluateIsActive
+                // *only* when the active state is re-evaluated via this event handler. The docs
+                // for EvaluateIsActive say:
+                //
+                // > This method should read directly from its inputs and not rely on any
+                // > subscriptions to fire in order to be up-to-date.
+                //
+                // Which is good advice in general, however in this case we need to break the rule
+                // and use the value from the event subscription instead of calling
+                // IChildIndexProvider.GetChildIndex. This is because this event can be fired during
+                // the process of realizing an element of a virtualized list; in this case calling
+                // GetChildIndex may not return the correct index as the element isn't yet realized.
+                _index = e.Index;
                 ReevaluateIsActive();
+                _index = -1;
             }
         }
+
+        private void TotalCountChanged(object? sender, EventArgs e)
+        {
+            if (_reversed)
+                ReevaluateIsActive();
+        }
     }
 }

+ 2 - 3
src/Avalonia.Base/Styling/NthChildSelector.cs

@@ -61,7 +61,7 @@ namespace Avalonia.Styling
             {
                 return subscribe
                     ? new SelectorMatch(new NthChildActivator(logical, childIndexProvider, Step, Offset, _reversed))
-                    : Evaluate(logical, childIndexProvider, Step, Offset, _reversed);
+                    : Evaluate(childIndexProvider.GetChildIndex(logical), childIndexProvider, Step, Offset, _reversed);
             }
             else
             {
@@ -70,10 +70,9 @@ namespace Avalonia.Styling
         }
 
         internal static SelectorMatch Evaluate(
-            ILogical logical, IChildIndexProvider childIndexProvider,
+            int index, IChildIndexProvider childIndexProvider,
             int step, int offset, bool reversed)
         {
-            var index = childIndexProvider.GetChildIndex(logical);
             if (index < 0)
             {
                 return SelectorMatch.NeverThisInstance;

+ 1 - 1
src/Avalonia.Controls.DataGrid/Primitives/DataGridCellsPresenter.cs

@@ -336,7 +336,7 @@ namespace Avalonia.Controls.Primitives
 
         internal void InvalidateChildIndex()
         {
-            _childIndexChanged?.Invoke(this, ChildIndexChangedEventArgs.Empty);
+            _childIndexChanged?.Invoke(this, ChildIndexChangedEventArgs.ChildIndexesReset);
         }
 
         private bool ShouldDisplayCell(DataGridColumn column, double frozenLeftEdge, double scrollingLeftEdge)

+ 1 - 1
src/Avalonia.Controls.DataGrid/Primitives/DataGridColumnHeadersPresenter.cs

@@ -423,7 +423,7 @@ namespace Avalonia.Controls.Primitives
 
         internal void InvalidateChildIndex()
         {
-            _childIndexChanged?.Invoke(this, ChildIndexChangedEventArgs.Empty);
+            _childIndexChanged?.Invoke(this, ChildIndexChangedEventArgs.ChildIndexesReset);
         }
     }
 }

+ 1 - 1
src/Avalonia.Controls.DataGrid/Primitives/DataGridRowsPresenter.cs

@@ -66,7 +66,7 @@ namespace Avalonia.Controls.Primitives
 
         internal void InvalidateChildIndex(DataGridRow row)
         {
-            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(row));
+            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(row, row.Index));
         }
 
         /// <summary>

+ 5 - 4
src/Avalonia.Controls.ItemsRepeater/Controls/ItemsRepeater.cs

@@ -536,11 +536,12 @@ namespace Avalonia.Controls
 
         internal void OnElementPrepared(Control element, VirtualizationInfo virtInfo)
         {
+            var index = virtInfo.Index;
+
             _viewportManager.OnElementPrepared(element, virtInfo);
 
             if (ElementPrepared != null)
             {
-                var index = virtInfo.Index;
 
                 if (_elementPreparedArgs == null)
                 {
@@ -554,7 +555,7 @@ namespace Avalonia.Controls
                 ElementPrepared(this, _elementPreparedArgs);
             }
 
-            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element));
+            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element, index));
         }
 
         internal void OnElementClearing(Control element)
@@ -573,7 +574,7 @@ namespace Avalonia.Controls
                 ElementClearing(this, _elementClearingArgs);
             }
 
-            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element));
+            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element, -1));
         }
 
         internal void OnElementIndexChanged(Control element, int oldIndex, int newIndex)
@@ -592,7 +593,7 @@ namespace Avalonia.Controls
                 ElementIndexChanged(this, _elementIndexChangedArgs);
             }
 
-            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element));
+            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element, newIndex));
         }
 
         private void OnDataSourcePropertyChanged(ItemsSourceView? oldValue, ItemsSourceView? newValue)

+ 4 - 11
src/Avalonia.Controls/ItemsControl.cs

@@ -102,7 +102,6 @@ namespace Avalonia.Controls
         private ItemContainerGenerator? _itemContainerGenerator;
         private EventHandler<ChildIndexChangedEventArgs>? _childIndexChanged;
         private IDataTemplate? _displayMemberItemTemplate;
-        private Tuple<int, Control>? _containerBeingPrepared;
         private ScrollViewer? _scrollViewer;
         private ItemsPresenter? _itemsPresenter;
 
@@ -218,7 +217,6 @@ namespace Avalonia.Controls
             remove => _childIndexChanged -= value;
         }
 
-
         /// <inheritdoc />
         public event EventHandler<RoutedEventArgs> HorizontalSnapPointsChanged
         {
@@ -495,6 +493,7 @@ namespace Avalonia.Controls
             else if (change.Property == ItemCountProperty)
             {
                 UpdatePseudoClasses(change.GetNewValue<int>());
+                _childIndexChanged?.Invoke(this, ChildIndexChangedEventArgs.TotalCountChanged);
             }
             else if (change.Property == ItemContainerThemeProperty && _itemContainerGenerator is not null)
             {
@@ -579,7 +578,7 @@ namespace Avalonia.Controls
         internal void RegisterItemsPresenter(ItemsPresenter presenter)
         {
             Presenter = presenter;
-            _childIndexChanged?.Invoke(this, ChildIndexChangedEventArgs.Empty);
+            _childIndexChanged?.Invoke(this, ChildIndexChangedEventArgs.ChildIndexesReset);
         }
 
         internal void PrepareItemContainer(Control container, object? item, int index)
@@ -601,17 +600,14 @@ namespace Avalonia.Controls
 
         internal void ItemContainerPrepared(Control container, object? item, int index)
         {
-            _containerBeingPrepared = new(index, container);
-            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(container));
-            _containerBeingPrepared = null;
-
+            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(container, index));
             _scrollViewer?.RegisterAnchorCandidate(container);
         }
 
         internal void ItemContainerIndexChanged(Control container, int oldIndex, int newIndex)
         {
             ContainerIndexChangedOverride(container, oldIndex, newIndex);
-            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(container));
+            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(container, newIndex));
         }
 
         internal void ClearItemContainer(Control container)
@@ -742,9 +738,6 @@ namespace Avalonia.Controls
 
         int IChildIndexProvider.GetChildIndex(ILogical child)
         {
-            if (_containerBeingPrepared?.Item2 == child)
-                return _containerBeingPrepared.Item1;
-
             return child is Control container ? IndexFromContainer(container) : -1;
         }
 

+ 21 - 3
src/Avalonia.Controls/Panel.cs

@@ -1,6 +1,7 @@
 using System;
 using System.Collections.Generic;
 using System.Collections.Specialized;
+using System.ComponentModel;
 using System.Linq;
 using Avalonia.LogicalTree;
 using Avalonia.Media;
@@ -60,8 +61,19 @@ namespace Avalonia.Controls
 
         event EventHandler<ChildIndexChangedEventArgs>? IChildIndexProvider.ChildIndexChanged
         {
-            add => _childIndexChanged += value;
-            remove => _childIndexChanged -= value;
+            add
+            {
+                if (_childIndexChanged is null)
+                    Children.PropertyChanged += ChildrenPropertyChanged;
+                _childIndexChanged += value;
+            }
+
+            remove
+            {
+                _childIndexChanged -= value;
+                if (_childIndexChanged is null)
+                    Children.PropertyChanged -= ChildrenPropertyChanged;
+            }
         }
 
         /// <summary>
@@ -152,7 +164,7 @@ namespace Avalonia.Controls
                     throw new NotSupportedException();
             }
 
-            _childIndexChanged?.Invoke(this, ChildIndexChangedEventArgs.Empty);
+            _childIndexChanged?.Invoke(this, ChildIndexChangedEventArgs.ChildIndexesReset);
             InvalidateMeasureOnChildrenChanged();
         }
 
@@ -161,6 +173,12 @@ namespace Avalonia.Controls
             InvalidateMeasure();
         }
 
+        private void ChildrenPropertyChanged(object? sender, PropertyChangedEventArgs e)
+        {
+            if (e.PropertyName == nameof(Children.Count) || e.PropertyName is null)
+                _childIndexChanged?.Invoke(this, ChildIndexChangedEventArgs.TotalCountChanged);
+        }
+
         private static void AffectsParentArrangeInvalidate<TPanel>(AvaloniaPropertyChangedEventArgs e)
             where TPanel : Panel
         {

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

@@ -1167,7 +1167,7 @@ namespace Avalonia.Controls
 
                     // Update the indexes of the elements after the removed range.
                     end = _elements.Count;
-                    var newIndex = first;
+                    var newIndex = first + start;
                     for (var i = start; i < end; ++i)
                     {
                         if (_elements[i] is Control element)

+ 60 - 1
tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

@@ -10,6 +10,8 @@ using Avalonia.Controls.Templates;
 using Avalonia.Data;
 using Avalonia.Layout;
 using Avalonia.LogicalTree;
+using Avalonia.Media;
+using Avalonia.Styling;
 using Avalonia.UnitTests;
 using Avalonia.VisualTree;
 using Xunit;
@@ -355,6 +357,58 @@ namespace Avalonia.Controls.UnitTests
             Assert.Equal(new Vector(0, 0), scroll.Offset);
         }
 
+        [Fact]
+        public void NthChild_Selector_Works()
+        {
+            using var app = App();
+            
+            var style = new Style(x => x.OfType<ContentPresenter>().NthChild(5, 0))
+            {
+                Setters = { new Setter(ListBoxItem.BackgroundProperty, Brushes.Red) },
+            };
+
+            var (target, _, _) = CreateTarget(styles: new[] { style });
+            var realized = target.GetRealizedContainers()!.Cast<ContentPresenter>().ToList();
+            
+            Assert.Equal(10, realized.Count);
+            
+            for (var i = 0; i < 10; ++i)
+            {
+                var container = realized[i];
+                var index = target.IndexFromContainer(container);
+                var expectedBackground = (i == 4 || i == 9) ? Brushes.Red : null;
+
+                Assert.Equal(i, index);
+                Assert.Equal(expectedBackground, container.Background);
+            }
+        }
+
+        [Fact]
+        public void NthLastChild_Selector_Works()
+        {
+            using var app = App();
+
+            var style = new Style(x => x.OfType<ContentPresenter>().NthLastChild(5, 0))
+            {
+                Setters = { new Setter(ListBoxItem.BackgroundProperty, Brushes.Red) },
+            };
+
+            var (target, _, _) = CreateTarget(styles: new[] { style });
+            var realized = target.GetRealizedContainers()!.Cast<ContentPresenter>().ToList();
+
+            Assert.Equal(10, realized.Count);
+
+            for (var i = 0; i < 10; ++i)
+            {
+                var container = realized[i];
+                var index = target.IndexFromContainer(container);
+                var expectedBackground = (i == 0 || i == 5) ? Brushes.Red : null;
+
+                Assert.Equal(i, index);
+                Assert.Equal(expectedBackground, container.Background);
+            }
+        }
+
         private static IReadOnlyList<int> GetRealizedIndexes(VirtualizingStackPanel target, ItemsControl itemsControl)
         {
             return target.GetRealizedElements()
@@ -399,7 +453,8 @@ namespace Avalonia.Controls.UnitTests
 
         private static (VirtualizingStackPanel, ScrollViewer, ItemsControl) CreateTarget(
             IEnumerable<object>? items = null,
-            bool useItemTemplate = true)
+            bool useItemTemplate = true,
+            IEnumerable<Style>? styles = null)
         {
             var target = new VirtualizingStackPanel();
 
@@ -428,6 +483,10 @@ namespace Avalonia.Controls.UnitTests
 
             var root = new TestRoot(true, itemsControl);
             root.ClientSize = new(100, 100);
+
+            if (styles is not null)
+                root.Styles.AddRange(styles);
+
             root.LayoutManager.ExecuteInitialLayoutPass();
 
             return (target, scroll, itemsControl);