Преглед на файлове

Merge pull request #6381 from AvaloniaUI/feature/nth-child

NthChild and NthLastChild selectors support
Jumar Macato преди 4 години
родител
ревизия
43c04c2266
променени са 21 файла, в които са добавени 1542 реда и са изтрити 21 реда
  1. 20 4
      samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml
  2. 11 0
      samples/ControlCatalog/Pages/ListBoxPage.xaml
  3. 42 1
      src/Avalonia.Controls/ItemsControl.cs
  4. 23 1
      src/Avalonia.Controls/Panel.cs
  5. 40 1
      src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs
  6. 29 2
      src/Avalonia.Controls/Repeater/ItemsRepeater.cs
  7. 20 7
      src/Avalonia.Controls/Utils/IEnumerableUtils.cs
  8. 26 0
      src/Avalonia.Styling/LogicalTree/ChildIndexChangedEventArgs.cs
  9. 32 0
      src/Avalonia.Styling/LogicalTree/IChildIndexProvider.cs
  10. 56 0
      src/Avalonia.Styling/Styling/Activators/NthChildActivator.cs
  11. 145 0
      src/Avalonia.Styling/Styling/NthChildSelector.cs
  12. 23 0
      src/Avalonia.Styling/Styling/NthLastChildSelector.cs
  13. 16 0
      src/Avalonia.Styling/Styling/Selectors.cs
  14. 35 0
      src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSelectorTransformer.cs
  15. 147 2
      src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs
  16. 6 0
      src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs
  17. 159 0
      tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs
  18. 196 1
      tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs
  19. 291 0
      tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs
  20. 220 0
      tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs
  21. 5 2
      tests/Avalonia.Styling.UnitTests/StyleActivatorExtensions.cs

+ 20 - 4
samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml

@@ -1,17 +1,33 @@
 <UserControl xmlns="https://github.com/avaloniaui"
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              x:Class="ControlCatalog.Pages.ItemsRepeaterPage">
+  <UserControl.Styles>
+    <Style Selector="ItemsRepeater TextBlock.oddTemplate">
+      <Setter Property="Background" Value="Yellow" />
+      <Setter Property="Foreground" Value="Black" />
+    </Style>
+    <Style Selector="ItemsRepeater TextBlock.evenTemplate">
+      <Setter Property="Background" Value="Wheat" />
+      <Setter Property="Foreground" Value="Black" />
+    </Style>
+    <Style Selector="ItemsRepeater TextBlock:nth-child(5n+3)">
+      <Setter Property="Foreground" Value="Red" />
+      <Setter Property="FontWeight" Value="Bold" />
+    </Style>
+    <Style Selector="ItemsRepeater TextBlock:nth-last-child(5n+4)">
+      <Setter Property="Foreground" Value="Blue" />
+      <Setter Property="FontWeight" Value="Bold" />
+    </Style>
+  </UserControl.Styles>
   <UserControl.Resources>
     <RecyclePool x:Key="RecyclePool" />
     <DataTemplate x:Key="odd">
-      <TextBlock Background="Yellow"
-                 Foreground="Black"
+      <TextBlock Classes="oddTemplate"
                  Height="{Binding Height}"
                  Text="{Binding Text}"/>
     </DataTemplate>
     <DataTemplate x:Key="even">
-      <TextBlock Background="Wheat"
-                 Foreground="Black"
+      <TextBlock Classes="evenTemplate"
                  Height="{Binding Height}"
                  Text="{Binding Text}"/>
     </DataTemplate>

+ 11 - 0
samples/ControlCatalog/Pages/ListBoxPage.xaml

@@ -2,9 +2,20 @@
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              x:Class="ControlCatalog.Pages.ListBoxPage">
   <DockPanel>
+    <DockPanel.Styles>
+      <Style Selector="ListBox ListBoxItem:nth-child(5n+3)">
+        <Setter Property="Foreground" Value="Red" />
+        <Setter Property="FontWeight" Value="Bold" />
+      </Style>
+      <Style Selector="ListBox ListBoxItem:nth-last-child(5n+4)">
+        <Setter Property="Foreground" Value="Blue" />
+        <Setter Property="FontWeight" Value="Bold" />
+      </Style>
+    </DockPanel.Styles>
     <StackPanel DockPanel.Dock="Top" Margin="4">
       <TextBlock Classes="h1">ListBox</TextBlock>
       <TextBlock Classes="h2">Hosts a collection of ListBoxItem.</TextBlock>
+      <TextBlock Classes="h2">Each 5th item is highlighted with nth-child(5n+3) and nth-last-child(5n+4) rules.</TextBlock>
     </StackPanel>
     <StackPanel DockPanel.Dock="Right" Margin="4">
       <CheckBox IsChecked="{Binding Multiple}">Multiple</CheckBox>

+ 42 - 1
src/Avalonia.Controls/ItemsControl.cs

@@ -21,7 +21,7 @@ namespace Avalonia.Controls
     /// Displays a collection of items.
     /// </summary>
     [PseudoClasses(":empty", ":singleitem")]
-    public class ItemsControl : TemplatedControl, IItemsPresenterHost, ICollectionChangedListener
+    public class ItemsControl : TemplatedControl, IItemsPresenterHost, ICollectionChangedListener, IChildIndexProvider
     {
         /// <summary>
         /// The default value for the <see cref="ItemsPanel"/> property.
@@ -56,6 +56,7 @@ namespace Avalonia.Controls
         private IEnumerable _items = new AvaloniaList<object>();
         private int _itemCount;
         private IItemContainerGenerator _itemContainerGenerator;
+        private EventHandler<ChildIndexChangedEventArgs> _childIndexChanged;
 
         /// <summary>
         /// Initializes static members of the <see cref="ItemsControl"/> class.
@@ -145,11 +146,28 @@ namespace Avalonia.Controls
             protected set;
         }
 
+        event EventHandler<ChildIndexChangedEventArgs> IChildIndexProvider.ChildIndexChanged
+        {
+            add => _childIndexChanged += value;
+            remove => _childIndexChanged -= value;
+        }
+
         /// <inheritdoc/>
         void IItemsPresenterHost.RegisterItemsPresenter(IItemsPresenter presenter)
         {
+            if (Presenter is IChildIndexProvider oldInnerProvider)
+            {
+                oldInnerProvider.ChildIndexChanged -= PresenterChildIndexChanged;
+            }
+
             Presenter = presenter;
             ItemContainerGenerator.Clear();
+
+            if (Presenter is IChildIndexProvider innerProvider)
+            {
+                innerProvider.ChildIndexChanged += PresenterChildIndexChanged;
+                _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs());
+            }
         }
 
         void ICollectionChangedListener.PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)
@@ -506,5 +524,28 @@ namespace Avalonia.Controls
 
             return null;
         }
+
+        private void PresenterChildIndexChanged(object sender, ChildIndexChangedEventArgs e)
+        {
+            _childIndexChanged?.Invoke(this, e);
+        }
+
+        int IChildIndexProvider.GetChildIndex(ILogical child)
+        {
+            return Presenter is IChildIndexProvider innerProvider
+                ? innerProvider.GetChildIndex(child) : -1;
+        }
+
+        bool IChildIndexProvider.TryGetTotalCount(out int count)
+        {
+            if (Presenter is IChildIndexProvider presenter
+                && presenter.TryGetTotalCount(out count))
+            {
+                return true;
+            }
+
+            count = ItemCount;
+            return true;
+        }
     }
 }

+ 23 - 1
src/Avalonia.Controls/Panel.cs

@@ -2,8 +2,10 @@ using System;
 using System.Collections.Generic;
 using System.Collections.Specialized;
 using System.Linq;
+using Avalonia.LogicalTree;
 using Avalonia.Media;
 using Avalonia.Metadata;
