Bläddra i källkod

Reimplemented SelectionModel.

Handles only list selections, not nested selections.
Steven Kirk 5 år sedan
förälder
incheckning
e62bacab7e

+ 3 - 0
src/Avalonia.Controls/Avalonia.Controls.csproj

@@ -2,6 +2,9 @@
   <PropertyGroup>
     <TargetFramework>netstandard2.0</TargetFramework>    
   </PropertyGroup>
+  <ItemGroup>
+    <Compile Include="..\Avalonia.Base\Metadata\NullableAttributes.cs" Link="NullableAttributes.cs" />
+  </ItemGroup>
   <ItemGroup>
     <ProjectReference Include="..\Avalonia.Animation\Avalonia.Animation.csproj" />
     <ProjectReference Include="..\Avalonia.Base\Avalonia.Base.csproj" />

+ 76 - 13
src/Avalonia.Controls/Repeater/ItemsSourceView.cs

@@ -8,6 +8,9 @@ using System.Collections;
 using System.Collections.Generic;
 using System.Collections.Specialized;
 using System.Linq;
+using Avalonia.Controls.Utils;
+
+#nullable enable
 
 namespace Avalonia.Controls
 {
@@ -21,30 +24,40 @@ namespace Avalonia.Controls
     /// view of the Items. That way, each component does not need to know if the source is an
     /// IEnumerable, an IList, or something else.
     /// </remarks>
-    public class ItemsSourceView : INotifyCollectionChanged, IDisposable
+    public class ItemsSourceView<T> : INotifyCollectionChanged, IDisposable, IReadOnlyList<T>
     {
-        private readonly IList _inner;
-        private INotifyCollectionChanged _notifyCollectionChanged;
+        /// <summary>
+        ///  Gets an empty <see cref="ItemsSourceView"/>
+        /// </summary>
+        public static ItemsSourceView<T> Empty { get; } = new ItemsSourceView<T>(Array.Empty<T>());
+
+        private readonly IList<T> _inner;
+        private INotifyCollectionChanged? _notifyCollectionChanged;
 
         /// <summary>
         /// Initializes a new instance of the ItemsSourceView class for the specified data source.
         /// </summary>
         /// <param name="source">The data source.</param>
-        public ItemsSourceView(IEnumerable source)
+        public ItemsSourceView(IEnumerable<T> source)
+            : this((IEnumerable)source)
         {
-            Contract.Requires<ArgumentNullException>(source != null);
+        }
 
-            if (source is IList list)
+        private protected ItemsSourceView(IEnumerable source)
+        {
+            source = source ?? throw new ArgumentNullException(nameof(source));
+
+            if (source is IList<T> list)
             {
                 _inner = list;
             }
-            else if (source is IEnumerable<object> objectEnumerable)
+            else if (source is IEnumerable<T> objectEnumerable)
             {
-                _inner = new List<object>(objectEnumerable);
+                _inner = new List<T>(objectEnumerable);
             }
             else
             {
-                _inner = new List<object>(source.Cast<object>());
+                _inner = new List<T>(source.Cast<T>());
             }
 
             ListenToCollectionChanges();
@@ -63,10 +76,17 @@ namespace Avalonia.Controls
         /// </remarks>
         public bool HasKeyIndexMapping => false;
 
+        /// <summary>
+        /// Retrieves the item at the specified index.
+        /// </summary>
+        /// <param name="index">The index.</param>
+        /// <returns>The item.</returns>
+        public T this[int index] => GetAt(index);
+
         /// <summary>
         /// Occurs when the collection has changed to indicate the reason for the change and which items changed.
         /// </summary>
-        public event NotifyCollectionChangedEventHandler CollectionChanged;
+        public event NotifyCollectionChangedEventHandler? CollectionChanged;
 
         /// <inheritdoc/>
         public void Dispose()
@@ -81,10 +101,26 @@ namespace Avalonia.Controls
         /// Retrieves the item at the specified index.
         /// </summary>
         /// <param name="index">The index.</param>
-        /// <returns>the item.</returns>
-        public object GetAt(int index) => _inner[index];
+        /// <returns>The item.</returns>
+        public T GetAt(int index) => _inner[index];
+
+        public int IndexOf(T item) => _inner.IndexOf(item);
 
-        public int IndexOf(object item) => _inner.IndexOf(item);
+        public static ItemsSourceView<T> GetOrCreate(IEnumerable<T>? items)
+        {
+            if (items is ItemsSourceView<T> isv)
+            {
+                return isv;
+            }
+            else if (items is null)
+            {
+                return Empty;
+            }
+            else
+            {
+                return new ItemsSourceView<T>(items);
+            }
+        }
 
         /// <summary>
         /// Retrieves the index of the item that has the specified unique identifier (key).
@@ -112,6 +148,25 @@ namespace Avalonia.Controls
             throw new NotImplementedException();
         }
 
+        public IEnumerator<T> GetEnumerator() => _inner.GetEnumerator();
+        IEnumerator IEnumerable.GetEnumerator() => _inner.GetEnumerator();
+
+        internal void AddListener(ICollectionChangedListener listener)
+        {
+            if (_inner is INotifyCollectionChanged incc)
+            {
+                CollectionChangedEventManager.Instance.AddListener(incc, listener);
+            }
+        }
+
+        internal void RemoveListener(ICollectionChangedListener listener)
+        {
+            if (_inner is INotifyCollectionChanged incc)
+            {
+                CollectionChangedEventManager.Instance.RemoveListener(incc, listener);
+            }
+        }
+
         protected void OnItemsSourceChanged(NotifyCollectionChangedEventArgs args)
         {
             CollectionChanged?.Invoke(this, args);
@@ -131,4 +186,12 @@ namespace Avalonia.Controls
             OnItemsSourceChanged(e);
         }
     }
+
+    public class ItemsSourceView : ItemsSourceView<object>
+    {
+        public ItemsSourceView(IEnumerable source)
+            : base(source)
+        {
+        }
+    }
 }

+ 48 - 0
src/Avalonia.Controls/Selection/ISelectionModel.cs

@@ -0,0 +1,48 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.ComponentModel;
+
+#nullable enable
+
+namespace Avalonia.Controls.Selection
+{
+    public interface ISelectionModel : INotifyPropertyChanged
+    {
+        IEnumerable? Source { get; set; }
+        bool SingleSelect { get; set; }
+        int SelectedIndex { get; set; }
+        IReadOnlyList<int> SelectedIndexes { get; }
+        object? SelectedItem { get; }
+        IReadOnlyList<object?> SelectedItems { get; }
+        int AnchorIndex { get; set; }
+        int Count { get; }
+
+        public event EventHandler<SelectionModelIndexesChangedEventArgs>? IndexesChanged;
+        public event EventHandler<SelectionModelSelectionChangedEventArgs>? SelectionChanged;
+        public event EventHandler? LostSelection;
+        public event EventHandler? SourceReset;
+
+        public void BeginBatchUpdate();
+        public void EndBatchUpdate();
+        bool IsSelected(int index);
+        void Select(int index);
+        void Deselect(int index);
+        void SelectRange(int start, int end);
+        void DeselectRange(int start, int end);
+        void Clear();
+    }
+
+    public static class SelectionModelExtensions
+    {
+        public static void SelectAll(this ISelectionModel model)
+        {
+            model.SelectRange(0, int.MaxValue);
+        }
+
+        public static void SelectRangeFromAnchor(this ISelectionModel model, int to)
+        {
+            model.SelectRange(model.AnchorIndex, to);
+        }
+    }
+}

+ 343 - 0
src/Avalonia.Controls/Selection/IndexRange.cs

@@ -0,0 +1,343 @@
+// This source file is adapted from the WinUI project.
+// (https://github.com/microsoft/microsoft-ui-xaml)
+//
+// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
+
+using System;
+using System.Collections.Generic;
+
+#nullable enable
+
+namespace Avalonia.Controls.Selection
+{
+    internal readonly struct IndexRange : IEquatable<IndexRange>
+    {
+        private static readonly IndexRange s_invalid = new IndexRange(int.MinValue, int.MinValue);
+
+        public IndexRange(int index)
+        {
+            Begin = index;
+            End = index;
+        }
+
+        public IndexRange(int begin, int end)
+        {
+            // Accept out of order begin/end pairs, just swap them.
+            if (begin > end)
+            {
+                int temp = begin;
+                begin = end;
+                end = temp;
+            }
+
+            Begin = begin;
+            End = end;
+        }
+
+        public int Begin { get; }
+        public int End { get; }
+        public int Count => (End - Begin) + 1;
+
+        public bool Contains(int index) => index >= Begin && index <= End;
+
+        public bool Split(int splitIndex, out IndexRange before, out IndexRange after)
+        {
+            bool afterIsValid;
+
+            before = new IndexRange(Begin, splitIndex);
+
+            if (splitIndex < End)
+            {
+                after = new IndexRange(splitIndex + 1, End);
+                afterIsValid = true;
+            }
+            else
+            {
+                after = new IndexRange();
+                afterIsValid = false;
+            }
+
+            return afterIsValid;
+        }
+
+        public bool Intersects(IndexRange other)
+        {
+            return (Begin <= other.End) && (End >= other.Begin);
+        }
+
+        public bool Adjacent(IndexRange other)
+        {
+            return Begin == other.End + 1 || End == other.Begin - 1;
+        }
+
+        public override bool Equals(object? obj)
+        {
+            return obj is IndexRange range && Equals(range);
+        }
+
+        public bool Equals(IndexRange other)
+        {
+            return Begin == other.Begin && End == other.End;
+        }
+
+        public override int GetHashCode()
+        {
+            var hashCode = 1903003160;
+            hashCode = hashCode * -1521134295 + Begin.GetHashCode();
+            hashCode = hashCode * -1521134295 + End.GetHashCode();
+            return hashCode;
+        }
+
+        public override string ToString() => $"[{Begin}..{End}]";
+
+        public static bool operator ==(IndexRange left, IndexRange right) => left.Equals(right);
+        public static bool operator !=(IndexRange left, IndexRange right) => !(left == right);
+
+        public static bool Contains(IReadOnlyList<IndexRange>? ranges, int index)
+        {
+            if (ranges is null || index < 0)
+            {
+                return false;
+            }
+
+            foreach (var range in ranges)
+            {
+                if (range.Contains(index))
+                {
+                    return true;
+                }
+            }
+
+            return false;
+        }
+
+        public static int GetAt(IReadOnlyList<IndexRange> ranges, int index)
+        {
+            var currentIndex = 0;
+
+            foreach (var range in ranges)
+            {
+                var currentCount = range.Count;
+
+                if (index >= currentIndex && index < currentIndex + currentCount)
+                {
+                    return range.Begin + (index - currentIndex);
+                }
+
+                currentIndex += currentCount;
+            }
+
+            throw new IndexOutOfRangeException("The index was out of range.");
+        }
+
+        public static int Add(
+            IList<IndexRange> ranges,
+            IndexRange range,
+            IList<IndexRange>? added = null)
+        {
+            var result = 0;
+
+            for (var i = 0; i < ranges.Count && range != s_invalid; ++i)
+            {
+                var existing = ranges[i];
+
+                if (range.Intersects(existing) || range.Adjacent(existing))
+                {
+                    if (range.Begin < existing.Begin)
+                    {
+                        var add = new IndexRange(range.Begin, existing.Begin - 1);
+                        ranges[i] = new IndexRange(range.Begin, existing.End);
+                        added?.Add(add);
+                        result += add.Count;
+                    }
+
+                    range = range.End <= existing.End ?
+                        s_invalid :
+                        new IndexRange(existing.End + 1, range.End);
+                }
+                else if (range.End < existing.Begin)
+                {
+                    ranges.Insert(i, range);
+                    added?.Add(range);
+                    result += range.Count;
+                    range = s_invalid;
+                }
+            }
+
+            if (range != s_invalid)
+            {
+                ranges.Add(range);
+                added?.Add(range);
+                result += range.Count;
+            }
+
+            MergeRanges(ranges);
+            return result;
+        }
+
+        public static int Add(
+            IList<IndexRange> destination,
+            IReadOnlyList<IndexRange> source,
+            IList<IndexRange>? added = null)
+        {
+            var result = 0;
+
+            foreach (var range in source)
+            {
+                result += Add(destination, range, added);
+            }
+
+            return result;
+        }
+
+        public static int Intersect(
+            IList<IndexRange> ranges,
+            IndexRange range,
+            IList<IndexRange>? removed = null)
+        {
+            var result = 0;
+
+            for (var i = 0; i < ranges.Count && range != s_invalid; ++i)
+            {
+                var existing = ranges[i];
+
+                if (existing.End < range.Begin || existing.Begin > range.End)
+                {
+                    removed?.Add(existing);
+                    ranges.RemoveAt(i--);
+                    result += existing.Count;
+                }
+                else
+                {
+                    if (existing.Begin < range.Begin)
+                    {
+                        var except = new IndexRange(existing.Begin, range.Begin - 1);
+                        removed?.Add(except);
+                        ranges[i] = existing = new IndexRange(range.Begin, existing.End);
+                        result += except.Count;
+                    }
+
+                    if (existing.End > range.End)
+                    {
+                        var except = new IndexRange(range.End + 1, existing.End);
+                        removed?.Add(except);
+                        ranges[i] = new IndexRange(existing.Begin, range.End);
+                        result += except.Count;
+                    }
+                }
+            }
+
+            MergeRanges(ranges);
+
+            if (removed is object)
+            {
+                MergeRanges(removed);
+            }
+
+            return result;
+        }
+
+        public static int Remove(
+            IList<IndexRange>? ranges,
+            IndexRange range,
+            IList<IndexRange>? removed = null)
+        {
+            if (ranges is null)
+            {
+                return 0;
+            }
+
+            var result = 0;
+
+            for (var i = 0; i < ranges.Count; ++i)
+            {
+                var existing = ranges[i];
+
+                if (range.Intersects(existing))
+                {
+                    if (range.Begin <= existing.Begin && range.End >= existing.End)
+                    {
+                        ranges.RemoveAt(i--);
+                        removed?.Add(existing);
+                        result += existing.Count;
+                    }
+                    else if (range.Begin > existing.Begin && range.End >= existing.End)
+                    {
+                        ranges[i] = new IndexRange(existing.Begin, range.Begin - 1);
+                        removed?.Add(new IndexRange(range.Begin, existing.End));
+                        result += existing.End - (range.Begin - 1);
+                    }
+                    else if (range.Begin > existing.Begin && range.End < existing.End)
+                    {
+                        ranges[i] = new IndexRange(existing.Begin, range.Begin - 1);
+                        ranges.Insert(++i, new IndexRange(range.End + 1, existing.End));
+                        removed?.Add(range);
+                        result += range.Count;
+                    }
+                    else if (range.End <= existing.End)
+                    {
+                        var remove = new IndexRange(existing.Begin, range.End);
+                        ranges[i] = new IndexRange(range.End + 1, existing.End);
+                        removed?.Add(remove);
+                        result += remove.Count;
+                    }
+                }
+            }
+
+            return result;
+        }
+
+        public static int Remove(
+            IList<IndexRange> destination,
+            IReadOnlyList<IndexRange> source,
+            IList<IndexRange>? added = null)
+        {
+            var result = 0;
+
+            foreach (var range in source)
+            {
+                result += Remove(destination, range, added);
+            }
+
+            return result;
+        }
+
+        public static IEnumerable<int> EnumerateIndices(IEnumerable<IndexRange> ranges)
+        {
+            foreach (var range in ranges)
+            {
+                for (var i = range.Begin; i <= range.End; ++i)
+                {
+                    yield return i;
+                }
+            }
+        }
+
+        public static int GetCount(IEnumerable<IndexRange> ranges)
+        {
+            var result = 0;
+
+            foreach (var range in ranges)
+            {
+                result += (range.End - range.Begin) + 1;
+            }
+
+            return result;
+        }
+
+        private static void MergeRanges(IList<IndexRange> ranges)
+        {
+            for (var i = ranges.Count - 2; i >= 0; --i)
+            {
+                var r = ranges[i];
+                var r1 = ranges[i + 1];
+
+                if (r.Intersects(r1) || r.End == r1.Begin - 1)
+                {
+                    ranges[i] = new IndexRange(r.Begin, r1.End);
+                    ranges.RemoveAt(i + 1);
+                }
+            }
+        }
+    }
+}