+using Avalonia.Styling;
 
 namespace Avalonia.Controls
 {
@@ -14,7 +16,7 @@ namespace Avalonia.Controls
     /// Controls can be added to a <see cref="Panel"/> by adding them to its <see cref="Children"/>
     /// collection. All children are layed out to fill the panel.
     /// </remarks>
-    public class Panel : Control, IPanel
+    public class Panel : Control, IPanel, IChildIndexProvider
     {
         /// <summary>
         /// Defines the <see cref="Background"/> property.
@@ -30,6 +32,8 @@ namespace Avalonia.Controls
             AffectsRender<Panel>(BackgroundProperty);
         }
 
+        private EventHandler<ChildIndexChangedEventArgs> _childIndexChanged;
+
         /// <summary>
         /// Initializes a new instance of the <see cref="Panel"/> class.
         /// </summary>
@@ -53,6 +57,12 @@ namespace Avalonia.Controls
             set { SetValue(BackgroundProperty, value); }
         }
 
+        event EventHandler<ChildIndexChangedEventArgs> IChildIndexProvider.ChildIndexChanged
+        {
+            add => _childIndexChanged += value;
+            remove => _childIndexChanged -= value;
+        }
+
         /// <summary>
         /// Renders the visual to a <see cref="DrawingContext"/>.
         /// </summary>
@@ -137,6 +147,7 @@ namespace Avalonia.Controls
                     throw new NotSupportedException();
             }
 
+            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs());
             InvalidateMeasureOnChildrenChanged();
         }
 
@@ -160,5 +171,16 @@ namespace Avalonia.Controls
             var panel = control?.VisualParent as TPanel;
             panel?.InvalidateMeasure();
         }
+
+        int IChildIndexProvider.GetChildIndex(ILogical child)
+        {
+            return child is IControl control ? Children.IndexOf(control) : -1;
+        }
+
+        public bool TryGetTotalCount(out int count)
+        {
+            count = Children.Count;
+            return true;
+        }
     }
 }

+ 40 - 1
src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs

@@ -5,6 +5,7 @@ using Avalonia.Collections;
 using Avalonia.Controls.Generators;
 using Avalonia.Controls.Templates;
 using Avalonia.Controls.Utils;
+using Avalonia.LogicalTree;
 using Avalonia.Styling;
 
 namespace Avalonia.Controls.Presenters
@@ -12,7 +13,7 @@ namespace Avalonia.Controls.Presenters
     /// <summary>
     /// Base class for controls that present items inside an <see cref="ItemsControl"/>.
     /// </summary>
-    public abstract class ItemsPresenterBase : Control, IItemsPresenter, ITemplatedControl
+    public abstract class ItemsPresenterBase : Control, IItemsPresenter, ITemplatedControl, IChildIndexProvider
     {
         /// <summary>
         /// Defines the <see cref="Items"/> property.
@@ -36,6 +37,7 @@ namespace Avalonia.Controls.Presenters
         private IDisposable _itemsSubscription;
         private bool _createdPanel;
         private IItemContainerGenerator _generator;
+        private EventHandler<ChildIndexChangedEventArgs> _childIndexChanged;
 
         /// <summary>
         /// Initializes static members of the <see cref="ItemsPresenter"/> class.
@@ -129,6 +131,12 @@ namespace Avalonia.Controls.Presenters
 
         protected bool IsHosted => TemplatedParent is IItemsPresenterHost;
 
+        event EventHandler<ChildIndexChangedEventArgs> IChildIndexProvider.ChildIndexChanged
+        {
+            add => _childIndexChanged += value;
+            remove => _childIndexChanged -= value;
+        }
+
         /// <inheritdoc/>
         public override sealed void ApplyTemplate()
         {
@@ -149,6 +157,8 @@ namespace Avalonia.Controls.Presenters
             if (Panel != null)
             {
                 ItemsChanged(e);
+
+                _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs());
             }
         }
 
@@ -169,9 +179,21 @@ namespace Avalonia.Controls.Presenters
                 result.ItemTemplate = ItemTemplate;
             }
 
+            result.Materialized += ContainerActionHandler;
+            result.Dematerialized += ContainerActionHandler;
+            result.Recycled += ContainerActionHandler;
+
             return result;
         }
 
+        private void ContainerActionHandler(object sender, ItemContainerEventArgs e)
+        {
+            for (var i = 0; i < e.Containers.Count; i++)
+            {
+                _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(e.Containers[i].ContainerControl));
+            }
+        }
+
         /// <inheritdoc/>
         protected override Size MeasureOverride(Size availableSize)
         {
@@ -248,5 +270,22 @@ namespace Avalonia.Controls.Presenters
         {
             (e.NewValue as IItemsPresenterHost)?.RegisterItemsPresenter(this);
         }
+
+        int IChildIndexProvider.GetChildIndex(ILogical child)
+        {
+            if (child is IControl control && ItemContainerGenerator is { } generator)
+            {
+                var index = ItemContainerGenerator.IndexFromContainer(control);
+
+                return index;
+            }
+
+            return -1;
+        }
+
+        bool IChildIndexProvider.TryGetTotalCount(out int count)
+        {
+            return Items.TryGetCountFast(out count);
+        }
     }
 }

+ 29 - 2
src/Avalonia.Controls/Repeater/ItemsRepeater.cs

@@ -10,6 +10,7 @@ using Avalonia.Controls.Templates;
 using Avalonia.Input;
 using Avalonia.Layout;
 using Avalonia.Logging;
+using Avalonia.LogicalTree;
 using Avalonia.Utilities;
 using Avalonia.VisualTree;
 
@@ -19,7 +20,7 @@ namespace Avalonia.Controls
     /// Represents a data-driven collection control that incorporates a flexible layout system,
     /// custom views, and virtualization.
     /// </summary>
-    public class ItemsRepeater : Panel
+    public class ItemsRepeater : Panel, IChildIndexProvider
     {
         /// <summary>
         /// Defines the <see cref="HorizontalCacheLength"/> property.
@@ -61,8 +62,9 @@ namespace Avalonia.Controls
         private readonly ViewportManager _viewportManager;
         private IEnumerable _items;
         private VirtualizingLayoutContext _layoutContext;
-        private NotifyCollectionChangedEventArgs _processingItemsSourceChange;
+        private EventHandler<ChildIndexChangedEventArgs> _childIndexChanged;
         private bool _isLayoutInProgress;
+        private NotifyCollectionChangedEventArgs _processingItemsSourceChange;
         private ItemsRepeaterElementPreparedEventArgs _elementPreparedArgs;
         private ItemsRepeaterElementClearingEventArgs _elementClearingArgs;
         private ItemsRepeaterElementIndexChangedEventArgs _elementIndexChangedArgs;
@@ -163,6 +165,25 @@ namespace Avalonia.Controls
             }
         }
 
+        event EventHandler<ChildIndexChangedEventArgs> IChildIndexProvider.ChildIndexChanged
+        {
+            add => _childIndexChanged += value;
+            remove => _childIndexChanged -= value;
+        }
+
+        int IChildIndexProvider.GetChildIndex(ILogical child)
+        {
+            return child is IControl control
+                ? GetElementIndex(control)
+                : -1;
+        }
+
+        bool IChildIndexProvider.TryGetTotalCount(out int count)
+        {
+            count = ItemsSourceView.Count;
+            return true;
+        }
+
         /// <summary>
         /// Occurs each time an element is cleared and made available to be re-used.
         /// </summary>
@@ -545,6 +566,8 @@ namespace Avalonia.Controls
 
                 ElementPrepared(this, _elementPreparedArgs);
             }
+
+            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element));
         }
 
         internal void OnElementClearing(IControl element)
@@ -562,6 +585,8 @@ namespace Avalonia.Controls
 
                 ElementClearing(this, _elementClearingArgs);
             }
+
+            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element));
         }
 
         internal void OnElementIndexChanged(IControl element, int oldIndex, int newIndex)
@@ -579,6 +604,8 @@ namespace Avalonia.Controls
 
                 ElementIndexChanged(this, _elementIndexChangedArgs);
             }
+
+            _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element));
         }
 
         private void OnDataSourcePropertyChanged(ItemsSourceView oldValue, ItemsSourceView newValue)

+ 20 - 7
src/Avalonia.Controls/Utils/IEnumerableUtils.cs

@@ -12,23 +12,36 @@ namespace Avalonia.Controls.Utils
             return items.IndexOf(item) != -1;
         }
 
-        public static int Count(this IEnumerable items)
+        public static bool TryGetCountFast(this IEnumerable items, out int count)
         {
             if (items != null)
             {
                 if (items is ICollection collection)
                 {
-                    return collection.Count;
+                    count = collection.Count;
+                    return true;
                 }
                 else if (items is IReadOnlyCollection<object> readOnly)
                 {
-                    return readOnly.Count;
-                }
-                else
-                {
-                    return Enumerable.Count(items.Cast<object>());
+                    count = readOnly.Count;
+                    return true;
                 }
             }
+
+            count = 0;
+            return false;
+        }
+
+        public static int Count(this IEnumerable items)
+        {
+            if (TryGetCountFast(items, out var count))
+            {
+                return count;
+            }
+            else if (items != null)
+            {
+                return Enumerable.Count(items.Cast<object>());
+            }
             else
             {
                 return 0;

+ 26 - 0
src/Avalonia.Styling/LogicalTree/ChildIndexChangedEventArgs.cs

@@ -0,0 +1,26 @@
+#nullable enable
+using System;
+
+namespace Avalonia.LogicalTree
+{
+    /// <summary>
+    /// Event args for <see cref="IChildIndexProvider.ChildIndexChanged"/> event.
+    /// </summary>
+    public class ChildIndexChangedEventArgs : EventArgs
+    {
+        public ChildIndexChangedEventArgs()
+        {
+        }
+
+        public ChildIndexChangedEventArgs(ILogical child)
+        {
+            Child = child;
+        }
+
+        /// <summary>
+        /// Logical child which index was changed.
+        /// If null, all children should be reset.
+        /// </summary>
+        public ILogical? Child { get; }
+    }
+}

+ 32 - 0
src/Avalonia.Styling/LogicalTree/IChildIndexProvider.cs

@@ -0,0 +1,32 @@
+#nullable enable
+using System;
+
+namespace Avalonia.LogicalTree
+{
+    /// <summary>
+    /// Child's index and total count information provider used by list-controls (ListBox, StackPanel, etc.)
+    /// </summary>
+    /// <remarks>
+    /// Used by nth-child and nth-last-child selectors. 
+    /// </remarks>
+    public interface IChildIndexProvider
+    {
+        /// <summary>
+        /// Gets child's actual index in order of the original source.
+        /// </summary>
+        /// <param name="child">Logical child.</param>
+        /// <returns>Index or -1 if child was not found.</returns>
+        int GetChildIndex(ILogical child);
+
+        /// <summary>
+        /// Total children count or null if source is infinite.
+        /// Some Avalonia features might not work if <see cref="TryGetTotalCount"/> returns false, for instance: nth-last-child selector.
+        /// </summary>
+        bool TryGetTotalCount(out int count);
+
+        /// <summary>
+        /// Notifies subscriber when child's index or total count was changed.
+        /// </summary>
+        event EventHandler<ChildIndexChangedEventArgs>? ChildIndexChanged;
+    }
+}

+ 56 - 0
src/Avalonia.Styling/Styling/Activators/NthChildActivator.cs

@@ -0,0 +1,56 @@
+#nullable enable
+using Avalonia.LogicalTree;
+
+namespace Avalonia.Styling.Activators
+{
+    /// <summary>
+    /// An <see cref="IStyleActivator"/> which is active when control's index was changed.
+    /// </summary>
+    internal sealed class NthChildActivator : StyleActivatorBase
+    {
+        private readonly ILogical _control;
+        private readonly IChildIndexProvider _provider;
+        private readonly int _step;
+        private readonly int _offset;
+        private readonly bool _reversed;
+
+        public NthChildActivator(
+            ILogical control,
+            IChildIndexProvider provider,
+            int step, int offset, bool reversed)
+        {
+            _control = control;
+            _provider = provider;
+            _step = step;
+            _offset = offset;
+            _reversed = reversed;
+        }
+
+        protected override void Initialize()
+        {
+            PublishNext(IsMatching());
+            _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 indeces were changed.
+            // 3. Subscribed child index was changed.
+            if (_reversed
+                || e.Child is null                
+                || e.Child == _control)
+            {
+                PublishNext(IsMatching());
+            }
+        }
+
+        private bool IsMatching() => NthChildSelector.Evaluate(_control, _provider, _step, _offset, _reversed).IsMatch;
+    }
+}

+ 145 - 0
src/Avalonia.Styling/Styling/NthChildSelector.cs

@@ -0,0 +1,145 @@
+#nullable enable
+using System;
+using System.Text;
+using Avalonia.LogicalTree;
+using Avalonia.Styling.Activators;
+
+namespace Avalonia.Styling
+{
+    /// <summary>
+    /// The :nth-child() pseudo-class matches elements based on their position in a group of siblings.
+    /// </summary>
+    /// <remarks>
+    /// Element indices are 1-based.
+    /// </remarks>
+    public class NthChildSelector : Selector
+    {
+        private const string NthChildSelectorName = "nth-child";
+        private const string NthLastChildSelectorName = "nth-last-child";
+        private readonly Selector? _previous;
+        private readonly bool _reversed;
+
+        internal protected NthChildSelector(Selector? previous, int step, int offset, bool reversed)
+        {
+            _previous = previous;
+            Step = step;
+            Offset = offset;
+            _reversed = reversed;
+        }
+
+        /// <summary>
+        /// Creates an instance of <see cref="NthChildSelector"/>
+        /// </summary>
+        /// <param name="previous">Previous selector.</param>
+        /// <param name="step">Position step.</param>
+        /// <param name="offset">Initial index offset.</param>
+        public NthChildSelector(Selector? previous, int step, int offset)
+            : this(previous, step, offset, false)
+        {
+
+        }
+
+        public override bool InTemplate => _previous?.InTemplate ?? false;
+
+        public override bool IsCombinator => false;
+
+        public override Type? TargetType => _previous?.TargetType;
+
+        public int Step { get; }
+        public int Offset { get; }
+
+        protected override SelectorMatch Evaluate(IStyleable control, bool subscribe)
+        {
+            if (!(control is ILogical logical))
+            {
+                return SelectorMatch.NeverThisType;
+            }
+
+            var controlParent = logical.LogicalParent;
+
+            if (controlParent is IChildIndexProvider childIndexProvider)
+            {
+                return subscribe
+                    ? new SelectorMatch(new NthChildActivator(logical, childIndexProvider, Step, Offset, _reversed))
+                    : Evaluate(logical, childIndexProvider, Step, Offset, _reversed);
+            }
+            else
+            {
+                return SelectorMatch.NeverThisInstance;
+            }
+        }
+
+        internal static SelectorMatch Evaluate(
+            ILogical logical, IChildIndexProvider childIndexProvider,
+            int step, int offset, bool reversed)
+        {
+            var index = childIndexProvider.GetChildIndex(logical);
+            if (index < 0)
+            {
+                return SelectorMatch.NeverThisInstance;
+            }
+
+            if (reversed)
+            {
+                if (childIndexProvider.TryGetTotalCount(out var totalCountValue))
+                {
+                    index = totalCountValue - index;
+                }
+                else
+                {
+                    return SelectorMatch.NeverThisInstance;
+                }
+            }
+            else
+            {
+                // nth child index is 1-based
+                index += 1;
+            }
+
+            var n = Math.Sign(step);
+
+            var diff = index - offset;
+            var match = diff == 0 || (Math.Sign(diff) == n && diff % step == 0);
+
+            return match ? SelectorMatch.AlwaysThisInstance : SelectorMatch.NeverThisInstance;
+        }
+
+        protected override Selector? MovePrevious() => _previous;
+
+        public override string ToString()
+        {
+            var expectedCapacity = NthLastChildSelectorName.Length + 8;
+            var stringBuilder = new StringBuilder(_previous?.ToString(), expectedCapacity);
+            
+            stringBuilder.Append(':');
+            stringBuilder.Append(_reversed ? NthLastChildSelectorName : NthChildSelectorName);
+            stringBuilder.Append('(');
+
+            var hasStep = false;
+            if (Step != 0)
+            {
+                hasStep = true;
+                stringBuilder.Append(Step);
+                stringBuilder.Append('n');
+            }
+
+            if (Offset > 0)
+            {
+                if (hasStep)
+                {
+                    stringBuilder.Append('+');
+                }
+                stringBuilder.Append(Offset);
+            }
+            else if (Offset < 0)
+            {
+                stringBuilder.Append('-');
+                stringBuilder.Append(-Offset);
+            }
+
+            stringBuilder.Append(')');
+
+            return stringBuilder.ToString();
+        }
+    }
+}

+ 23 - 0
src/Avalonia.Styling/Styling/NthLastChildSelector.cs

@@ -0,0 +1,23 @@
+#nullable enable
+
+namespace Avalonia.Styling
+{
+    /// <summary>
+    /// The :nth-child() pseudo-class matches elements based on their position among a group of siblings, counting from the end.
+    /// </summary>
+    /// <remarks>
+    /// Element indices are 1-based.
+    /// </remarks>
+    public class NthLastChildSelector : NthChildSelector
+    {
+        /// <summary>
+        /// Creates an instance of <see cref="NthLastChildSelector"/>
+        /// </summary>
+        /// <param name="previous">Previous selector.</param>
+        /// <param name="step">Position step.</param>
+        /// <param name="offset">Initial index offset, counting from the end.</param>
+        public NthLastChildSelector(Selector? previous, int step, int offset) : base(previous, step, offset, true)
+        {
+        }
+    }
+}

+ 16 - 0
src/Avalonia.Styling/Styling/Selectors.cs

@@ -123,6 +123,22 @@ namespace Avalonia.Styling
             return new NotSelector(previous, argument);
         }
 
+        /// <inheritdoc cref="NthChildSelector"/>
+        /// <inheritdoc cref="NthChildSelector(Selector?, int, int)"/>
+        /// <returns>The selector.</returns>
+        public static Selector NthChild(this Selector previous, int step, int offset)
+        {
+            return new NthChildSelector(previous, step, offset);
+        }
+
+        /// <inheritdoc cref="NthLastChildSelector"/>
+        /// <inheritdoc cref="NthLastChildSelector(Selector?, int, int)"/>
+        /// <returns>The selector.</returns>
+        public static Selector NthLastChild(this Selector previous, int step, int offset)
+        {
+            return new NthLastChildSelector(previous, step, offset);
+        }
+
         /// <summary>
         /// Returns a selector which matches a type.
         /// </summary>

+ 35 - 0
src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSelectorTransformer.cs

@@ -97,6 +97,12 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers
                         case SelectorGrammar.NotSyntax not:
                             result = new XamlIlNotSelector(result, Create(not.Argument, typeResolver));
                             break;
+                        case SelectorGrammar.NthChildSyntax nth:
+                            result = new XamlIlNthChildSelector(result, nth.Step, nth.Offset, XamlIlNthChildSelector.SelectorType.NthChild);
+                            break;
+                        case SelectorGrammar.NthLastChildSyntax nth:
+                            result = new XamlIlNthChildSelector(result, nth.Step, nth.Offset, XamlIlNthChildSelector.SelectorType.NthLastChild);
+                            break;
                         case SelectorGrammar.CommaSyntax comma:
                             if (results == null) 
                                 results = new XamlIlOrSelectorNode(node, selectorType);
@@ -273,6 +279,35 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers
         }
     }
 