+ 82 - 0
src/Avalonia.Controls/Selection/SelectedIndexes.cs

@@ -0,0 +1,82 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+
+#nullable enable
+
+namespace Avalonia.Controls.Selection
+{
+    internal class SelectedIndexes<T> : IReadOnlyList<int>
+    {
+        private readonly SelectionModel<T>? _owner;
+        private readonly IReadOnlyList<IndexRange>? _ranges;
+
+        public SelectedIndexes(SelectionModel<T> owner) => _owner = owner;
+        public SelectedIndexes(IReadOnlyList<IndexRange> ranges) => _ranges = ranges;
+
+        public int this[int index]
+        {
+            get
+            {
+                if (index >= Count)
+                {
+                    throw new IndexOutOfRangeException("The index was out of range.");
+                }
+
+                if (_owner?.SingleSelect == true)
+                {
+                    return _owner.SelectedIndex;
+                }
+                else
+                {
+                    return IndexRange.GetAt(Ranges!, index);
+                }
+            }
+        }
+
+        public int Count
+        {
+            get
+            {
+                if (_owner?.SingleSelect == true)
+                {
+                    return _owner.SelectedIndex == -1 ? 0 : 1;
+                }
+                else
+                {
+                    return IndexRange.GetCount(Ranges!);
+                }
+            }
+        }
+
+        private IReadOnlyList<IndexRange> Ranges => _ranges ?? _owner!.Ranges!;
+
+        public IEnumerator<int> GetEnumerator()
+        {
+            IEnumerator<int> SingleSelect()
+            {
+                if (_owner.SelectedIndex >= 0)
+                {
+                    yield return _owner.SelectedIndex;
+                }
+            }
+
+            if (_owner?.SingleSelect == true)
+            {
+                return SingleSelect();
+            }
+            else
+            {
+                return IndexRange.EnumerateIndices(Ranges).GetEnumerator();
+            }
+        }
+
+        public static SelectedIndexes<T>? Create(IReadOnlyList<IndexRange>? ranges)
+        {
+            return ranges is object ? new SelectedIndexes<T>(ranges) : null;
+        }
+
+        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+    }
+}

+ 121 - 0
src/Avalonia.Controls/Selection/SelectedItems.cs

@@ -0,0 +1,121 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+
+#nullable enable
+
+namespace Avalonia.Controls.Selection
+{
+    internal class SelectedItems<T> : IReadOnlyList<T>
+    {
+        private readonly SelectionModel<T>? _owner;
+        private readonly ItemsSourceView<T>? _items;
+        private readonly IReadOnlyList<IndexRange>? _ranges;
+
+        public SelectedItems(SelectionModel<T> owner) => _owner = owner;
+        
+        public SelectedItems(IReadOnlyList<IndexRange> ranges, ItemsSourceView<T>? items)
+        {
+            _ranges = ranges ?? throw new ArgumentNullException(nameof(ranges));
+            _items = items;
+        }
+
+        [MaybeNull]
+        public T this[int index]
+        {
+#pragma warning disable CS8766
+            get
+#pragma warning restore CS8766
+            {
+                if (index >= Count)
+                {
+                    throw new IndexOutOfRangeException("The index was out of range.");
+                }
+
+                if (_owner?.SingleSelect == true)
+                {
+                    return _owner.SelectedItem;
+                }
+                else if (Items is object)
+                {
+                    return Items[index];
+                }
+                else
+                {
+                    return default;
+                }
+            }
+        }
+
+        public int Count
+        {
+            get
+            {
+                if (_owner?.SingleSelect == true)
+                {
+                    return _owner.SelectedIndex == -1 ? 0 : 1;
+                }
+                else
+                {
+                    return Ranges is object ? IndexRange.GetCount(Ranges) : 0;
+                }
+            }
+        }
+
+        private ItemsSourceView<T>? Items => _items ?? _owner?.ItemsView;
+        private IReadOnlyList<IndexRange>? Ranges => _ranges ?? _owner!.Ranges;
+
+        public IEnumerator<T> GetEnumerator()
+        {
+            if (_owner?.SingleSelect == true)
+            {
+                if (_owner.SelectedIndex >= 0)
+                {
+#pragma warning disable CS8603
+                    yield return _owner.SelectedItem;
+#pragma warning restore CS8603
+                }
+            }
+            else
+            {
+                var items = Items;
+
+                foreach (var range in Ranges!)
+                {
+                    for (var i = range.Begin; i <= range.End; ++i)
+                    {
+#pragma warning disable CS8603
+                        yield return items is object ? items[i] : default;
+#pragma warning restore CS8603
+                    }
+                }
+            }
+        }
+
+        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+        public static SelectedItems<T>? Create(
+            IReadOnlyList<IndexRange>? ranges,
+            ItemsSourceView<T>? items)
+        {
+            return ranges is object ? new SelectedItems<T>(ranges, items) : null;
+        }
+
+        public class Untyped : IReadOnlyList<object?>
+        {
+            private readonly IReadOnlyList<T> _source;
+            public Untyped(IReadOnlyList<T> source) => _source = source;
+            public object? this[int index] => _source[index];
+            public int Count => _source.Count;
+            IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+            public IEnumerator<object?> GetEnumerator()
+            {
+                foreach (var i in _source)
+                {
+                    yield return i;
+                }
+            }
+        }
+    }
+}

+ 632 - 0
src/Avalonia.Controls/Selection/SelectionModel.cs