+    class XamlIlNthChildSelector : XamlIlSelectorNode
+    {
+        private readonly int _step;
+        private readonly int _offset;
+        private readonly SelectorType _type;
+
+        public enum SelectorType
+        {
+            NthChild,
+            NthLastChild
+        }
+
+        public XamlIlNthChildSelector(XamlIlSelectorNode previous, int step, int offset, SelectorType type) : base(previous)
+        {
+            _step = step;
+            _offset = offset;
+            _type = type;
+        }
+
+        public override IXamlType TargetType => Previous?.TargetType;
+        protected override void DoEmit(XamlEmitContext<IXamlILEmitter, XamlILNodeEmitResult> context, IXamlILEmitter codeGen)
+        {
+            codeGen.Ldc_I4(_step);
+            codeGen.Ldc_I4(_offset);
+            EmitCall(context, codeGen,
+                m => m.Name == _type.ToString() && m.Parameters.Count == 3);
+        }
+    }
+
     class XamlIlPropertyEqualsSelector : XamlIlSelectorNode
     {
         public XamlIlPropertyEqualsSelector(XamlIlSelectorNode previous,

+ 147 - 2
src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs

@@ -160,11 +160,13 @@ namespace Avalonia.Markup.Parsers
 
             if (identifier.IsEmpty)
             {
-                throw new ExpressionParseException(r.Position, "Expected class name or is selector after ':'.");
+                throw new ExpressionParseException(r.Position, "Expected class name, is, nth-child or nth-last-child selector after ':'.");
             }
 
             const string IsKeyword = "is";
             const string NotKeyword = "not";
+            const string NthChildKeyword = "nth-child";
+            const string NthLastChildKeyword = "nth-last-child";
 
             if (identifier.SequenceEqual(IsKeyword.AsSpan()) && r.TakeIf('('))
             {
@@ -181,6 +183,20 @@ namespace Avalonia.Markup.Parsers
                 var syntax = new NotSyntax { Argument = argument };
                 return (State.Middle, syntax);
             }
+            if (identifier.SequenceEqual(NthChildKeyword.AsSpan()) && r.TakeIf('('))
+            {
+                var (step, offset) = ParseNthChildArguments(ref r);
+
+                var syntax = new NthChildSyntax { Step = step, Offset = offset };
+                return (State.Middle, syntax);
+            }
+            if (identifier.SequenceEqual(NthLastChildKeyword.AsSpan()) && r.TakeIf('('))
+            {
+                var (step, offset) = ParseNthChildArguments(ref r);
+
+                var syntax = new NthLastChildSyntax { Step = step, Offset = offset };
+                return (State.Middle, syntax);
+            }
             else
             {
                 return (
@@ -191,7 +207,6 @@ namespace Avalonia.Markup.Parsers
                     });
             }
         }
-
         private static (State, ISyntax?) ParseTraversal(ref CharacterReader r)
         {
             r.SkipWhitespace();
@@ -302,6 +317,114 @@ namespace Avalonia.Markup.Parsers
             return syntax;
         }
 