@@ -0,0 +1,632 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+
+#nullable enable
+
+namespace Avalonia.Controls.Selection
+{
+    public class SelectionModel<T> : SelectionNodeBase<T>, ISelectionModel
+    {
+        private bool _singleSelect = true;
+        private int _anchorIndex = -1;
+        private int _selectedIndex = -1;
+        private Operation? _operation;
+        private SelectedIndexes<T>? _selectedIndexes;
+        private SelectedItems<T>? _selectedItems;
+        private SelectedItems<T>.Untyped? _selectedItemsUntyped;
+        private EventHandler<SelectionModelSelectionChangedEventArgs>? _untypedSelectionChanged;
+
+        public SelectionModel()
+        {
+        }
+
+        public SelectionModel(IEnumerable<T>? source)
+        {
+            Source = source;
+        }
+
+        public override IEnumerable<T>? Source
+        {
+            get => base.Source;
+            set
+            {
+                if (base.Source != value)
+                {
+                    if (_operation is object)
+                    {
+                        throw new InvalidOperationException("Cannot change source while update is in progress.");
+                    }
+
+                    if (base.Source is object)
+                    {
+                        Clear();
+                    }
+
+                    base.Source = value;
+
+                    using var update = BatchUpdate();
+                    update.Operation.IsSourceUpdate = true;
+                    TrimInvalidSelections(update.Operation);
+                    RaisePropertyChanged(nameof(Source));
+                }
+            }
+        }
+
+        public bool SingleSelect 
+        {
+            get => _singleSelect;
+            set
+            {
+                if (_singleSelect != value)
+                {
+                    _singleSelect = value;
+                    RangesEnabled = !value;
+
+                    if (RangesEnabled && _selectedIndex >= 0)
+                    {
+                        CommitSelect(new IndexRange(_selectedIndex));
+                    }
+
+                    RaisePropertyChanged(nameof(SingleSelect));
+               }
+            }
+        }
+
+        public int SelectedIndex 
+        {
+            get => _selectedIndex;
+            set
+            {
+                using var update = BatchUpdate();
+                Clear();
+                Select(value);
+            }
+        }
+
+        public IReadOnlyList<int> SelectedIndexes => _selectedIndexes ??= new SelectedIndexes<T>(this);
+
+        [MaybeNull]
+        public T SelectedItem => GetItemAt(_selectedIndex);
+
+        public IReadOnlyList<T> SelectedItems => _selectedItems ??= new SelectedItems<T>(this);
+
+        public int AnchorIndex 
+        {
+            get => _anchorIndex;
+            set
+            {
+                using var update = BatchUpdate();
+                var index = CoerceIndex(value);
+                update.Operation.AnchorIndex = index;
+            }
+        }
+
+        public int Count
+        {
+            get
+            {
+                if (SingleSelect)
+                {
+                    return _selectedIndex >= 0 ? 1 : 0;
+                }
+                else
+                {
+                    return IndexRange.GetCount(Ranges);
+                }
+            }
+        }
+
+        IEnumerable? ISelectionModel.Source 
+        {
+            get => Source;
+            set => Source = (IEnumerable<T>?)value;
+        }
+
+        object? ISelectionModel.SelectedItem => SelectedItem;
+
+        IReadOnlyList<object?> ISelectionModel.SelectedItems 
+        {
+            get => _selectedItemsUntyped ??= new SelectedItems<T>.Untyped(SelectedItems);
+        }
+
+        public event EventHandler<SelectionModelIndexesChangedEventArgs>? IndexesChanged;
+        public event EventHandler<SelectionModelSelectionChangedEventArgs<T>>? SelectionChanged;
+        public event EventHandler? LostSelection;
+        public event EventHandler? SourceReset;
+        public event PropertyChangedEventHandler? PropertyChanged;
+
+        event EventHandler<SelectionModelSelectionChangedEventArgs>? ISelectionModel.SelectionChanged
+        {
+            add => _untypedSelectionChanged += value;
+            remove => _untypedSelectionChanged -= value;
+        }
+
+        public BatchUpdateOperation BatchUpdate() => new BatchUpdateOperation(this);
+
+        public void BeginBatchUpdate()
+        {
+            _operation ??= new Operation(this);
+            ++_operation.UpdateCount;
+        }
+
+        public void EndBatchUpdate()
+        {
+            if (_operation is null || _operation.UpdateCount == 0)
+            {
+                throw new InvalidOperationException("No batch update in progress.");
+            }
+
+            if (--_operation.UpdateCount == 0)
+            {
+                // If the collection is currently changing, commit the update when the
+                // collection change finishes.
+                if (!IsSourceCollectionChanging)
+                {
+                    CommitOperation(_operation);
+                }
+            }
+        }
+
+        public bool IsSelected(int index)
+        {
+            if (index < 0)
+            {
+                return false;
+            }
+            else if (SingleSelect)
+            {
+                return _selectedIndex == index;
+            }
+            else
+            {
+                return IndexRange.Contains(Ranges, index);
+            }
+        }
+
+        public void Select(int index) => SelectRange(index, index, false, true);
+
+        public void Deselect(int index) => DeselectRange(index, index);
+
+        public void SelectRange(int start, int end) => SelectRange(start, end, false, false);
+
+        public void DeselectRange(int start, int end)
+        {
+            using var update = BatchUpdate();
+            var o = update.Operation;
+            var range = CoerceRange(start, end);
+
+            if (range.Begin == -1)
+            {
+                return;
+            }
+
+            if (RangesEnabled)
+            {
+                var selected = Ranges.ToList();
+                var deselected = new List<IndexRange>();
+                var operationDeselected = new List<IndexRange>();
+
+                o.DeselectedRanges ??= new List<IndexRange>();
+                IndexRange.Remove(o.SelectedRanges, range, operationDeselected);
+                IndexRange.Remove(selected, range, deselected);
+                IndexRange.Add(o.DeselectedRanges, deselected);
+
+                if (IndexRange.Contains(deselected, o.SelectedIndex) ||
+                    IndexRange.Contains(operationDeselected, o.SelectedIndex))
+                {
+                    o.SelectedIndex = GetFirstSelectedIndexFromRanges(except: deselected);
+                }
+            }
+            else if(range.Contains(_selectedIndex))
+            {
+                o.SelectedIndex = -1;
+            }
+        }
+
+        public void Clear() => DeselectRange(0, int.MaxValue);
+
+        protected void RaisePropertyChanged(string propertyName)
+        {
+            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+        }
+
+        private protected override void OnIndexesChanged(int shiftIndex, int shiftDelta)
+        {
+            IndexesChanged?.Invoke(this, new SelectionModelIndexesChangedEventArgs(shiftIndex, shiftDelta));
+        }
+
+        private protected override void OnSourceReset()
+        {
+            _selectedIndex = _anchorIndex = -1;
+            CommitDeselect(new IndexRange(0, int.MaxValue));
+
+            if (SourceReset is object)
+            {
+                SourceReset.Invoke(this, EventArgs.Empty);
+            }
+            else
+            {
+                //Logger.TryGet(LogEventLevel.Warning, LogArea.Control)?.Log(
+                //    this,
+                //    "SelectionModel received Reset but no SourceReset handler was registered to handle it. " +
+                //    "Selection may be out of sync.",
+                //    typeof(SelectionModel));
+            }
+        }
+
+        private protected override void OnSelectionChanged(IReadOnlyList<T> deselectedItems)
+        {
+            if (SelectionChanged is object || _untypedSelectionChanged is object)
+            {
+                var e = new SelectionModelSelectionChangedEventArgs<T>(deselectedItems: deselectedItems);
+                SelectionChanged?.Invoke(this, e);
+                _untypedSelectionChanged?.Invoke(this, e);
+            }
+        }
+
+        private protected override CollectionChangeState OnItemsAdded(int index, IList items)
+        {
+            var count = items.Count;
+            var shifted = SelectedIndex >= index;
+            var shiftCount = shifted ? count : 0;
+
+            _selectedIndex += shiftCount;
+            _anchorIndex += shiftCount;
+
+            var baseResult = base.OnItemsAdded(index, items);
+            shifted |= baseResult.ShiftDelta != 0;
+
+            return new CollectionChangeState
+            {
+                ShiftIndex = index,
+                ShiftDelta = shifted ? count : 0,
+            };
+        }
+
+        private protected override CollectionChangeState OnItemsRemoved(int index, IList items)
+        {
+            var count = items.Count;
+            var removedRange = new IndexRange(index, index + count - 1);
+            var shifted = false;
+            List<T>? removed;
+
+            var baseResult = base.OnItemsRemoved(index, items);
+            shifted |= baseResult.ShiftDelta != 0;
+            removed = baseResult.RemovedItems;
+
+            if (removedRange.Contains(SelectedIndex))
+            {
+                if (SingleSelect)
+                {
+#pragma warning disable CS8604
+                    removed = new List<T> { (T)items[SelectedIndex - index] };
+#pragma warning restore CS8604
+                }
+
+                _selectedIndex = GetFirstSelectedIndexFromRanges();
+            }
+            else if (SelectedIndex >= index)
+            {
+                _selectedIndex -= count;
+                shifted = true;
+            }
+
+            if (removedRange.Contains(AnchorIndex))
+            {
+                _anchorIndex = GetFirstSelectedIndexFromRanges();
+            }
+            else if (AnchorIndex >= index)
+            {
+                _anchorIndex -= count;
+                shifted = true;
+            }
+
+            return new CollectionChangeState
+            {
+                ShiftIndex = index,
+                ShiftDelta = shifted ? -count : 0,
+                RemovedItems = removed,
+            };
+        }
+
+        private protected override void OnSourceCollectionChanged(NotifyCollectionChangedEventArgs e)
+        {
+            if (_operation?.UpdateCount > 0)
+            {
+                throw new InvalidOperationException("Source collection was modified during selection update.");
+            }
+
+            var oldAnchorIndex = _anchorIndex;
+            var oldSelectedIndex = _selectedIndex;
+
+            base.OnSourceCollectionChanged(e);
+
+            if (oldSelectedIndex != _selectedIndex)
+            {
+                RaisePropertyChanged(nameof(SelectedIndex));
+            }
+
+            if (oldAnchorIndex != _anchorIndex)
+            {
+                RaisePropertyChanged(nameof(AnchorIndex));
+            }
+        }
+
+        protected override void OnSourceCollectionChangeFinished()
+        {
+            if (_operation is object)
+            {
+                CommitOperation(_operation);
+            }
+        }
+
+        private int GetFirstSelectedIndexFromRanges(List<IndexRange>? except = null)
+        {
+            if (RangesEnabled)
+            {
+                var count = IndexRange.GetCount(Ranges);
+                var index = 0;
+
+                while (index < count)
+                {
+                    var result = IndexRange.GetAt(Ranges, index++);
+
+                    if (!IndexRange.Contains(except, result))
+                    {
+                        return result;
+                    }
+                }
+            }
+
+            return -1;
+        }
+
+        private void SelectRange(
+            int start,
+            int end,
+            bool forceSelectedIndex,
+            bool forceAnchorIndex)
+        {
+            if (SingleSelect && start != end)
+            {
+                throw new InvalidOperationException("Cannot select range with single selection.");
+            }
+
+            var range = CoerceRange(start, end);
+
+            if (range.Begin == -1)
+            {
+                return;
+            }
+
+            using var update = BatchUpdate();
+            var o = update.Operation;
+            var selected = new List<IndexRange>();
+
+            if (RangesEnabled)
+            {
+                o.SelectedRanges ??= new List<IndexRange>();
+                IndexRange.Remove(o.DeselectedRanges, range);
+                IndexRange.Add(o.SelectedRanges, range);
+                IndexRange.Remove(o.SelectedRanges, Ranges);
+
+                if (o.SelectedIndex == -1 || forceSelectedIndex)
+                {
+                    o.SelectedIndex = range.Begin;
+                }
+
+                if (o.AnchorIndex == -1 || forceAnchorIndex)
+                {
+                    o.AnchorIndex = range.Begin;
+                }
+            }
+            else
+            {
+                o.SelectedIndex = o.AnchorIndex = start;
+            }
+        }
+
+        [return: MaybeNull]
+        private T GetItemAt(int index)
+        {
+            if (ItemsView is null || index < 0 || index >= ItemsView.Count)
+            {
+                return default;
+            }
+
+            return ItemsView.GetAt(index);
+        }
+
+        private int CoerceIndex(int index)
+        {
+            index = Math.Max(index, -1);
+
+            if (ItemsView is object && index >= ItemsView.Count)
+            {
+                index = -1;
+            }
+
+            return index;
+        }
+
+        private IndexRange CoerceRange(int start, int end)
+        {
+            var max = ItemsView is object ? ItemsView.Count - 1 : int.MaxValue;
+
+            if (start > max || (start < 0 && end < 0))
+            {
+                return new IndexRange(-1);
+            }
+
+            start = Math.Max(start, 0);
+            end = Math.Min(end, max);
+
+            return new IndexRange(start, end);
+        }
+
+        private void TrimInvalidSelections(Operation operation)
+        {
+            if (ItemsView is null)
+            {
+                return;
+            }
+
+            var max = ItemsView.Count - 1;
+
+            if (operation.SelectedIndex > max)
+            {
+                operation.SelectedIndex = GetFirstSelectedIndexFromRanges();
+            }
+
+            if (operation.AnchorIndex > max)
+            {
+                operation.AnchorIndex = GetFirstSelectedIndexFromRanges();
+            }
+
+            if (RangesEnabled && Ranges.Count > 0)
+            {
+                var selected = Ranges.ToList();
+                
+                if (max < 0)
+                {
+                    operation.DeselectedRanges = selected;
+                }
+                else
+                {
+                    var valid = new IndexRange(0, max);
+                    var removed = new List<IndexRange>();
+                    IndexRange.Intersect(selected, valid, removed);
+                    operation.DeselectedRanges = removed;
+                }
+            }
+        }
+
+        private void CommitOperation(Operation operation)
+        {
+            try
+            {
+                var oldAnchorIndex = _anchorIndex;
+                var oldSelectedIndex = _selectedIndex;
+                var indexesChanged = false;
+
+                if (operation.SelectedIndex == -1 && LostSelection is object)
+                {
+                    operation.UpdateCount++;
+                    LostSelection?.Invoke(this, EventArgs.Empty);
+                }
+
+                _selectedIndex = operation.SelectedIndex;
+                _anchorIndex = operation.AnchorIndex;
+
+                if (operation.SelectedRanges is object)
+                {
+                    indexesChanged |= CommitSelect(operation.SelectedRanges) > 0;
+                }
+
+                if (operation.DeselectedRanges is object)
+                {
+                    indexesChanged |= CommitDeselect(operation.DeselectedRanges) > 0;
+                }
+
+                if (SelectionChanged is object || _untypedSelectionChanged is object)
+                {
+                    IReadOnlyList<IndexRange>? deselected = operation.DeselectedRanges;
+                    IReadOnlyList<IndexRange>? selected = operation.SelectedRanges;
+
+                    if (SingleSelect && oldSelectedIndex != _selectedIndex)
+                    {
+                        if (oldSelectedIndex != -1)
+                        {
+                            deselected = new[] { new IndexRange(oldSelectedIndex) };
+                        }
+
+                        if (_selectedIndex != -1)
+                        {
+                            selected = new[] { new IndexRange(_selectedIndex) };
+                        }
+                    }
+
+                    if (deselected?.Count > 0 || selected?.Count > 0)
+                    {
+                        var deselectedSource = operation.IsSourceUpdate ? null : ItemsView;
+                        var e = new SelectionModelSelectionChangedEventArgs<T>(
+                            SelectedIndexes<T>.Create(deselected),
+                            SelectedIndexes<T>.Create(selected),
+                            SelectedItems<T>.Create(deselected, deselectedSource),
+                            SelectedItems<T>.Create(selected, ItemsView));
+                        SelectionChanged?.Invoke(this, e);
+                        _untypedSelectionChanged?.Invoke(this, e);
+                    }
+                }
+
+                if (oldSelectedIndex != _selectedIndex)
+                {
+                    indexesChanged = true;
+                    RaisePropertyChanged(nameof(SelectedIndex));
+                    RaisePropertyChanged(nameof(SelectedItem));
+                }
+
+                if (oldAnchorIndex != _anchorIndex)
+                {
+                    indexesChanged = true;
+                    RaisePropertyChanged(nameof(AnchorIndex));
+                }
+
+                if (indexesChanged)
+                {
+                    RaisePropertyChanged(nameof(SelectedIndexes));
+                    RaisePropertyChanged(nameof(SelectedItems));
+                }
+            }
+            finally
+            {
+                _operation = null;
+            }
+        }
+
+        public struct BatchUpdateOperation : IDisposable
+        {
+            private readonly SelectionModel<T> _owner;
+            private bool _isDisposed;
+
+            public BatchUpdateOperation(SelectionModel<T> owner)
+            {
+                _owner = owner;
+                _isDisposed = false;
+                owner.BeginBatchUpdate();
+            }
+
+            internal Operation Operation => _owner._operation!;
+
+            public void Dispose()
+            {
+                if (!_isDisposed)
+                {
+                    _owner?.EndBatchUpdate();
+                    _isDisposed = true;
+                }
+            }
+        }
+
+        internal class Operation
+        {
+            public Operation(SelectionModel<T> owner)
+            {
+                AnchorIndex = owner.AnchorIndex;
+                SelectedIndex = owner.SelectedIndex;
+            }
+
+            public int UpdateCount { get; set; }
+            public bool IsSourceUpdate { get; set; }
+            public int AnchorIndex { get; set; }
+            public int SelectedIndex { get; set; }
+            public List<IndexRange>? SelectedRanges { get; set; }
+            public List<IndexRange>? DeselectedRanges { get; set; }
+        }
+    }
+}

+ 18 - 0
src/Avalonia.Controls/Selection/SelectionModelIndexesChangedEventArgs.cs

@@ -0,0 +1,18 @@
+using System;
+
+#nullable enable
+
+namespace Avalonia.Controls.Selection
+{
+    public class SelectionModelIndexesChangedEventArgs : EventArgs
+    {
+        public SelectionModelIndexesChangedEventArgs(int startIndex, int delta)
+        {
+            StartIndex = startIndex;
+            Delta = delta;
+        }
+
+        public int StartIndex { get; }
+        public int Delta { get; }
+    }
+}

+ 85 - 0
src/Avalonia.Controls/Selection/SelectionModelSelectionChangedEventArgs.cs

@@ -0,0 +1,85 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using Avalonia.Controls.Selection;
+
+#nullable enable
+
+namespace Avalonia.Controls.Selection
+{
+    public abstract class SelectionModelSelectionChangedEventArgs : EventArgs
+    {
+        /// <summary>
+        /// Gets the indexes of the items that were removed from the selection.
+        /// </summary>
+        public abstract IReadOnlyList<int> DeselectedIndexes { get; }
+
+        /// <summary>
+        /// Gets the indexes of the items that were added to the selection.
+        /// </summary>
+        public abstract IReadOnlyList<int> SelectedIndexes { get; }
+
+        /// <summary>
+        /// Gets the items that were removed from the selection.
+        /// </summary>
+        public IReadOnlyList<object?> DeselectedItems => GetUntypedDeselectedItems();
+
+        /// <summary>
+        /// Gets the items that were added to the selection.
+        /// </summary>
+        public IReadOnlyList<object?> SelectedItems => GetUntypedSelectedItems();
+
+        protected abstract IReadOnlyList<object?> GetUntypedDeselectedItems();
+        protected abstract IReadOnlyList<object?> GetUntypedSelectedItems();
+    }
+
+    public class SelectionModelSelectionChangedEventArgs<T> : SelectionModelSelectionChangedEventArgs
+    {
+        private IReadOnlyList<object?>? _deselectedItems;
+        private IReadOnlyList<object?>? _selectedItems;
+
+        public SelectionModelSelectionChangedEventArgs(
+            IReadOnlyList<int>? deselectedIndices = null,
+            IReadOnlyList<int>? selectedIndices = null,
+            IReadOnlyList<T>? deselectedItems = null,
+            IReadOnlyList<T>? selectedItems = null)
+        {
+            DeselectedIndexes = deselectedIndices ?? Array.Empty<int>();
+            SelectedIndexes = selectedIndices ?? Array.Empty<int>();
+            DeselectedItems = deselectedItems ?? Array.Empty<T>();
+            SelectedItems = selectedItems ?? Array.Empty<T>();
+        }
+
+        /// <summary>
+        /// Gets the indexes of the items that were removed from the selection.
+        /// </summary>
+        public override IReadOnlyList<int> DeselectedIndexes { get; }
+
+        /// <summary>
+        /// Gets the indexes of the items that were added to the selection.
+        /// </summary>
+        public override IReadOnlyList<int> SelectedIndexes { get; }
+
+        /// <summary>
+        /// Gets the items that were removed from the selection.
+        /// </summary>
+        public new IReadOnlyList<T> DeselectedItems { get; }
+
+        /// <summary>
+        /// Gets the items that were added to the selection.
+        /// </summary>
+        public new IReadOnlyList<T> SelectedItems { get; }
+
+        protected override IReadOnlyList<object?> GetUntypedDeselectedItems()
+        {
+            return _deselectedItems ??= (DeselectedItems as IReadOnlyList<object?>) ??
+                new SelectedItems<T>.Untyped(DeselectedItems);
+        }
+
+        protected override IReadOnlyList<object?> GetUntypedSelectedItems()
+        {
+            return _selectedItems ??= (SelectedItems as IReadOnlyList<object?>) ??
+                new SelectedItems<T>.Untyped(SelectedItems);
+        }
+    }
+}

+ 286 - 0
src/Avalonia.Controls/Selection/SelectionNodeBase.cs

@@ -0,0 +1,286 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using Avalonia.Controls.Utils;
+
+#nullable enable
+
+namespace Avalonia.Controls.Selection
+{
+    public abstract class SelectionNodeBase<T> : ICollectionChangedListener
+    {
+        private IEnumerable<T>? _source;
+        private bool _rangesEnabled;
+        private List<IndexRange>? _ranges;
+        private int _collectionChanging;
+
+        public virtual IEnumerable<T>? Source
+        {
+            get => _source;
+            set
+            {
+                if (_source != value)
+                {
+                    ItemsView?.RemoveListener(this);
+                    _source = value;
+                    ItemsView = value is object ? ItemsSourceView<T>.GetOrCreate(value) : null;
+                    ItemsView?.AddListener(this);
+                }
+            }
+        }
+
+        protected bool IsSourceCollectionChanging => _collectionChanging > 0;
+
+        protected bool RangesEnabled
+        {
+            get => _rangesEnabled;
+            set
+            {
+                if (_rangesEnabled != value)
+                {
+                    _rangesEnabled = value;
+
+                    if (!_rangesEnabled)
+                    {
+                        _ranges = null;
+                    }
+                }
+            }
+        }
+
+        internal ItemsSourceView<T>? ItemsView { get; set; }
+
+        internal IReadOnlyList<IndexRange> Ranges
+        {
+            get
+            {
+                if (!RangesEnabled)
+                {
+                    throw new InvalidOperationException("Ranges not enabled.");
+                }
+
+                return _ranges ??= new List<IndexRange>();
+            }
+        }
+
+        void ICollectionChangedListener.PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)
+        {
+            ++_collectionChanging;
+        }
+
+        void ICollectionChangedListener.Changed(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)
+        {
+            OnSourceCollectionChanged(e);
+        }
+
+        void ICollectionChangedListener.PostChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)
+        {
+            if (--_collectionChanging == 0)
+            {
+                OnSourceCollectionChangeFinished();
+            }
+        }
+
+        protected abstract void OnSourceCollectionChangeFinished();
+
+        private protected abstract void OnIndexesChanged(int shiftIndex, int shiftDelta);
+
+        private protected abstract void OnSourceReset();
+
+        private protected abstract void OnSelectionChanged(IReadOnlyList<T> deselectedItems);
+
+        private protected int CommitSelect(IndexRange range)
+        {
+            if (RangesEnabled)
+            {
+                _ranges ??= new List<IndexRange>();
+                return IndexRange.Add(_ranges, range);
+            }
+
+            return 0;
+        }
+
+        private protected int CommitSelect(IReadOnlyList<IndexRange> ranges)
+        {
+            if (RangesEnabled)
+            {
+                _ranges ??= new List<IndexRange>();
+                return IndexRange.Add(_ranges, ranges);
+            }
+
+            return 0;
+        }
+
+        private protected int CommitDeselect(IndexRange range)
+        {
+            if (RangesEnabled)
+            {
+                _ranges ??= new List<IndexRange>();
+                return IndexRange.Remove(_ranges, range);
+            }
+
+            return 0;
+        }
+
+        private protected int CommitDeselect(IReadOnlyList<IndexRange> ranges)
+        {
+            if (RangesEnabled && _ranges is object)
+            {
+                return IndexRange.Remove(_ranges, ranges);
+            }
+
+            return 0;
+        }
+
+        private protected virtual CollectionChangeState OnItemsAdded(int index, IList items)
+        {
+            var count = items.Count;
+            var shifted = false;
+
+            if (_ranges is object)
+            {
+                List<IndexRange>? toAdd = null;
+
+                for (var i = 0; i < Ranges!.Count; ++i)
+                {
+                    var range = Ranges[i];
+
+                    // The range is after the inserted items, need to shift the range right
+                    if (range.End >= index)
+                    {
+                        int begin = range.Begin;
+
+                        // If the index left of newIndex is inside the range,
+                        // Split the range and remember the left piece to add later
+                        if (range.Contains(index - 1))
+                        {
+                            range.Split(index - 1, out var before, out _);
+                            (toAdd ??= new List<IndexRange>()).Add(before);
+                            begin = index;
+                        }
+
+                        // Shift the range to the right
+                        _ranges[i] = new IndexRange(begin + count, range.End + count);
+                        shifted = true;
+                    }
+                }
+
+                if (toAdd is object)
+                {
+                    foreach (var range in toAdd)
+                    {
+                        IndexRange.Add(_ranges, range);
+                    }
+                }
+            }
+
+            return new CollectionChangeState
+            {
+                ShiftIndex = index,
+                ShiftDelta = shifted ? count : 0,
+            };
+        }
+
+        private protected virtual CollectionChangeState OnItemsRemoved(int index, IList items)
+        {
+            var count = items.Count;
+            var removedRange = new IndexRange(index, index + count - 1);
+            bool shifted = false;
+            List<T>? removed = null;
+
+            if (_ranges is object)
+            {
+                var deselected = new List<IndexRange>();
+
+                if (IndexRange.Remove(_ranges, removedRange, deselected) > 0)
+                {
+                    removed = new List<T>();
+
+                    foreach (var range in deselected)
+                    {
+                        for (var i = range.Begin; i <= range.End; ++i)
+                        {
+#pragma warning disable CS8604
+                            removed.Add((T)items[i - index]);
+#pragma warning restore CS8604
+                        }
+                    }
+                }
+
+                for (var i = 0; i < Ranges!.Count; ++i)
+                {
+                    var existing = Ranges[i];
+
+                    if (existing.End > removedRange.Begin)
+                    {
+                        _ranges[i] = new IndexRange(existing.Begin - count, existing.End - count);
+                        shifted = true;
+                    }
+                }
+            }
+
+            return new CollectionChangeState
+            {
+                ShiftIndex = index,
+                ShiftDelta = shifted ? -count : 0,
+                RemovedItems = removed,
+            };
+        }
+
+        private protected virtual void OnSourceCollectionChanged(NotifyCollectionChangedEventArgs e)
+        {
+            var shiftDelta = 0;
+            var shiftIndex = -1;
+            List<T>? removed = null;
+
+            switch (e.Action)
+            {
+                case NotifyCollectionChangedAction.Add:
+                    {
+                        var change = OnItemsAdded(e.NewStartingIndex, e.NewItems);
+                        shiftIndex = change.ShiftIndex;
+                        shiftDelta = change.ShiftDelta;
+                        break;
+                    }
+                case NotifyCollectionChangedAction.Remove:
+                    {
+                        var change = OnItemsRemoved(e.OldStartingIndex, e.OldItems);
+                        shiftIndex = change.ShiftIndex;
+                        shiftDelta = change.ShiftDelta;
+                        removed = change.RemovedItems;
+                        break;
+                    }
+                case NotifyCollectionChangedAction.Replace:
+                    {
+                        var removeChange = OnItemsRemoved(e.OldStartingIndex, e.OldItems);
+                        var addChange = OnItemsAdded(e.NewStartingIndex, e.NewItems);
+                        shiftIndex = removeChange.ShiftIndex;
+                        shiftDelta = removeChange.ShiftDelta + addChange.ShiftDelta;
+                        removed = removeChange.RemovedItems;
+                    }
+                    break;
+                case NotifyCollectionChangedAction.Reset:
+                    OnSourceReset();
+                    break;
+            }
+
+            if (shiftDelta != 0)
+            {
+                OnIndexesChanged(shiftIndex, shiftDelta);
+            }
+
+            if (removed is object)
+            {
+                OnSelectionChanged(removed);
+            }
+        }
+
+        private protected struct CollectionChangeState
+        {
+            public int ShiftIndex;
+            public int ShiftDelta;
+            public List<T>? RemovedItems;
+        }
+    }
+}