+        private static (int step, int offset) ParseNthChildArguments(ref CharacterReader r)
+        {
+            int step = 0;
+            int offset = 0;
+
+            if (r.Peek == 'o')
+            {
+                var constArg = r.TakeUntil(')').ToString().Trim();
+                if (constArg.Equals("odd", StringComparison.Ordinal))
+                {
+                    step = 2;
+                    offset = 1;
+                }
+                else
+                {
+                    throw new ExpressionParseException(r.Position, $"Expected nth-child(odd). Actual '{constArg}'.");
+                }
+            }
+            else if (r.Peek == 'e')
+            {
+                var constArg = r.TakeUntil(')').ToString().Trim();
+                if (constArg.Equals("even", StringComparison.Ordinal))
+                {
+                    step = 2;
+                    offset = 0;
+                }
+                else
+                {
+                    throw new ExpressionParseException(r.Position, $"Expected nth-child(even). Actual '{constArg}'.");
+                }
+            }
+            else
+            {
+                r.SkipWhitespace();
+
+                var stepOrOffset = 0;
+                var stepOrOffsetStr = r.TakeWhile(c => char.IsDigit(c) || c == '-' || c == '+').ToString();
+                if (stepOrOffsetStr.Length == 0
+                    || (stepOrOffsetStr.Length == 1
+                    && stepOrOffsetStr[0] == '+'))
+                {
+                    stepOrOffset = 1;
+                }
+                else if (stepOrOffsetStr.Length == 1
+                    && stepOrOffsetStr[0] == '-')
+                {
+                    stepOrOffset = -1;
+                }
+                else if (!int.TryParse(stepOrOffsetStr.ToString(), out stepOrOffset))
+                {
+                    throw new ExpressionParseException(r.Position, "Couldn't parse nth-child step or offset value. Integer was expected.");
+                }
+
+                r.SkipWhitespace();
+
+                if (r.Peek == ')')
+                {
+                    step = 0;
+                    offset = stepOrOffset;
+                }
+                else
+                {
+                    step = stepOrOffset;
+
+                    if (r.Peek != 'n')
+                    {
+                        throw new ExpressionParseException(r.Position, "Couldn't parse nth-child step value, \"xn+y\" pattern was expected.");
+                    }
+
+                    r.Skip(1); // skip 'n'
+
+                    r.SkipWhitespace();
+
+                    if (r.Peek != ')')
+                    {
+                        int sign;
+                        var nextChar = r.Take();
+                        if (nextChar == '+')
+                        {
+                            sign = 1;
+                        }
+                        else if (nextChar == '-')
+                        {
+                            sign = -1;
+                        }
+                        else
+                        {
+                            throw new ExpressionParseException(r.Position, "Couldn't parse nth-child sign. '+' or '-' was expected.");
+                        }
+
+                        r.SkipWhitespace();
+
+                        if (sign != 0
+                            && !int.TryParse(r.TakeUntil(')').ToString(), out offset))
+                        {
+                            throw new ExpressionParseException(r.Position, "Couldn't parse nth-child offset value. Integer was expected.");
+                        }
+
+                        offset *= sign;
+                    }
+                }
+            }
+
+            Expect(ref r, ')');
+
+            return (step, offset);
+        }
+
         private static void Expect(ref CharacterReader r, char c)
         {
             if (r.End)
@@ -419,6 +542,28 @@ namespace Avalonia.Markup.Parsers
             }
         }
 
+        public class NthChildSyntax : ISyntax
+        {
+            public int Offset { get; set; }
+            public int Step { get; set; }
+
+            public override bool Equals(object? obj)
+            {
+                return (obj is NthChildSyntax nth) && nth.Offset == Offset && nth.Step == Step;
+            }
+        }
+
+        public class NthLastChildSyntax : ISyntax
+        {
+            public int Offset { get; set; }
+            public int Step { get; set; }
+
+            public override bool Equals(object? obj)
+            {
+                return (obj is NthLastChildSyntax nth) && nth.Offset == Offset && nth.Step == Step;
+            }
+        }
+
         public class CommaSyntax : ISyntax
         {
             public override bool Equals(object? obj)

+ 6 - 0
src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs

@@ -104,6 +104,12 @@ namespace Avalonia.Markup.Parsers
                     case SelectorGrammar.NotSyntax not:
                         result = result.Not(x => Create(not.Argument));
                         break;
+                    case SelectorGrammar.NthChildSyntax nth:
+                        result = result.NthChild(nth.Step, nth.Offset);
+                        break;
+                    case SelectorGrammar.NthLastChildSyntax nth:
+                        result = result.NthLastChild(nth.Step, nth.Offset);
+                        break;
                     case SelectorGrammar.CommaSyntax comma:
                         if (results == null)
                         {

+ 159 - 0
tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs

@@ -236,6 +236,165 @@ namespace Avalonia.Markup.UnitTests.Parsers
                 result);
         }
 
+        [Theory]
+        [InlineData(":nth-child(xn+2)")]
+        [InlineData(":nth-child(2n+b)")]
+        [InlineData(":nth-child(2n+)")]
+        [InlineData(":nth-child(2na)")]
+        [InlineData(":nth-child(2x+1)")]
+        public void NthChild_Invalid_Inputs(string input)
+        {
+            Assert.Throws<ExpressionParseException>(() => SelectorGrammar.Parse(input));
+        }
+
+        [Theory]
+        [InlineData(":nth-child(+1)", 0, 1)]
+        [InlineData(":nth-child(1)", 0, 1)]
+        [InlineData(":nth-child(-1)", 0, -1)]
+        [InlineData(":nth-child(2n+1)", 2, 1)]
+        [InlineData(":nth-child(n)", 1, 0)]
+        [InlineData(":nth-child(+n)", 1, 0)]
+        [InlineData(":nth-child(-n)", -1, 0)]
+        [InlineData(":nth-child(-2n)", -2, 0)]
+        [InlineData(":nth-child(n+5)", 1, 5)]
+        [InlineData(":nth-child(n-5)", 1, -5)]
+        [InlineData(":nth-child( 2n + 1 )", 2, 1)]
+        [InlineData(":nth-child( 2n - 1 )", 2, -1)]
+        public void NthChild_Variations(string input, int step, int offset)
+        {
+            var result = SelectorGrammar.Parse(input);
+
+            Assert.Equal(
+                new SelectorGrammar.ISyntax[]
+                {
+                    new SelectorGrammar.NthChildSyntax()
+                    {
+                        Step = step,
+                        Offset = offset
+                    }
+                },
+                result);
+        }
+
+        [Theory]
+        [InlineData(":nth-last-child(+1)", 0, 1)]
+        [InlineData(":nth-last-child(1)", 0, 1)]
+        [InlineData(":nth-last-child(-1)", 0, -1)]
+        [InlineData(":nth-last-child(2n+1)", 2, 1)]
+        [InlineData(":nth-last-child(n)", 1, 0)]
+        [InlineData(":nth-last-child(+n)", 1, 0)]
+        [InlineData(":nth-last-child(-n)", -1, 0)]
+        [InlineData(":nth-last-child(-2n)", -2, 0)]
+        [InlineData(":nth-last-child(n+5)", 1, 5)]
+        [InlineData(":nth-last-child(n-5)", 1, -5)]
+        [InlineData(":nth-last-child( 2n + 1 )", 2, 1)]
+        [InlineData(":nth-last-child( 2n - 1 )", 2, -1)]
+        public void NthLastChild_Variations(string input, int step, int offset)
+        {
+            var result = SelectorGrammar.Parse(input);
+
+            Assert.Equal(
+                new SelectorGrammar.ISyntax[]
+                {
+                    new SelectorGrammar.NthLastChildSyntax()
+                    {
+                        Step = step,
+                        Offset = offset
+                    }
+                },
+                result);
+        }
+
+        [Fact]
+        public void OfType_NthChild()
+        {
+            var result = SelectorGrammar.Parse("Button:nth-child(2n+1)");
+
+            Assert.Equal(
+                new SelectorGrammar.ISyntax[]
+                {
+                    new SelectorGrammar.OfTypeSyntax { TypeName = "Button" },
+                    new SelectorGrammar.NthChildSyntax()
+                    {
+                        Step = 2,
+                        Offset = 1
+                    }
+                },
+                result);
+        }
+
+        [Fact]
+        public void OfType_NthChild_Without_Offset()
+        {
+            var result = SelectorGrammar.Parse("Button:nth-child(2147483647n)");
+
+            Assert.Equal(
+                new SelectorGrammar.ISyntax[]
+                {
+                    new SelectorGrammar.OfTypeSyntax { TypeName = "Button" },
+                    new SelectorGrammar.NthChildSyntax()
+                    {
+                        Step = int.MaxValue,
+                        Offset = 0
+                    }
+                },
+                result);
+        }
+
+        [Fact]
+        public void OfType_NthLastChild()
+        {
+            var result = SelectorGrammar.Parse("Button:nth-last-child(2n+1)");
+
+            Assert.Equal(
+                new SelectorGrammar.ISyntax[]
+                {
+                    new SelectorGrammar.OfTypeSyntax { TypeName = "Button" },
+                    new SelectorGrammar.NthLastChildSyntax()
+                    {
+                        Step = 2,
+                        Offset = 1
+                    }
+                },
+                result);
+        }
+
+        [Fact]
+        public void OfType_NthChild_Odd()
+        {
+            var result = SelectorGrammar.Parse("Button:nth-child(odd)");
+
+            Assert.Equal(
+                new SelectorGrammar.ISyntax[]
+                {
+                    new SelectorGrammar.OfTypeSyntax { TypeName = "Button" },
+                    new SelectorGrammar.NthChildSyntax()
+                    {
+                        Step = 2,
+                        Offset = 1
+                    }
+                },
+                result);
+        }
+
+        [Fact]
+        public void OfType_NthChild_Even()
+        {
+            var result = SelectorGrammar.Parse("Button:nth-child(even)");
+
+            Assert.Equal(
+                new SelectorGrammar.ISyntax[]
+                {
+                    new SelectorGrammar.OfTypeSyntax { TypeName = "Button" },
+                    new SelectorGrammar.NthChildSyntax()
+                    {
+                        Step = 2,
+                        Offset = 0
+                    }
+                },
+                result);
+        }
+
         [Fact]
         public void Is_Descendent_Not_OfType_Class()
         {

+ 196 - 1
tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs

@@ -1,6 +1,8 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
 using System.Xml;
 using Avalonia.Controls;
-using Avalonia.Markup.Data;
 using Avalonia.Markup.Xaml.Styling;
 using Avalonia.Markup.Xaml.Templates;
 using Avalonia.Media;
@@ -267,6 +269,199 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml
             }
         }
 