+ 135 - 0
src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs

@@ -0,0 +1,135 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Runtime.CompilerServices;
+using Avalonia.Threading;
+using Avalonia.Utilities;
+
+#nullable enable
+
+namespace Avalonia.Controls.Utils
+{
+    internal interface ICollectionChangedListener
+    {
+        void PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e);
+        void Changed(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e);
+        void PostChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e);
+    }
+
+    internal class CollectionChangedEventManager : IWeakSubscriber<NotifyCollectionChangedEventArgs>
+    {
+        public static CollectionChangedEventManager Instance { get; } = new CollectionChangedEventManager();
+
+        private ConditionalWeakTable<INotifyCollectionChanged, List<WeakReference<ICollectionChangedListener>>> _entries =
+            new ConditionalWeakTable<INotifyCollectionChanged, List<WeakReference<ICollectionChangedListener>>>();
+
+        private CollectionChangedEventManager()
+        {
+        }
+
+        public void AddListener(INotifyCollectionChanged collection, ICollectionChangedListener listener)
+        {
+            collection = collection ?? throw new ArgumentNullException(nameof(collection));
+            listener = listener ?? throw new ArgumentNullException(nameof(listener));
+            Dispatcher.UIThread.VerifyAccess();
+
+            if (!_entries.TryGetValue(collection, out var listeners))
+            {
+                listeners = new List<WeakReference<ICollectionChangedListener>>();
+                _entries.Add(collection, listeners);
+                WeakSubscriptionManager.Subscribe(
+                    collection,
+                    nameof(INotifyCollectionChanged.CollectionChanged),
+                    this);
+            }
+
+            //if (listeners.Contains(listener))
+            //{
+            //    throw new InvalidOperationException(
+            //        "Collection listener already added for this collection/listener combination.");
+            //}
+
+            listeners.Add(new WeakReference<ICollectionChangedListener>(listener));
+        }
+
+        public void RemoveListener(INotifyCollectionChanged collection, ICollectionChangedListener listener)
+        {
+            collection = collection ?? throw new ArgumentNullException(nameof(collection));
+            listener = listener ?? throw new ArgumentNullException(nameof(listener));
+            Dispatcher.UIThread.VerifyAccess();
+
+            if (_entries.TryGetValue(collection, out var listeners))
+            {
+                for (var i = 0; i < listeners.Count; ++i)
+                {
+                    if (listeners[i].TryGetTarget(out var target) && target == listener)
+                    {
+                        listeners.RemoveAt(i);
+
+                        if (listeners.Count == 0)
+                        {
+                            WeakSubscriptionManager.Unsubscribe(
+                                collection,
+                                nameof(INotifyCollectionChanged.CollectionChanged),
+                                this);
+                            _entries.Remove(collection);
+                        }
+
+                        return;
+                    }
+                }
+            }
+
+            throw new InvalidOperationException(
+                "Collection listener not registered for this collection/listener combination.");
+        }
+
+        void IWeakSubscriber<NotifyCollectionChangedEventArgs>.OnEvent(object sender, NotifyCollectionChangedEventArgs e)
+        {
+            static void Notify(
+                INotifyCollectionChanged incc,
+                NotifyCollectionChangedEventArgs args,
+                List<WeakReference<ICollectionChangedListener>> listeners)
+            {
+                foreach (var l in listeners)
+                {
+                    if (l.TryGetTarget(out var target))
+                    {
+                        target.PreChanged(incc, args);
+                    }
+                }
+
+                foreach (var l in listeners)
+                {
+                    if (l.TryGetTarget(out var target))
+                    {
+                        target.Changed(incc, args);
+                    }
+                }
+
+                foreach (var l in listeners)
+                {
+                    if (l.TryGetTarget(out var target))
+                    {
+                        target.PostChanged(incc, args);
+                    }
+                }
+            }
+
+            if (sender is INotifyCollectionChanged incc && _entries.TryGetValue(incc, out var listeners))
+            {
+                if (Dispatcher.UIThread.CheckAccess())
+                {
+                    Notify(incc, e, listeners);
+                }
+                else
+                {
+                    var inccCapture = incc;
+                    var eCapture = e;
+                    var listenersCapture = listeners;
+                    Dispatcher.UIThread.Post(() => Notify(inccCapture, eCapture, listenersCapture));
+                }
+            }
+        }
+    }
+}

+ 1474 - 0
tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Multiple.cs

@@ -0,0 +1,1474 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using Avalonia.Collections;
+using Avalonia.Controls.Selection;
+using Xunit;
+
+#nullable enable
+
+namespace Avalonia.Controls.UnitTests.Selection
+{
+    public class SelectionModelTests_Multiple
+    {
+        public class No_Source
+        {
+            [Fact]
+            public void Can_Select_Multiple_Items_Before_Source_Assigned()
+            {
+                var target = CreateTarget(false);
+                var raised = 0;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    var index = raised switch
+                    {
+                        0 => 5,
+                        1 => 10,
+                        2 => 100,
+                        _ => throw new NotSupportedException(),
+                    };
+
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Equal(new[] { index }, e.SelectedIndexes);
+                    Assert.Equal(new string?[] { null }, e.SelectedItems);
+                    ++raised;
+                };
+
+                target.SelectedIndex = 5;
+                target.Select(10);
+                target.Select(100);
+
+                Assert.Equal(5, target.SelectedIndex);
+                Assert.Equal(new[] { 5, 10, 100 }, target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Equal(new string?[] { null, null, null }, target.SelectedItems);
+                Assert.Equal(3, raised);
+            }
+
+            [Fact]
+            public void Initializing_Source_Retains_Valid_Selection_And_Removes_Invalid()
+            {
+                var target = CreateTarget(false);
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+                target.Select(2);
+                target.Select(10);
+                target.Select(100);
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 10, 100 }, e.DeselectedIndexes);
+                    Assert.Equal(new string?[] { null, null }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                target.Source = new[] { "foo", "bar", "baz" };
+
+                Assert.Equal(1, target.SelectedIndex);
+                Assert.Equal(new[] { 1, 2 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Initializing_Source_Coerces_SelectedIndex()
+            {
+                var target = CreateTarget(false);
+                var raised = 0;
+
+                target.SelectedIndex = 100;
+                target.Select(2);
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.Source = new[] { "foo", "bar", "baz" };
+
+                Assert.Equal(2, target.SelectedIndex);
+                Assert.Equal(new[] { 2 }, target.SelectedIndexes);
+                Assert.Equal("baz", target.SelectedItem);
+                Assert.Equal(new[] { "baz" }, target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Initializing_Source_Doesnt_Raise_SelectionChanged_If_Selection_Valid()
+            {
+                var target = CreateTarget(false);
+                var raised = 0;
+
+                target.Select(1);
+                target.Select(2);
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    ++raised;
+                };
+
+                target.Source = new[] { "foo", "bar", "baz" };
+
+                Assert.Equal(0, raised);
+            }
+        }
+
+        public class SelectedIndex
+        {
+            [Fact]
+            public void SelectedIndex_Larger_Than_Source_Clears_Selection()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 1 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                target.SelectedIndex = 15;
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Negative_SelectedIndex_Is_Coerced_To_Minus_1()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectionChanged += (s, e) => ++raised;
+
+                target.SelectedIndex = -5;
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(0, raised);
+            }
+
+            [Fact]
+            public void Setting_SelectedIndex_Clears_Old_Selection()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 0;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 0 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "foo" }, e.DeselectedItems);
+                    Assert.Equal(new[] { 1 }, e.SelectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.SelectedItems);
+                    ++raised;
+                };
+
+                target.SelectedIndex = 1;
+
+                Assert.Equal(1, target.SelectedIndex);
+                Assert.Equal(new[] { 1 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar" }, target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void PropertyChanged_Is_Raised()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+                
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SelectedIndex = 1;
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class SelectedIndexes
+        {
+            [Fact]
+            public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndexes))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SelectedIndex = 1;
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class SelectedItem
+        {
+            [Fact]
+            public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedItem))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SelectedIndex = 1;
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class SelectedItems
+        {
+            [Fact]
+            public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedItems))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SelectedIndex = 1;
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class Select
+        {
+            [Fact]
+            public void Select_Sets_SelectedIndex_If_Previously_Unset()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.Select(1);
+
+                Assert.Equal(1, target.SelectedIndex);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Select_Adds_To_Selection()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 0;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Equal(new[] { 1 }, e.SelectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.SelectedItems);
+                    ++raised;
+                };
+
+                target.Select(1);
+
+                Assert.Equal(0, target.SelectedIndex);
+                Assert.Equal(new[] { 0, 1 }, target.SelectedIndexes);
+                Assert.Equal("foo", target.SelectedItem);
+                Assert.Equal(new[] { "foo", "bar" }, target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Select_With_Invalid_Index_Does_Nothing()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 0;
+
+                target.PropertyChanged += (s, e) => ++raised;
+                target.SelectionChanged += (s, e) => ++raised;
+
+                target.Select(15);
+
+                Assert.Equal(0, target.SelectedIndex);
+                Assert.Equal(new[] { 0 }, target.SelectedIndexes);
+                Assert.Equal("foo", target.SelectedItem);
+                Assert.Equal(new[] { "foo" }, target.SelectedItems);
+                Assert.Equal(0, raised);
+            }
+
+            [Fact]
+            public void Selecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.Select(2);
+                target.SelectionChanged += (s, e) => ++raised;
+                target.Select(2);
+
+                Assert.Equal(0, raised);
+            }
+        }
+
+        public class SelectRange
+        {
+            [Fact]
+            public void SelectRange_Selects_Items()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Equal(new[] { 1, 2 }, e.SelectedIndexes);
+                    Assert.Equal(new[] { "bar", "baz" }, e.SelectedItems);
+                    ++raised;
+                };
+
+                target.SelectRange(1, 2);
+
+                Assert.Equal(1, target.SelectedIndex);
+                Assert.Equal(new[] { 1, 2 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems);
+                Assert.Equal(1, target.AnchorIndex);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void SelectRange_Ignores_Out_Of_Bounds_Items()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Equal(new[] { 11, 12 }, e.SelectedIndexes);
+                    Assert.Equal(new[] { "xyzzy", "thud" }, e.SelectedItems);
+                    ++raised;
+                };
+
+                target.SelectRange(11, 20);
+
+                Assert.Equal(11, target.SelectedIndex);
+                Assert.Equal(new[] { 11, 12 }, target.SelectedIndexes);
+                Assert.Equal("xyzzy", target.SelectedItem);
+                Assert.Equal(new[] { "xyzzy", "thud" }, target.SelectedItems);
+                Assert.Equal(11, target.AnchorIndex);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void SelectRange_Does_Nothing_For_Non_Intersecting_Range()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectionChanged += (s, e) => ++raised;
+
+                target.SelectRange(18, 30);
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Equal(-1, target.AnchorIndex);
+                Assert.Equal(0, raised);
+            }
+        }
+
+        public class Deselect
+        {
+            [Fact]
+            public void Deselect_Clears_Selected_Item()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 0;
+                target.Select(1);
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 1 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                target.Deselect(1);
+
+                Assert.Equal(0, target.SelectedIndex);
+                Assert.Equal(new[] { 0 }, target.SelectedIndexes);
+                Assert.Equal("foo", target.SelectedItem);
+                Assert.Equal(new[] { "foo" }, target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Deselect_Updates_SelectedItem_To_First_Selected_Item()
+            {
+                var target = CreateTarget();
+
+                target.SelectRange(3, 5);
+                target.Deselect(3);
+
+                Assert.Equal(4, target.SelectedIndex);
+            }
+        }
+
+        public class DeselectRange
+        {
+            [Fact]
+            public void DeselectRange_Clears_Identical_Range()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectRange(1, 2);
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 1, 2 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "bar", "baz" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                target.DeselectRange(1, 2);
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void DeselectRange_Clears_Intersecting_Range()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectRange(1, 2);
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 1 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                target.DeselectRange(0, 1);
+
+                Assert.Equal(2, target.SelectedIndex);
+                Assert.Equal(new[] { 2 }, target.SelectedIndexes);
+                Assert.Equal("baz", target.SelectedItem);
+                Assert.Equal(new[] { "baz" }, target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void DeselectRange_Does_Nothing_For_Nonintersecting_Range()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 0;
+                target.SelectionChanged += (s, e) => ++raised;
+                target.DeselectRange(1, 2);
+
+                Assert.Equal(0, target.SelectedIndex);
+                Assert.Equal(new[] { 0 }, target.SelectedIndexes);
+                Assert.Equal("foo", target.SelectedItem);
+                Assert.Equal(new[] { "foo" }, target.SelectedItems);
+                Assert.Equal(0, raised);
+            }
+        }
+
+        public class Clear
+        {
+            [Fact]
+            public void Clear_Raises_SelectionChanged()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.Select(1);
+                target.Select(2);
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 1, 2 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "bar", "baz" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                target.Clear();
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class AnchorIndex
+        {
+            [Fact]
+            public void Setting_SelectedIndex_Sets_AnchorIndex()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.AnchorIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SelectedIndex = 1;
+
+                Assert.Equal(1, target.AnchorIndex);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Setting_SelectedIndex_To_Minus_1_Doesnt_Clear_AnchorIndex()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.AnchorIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SelectedIndex = -1;
+
+                Assert.Equal(1, target.AnchorIndex);
+                Assert.Equal(0, raised);
+            }
+
+            [Fact]
+            public void Select_Sets_AnchorIndex()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.AnchorIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.Select(1);
+
+                Assert.Equal(1, target.AnchorIndex);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void SelectRange_Doesnt_Overwrite_AnchorIndex()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.AnchorIndex = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.AnchorIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SelectRange(1, 2);
+
+                Assert.Equal(0, target.AnchorIndex);
+                Assert.Equal(0, raised);
+            }
+
+            [Fact]
+            public void Deselect_Doesnt_Clear_AnchorIndex()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.Select(0);
+                target.Select(1);
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.AnchorIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.Deselect(1);
+
+                Assert.Equal(1, target.AnchorIndex);
+                Assert.Equal(0, raised);
+            }
+        }
+
+        public class CollectionChanges
+        {
+            [Fact]
+            public void Adding_Item_Before_Selected_Item_Updates_Indexes()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var indexesChangedraised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.SelectionChanged += (s, e) => ++selectionChangedRaised;
+
+                target.IndexesChanged += (s, e) =>
+                {
+                    Assert.Equal(0, e.StartIndex);
+                    Assert.Equal(1, e.Delta);
+                    ++indexesChangedraised;
+                };
+
+                data.Insert(0, "new");
+
+                Assert.Equal(2, target.SelectedIndex);
+                Assert.Equal(new[] { 2 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar" }, target.SelectedItems);
+                Assert.Equal(2, target.AnchorIndex);
+                Assert.Equal(1, indexesChangedraised);
+                Assert.Equal(0, selectionChangedRaised);
+            }
+
+            [Fact]
+            public void Adding_Item_After_Selected_Doesnt_Raise_Events()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.PropertyChanged += (s, e) => ++raised;
+                target.SelectionChanged += (s, e) => ++raised;
+                target.IndexesChanged += (s, e) => ++raised;
+
+                data.Insert(2, "new");
+
+                Assert.Equal(1, target.SelectedIndex);
+                Assert.Equal(new[] { 1 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar" }, target.SelectedItems);
+                Assert.Equal(1, target.AnchorIndex);
+                Assert.Equal(0, raised);
+            }
+
+            [Fact]
+            public void Adding_Item_At_Beginning_Of_SelectedRange_Updates_Indexes()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var indexesChangedraised = 0;
+
+                target.SelectRange(4, 8);
+
+                target.SelectionChanged += (s, e) => ++selectionChangedRaised;
+
+                target.IndexesChanged += (s, e) =>
+                {
+                    Assert.Equal(4, e.StartIndex);
+                    Assert.Equal(2, e.Delta);
+                    ++indexesChangedraised;
+                };
+
+                data.InsertRange(4, new[] { "frank", "tank" });
+
+                Assert.Equal(6, target.SelectedIndex);
+                Assert.Equal(new[] { 6, 7, 8, 9, 10 }, target.SelectedIndexes);
+                Assert.Equal("quux", target.SelectedItem);
+                Assert.Equal(new[] { "quux", "corge", "grault", "garply", "waldo" }, target.SelectedItems);
+                Assert.Equal(6, target.AnchorIndex);
+                Assert.Equal(1, indexesChangedraised);
+                Assert.Equal(0, selectionChangedRaised);
+            }
+
+            [Fact]
+            public void Adding_Item_At_End_Of_SelectedRange_Updates_Indexes()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var indexesChangedraised = 0;
+
+                target.SelectRange(4, 8);
+
+                target.SelectionChanged += (s, e) => ++selectionChangedRaised;
+
+                target.IndexesChanged += (s, e) =>
+                {
+                    Assert.Equal(8, e.StartIndex);
+                    Assert.Equal(2, e.Delta);
+                    ++indexesChangedraised;
+                };
+
+                data.InsertRange(8, new[] { "frank", "tank" });
+
+                Assert.Equal(4, target.SelectedIndex);
+                Assert.Equal(new[] { 4, 5, 6, 7, 10 }, target.SelectedIndexes);
+                Assert.Equal("quux", target.SelectedItem);
+                Assert.Equal(new[] { "quux", "corge", "grault", "garply", "waldo" }, target.SelectedItems);
+                Assert.Equal(4, target.AnchorIndex);
+                Assert.Equal(1, indexesChangedraised);
+                Assert.Equal(0, selectionChangedRaised);
+            }
+
+            [Fact]
+            public void Adding_Item_In_Middle_Of_SelectedRange_Updates_Indexes()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var indexesChangedraised = 0;
+
+                target.SelectRange(4, 8);
+
+                target.SelectionChanged += (s, e) => ++selectionChangedRaised;
+
+                target.IndexesChanged += (s, e) =>
+                {
+                    Assert.Equal(6, e.StartIndex);
+                    Assert.Equal(2, e.Delta);
+                    ++indexesChangedraised;
+                };
+
+                data.InsertRange(6, new[] { "frank", "tank" });
+
+                Assert.Equal(4, target.SelectedIndex);
+                Assert.Equal(new[] { 4, 5, 8, 9, 10 }, target.SelectedIndexes);
+                Assert.Equal("quux", target.SelectedItem);
+                Assert.Equal(new[] { "quux", "corge", "grault", "garply", "waldo" }, target.SelectedItems);
+                Assert.Equal(4, target.AnchorIndex);
+                Assert.Equal(1, indexesChangedraised);
+                Assert.Equal(0, selectionChangedRaised);
+            }
+
+            [Fact]
+            public void Removing_Selected_Item_Updates_State()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var selectedIndexRaised = 0;
+
+                target.Source = data;
+                target.Select(1);
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++selectedIndexRaised;
+                    }
+                };
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++selectionChangedRaised;
+                };
+
+                data.RemoveAt(1);
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(-1, target.AnchorIndex);
+                Assert.Equal(1, selectionChangedRaised);
+                Assert.Equal(1, selectedIndexRaised);
+            }
+
+            [Fact]
+            public void Removing_Item_Before_Selected_Item_Updates_Indexes()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var indexesChangedraised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.SelectionChanged += (s, e) => ++selectionChangedRaised;
+
+                target.IndexesChanged += (s, e) =>
+                {
+                    Assert.Equal(0, e.StartIndex);
+                    Assert.Equal(-1, e.Delta);
+                    ++indexesChangedraised;
+                };
+
+                data.RemoveAt(0);
+
+                Assert.Equal(0, target.SelectedIndex);
+                Assert.Equal(new[] { 0 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar" }, target.SelectedItems);
+                Assert.Equal(0, target.AnchorIndex);
+                Assert.Equal(1, indexesChangedraised);
+                Assert.Equal(0, selectionChangedRaised);
+            }
+
+            [Fact]
+            public void Removing_Item_After_Selected_Doesnt_Raise_Events()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.PropertyChanged += (s, e) => ++raised;
+                target.SelectionChanged += (s, e) => ++raised;
+                target.IndexesChanged += (s, e) => ++raised;
+
+                data.RemoveAt(2);
+
+                Assert.Equal(1, target.SelectedIndex);
+                Assert.Equal(new[] { 1 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar" }, target.SelectedItems);
+                Assert.Equal(1, target.AnchorIndex);
+                Assert.Equal(0, raised);
+            }
+
+            [Fact]
+            public void Removing_Selected_Range_Raises_Events()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var selectedIndexRaised = 0;
+
+                target.Source = data;
+                target.SelectRange(4, 8);
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++selectedIndexRaised;
+                    }
+                };
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Equal(new[] { "quux", "corge", "grault", "garply", "waldo" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++selectionChangedRaised;
+                };
+
+                data.RemoveRange(4, 5);
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(-1, target.AnchorIndex);
+                Assert.Equal(1, selectionChangedRaised);
+                Assert.Equal(1, selectedIndexRaised);
+            }
+
+            [Fact]
+            public void Removing_Partial_Selected_Range_Raises_Events_1()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var selectedIndexRaised = 0;
+
+                target.Source = data;
+                target.SelectRange(4, 8);
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++selectedIndexRaised;
+                    }
+                };
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Equal(new[] { "quux", "corge", "grault" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++selectionChangedRaised;
+                };
+
+                data.RemoveRange(0, 7);
+
+                Assert.Equal(0, target.SelectedIndex);
+                Assert.Equal(new[] { 0, 1 }, target.SelectedIndexes);
+                Assert.Equal("garply", target.SelectedItem);
+                Assert.Equal(new[] { "garply", "waldo" }, target.SelectedItems);
+                Assert.Equal(0, target.AnchorIndex);
+                Assert.Equal(1, selectionChangedRaised);
+                Assert.Equal(1, selectedIndexRaised);
+            }
+
+            [Fact]
+            public void Removing_Partial_Selected_Range_Raises_Events_2()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var selectedIndexRaised = 0;
+
+                target.Source = data;
+                target.SelectRange(4, 8);
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++selectedIndexRaised;
+                    }
+                };
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Equal(new[] { "garply", "waldo" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++selectionChangedRaised;
+                };
+
+                data.RemoveRange(7, 3);
+
+                Assert.Equal(4, target.SelectedIndex);
+                Assert.Equal(new[] { 4, 5, 6 }, target.SelectedIndexes);
+                Assert.Equal("quux", target.SelectedItem);
+                Assert.Equal(new[] { "quux", "corge", "grault" }, target.SelectedItems);
+                Assert.Equal(4, target.AnchorIndex);
+                Assert.Equal(1, selectionChangedRaised);
+                Assert.Equal(0, selectedIndexRaised);
+            }
+
+            [Fact]
+            public void Removing_Partial_Selected_Range_Raises_Events_3()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var selectedIndexRaised = 0;
+
+                target.Source = data;
+                target.SelectRange(4, 8);
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++selectedIndexRaised;
+                    }
+                };
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Equal(new[] { "corge", "grault", "garply" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++selectionChangedRaised;
+                };
+
+                data.RemoveRange(5, 3);
+
+                Assert.Equal(4, target.SelectedIndex);
+                Assert.Equal(new[] { 4, 5 }, target.SelectedIndexes);
+                Assert.Equal("quux", target.SelectedItem);
+                Assert.Equal(new[] { "quux", "waldo" }, target.SelectedItems);
+                Assert.Equal(4, target.AnchorIndex);
+                Assert.Equal(1, selectionChangedRaised);
+                Assert.Equal(0, selectedIndexRaised);
+            }
+
+            [Fact]
+            public void Replacing_Selected_Item_Updates_State()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var selectedIndexRaised = 0;
+                var indexesChangedRaised = 0;
+
+                target.Source = data;
+                target.SelectRange(1, 4);
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++selectedIndexRaised;
+                    }
+                };
+
+                target.IndexesChanged += (s, e) => ++indexesChangedRaised;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++selectionChangedRaised;
+                };
+
+                data[1] = "new";
+
+                Assert.Equal(2, target.SelectedIndex);
+                Assert.Equal(new[] { 2, 3, 4 }, target.SelectedIndexes);
+                Assert.Equal("baz", target.SelectedItem);
+                Assert.Equal(new[] { "baz", "qux", "quux" }, target.SelectedItems);
+                Assert.Equal(2, target.AnchorIndex);
+                Assert.Equal(1, selectionChangedRaised);
+                Assert.Equal(1, selectedIndexRaised);
+                Assert.Equal(0, indexesChangedRaised);
+            }
+
+            [Fact]
+            public void Resetting_Source_Updates_State()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var selectedIndexRaised = 0;
+                var resetRaised = 0;
+
+                target.Source = data;
+                target.Select(1);
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++selectedIndexRaised;
+                    }
+                };
+
+                target.SelectionChanged += (s, e) => ++selectionChangedRaised;
+                target.SourceReset += (s, e) => ++resetRaised;
+
+                data.Clear();
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(-1, target.AnchorIndex);
+                Assert.Equal(0, selectionChangedRaised);
+                Assert.Equal(1, resetRaised);
+                Assert.Equal(1, selectedIndexRaised);
+            }
+        }
+
+        public class BatchUpdate
+        {
+            [Fact]
+            public void Correctly_Batches_Selects()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Equal(new[] { 2, 3 }, e.SelectedIndexes);
+                    Assert.Equal(new[] { "baz", "qux" }, e.SelectedItems);
+                    ++raised;
+                };
+
+                using (target.BatchUpdate())
+                {
+                    target.Select(2);
+                    target.Select(3);
+                }
+
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Correctly_Batches_SelectRanges()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Equal(new[] { 2, 3, 5, 6 }, e.SelectedIndexes);
+                    Assert.Equal(new[] { "baz", "qux", "corge", "grault" }, e.SelectedItems);
+                    ++raised;
+                };
+
+                using (target.BatchUpdate())
+                {
+                    target.SelectRange(2, 3);
+                    target.SelectRange(5, 6);
+                }
+
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Correctly_Batches_Select_Deselect()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Equal(new[] { 2, 3 }, e.SelectedIndexes);
+                    Assert.Equal(new[] { "baz", "qux" }, e.SelectedItems);
+                    ++raised;
+                };
+
+                using (target.BatchUpdate())
+                {
+                    target.Select(2);
+                    target.Select(3);
+                    target.Select(4);
+                    target.Deselect(4);
+                }
+
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Correctly_Batches_Deselect_Select()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectRange(2, 8);
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 2, 3 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "baz", "qux" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                using (target.BatchUpdate())
+                {
+                    target.Deselect(2);
+                    target.Deselect(3);
+                    target.Deselect(4);
+                    target.Select(4);
+                }
+
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Correctly_Batches_Select_Deselect_Range()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Equal(new[] { 2, 3 }, e.SelectedIndexes);
+                    Assert.Equal(new[] { "baz", "qux" }, e.SelectedItems);
+                    ++raised;
+                };
+
+                using (target.BatchUpdate())
+                {
+                    target.SelectRange(2, 6);
+                    target.DeselectRange(4, 8);
+                }
+
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Correctly_Batches_Deselect_Select_Range()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectRange(2, 8);
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 2, 3 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "baz", "qux" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                using (target.BatchUpdate())
+                {
+                    target.DeselectRange(2, 6);
+                    target.SelectRange(4, 8);
+                }
+
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Correctly_Batches_Clear_Select()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectRange(2, 3);
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 3 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "qux" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                using (target.BatchUpdate())
+                {
+                    target.Clear();
+                    target.Select(2);
+                }
+
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Correctly_Batches_Clear_SelectedIndex()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectRange(2, 3);
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 3 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "qux" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                using (target.BatchUpdate())
+                {
+                    target.Clear();
+                    target.SelectedIndex = 2;
+                }
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class LostSelection
+        {
+            [Fact]
+            public void Can_Select_First_Item_On_LostSelection()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 1 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.DeselectedItems);
+                    Assert.Equal(new[] { 0 }, e.SelectedIndexes);
+                    Assert.Equal(new[] { "foo" }, e.SelectedItems);
+                    ++raised;
+                };
+
+                target.LostSelection += (s, e) =>
+                {
+                    target.Select(0);
+                };
+
+                target.Clear();
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class SourceReset
+        {
+            [Fact]
+            public void Can_Restore_Selection_In_SourceReset_Event()
+            {
+                var data = new ResettingList<string> { "foo", "bar", "baz" };
+                var target = CreateTarget(createData: false);
+                var sourceResetRaised = 0;
+                var selectionChangedRaised = 0;
+
+                target.Source = data;
+                target.SelectedIndex = 1;
+
+                target.SourceReset += (s, e) =>
+                {
+                    target.SelectedIndex = data.IndexOf("bar");
+                    ++sourceResetRaised;
+                };
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Equal(new[] { 3 }, e.SelectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.SelectedItems);
+                    ++selectionChangedRaised;
+                };
+
+                data.Reset(new[] { "qux", "foo", "quux", "bar", "baz" });
+
+                Assert.Equal(3, target.SelectedIndex);
+                Assert.Equal(1, selectionChangedRaised);
+                Assert.Equal(1, sourceResetRaised);
+            }
+        }
+
+        private static SelectionModel<string?> CreateTarget(bool createData = true)
+        {
+            var result = new SelectionModel<string?> { SingleSelect = false };
+
+            if (createData)
+            {
+                result.Source = new AvaloniaList<string>
+                {
+                    "foo",
+                    "bar",
+                    "baz",
+                    "qux",
+                    "quux",
+                    "corge",
+                    "grault",
+                    "garply",
+                    "waldo",
+                    "fred",
+                    "plugh",
+                    "xyzzy",
+                    "thud"
+                };
+            }
+
+            return result;
+        }
+
+        private class ResettingList<T> : List<T>, INotifyCollectionChanged
+        {
+            public event NotifyCollectionChangedEventHandler? CollectionChanged;
+
+            public void Reset(IEnumerable<T>? items = null)
+            {
+                if (items != null)
+                {
+                    Clear();
+                    AddRange(items);
+                }
+
+                CollectionChanged?.Invoke(
+                    this,
+                    new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+            }
+        }
+
+    }
+}