+        [Fact]
+        public void Style_Can_Use_NthChild_Selector()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var xaml = @"
+<Window xmlns='https://github.com/avaloniaui'
+             xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'>
+    <Window.Styles>
+        <Style Selector='Border.foo:nth-child(2n+1)'>
+            <Setter Property='Background' Value='Red'/>
+        </Style>
+    </Window.Styles>
+    <StackPanel>
+        <Border x:Name='b1' Classes='foo'/>
+        <Border x:Name='b2' />
+    </StackPanel>
+</Window>";
+                var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml);
+                var b1 = window.FindControl<Border>("b1");
+                var b2 = window.FindControl<Border>("b2");
+
+                Assert.Equal(Brushes.Red, b1.Background);
+                Assert.Null(b2.Background);
+            }
+        }
+
+        [Fact]
+        public void Style_Can_Use_NthChild_Selector_After_Reoder()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var xaml = @"
+<Window xmlns='https://github.com/avaloniaui'
+             xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'>
+    <Window.Styles>
+        <Style Selector='Border:nth-child(2n)'>
+            <Setter Property='Background' Value='Red'/>
+        </Style>
+    </Window.Styles>
+    <StackPanel x:Name='parent'>
+        <Border x:Name='b1' />
+        <Border x:Name='b2' />
+    </StackPanel>
+</Window>";
+                var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml);
+
+                var parent = window.FindControl<StackPanel>("parent");
+                var b1 = window.FindControl<Border>("b1");
+                var b2 = window.FindControl<Border>("b2");
+
+                Assert.Null(b1.Background);
+                Assert.Equal(Brushes.Red, b2.Background);
+
+                parent.Children.Remove(b1);
+
+                Assert.Null(b1.Background);
+                Assert.Null(b2.Background);
+
+                parent.Children.Add(b1);
+
+                Assert.Equal(Brushes.Red, b1.Background);
+                Assert.Null(b2.Background);
+            }
+        }
+
+        [Fact]
+        public void Style_Can_Use_NthLastChild_Selector_After_Reoder()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var xaml = @"
+<Window xmlns='https://github.com/avaloniaui'
+             xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'>
+    <Window.Styles>
+        <Style Selector='Border:nth-last-child(2n)'>
+            <Setter Property='Background' Value='Red'/>
+        </Style>
+    </Window.Styles>
+    <StackPanel x:Name='parent'>
+        <Border x:Name='b1' />
+        <Border x:Name='b2' />
+    </StackPanel>
+</Window>";
+                var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml);
+
+                var parent = window.FindControl<StackPanel>("parent");
+                var b1 = window.FindControl<Border>("b1");
+                var b2 = window.FindControl<Border>("b2");
+
+                Assert.Equal(Brushes.Red, b1.Background);
+                Assert.Null(b2.Background);
+
+                parent.Children.Remove(b1);
+
+                Assert.Null(b1.Background);
+                Assert.Null(b2.Background);
+
+                parent.Children.Add(b1);
+
+                Assert.Null(b1.Background);
+                Assert.Equal(Brushes.Red, b2.Background);
+            }
+        }
+
+
+        [Fact]
+        public void Style_Can_Use_NthChild_Selector_With_ListBox()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var xaml = @"
+<Window xmlns='https://github.com/avaloniaui'
+             xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'>
+    <Window.Styles>
+        <Style Selector='ListBoxItem:nth-child(2n)'>
+            <Setter Property='Background' Value='{Binding}'/>
+        </Style>
+    </Window.Styles>
+    <ListBox x:Name='list' />
+</Window>";
+                var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml);
+                var collection = new ObservableCollection<IBrush>()
+                {
+                    Brushes.Red, Brushes.Green, Brushes.Blue
+                };
+
+                var list = window.FindControl<ListBox>("list");
+                list.VirtualizationMode = ItemVirtualizationMode.Simple;
+                list.Items = collection;
+
+                window.Show();
+
+                IEnumerable<IBrush> GetColors() => list.Presenter.Panel.Children.Cast<ListBoxItem>().Select(t => t.Background);
+
+                Assert.Equal(new[] { Brushes.Transparent, Brushes.Green, Brushes.Transparent }, GetColors());
+
+                collection.Remove(Brushes.Green);
+
+                Assert.Equal(new[] { Brushes.Transparent, Brushes.Blue }, GetColors());
+
+                collection.Add(Brushes.Violet);
+                collection.Add(Brushes.Black);
+
+                Assert.Equal(new[] { Brushes.Transparent, Brushes.Blue, Brushes.Transparent, Brushes.Black }, GetColors());
+            }
+        }
+
+        [Fact]
+        public void Style_Can_Use_NthChild_Selector_With_ItemsRepeater()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var xaml = @"
+<Window xmlns='https://github.com/avaloniaui'
+             xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'>
+    <Window.Styles>
+        <Style Selector='TextBlock'>
+            <Setter Property='Foreground' Value='Transparent'/>
+        </Style>
+        <Style Selector='TextBlock:nth-child(2n)'>
+            <Setter Property='Foreground' Value='{Binding}'/>
+        </Style>
+    </Window.Styles>
+    <ItemsRepeater x:Name='list' />
+</Window>";
+                var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml);
+                var collection = new ObservableCollection<IBrush>()
+                {
+                    Brushes.Red, Brushes.Green, Brushes.Blue
+                };
+
+                var list = window.FindControl<ItemsRepeater>("list");
+                list.Items = collection;
+
+                window.Show();
+
+                IEnumerable<IBrush> GetColors() => Enumerable.Range(0, list.ItemsSourceView.Count)
+                    .Select(t => (list.GetOrCreateElement(t) as TextBlock)!.Foreground);
+
+                Assert.Equal(new[] { Brushes.Transparent, Brushes.Green, Brushes.Transparent }, GetColors());
+
+                collection.Remove(Brushes.Green);
+
+                Assert.Equal(new[] { Brushes.Transparent, Brushes.Blue }, GetColors());
+
+                collection.Add(Brushes.Violet);
+                collection.Add(Brushes.Black);
+
+                Assert.Equal(new[] { Brushes.Transparent, Brushes.Blue, Brushes.Transparent, Brushes.Black }, GetColors());
+            }
+        }
+
         [Fact]
         public void Style_Can_Use_Or_Selector_1()
         {

+ 291 - 0
tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs

@@ -0,0 +1,291 @@
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Xunit;
+
+namespace Avalonia.Styling.UnitTests
+{
+    public class SelectorTests_NthChild
+    {
+        [Theory]
+        [InlineData(2, 0, ":nth-child(2n)")]
+        [InlineData(2, 1, ":nth-child(2n+1)")]
+        [InlineData(1, 0, ":nth-child(1n)")]
+        [InlineData(4, -1, ":nth-child(4n-1)")]
+        [InlineData(0, 1, ":nth-child(1)")]
+        [InlineData(0, -1, ":nth-child(-1)")]
+        [InlineData(int.MaxValue, int.MinValue + 1, ":nth-child(2147483647n-2147483647)")]
+        public void Not_Selector_Should_Have_Correct_String_Representation(int step, int offset, string expected)
+        {
+            var target = default(Selector).NthChild(step, offset);
+
+            Assert.Equal(expected, target.ToString());
+        }
+
+        [Fact]
+        public async Task Nth_Child_Match_Control_In_Panel()
+        {
+            Border b1, b2, b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Border(),
+                b4 = new Border()
+            });
+
+            var target = default(Selector).NthChild(2, 0);
+
+            Assert.False(await target.Match(b1).Activator!.Take(1));
+            Assert.True(await target.Match(b2).Activator!.Take(1));
+            Assert.False(await target.Match(b3).Activator!.Take(1));
+            Assert.True(await target.Match(b4).Activator!.Take(1));
+        }
+
+        [Fact]
+        public async Task Nth_Child_Match_Control_In_Panel_With_Offset()
+        {
+            Border b1, b2, b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Border(),
+                b4 = new Border()
+            });
+
+            var target = default(Selector).NthChild(2, 1);
+
+            Assert.True(await target.Match(b1).Activator!.Take(1));
+            Assert.False(await target.Match(b2).Activator!.Take(1));
+            Assert.True(await target.Match(b3).Activator!.Take(1));
+            Assert.False(await target.Match(b4).Activator!.Take(1));
+        }
+
+        [Fact]
+        public async Task Nth_Child_Match_Control_In_Panel_With_Negative_Offset()
+        {
+            Border b1, b2, b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Border(),
+                b4 = new Border()
+            });
+
+            var target = default(Selector).NthChild(4, -1);
+
+            Assert.False(await target.Match(b1).Activator!.Take(1));
+            Assert.False(await target.Match(b2).Activator!.Take(1));
+            Assert.True(await target.Match(b3).Activator!.Take(1));
+            Assert.False(await target.Match(b4).Activator!.Take(1));
+        }
+
+        [Fact]
+        public async Task Nth_Child_Match_Control_In_Panel_With_Singular_Step()
+        {
+            Border b1, b2, b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Border(),
+                b4 = new Border()
+            });
+
+            var target = default(Selector).NthChild(1, 2);
+
+            Assert.False(await target.Match(b1).Activator!.Take(1));
+            Assert.True(await target.Match(b2).Activator!.Take(1));
+            Assert.True(await target.Match(b3).Activator!.Take(1));
+            Assert.True(await target.Match(b4).Activator!.Take(1));
+        }
+
+        [Fact]
+        public async Task Nth_Child_Match_Control_In_Panel_With_Singular_Step_With_Negative_Offset()
+        {
+            Border b1, b2, b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Border(),
+                b4 = new Border()
+            });
+
+            var target = default(Selector).NthChild(1, -1);
+
+            Assert.True(await target.Match(b1).Activator!.Take(1));
+            Assert.True(await target.Match(b2).Activator!.Take(1));
+            Assert.True(await target.Match(b3).Activator!.Take(1));
+            Assert.True(await target.Match(b4).Activator!.Take(1));
+        }
+
+        [Fact]
+        public async Task Nth_Child_Match_Control_In_Panel_With_Zero_Step_With_Offset()
+        {
+            Border b1, b2, b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Border(),
+                b4 = new Border()
+            });
+
+            var target = default(Selector).NthChild(0, 2);
+
+            Assert.False(await target.Match(b1).Activator!.Take(1));
+            Assert.True(await target.Match(b2).Activator!.Take(1));
+            Assert.False(await target.Match(b3).Activator!.Take(1));
+            Assert.False(await target.Match(b4).Activator!.Take(1));
+        }
+
+        [Fact]
+        public async Task Nth_Child_Doesnt_Match_Control_In_Panel_With_Zero_Step_With_Negative_Offset()
+        {
+            Border b1, b2, b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Border(),
+                b4 = new Border()
+            });
+
+            var target = default(Selector).NthChild(0, -2);
+
+            Assert.False(await target.Match(b1).Activator!.Take(1));
+            Assert.False(await target.Match(b2).Activator!.Take(1));
+            Assert.False(await target.Match(b3).Activator!.Take(1));
+            Assert.False(await target.Match(b4).Activator!.Take(1));
+        }
+
+        [Fact]
+        public async Task Nth_Child_Match_Control_In_Panel_With_Previous_Selector()
+        {
+            Border b1, b2;
+            Button b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new Control[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Button(),
+                b4 = new Button()
+            });
+
+            var previous = default(Selector).OfType<Border>();
+            var target = previous.NthChild(2, 0);
+
+            Assert.False(await target.Match(b1).Activator!.Take(1));
+            Assert.True(await target.Match(b2).Activator!.Take(1));
+            Assert.Null(target.Match(b3).Activator);
+            Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(b3).Result);
+            Assert.Null(target.Match(b4).Activator);
+            Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(b4).Result);
+        }
+
+        [Fact]
+        public void Nth_Child_Doesnt_Match_Control_Out_Of_Panel_Parent()
+        {
+            Border b1;
+            var contentControl = new ContentControl();
+            contentControl.Content = b1 = new Border();
+
+            var target = default(Selector).NthChild(1, 0);
+
+            Assert.Equal(SelectorMatch.NeverThisInstance, target.Match(b1));
+        }
+
+
+        [Theory] // http://nthmaster.com/
+        [InlineData(+0, 8, false, false, false, false, false, false, false, true , false, false, false)]
+        [InlineData(+1, 6, false, false, false, false, false, true , true , true , true , true , true )]
+        [InlineData(-1, 9, true , true , true , true , true , true , true , true , true , false, false)]
+        public async Task Nth_Child_Master_Com_Test_Sigle_Selector(
+            int step, int offset, params bool[] items)
+        {
+            var panel = new StackPanel();
+            panel.Children.AddRange(items.Select(_ => new Border()));
+
+            var previous = default(Selector).OfType<Border>();
+            var target = previous.NthChild(step, offset);
+
+            var results = new bool[items.Length];
+            for (int index = 0; index < items.Length; index++)
+            {
+                var border = panel.Children[index];
+                results[index] = await target.Match(border).Activator!.Take(1);
+            }
+
+            Assert.Equal(items, results);
+        }
+
+        [Theory] // http://nthmaster.com/
+        [InlineData(+1, 4, -1, 8, false, false, false, true , true , true , true , true , false, false, false)]
+        [InlineData(+3, 1, +2, 0, false, false, false, true , false, false, false, false, false, true , false)]
+        public async Task Nth_Child_Master_Com_Test_Double_Selector(
+            int step1, int offset1, int step2, int offset2, params bool[] items)
+        {
+            var panel = new StackPanel();
+            panel.Children.AddRange(items.Select(_ => new Border()));
+
+            var previous = default(Selector).OfType<Border>();
+            var middle = previous.NthChild(step1, offset1);
+            var target = middle.NthChild(step2, offset2);
+
+            var results = new bool[items.Length];
+            for (int index = 0; index < items.Length; index++)
+            {
+                var border = panel.Children[index];
+                results[index] = await target.Match(border).Activator!.Take(1);
+            }
+
+            Assert.Equal(items, results);
+        }
+
+        [Theory] // http://nthmaster.com/
+        [InlineData(+1, 2, 2, 1, -1, 9, false, false, true , false, true , false, true , false, true , false, false)]
+        public async Task Nth_Child_Master_Com_Test_Triple_Selector(
+            int step1, int offset1, int step2, int offset2, int step3, int offset3, params bool[] items)
+        {
+            var panel = new StackPanel();
+            panel.Children.AddRange(items.Select(_ => new Border()));
+
+            var previous = default(Selector).OfType<Border>();
+            var middle1 = previous.NthChild(step1, offset1);
+            var middle2 = middle1.NthChild(step2, offset2);
+            var target = middle2.NthChild(step3, offset3);
+
+            var results = new bool[items.Length];
+            for (int index = 0; index < items.Length; index++)
+            {
+                var border = panel.Children[index];
+                results[index] = await target.Match(border).Activator!.Take(1);
+            }
+
+            Assert.Equal(items, results);
+        }
+
+        [Fact]
+        public void Returns_Correct_TargetType()
+        {
+            var target = new NthChildSelector(default(Selector).OfType<Control1>(), 1, 0);
+
+            Assert.Equal(typeof(Control1), target.TargetType);
+        }
+
+        public class Control1 : Control
+        {
+        }
+    }
+}

+ 220 - 0
tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs

@@ -0,0 +1,220 @@
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Xunit;
+
+namespace Avalonia.Styling.UnitTests
+{
+    public class SelectorTests_NthLastChild
+    {
+        [Theory]
+        [InlineData(2, 0, ":nth-last-child(2n)")]
+        [InlineData(2, 1, ":nth-last-child(2n+1)")]
+        [InlineData(1, 0, ":nth-last-child(1n)")]
+        [InlineData(4, -1, ":nth-last-child(4n-1)")]
+        [InlineData(0, 1, ":nth-last-child(1)")]
+        [InlineData(0, -1, ":nth-last-child(-1)")]
+        [InlineData(int.MaxValue, int.MinValue + 1, ":nth-last-child(2147483647n-2147483647)")]
+        public void Not_Selector_Should_Have_Correct_String_Representation(int step, int offset, string expected)
+        {
+            var target = default(Selector).NthLastChild(step, offset);
+
+            Assert.Equal(expected, target.ToString());
+        }
+
+        [Fact]
+        public async Task Nth_Child_Match_Control_In_Panel()
+        {
+            Border b1, b2, b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Border(),
+                b4 = new Border()
+            });
+
+            var target = default(Selector).NthLastChild(2, 0);
+
+            Assert.True(await target.Match(b1).Activator!.Take(1));
+            Assert.False(await target.Match(b2).Activator!.Take(1));
+            Assert.True(await target.Match(b3).Activator!.Take(1));
+            Assert.False(await target.Match(b4).Activator!.Take(1));
+        }
+
+        [Fact]
+        public async Task Nth_Child_Match_Control_In_Panel_With_Offset()
+        {
+            Border b1, b2, b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Border(),
+                b4 = new Border()
+            });
+
+            var target = default(Selector).NthLastChild(2, 1);
+
+            Assert.False(await target.Match(b1).Activator!.Take(1));
+            Assert.True(await target.Match(b2).Activator!.Take(1));
+            Assert.False(await target.Match(b3).Activator!.Take(1));
+            Assert.True(await target.Match(b4).Activator!.Take(1));
+        }
+
+        [Fact]
+        public async Task Nth_Child_Match_Control_In_Panel_With_Negative_Offset()
+        {
+            Border b1, b2, b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Border(),
+                b4 = new Border()
+            });
+
+            var target = default(Selector).NthLastChild(4, -1);
+
+            Assert.False(await target.Match(b1).Activator!.Take(1));
+            Assert.True(await target.Match(b2).Activator!.Take(1));
+            Assert.False(await target.Match(b3).Activator!.Take(1));
+            Assert.False(await target.Match(b4).Activator!.Take(1));
+        }
+
+        [Fact]
+        public async Task Nth_Child_Match_Control_In_Panel_With_Singular_Step()
+        {
+            Border b1, b2, b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Border(),
+                b4 = new Border()
+            });
+
+            var target = default(Selector).NthLastChild(1, 2);
+
+            Assert.True(await target.Match(b1).Activator!.Take(1));
+            Assert.True(await target.Match(b2).Activator!.Take(1));
+            Assert.True(await target.Match(b3).Activator!.Take(1));
+            Assert.False(await target.Match(b4).Activator!.Take(1));
+        }
+
+        [Fact]
+        public async Task Nth_Child_Match_Control_In_Panel_With_Singular_Step_With_Negative_Offset()
+        {
+            Border b1, b2, b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Border(),
+                b4 = new Border()
+            });
+
+            var target = default(Selector).NthLastChild(1, -2);
+
+            Assert.True(await target.Match(b1).Activator!.Take(1));
+            Assert.True(await target.Match(b2).Activator!.Take(1));
+            Assert.True(await target.Match(b3).Activator!.Take(1));
+            Assert.True(await target.Match(b4).Activator!.Take(1));
+        }
+
+        [Fact]
+        public async Task Nth_Child_Match_Control_In_Panel_With_Zero_Step_With_Offset()
+        {
+            Border b1, b2, b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Border(),
+                b4 = new Border()
+            });
+
+            var target = default(Selector).NthLastChild(0, 2);
+
+            Assert.False(await target.Match(b1).Activator!.Take(1));
+            Assert.False(await target.Match(b2).Activator!.Take(1));
+            Assert.True(await target.Match(b3).Activator!.Take(1));
+            Assert.False(await target.Match(b4).Activator!.Take(1));
+        }
+
+        [Fact]
+        public async Task Nth_Child_Doesnt_Match_Control_In_Panel_With_Zero_Step_With_Negative_Offset()
+        {
+            Border b1, b2, b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Border(),
+                b4 = new Border()
+            });
+
+            var target = default(Selector).NthLastChild(0, -2);
+
+            Assert.False(await target.Match(b1).Activator!.Take(1));
+            Assert.False(await target.Match(b2).Activator!.Take(1));
+            Assert.False(await target.Match(b3).Activator!.Take(1));
+            Assert.False(await target.Match(b4).Activator!.Take(1));
+        }
+
+        [Fact]
+        public async Task Nth_Child_Match_Control_In_Panel_With_Previous_Selector()
+        {
+            Border b1, b2;
+            Button b3, b4;
+            var panel = new StackPanel();
+            panel.Children.AddRange(new Control[]
+            {
+                b1 = new Border(),
+                b2 = new Border(),
+                b3 = new Button(),
+                b4 = new Button()
+            });
+
+            var previous = default(Selector).OfType<Border>();
+            var target = previous.NthLastChild(2, 0);
+
+            Assert.True(await target.Match(b1).Activator!.Take(1));
+            Assert.False(await target.Match(b2).Activator!.Take(1));
+            Assert.Null(target.Match(b3).Activator);
+            Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(b3).Result);
+            Assert.Null(target.Match(b4).Activator);
+            Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(b4).Result);
+        }
+
+        [Fact]
+        public void Nth_Child_Doesnt_Match_Control_Out_Of_Panel_Parent()
+        {
+            Border b1;
+            var contentControl = new ContentControl();
+            contentControl.Content = b1 = new Border();
+
+            var target = default(Selector).NthLastChild(1, 0);
+
+            Assert.Equal(SelectorMatch.NeverThisInstance, target.Match(b1));
+        }
+
+        [Fact]
+        public void Returns_Correct_TargetType()
+        {
+            var target = new NthLastChildSelector(default(Selector).OfType<Control1>(), 1, 0);
+
+            Assert.Equal(typeof(Control1), target.TargetType);
+        }
+
+        public class Control1 : Control
+        {
+        }
+    }
+}

+ 5 - 2
tests/Avalonia.Styling.UnitTests/StyleActivatorExtensions.cs

@@ -20,13 +20,17 @@ namespace Avalonia.Styling.UnitTests
 
         public static IObservable<bool> ToObservable(this IStyleActivator activator)
         {
+            if (activator == null)
+            {
+                throw new ArgumentNullException(nameof(activator));
+            }
+
             return new ObservableAdapter(activator);
         }
 
         private class ObservableAdapter : LightweightObservableBase<bool>, IStyleActivatorSink
         {
             private readonly IStyleActivator _source;
-            private bool _value;
             
             public ObservableAdapter(IStyleActivator source) => _source = source;
             protected override void Initialize() => _source.Subscribe(this);
@@ -34,7 +38,6 @@ namespace Avalonia.Styling.UnitTests
 
             void IStyleActivatorSink.OnNext(bool value, int tag)
             {
-                _value = value;
                 PublishNext(value);
             }
         }