+ 1021 - 0
tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Single.cs

@@ -0,0 +1,1021 @@
+using System;
+using System.Collections.Specialized;
+using Avalonia.Collections;
+using Avalonia.Controls.Selection;
+using Avalonia.Controls.Utils;
+using Xunit;
+
+#nullable enable
+
+namespace Avalonia.Controls.UnitTests.Selection
+{
+    public class SelectionModelTests_Single
+    {
+        public class Source
+        {
+            [Fact]
+            public void Can_Select_Item_Before_Source_Assigned()
+            {
+                var target = CreateTarget(false);
+                var raised = 0;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Equal(new[] { 5 }, e.SelectedIndexes);
+                    Assert.Equal(new string?[] { null }, e.SelectedItems);
+                    ++raised;
+                };
+
+                target.SelectedIndex = 5;
+
+                Assert.Equal(5, target.SelectedIndex);
+                Assert.Equal(new[] { 5 }, target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Equal(new string?[] { null }, target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Initializing_Source_Retains_Valid_Selection()
+            {
+                var target = CreateTarget(false);
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.SelectionChanged += (s, e) => ++raised;
+
+                target.Source = new[] { "foo", "bar", "baz" };
+
+                Assert.Equal(1, target.SelectedIndex);
+                Assert.Equal(new[] { 1 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar" }, target.SelectedItems);
+                Assert.Equal(0, raised);
+            }
+
+            [Fact]
+            public void Initializing_Source_Removes_Invalid_Selection()
+            {
+                var target = CreateTarget(false);
+                var raised = 0;
+
+                target.SelectedIndex = 5;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 5 }, e.DeselectedIndexes);
+                    Assert.Equal(new string?[] { null }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                target.Source = new[] { "foo", "bar", "baz" };
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Changing_Source_First_Clears_Old_Selection()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 2;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 2 }, e.DeselectedIndexes);
+                    Assert.Equal(new string?[] { "baz" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                target.Source = new[] { "qux", "quux", "corge" };
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Raises_PropertyChanged()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.Source))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.Source = new[] { "qux", "quux", "corge" };
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class SelectedIndex
+        {
+            [Fact]
+            public void SelectedIndex_Larger_Than_Source_Clears_Selection()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 1 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                target.SelectedIndex = 5;
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Negative_SelectedIndex_Is_Coerced_To_Minus_1()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectionChanged += (s, e) => ++raised;
+
+                target.SelectedIndex = -5;
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(0, raised);
+            }
+
+            [Fact]
+            public void Setting_SelectedIndex_Clears_Old_Selection()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 0;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 0 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "foo" }, e.DeselectedItems);
+                    Assert.Equal(new[] { 1 }, e.SelectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.SelectedItems);
+                    ++raised;
+                };
+
+                target.SelectedIndex = 1;
+
+                Assert.Equal(1, target.SelectedIndex);
+                Assert.Equal(new[] { 1 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar" }, target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Setting_SelectedIndex_During_CollectionChanged_Results_In_Correct_Selection()
+            {
+                // Issue #4496
+                var data = new AvaloniaList<string>();
+                var target = CreateTarget();
+                var binding = new MockBinding(target, data);
+
+                target.Source = data;
+
+                data.Add("foo");
+
+                Assert.Equal(0, target.SelectedIndex);
+            }
+
+            [Fact]
+            public void PropertyChanged_Is_Raised()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SelectedIndex = 1;
+
+                Assert.Equal(1, raised);
+            }
+
+            private class MockBinding : ICollectionChangedListener
+            {
+                private readonly SelectionModel<string?> _target;
+
+                public MockBinding(SelectionModel<string?> target, AvaloniaList<string> data)
+                {
+                    _target = target;
+                    Avalonia.Controls.Utils.CollectionChangedEventManager.Instance.AddListener(data, this);
+                }
+
+                public void Changed(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)
+                {
+                    _target.Select(0);
+                }
+
+                public void PostChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)
+                {
+                }
+
+                public void PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)
+                {
+                }
+            }
+        }
+
+        public class SelectedItem
+        {
+            [Fact]
+            public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedItem))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SelectedIndex = 1;
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class SelectedIndexes
+        {
+            [Fact]
+            public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndexes))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SelectedIndex = 1;
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class SelectedItems
+        {
+            [Fact]
+            public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedItems))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SelectedIndex = 1;
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class Select
+        {
+            [Fact]
+            public void Select_Sets_SelectedIndex()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.Select(1);
+
+                Assert.Equal(1, target.SelectedIndex);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Select_Clears_Old_Selection()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 0;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 0 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "foo" }, e.DeselectedItems);
+                    Assert.Equal(new[] { 1 }, e.SelectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.SelectedItems);
+                    ++raised;
+                };
+
+                target.Select(1);
+
+                Assert.Equal(1, target.SelectedIndex);
+                Assert.Equal(new[] { 1 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar" }, target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Select_With_Invalid_Index_Does_Nothing()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 0;
+
+                target.PropertyChanged += (s, e) => ++raised;
+                target.SelectionChanged += (s, e) => ++raised;
+
+                target.Select(5);
+
+                Assert.Equal(0, target.SelectedIndex);
+                Assert.Equal(new[] { 0 }, target.SelectedIndexes);
+                Assert.Equal("foo", target.SelectedItem);
+                Assert.Equal(new[] { "foo" }, target.SelectedItems);
+                Assert.Equal(0, raised);
+            }
+
+            [Fact]
+            public void Selecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.Select(2);
+                target.SelectionChanged += (s, e) => ++raised;
+                target.Select(2);
+
+                Assert.Equal(0, raised);
+            }
+        }
+
+        public class SelectRange
+        {
+            [Fact]
+            public void SelectRange_Throws()
+            {
+                var target = CreateTarget();
+
+                Assert.Throws<InvalidOperationException>(() => target.SelectRange(0, 10));
+            }
+        }
+
+        public class Deselect
+        {
+            [Fact]
+            public void Deselect_Clears_Current_Selection()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 0;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 0 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "foo" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                target.Deselect(0);
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Deselect_Does_Nothing_For_Nonselected_Item()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+                target.SelectionChanged += (s, e) => ++raised;
+                target.Deselect(0);
+
+                Assert.Equal(1, target.SelectedIndex);
+                Assert.Equal(new[] { 1 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar" }, target.SelectedItems);
+                Assert.Equal(0, raised);
+            }
+        }
+
+        public class DeselectRange
+        {
+            [Fact]
+            public void DeselectRange_Clears_Current_Selection_For_Intersecting_Range()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 0;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 0 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "foo" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                target.DeselectRange(0, 2);
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void DeselectRange_Does_Nothing_For_Nonintersecting_Range()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 0;
+                target.SelectionChanged += (s, e) => ++raised;
+                target.DeselectRange(1, 2);
+
+                Assert.Equal(0, target.SelectedIndex);
+                Assert.Equal(new[] { 0 }, target.SelectedIndexes);
+                Assert.Equal("foo", target.SelectedItem);
+                Assert.Equal(new[] { "foo" }, target.SelectedItems);
+                Assert.Equal(0, raised);
+            }
+        }
+
+        public class Clear
+        {
+            [Fact]
+            public void Clear_Raises_SelectionChanged()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.Select(1);
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 1 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++raised;
+                };
+
+                target.Clear();
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class AnchorIndex
+        {
+            [Fact]
+            public void Setting_SelectedIndex_Sets_AnchorIndex()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.AnchorIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SelectedIndex = 1;
+
+                Assert.Equal(1, target.AnchorIndex);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Setting_SelectedIndex_To_Minus_1_Doesnt_Clear_AnchorIndex()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.AnchorIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SelectedIndex = -1;
+
+                Assert.Equal(1, target.AnchorIndex);
+                Assert.Equal(0, raised);
+            }
+
+            [Fact]
+            public void Select_Sets_AnchorIndex()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.AnchorIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.Select(1);
+
+                Assert.Equal(1, target.AnchorIndex);
+                Assert.Equal(1, raised);
+            }
+
+            [Fact]
+            public void Deselect_Doesnt_Clear_AnchorIndex()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.Select(1);
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.AnchorIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.Deselect(1);
+
+                Assert.Equal(1, target.AnchorIndex);
+                Assert.Equal(0, raised);
+            }
+
+            [Fact]
+            public void Raises_PropertyChanged()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.AnchorIndex))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SelectedIndex = 1;
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class SingleSelect
+        {
+            [Fact]
+            public void Converting_To_Multiple_Selection_Preserves_Selection()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.SelectionChanged += (s, e) => ++raised;
+
+                target.SingleSelect = false;
+
+                Assert.Equal(1, target.SelectedIndex);
+                Assert.Equal(new[] { 1 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar" }, target.SelectedItems);
+                Assert.Equal(0, raised);
+            }
+
+            [Fact]
+            public void Raises_PropertyChanged()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SingleSelect))
+                    {
+                        ++raised;
+                    }
+                };
+
+                target.SingleSelect = false;
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class CollectionChanges
+        {
+            [Fact]
+            public void Adding_Item_Before_Selected_Item_Updates_Indexes()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var indexesChangedRaised = 0;
+                var selectedIndexRaised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.SelectionChanged += (s, e) => ++selectionChangedRaised;
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++selectedIndexRaised;
+                    }
+                };
+
+                target.IndexesChanged += (s, e) =>
+                {
+                    Assert.Equal(0, e.StartIndex);
+                    Assert.Equal(1, e.Delta);
+                    ++indexesChangedRaised;
+                };
+
+                data.Insert(0, "new");
+
+                Assert.Equal(2, target.SelectedIndex);
+                Assert.Equal(new[] { 2 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar" }, target.SelectedItems);
+                Assert.Equal(2, target.AnchorIndex);
+                Assert.Equal(1, indexesChangedRaised);
+                Assert.Equal(1, selectedIndexRaised);
+                Assert.Equal(0, selectionChangedRaised);
+            }
+
+            [Fact]
+            public void Adding_Item_After_Selected_Doesnt_Raise_Events()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.PropertyChanged += (s, e) => ++raised;
+                target.SelectionChanged += (s, e) => ++raised;
+                target.IndexesChanged += (s, e) => ++raised;
+
+                data.Insert(2, "new");
+
+                Assert.Equal(1, target.SelectedIndex);
+                Assert.Equal(new[] { 1 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar" }, target.SelectedItems);
+                Assert.Equal(1, target.AnchorIndex);
+                Assert.Equal(0, raised);
+            }
+
+            [Fact]
+            public void Removing_Selected_Item_Updates_State()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var selectedIndexRaised = 0;
+
+                target.Source = data;
+                target.Select(1);
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++selectedIndexRaised;
+                    }
+                };
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++selectionChangedRaised;
+                };
+
+                data.RemoveAt(1);
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(-1, target.AnchorIndex);
+                Assert.Equal(1, selectionChangedRaised);
+                Assert.Equal(1, selectedIndexRaised);
+            }
+
+            [Fact]
+            public void Removing_Item_Before_Selected_Item_Updates_Indexes()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var indexesChangedraised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.SelectionChanged += (s, e) => ++selectionChangedRaised;
+
+                target.IndexesChanged += (s, e) =>
+                {
+                    Assert.Equal(0, e.StartIndex);
+                    Assert.Equal(-1, e.Delta);
+                    ++indexesChangedraised;
+                };
+
+                data.RemoveAt(0);
+
+                Assert.Equal(0, target.SelectedIndex);
+                Assert.Equal(new[] { 0 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar" }, target.SelectedItems);
+                Assert.Equal(0, target.AnchorIndex);
+                Assert.Equal(1, indexesChangedraised);
+                Assert.Equal(0, selectionChangedRaised);
+            }
+
+            [Fact]
+            public void Removing_Item_After_Selected_Doesnt_Raise_Events()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.PropertyChanged += (s, e) => ++raised;
+                target.SelectionChanged += (s, e) => ++raised;
+                target.IndexesChanged += (s, e) => ++raised;
+
+                data.RemoveAt(2);
+
+                Assert.Equal(1, target.SelectedIndex);
+                Assert.Equal(new[] { 1 }, target.SelectedIndexes);
+                Assert.Equal("bar", target.SelectedItem);
+                Assert.Equal(new[] { "bar" }, target.SelectedItems);
+                Assert.Equal(1, target.AnchorIndex);
+                Assert.Equal(0, raised);
+            }
+
+            [Fact]
+            public void Replacing_Selected_Item_Updates_State()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var selectedIndexRaised = 0;
+
+                target.Source = data;
+                target.Select(1);
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++selectedIndexRaised;
+                    }
+                };
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Empty(e.DeselectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndexes);
+                    Assert.Empty(e.SelectedItems);
+                    ++selectionChangedRaised;
+                };
+
+                data[1] = "new";
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(-1, target.AnchorIndex);
+                Assert.Equal(1, selectionChangedRaised);
+                Assert.Equal(1, selectedIndexRaised);
+            }
+
+            [Fact]
+            public void Resetting_Source_Updates_State()
+            {
+                var target = CreateTarget();
+                var data = (AvaloniaList<string>)target.Source!;
+                var selectionChangedRaised = 0;
+                var selectedIndexRaised = 0;
+                var resetRaised = 0;
+
+                target.Source = data;
+                target.Select(1);
+
+                target.PropertyChanged += (s, e) =>
+                {
+                    if (e.PropertyName == nameof(target.SelectedIndex))
+                    {
+                        ++selectedIndexRaised;
+                    }
+                };
+
+                target.SelectionChanged += (s, e) => ++selectionChangedRaised;
+                target.SourceReset += (s, e) => ++resetRaised;
+
+                data.Clear();
+
+                Assert.Equal(-1, target.SelectedIndex);
+                Assert.Empty(target.SelectedIndexes);
+                Assert.Null(target.SelectedItem);
+                Assert.Empty(target.SelectedItems);
+                Assert.Equal(-1, target.AnchorIndex);
+                Assert.Equal(0, selectionChangedRaised);
+                Assert.Equal(1, resetRaised);
+                Assert.Equal(1, selectedIndexRaised);
+            }
+        }
+
+        public class BatchUpdate
+        {
+            [Fact]
+            public void Changes_Do_Not_Take_Effect_Until_EndUpdate_Called()
+            {
+                var target = CreateTarget();
+
+                target.BeginBatchUpdate();
+                target.Select(0);
+
+                Assert.Equal(-1, target.SelectedIndex);
+
+                target.EndBatchUpdate();
+
+                Assert.Equal(0, target.SelectedIndex);
+            }
+
+            [Fact]
+            public void Correctly_Batches_Clear_SelectedIndex()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 2;
+                target.SelectionChanged += (s, e) => ++raised;
+
+                using (target.BatchUpdate())
+                {
+                    target.Clear();
+                    target.SelectedIndex = 2;
+                }
+
+                Assert.Equal(0, raised);
+            }
+        }
+
+        public class LostSelection
+        {
+            [Fact]
+            public void Can_Select_First_Item_On_LostSelection()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+
+                target.SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 1 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.DeselectedItems);
+                    Assert.Equal(new[] { 0 }, e.SelectedIndexes);
+                    Assert.Equal(new[] { "foo" }, e.SelectedItems);
+                    ++raised;
+                };
+
+                target.LostSelection += (s, e) =>
+                {
+                    target.Select(0);
+                };
+
+                target.Clear();
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        public class UntypedInterface
+        {
+            [Fact]
+            public void Raises_Untyped_SelectionChanged_Event()
+            {
+                var target = CreateTarget();
+                var raised = 0;
+
+                target.SelectedIndex = 1;
+
+                ((ISelectionModel)target).SelectionChanged += (s, e) =>
+                {
+                    Assert.Equal(new[] { 1 }, e.DeselectedIndexes);
+                    Assert.Equal(new[] { "bar" }, e.DeselectedItems);
+                    Assert.Equal(new[] { 2 }, e.SelectedIndexes);
+                    Assert.Equal(new[] { "baz" }, e.SelectedItems);
+                    ++raised;
+                };
+
+                target.SelectedIndex = 2;
+
+                Assert.Equal(1, raised);
+            }
+        }
+
+        private static SelectionModel<string?> CreateTarget(bool createData = true)
+        {
+            var result = new SelectionModel<string?> { SingleSelect = true };
+
+            if (createData)
+            {
+                result.Source = new AvaloniaList<string> { "foo", "bar", "baz" };
+            }
+
+            return result;
+        }
+    }
+}