Browse Source

Merge pull request #3583 from AvaloniaUI/refactor/use-selectionmodel

Refactor selection in SelectingItemsControl and TreeView
Steven Kirk 5 years ago
parent
commit
ea712f3733
47 changed files with 7342 additions and 1091 deletions
  1. 2 2
      samples/BindingDemo/MainWindow.xaml
  2. 3 2
      samples/BindingDemo/ViewModels/MainWindowViewModel.cs
  3. 1 1
      samples/ControlCatalog/Pages/ListBoxPage.xaml
  4. 1 1
      samples/ControlCatalog/Pages/TreeViewPage.xaml
  5. 9 8
      samples/ControlCatalog/Pages/TreeViewPage.xaml.cs
  6. 1 1
      samples/VirtualizationDemo/MainWindow.xaml
  7. 6 8
      samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs
  8. 2 2
      src/Avalonia.Controls/ComboBox.cs
  9. 14 6
      src/Avalonia.Controls/Generators/TreeContainerIndex.cs
  10. 249 0
      src/Avalonia.Controls/ISelectionModel.cs
  11. 180 0
      src/Avalonia.Controls/IndexPath.cs
  12. 232 0
      src/Avalonia.Controls/IndexRange.cs
  13. 17 2
      src/Avalonia.Controls/ListBox.cs
  14. 1 1
      src/Avalonia.Controls/Presenters/IItemsPresenter.cs
  15. 2 2
      src/Avalonia.Controls/Presenters/ItemVirtualizer.cs
  16. 5 10
      src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs
  17. 5 10
      src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs
  18. 2 2
      src/Avalonia.Controls/Presenters/ItemsPresenter.cs
  19. 1 1
      src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs
  20. 246 593
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  21. 2 0
      src/Avalonia.Controls/Repeater/ItemsSourceView.cs
  22. 49 0
      src/Avalonia.Controls/SelectedItems.cs
  23. 848 0
      src/Avalonia.Controls/SelectionModel.cs
  24. 170 0
      src/Avalonia.Controls/SelectionModelChangeSet.cs
  25. 83 0
      src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs
  26. 47 0
      src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs
  27. 966 0
      src/Avalonia.Controls/SelectionNode.cs
  28. 110 0
      src/Avalonia.Controls/SelectionNodeOperation.cs
  29. 298 367
      src/Avalonia.Controls/TreeView.cs
  30. 227 0
      src/Avalonia.Controls/Utils/SelectedItemsSync.cs
  31. 189 0
      src/Avalonia.Controls/Utils/SelectionTreeHelper.cs
  32. 1 1
      src/Avalonia.Dialogs/ManagedFileChooser.xaml.cs
  33. 3 8
      tests/Avalonia.Controls.UnitTests/AppBuilderTests.cs
  34. 1 0
      tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj
  35. 1 0
      tests/Avalonia.Controls.UnitTests/ButtonTests.cs
  36. 3 3
      tests/Avalonia.Controls.UnitTests/CarouselTests.cs
  37. 95 0
      tests/Avalonia.Controls.UnitTests/IndexPathTests.cs
  38. 307 0
      tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs
  39. 4 4
      tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization_Simple.cs
  40. 11 29
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs
  41. 2 2
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs
  42. 325 7
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs
  43. 4 5
      tests/Avalonia.Controls.UnitTests/Primitives/TabStripTests.cs
  44. 2384 0
      tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs
  45. 2 4
      tests/Avalonia.Controls.UnitTests/TabControlTests.cs
  46. 8 9
      tests/Avalonia.Controls.UnitTests/TreeViewTests.cs
  47. 223 0
      tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs

+ 2 - 2
samples/BindingDemo/MainWindow.xaml

@@ -74,11 +74,11 @@
         </StackPanel.DataTemplates>
         <StackPanel Margin="18" Spacing="4" Width="200">
           <TextBlock FontSize="16" Text="Multiple"/>
-          <ListBox Items="{Binding Items}" SelectionMode="Multiple" SelectedItems="{Binding SelectedItems}"/>
+          <ListBox Items="{Binding Items}" SelectionMode="Multiple" Selection="{Binding Selection}"/>
         </StackPanel>
         <StackPanel Margin="18" Spacing="4" Width="200">
           <TextBlock FontSize="16" Text="Multiple"/>
-          <ListBox Items="{Binding Items}" SelectionMode="Multiple" SelectedItems="{Binding SelectedItems}"/>
+          <ListBox Items="{Binding Items}" SelectionMode="Multiple" Selection="{Binding Selection}"/>
         </StackPanel>
         <ContentControl Content="{Binding SelectedItems[0]}">
           <ContentControl.DataTemplates>

+ 3 - 2
samples/BindingDemo/ViewModels/MainWindowViewModel.cs

@@ -6,6 +6,7 @@ using System.Reactive.Linq;
 using System.Threading.Tasks;
 using System.Threading;
 using ReactiveUI;
+using Avalonia.Controls;
 
 namespace BindingDemo.ViewModels
 {
@@ -27,7 +28,7 @@ namespace BindingDemo.ViewModels
                     Detail = "Item " + x + " details",
                 }));
 
-            SelectedItems = new ObservableCollection<TestItem>();
+            Selection = new SelectionModel();
 
             ShuffleItems = ReactiveCommand.Create(() =>
             {
@@ -56,7 +57,7 @@ namespace BindingDemo.ViewModels
         }
 
         public ObservableCollection<TestItem> Items { get; }
-        public ObservableCollection<TestItem> SelectedItems { get; }
+        public SelectionModel Selection { get; }
         public ReactiveCommand<Unit, Unit> ShuffleItems { get; }
 
         public string BooleanString

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

@@ -10,7 +10,7 @@
               HorizontalAlignment="Center"
               Spacing="16">
       <StackPanel Orientation="Vertical" Spacing="8">
-        <ListBox Items="{Binding Items}" SelectedItem="{Binding SelectedItem}" AutoScrollToSelectedItem="True"  SelectedItems="{Binding SelectedItems}" SelectionMode="{Binding SelectionMode}" Width="250" Height="350"></ListBox>
+        <ListBox Items="{Binding Items}" SelectedItem="{Binding SelectedItem}" AutoScrollToSelectedItem="True"  SelectionMode="{Binding SelectionMode}" Width="250" Height="350"></ListBox>
 
         <Button Command="{Binding AddItemCommand}">Add</Button>
 

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

@@ -10,7 +10,7 @@
                 HorizontalAlignment="Center"
                 Spacing="16">
       <StackPanel Orientation="Vertical" Spacing="8">
-        <TreeView Items="{Binding Items}" SelectedItems="{Binding SelectedItems}" SelectionMode="{Binding SelectionMode}" Width="250" Height="350">
+        <TreeView Items="{Binding Items}" Selection="{Binding Selection}" SelectionMode="{Binding SelectionMode}" Width="250" Height="350">
           <TreeView.ItemTemplate>
             <TreeDataTemplate ItemsSource="{Binding Children}">
               <TextBlock Text="{Binding Header}"/>

+ 9 - 8
samples/ControlCatalog/Pages/TreeViewPage.xaml.cs

@@ -28,21 +28,22 @@ namespace ControlCatalog.Pages
             {
                 Node root = new Node();
                 Items = root.Children;
-                SelectedItems = new ObservableCollection<Node>();
+                Selection = new SelectionModel();
 
                 AddItemCommand = ReactiveCommand.Create(() =>
                 {
-                    Node parentItem = SelectedItems.Count > 0 ? SelectedItems[0] : root;
+                    Node parentItem = Selection.SelectedItems.Count > 0 ?
+                        (Node)Selection.SelectedItems[0] : root;
                     parentItem.AddNewItem();
                 });
 
                 RemoveItemCommand = ReactiveCommand.Create(() =>
                 {
-                    while (SelectedItems.Count > 0)
+                    while (Selection.SelectedItems.Count > 0)
                     {
-                        Node lastItem = SelectedItems[0];
+                        Node lastItem = (Node)Selection.SelectedItems[0];
                         RecursiveRemove(Items, lastItem);
-                        SelectedItems.Remove(lastItem);
+                        Selection.DeselectAt(Selection.SelectedIndices[0]);
                     }
 
                     bool RecursiveRemove(ObservableCollection<Node> items, Node selectedItem)
@@ -67,7 +68,7 @@ namespace ControlCatalog.Pages
 
             public ObservableCollection<Node> Items { get; }
 
-            public ObservableCollection<Node> SelectedItems { get; }
+            public SelectionModel Selection { get; }
 
             public ReactiveCommand<Unit, Unit> AddItemCommand { get; }
 
@@ -78,7 +79,7 @@ namespace ControlCatalog.Pages
                 get => _selectionMode;
                 set
                 {
-                    SelectedItems.Clear();
+                    Selection.ClearSelection();
                     this.RaiseAndSetIfChanged(ref _selectionMode, value);
                 }
             }
@@ -109,7 +110,7 @@ namespace ControlCatalog.Pages
 
             public override string ToString() => Header;
 
-            private Node CreateNewNode() => new Node {Header = $"Item {_counter++}"};
+            private Node CreateNewNode() => new Node { Header = $"Item {_counter++}" };
         }
     }
 }

+ 1 - 1
samples/VirtualizationDemo/MainWindow.xaml

@@ -45,7 +45,7 @@
 
         <ListBox Name="listBox" 
                  Items="{Binding Items}" 
-                 SelectedItems="{Binding SelectedItems}"
+                 Selection="{Binding Selection}"
                  SelectionMode="Multiple"
                  VirtualizationMode="{Binding VirtualizationMode}"
                  ScrollViewer.HorizontalScrollBarVisibility="{Binding HorizontalScrollBarVisibility, Mode=TwoWay}"

+ 6 - 8
samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs

@@ -48,8 +48,7 @@ namespace VirtualizationDemo.ViewModels
             set { this.RaiseAndSetIfChanged(ref _itemCount, value); }
         }
 
-        public AvaloniaList<ItemViewModel> SelectedItems { get; } 
-            = new AvaloniaList<ItemViewModel>();
+        public SelectionModel Selection { get; } = new SelectionModel();
 
         public AvaloniaList<ItemViewModel> Items
         {
@@ -138,9 +137,9 @@ namespace VirtualizationDemo.ViewModels
         {
             var index = Items.Count;
 
-            if (SelectedItems.Count > 0)
+            if (Selection.SelectedIndices.Count > 0)
             {
-                index = Items.IndexOf(SelectedItems[0]);
+                index = Selection.SelectedIndex.GetAt(0);
             }
 
             Items.Insert(index, new ItemViewModel(_newItemIndex++, NewItemString));
@@ -148,9 +147,9 @@ namespace VirtualizationDemo.ViewModels
 
         private void Remove()
         {
-            if (SelectedItems.Count > 0)
+            if (Selection.SelectedItems.Count > 0)
             {
-                Items.RemoveAll(SelectedItems);
+                Items.RemoveAll(Selection.SelectedItems.Cast<ItemViewModel>().ToList());
             }
         }
 
@@ -164,8 +163,7 @@ namespace VirtualizationDemo.ViewModels
 
         private void SelectItem(int index)
         {
-            SelectedItems.Clear();
-            SelectedItems.Add(Items[index]);
+            Selection.SelectedIndex = new IndexPath(index);
         }
     }
 }

+ 2 - 2
src/Avalonia.Controls/ComboBox.cs

@@ -306,9 +306,9 @@ namespace Avalonia.Controls
             {
                 var container = ItemContainerGenerator.ContainerFromIndex(selectedIndex);
 
-                if (container == null && SelectedItems.Count > 0)
+                if (container == null && SelectedIndex != -1)
                 {
-                    ScrollIntoView(SelectedItems[0]);
+                    ScrollIntoView(Selection.SelectedIndex);
                     container = ItemContainerGenerator.ContainerFromIndex(selectedIndex);
                 }
 

+ 14 - 6
src/Avalonia.Controls/Generators/TreeContainerIndex.cs

@@ -94,9 +94,13 @@ namespace Avalonia.Controls.Generators
         /// <returns>The container, or null of not found.</returns>
         public IControl ContainerFromItem(object item)
         {
-            IControl result;
-            _itemToContainer.TryGetValue(item, out result);
-            return result;
+            if (item != null)
+            {
+                _itemToContainer.TryGetValue(item, out var result);
+                return result;
+            }
+
+            return null;
         }
 
         /// <summary>
@@ -106,9 +110,13 @@ namespace Avalonia.Controls.Generators
         /// <returns>The item, or null of not found.</returns>
         public object ItemFromContainer(IControl container)
         {
-            object result;
-            _containerToItem.TryGetValue(container, out result);
-            return result;
+            if (container != null)
+            {
+                _containerToItem.TryGetValue(container, out var result);
+                return result;
+            }
+
+            return null;
         }
     }
 }

+ 249 - 0
src/Avalonia.Controls/ISelectionModel.cs

@@ -0,0 +1,249 @@
+// 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;
+using System.ComponentModel;
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Holds the selected items for a control.
+    /// </summary>
+    public interface ISelectionModel : INotifyPropertyChanged
+    {
+        /// <summary>
+        /// Gets or sets the anchor index.
+        /// </summary>
+        IndexPath AnchorIndex { get; set; }
+
+        /// <summary>
+        /// Gets or set the index of the first selected item.
+        /// </summary>
+        IndexPath SelectedIndex { get; set; }
+
+        /// <summary>
+        /// Gets or set the indexes of the selected items.
+        /// </summary>
+        IReadOnlyList<IndexPath> SelectedIndices { get; }
+
+        /// <summary>
+        /// Gets the first selected item.
+        /// </summary>
+        object SelectedItem { get; }
+
+        /// <summary>
+        /// Gets the selected items.
+        /// </summary>
+        IReadOnlyList<object> SelectedItems { get; }
+        
+        /// <summary>
+        /// Gets a value indicating whether the model represents a single or multiple selection.
+        /// </summary>
+        bool SingleSelect { get; set; }
+
+        /// <summary>
+        /// Gets a value indicating whether to always keep an item selected where possible.
+        /// </summary>
+        bool AutoSelect { get; set; }
+
+        /// <summary>
+        /// Gets or sets the collection that contains the items that can be selected.
+        /// </summary>
+        object Source { get; set; }
+
+        /// <summary>
+        /// Raised when the children of a selection are required.
+        /// </summary>
+        event EventHandler<SelectionModelChildrenRequestedEventArgs> ChildrenRequested;
+
+        /// <summary>
+        /// Raised when the selection has changed.
+        /// </summary>
+        event EventHandler<SelectionModelSelectionChangedEventArgs> SelectionChanged;
+
+        /// <summary>
+        /// Clears the selection.
+        /// </summary>
+        void ClearSelection();
+
+        /// <summary>
+        /// Deselects an item.
+        /// </summary>
+        /// <param name="index">The index of the item.</param>
+        void Deselect(int index);
+
+        /// <summary>
+        /// Deselects an item.
+        /// </summary>
+        /// <param name="groupIndex">The index of the item group.</param>
+        /// <param name="itemIndex">The index of the item in the group.</param>
+        void Deselect(int groupIndex, int itemIndex);
+
+        /// <summary>
+        /// Deselects an item.
+        /// </summary>
+        /// <param name="index">The index of the item.</param>
+        void DeselectAt(IndexPath index);
+
+        /// <summary>
+        /// Deselects a range of items.
+        /// </summary>
+        /// <param name="start">The start index of the range.</param>
+        /// <param name="end">The end index of the range.</param>
+        void DeselectRange(IndexPath start, IndexPath end);
+
+        /// <summary>
+        /// Deselects a range of items, starting at <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="index">The end index of the range.</param>
+        void DeselectRangeFromAnchor(int index);
+
+        /// <summary>
+        /// Deselects a range of items, starting at <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="endGroupIndex">
+        /// The index of the item group that represents the end of the selection.
+        /// </param>
+        /// <param name="endItemIndex">
+        /// The index of the item in the group that represents the end of the selection.
+        /// </param>
+        void DeselectRangeFromAnchor(int endGroupIndex, int endItemIndex);
+
+        /// <summary>
+        /// Deselects a range of items, starting at <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="index">The end index of the range.</param>
+        void DeselectRangeFromAnchorTo(IndexPath index);
+
+        /// <summary>
+        /// Disposes the object and clears the selection.
+        /// </summary>
+        void Dispose();
+
+        /// <summary>
+        /// Checks whether an item is selected.
+        /// </summary>
+        /// <param name="index">The index of the item</param>
+        bool IsSelected(int index);
+
+        /// <summary>
+        /// Checks whether an item is selected.
+        /// </summary>
+        /// <param name="groupIndex">The index of the item group.</param>
+        /// <param name="itemIndex">The index of the item in the group.</param>
+        bool IsSelected(int groupIndex, int itemIndex);
+
+        /// <summary>
+        /// Checks whether an item is selected.
+        /// </summary>
+        /// <param name="index">The index of the item</param>
+        public bool IsSelectedAt(IndexPath index);
+
+        /// <summary>
+        /// Checks whether an item or its descendents are selected.
+        /// </summary>
+        /// <param name="index">The index of the item</param>
+        /// <returns>
+        /// True if the item and all its descendents are selected, false if the item and all its
+        /// descendents are deselected, or null if a combination of selected and deselected.
+        /// </returns>
+        bool? IsSelectedWithPartial(int index);
+
+        /// <summary>
+        /// Checks whether an item or its descendents are selected.
+        /// </summary>
+        /// <param name="groupIndex">The index of the item group.</param>
+        /// <param name="itemIndex">The index of the item in the group.</param>
+        /// <returns>
+        /// True if the item and all its descendents are selected, false if the item and all its
+        /// descendents are deselected, or null if a combination of selected and deselected.
+        /// </returns>
+        bool? IsSelectedWithPartial(int groupIndex, int itemIndex);
+
+        /// <summary>
+        /// Checks whether an item or its descendents are selected.
+        /// </summary>
+        /// <param name="index">The index of the item</param>
+        /// <returns>
+        /// True if the item and all its descendents are selected, false if the item and all its
+        /// descendents are deselected, or null if a combination of selected and deselected.
+        /// </returns>
+        bool? IsSelectedWithPartialAt(IndexPath index);
+
+        /// <summary>
+        /// Selects an item.
+        /// </summary>
+        /// <param name="index">The index of the item</param>
+        void Select(int index);
+
+        /// <summary>
+        /// Selects an item.
+        /// </summary>
+        /// <param name="groupIndex">The index of the item group.</param>
+        /// <param name="itemIndex">The index of the item in the group.</param>
+        void Select(int groupIndex, int itemIndex);
+
+        /// <summary>
+        /// Selects an item.
+        /// </summary>
+        /// <param name="index">The index of the item</param>
+        void SelectAt(IndexPath index);
+
+        /// <summary>
+        /// Selects all items.
+        /// </summary>
+        void SelectAll();
+
+        /// <summary>
+        /// Selects a range of items.
+        /// </summary>
+        /// <param name="start">The start index of the range.</param>
+        /// <param name="end">The end index of the range.</param>
+        void SelectRange(IndexPath start, IndexPath end);
+
+        /// <summary>
+        /// Selects a range of items, starting at <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="index">The end index of the range.</param>
+        void SelectRangeFromAnchor(int index);
+
+        /// <summary>
+        /// Selects a range of items, starting at <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="endGroupIndex">
+        /// The index of the item group that represents the end of the selection.
+        /// </param>
+        /// <param name="endItemIndex">
+        /// The index of the item in the group that represents the end of the selection.
+        /// </param>
+        void SelectRangeFromAnchor(int endGroupIndex, int endItemIndex);
+
+        /// <summary>
+        /// Selects a range of items, starting at <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="index">The end index of the range.</param>
+        void SelectRangeFromAnchorTo(IndexPath index);
+
+        /// <summary>
+        /// Sets the <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="index">The anchor index.</param>
+        void SetAnchorIndex(int index);
+
+        /// <summary>
+        /// Sets the <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="groupIndex">The index of the item group.</param>
+        /// <param name="index">The index of the item in the group.</param>
+        void SetAnchorIndex(int groupIndex, int index);
+
+        /// <summary>
+        /// Begins a batch update of the selection.
+        /// </summary>
+        /// <returns>An <see cref="IDisposable"/> that finishes the batch update.</returns>
+        IDisposable Update();
+    }
+}

+ 180 - 0
src/Avalonia.Controls/IndexPath.cs

@@ -0,0 +1,180 @@
+// 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;
+using System.Linq;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    public readonly struct IndexPath : IComparable<IndexPath>, IEquatable<IndexPath>
+    {
+        public static readonly IndexPath Unselected = default;
+
+        private readonly int _index;
+        private readonly int[]? _path;
+
+        public IndexPath(int index)
+        {
+            _index = index + 1;
+            _path = null;
+        }
+
+        public IndexPath(int groupIndex, int itemIndex)
+        {
+            _index = 0;
+            _path = new[] { groupIndex, itemIndex };
+        }
+
+        public IndexPath(IEnumerable<int>? indices)
+        {
+            if (indices != null)
+            {
+                _index = 0;
+                _path = indices.ToArray();
+            }
+            else
+            {
+                _index = 0;
+                _path = null;
+            }
+        }
+
+        private IndexPath(int[] basePath, int index)
+        {
+            basePath = basePath ?? throw new ArgumentNullException(nameof(basePath));
+            
+            _index = 0;
+            _path = new int[basePath.Length + 1];
+            Array.Copy(basePath, _path, basePath.Length);
+            _path[basePath.Length] = index;
+        }
+
+        public int GetSize() => _path?.Length ?? (_index == 0 ? 0 : 1);
+
+        public int GetAt(int index)
+        {
+            if (index >= GetSize())
+            {
+                throw new IndexOutOfRangeException();
+            }
+
+            return _path?[index] ?? (_index - 1);
+        }
+
+        public int CompareTo(IndexPath other)
+        {
+            var rhsPath = other;
+            int compareResult = 0;
+            int lhsCount = GetSize();
+            int rhsCount = rhsPath.GetSize();
+
+            if (lhsCount == 0 || rhsCount == 0)
+            {
+                // one of the paths are empty, compare based on size
+                compareResult = (lhsCount - rhsCount);
+            }
+            else
+            {
+                // both paths are non-empty, but can be of different size
+                for (int i = 0; i < Math.Min(lhsCount, rhsCount); i++)
+                {
+                    if (GetAt(i) < rhsPath.GetAt(i))
+                    {
+                        compareResult = -1;
+                        break;
+                    }
+                    else if (GetAt(i) > rhsPath.GetAt(i))
+                    {
+                        compareResult = 1;
+                        break;
+                    }
+                }
+
+                // if both match upto min(lhsCount, rhsCount), compare based on size
+                compareResult = compareResult == 0 ? (lhsCount - rhsCount) : compareResult;
+            }
+
+            if (compareResult != 0)
+            {
+                compareResult = compareResult > 0 ? 1 : -1;
+            }
+
+            return compareResult;
+        }
+
+        public IndexPath CloneWithChildIndex(int childIndex)
+        {
+            if (_path != null)
+            {
+                return new IndexPath(_path, childIndex);
+            }
+            else if (_index != 0)
+            {
+                return new IndexPath(_index - 1, childIndex);
+            }
+            else
+            {
+                return new IndexPath(childIndex);
+            }
+        }
+
+        public override string ToString()
+        {
+            if (_path != null)
+            {
+                return "R" + string.Join(".", _path);
+            }
+            else if (_index != 0)
+            {
+                return "R" + (_index - 1);
+            }
+            else
+            {
+                return "R";
+            }
+        }
+
+        public static IndexPath CreateFrom(int index) => new IndexPath(index);
+
+        public static IndexPath CreateFrom(int groupIndex, int itemIndex) => new IndexPath(groupIndex, itemIndex);
+
+        public static IndexPath CreateFromIndices(IList<int> indices) => new IndexPath(indices);
+
+        public override bool Equals(object obj) => obj is IndexPath other && Equals(other);
+
+        public bool Equals(IndexPath other) => CompareTo(other) == 0;
+
+        public override int GetHashCode()
+        {
+            var hashCode = -504981047;
+
+            if (_path != null)
+            {
+                foreach (var i in _path)
+                {
+                    hashCode = hashCode * -1521134295 + i.GetHashCode();
+                }
+            }
+            else
+            {
+                hashCode = hashCode * -1521134295 + _index.GetHashCode();
+            }
+
+            return hashCode;
+        }
+
+        public static bool operator <(IndexPath x, IndexPath y) { return x.CompareTo(y) < 0; }
+        public static bool operator >(IndexPath x, IndexPath y) { return x.CompareTo(y) > 0; }
+        public static bool operator <=(IndexPath x, IndexPath y) { return x.CompareTo(y) <= 0; }
+        public static bool operator >=(IndexPath x, IndexPath y) { return x.CompareTo(y) >= 0; }
+        public static bool operator ==(IndexPath x, IndexPath y) { return x.CompareTo(y) == 0; }
+        public static bool operator !=(IndexPath x, IndexPath y) { return x.CompareTo(y) != 0; }
+        public static bool operator ==(IndexPath? x, IndexPath? y) { return (x ?? default).CompareTo(y ?? default) == 0; }
+        public static bool operator !=(IndexPath? x, IndexPath? y) { return (x ?? default).CompareTo(y ?? default) != 0; }
+    }
+}

+ 232 - 0
src/Avalonia.Controls/IndexRange.cs

@@ -0,0 +1,232 @@
+// 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
+{
+    internal readonly struct IndexRange : IEquatable<IndexRange>
+    {
+        private static readonly IndexRange s_invalid = new IndexRange(int.MinValue, int.MinValue);
+
+        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 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 Remove(
+            IList<IndexRange> ranges,
+            IndexRange range,
+            IList<IndexRange>? removed = null)
+        {
+            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 IEnumerable<IndexRange> Subtract(
+            IndexRange lhs,
+            IEnumerable<IndexRange> rhs)
+        {
+            var result = new List<IndexRange> { lhs };
+            
+            foreach (var range in rhs)
+            {
+                Remove(result, range);
+            }
+
+            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);
+                }
+            }
+        }
+    }
+}

+ 17 - 2
src/Avalonia.Controls/ListBox.cs

@@ -31,6 +31,12 @@ namespace Avalonia.Controls
         public static readonly new DirectProperty<SelectingItemsControl, IList> SelectedItemsProperty =
             SelectingItemsControl.SelectedItemsProperty;
 
+        /// <summary>
+        /// Defines the <see cref="Selection"/> property.
+        /// </summary>
+        public static readonly new DirectProperty<SelectingItemsControl, ISelectionModel> SelectionProperty =
+            SelectingItemsControl.SelectionProperty;
+
         /// <summary>
         /// Defines the <see cref="SelectionMode"/> property.
         /// </summary>
@@ -70,6 +76,15 @@ namespace Avalonia.Controls
             set => base.SelectedItems = value;
         }
 
+        /// <summary>
+        /// Gets or sets a model holding the current selection.
+        /// </summary>
+        public new ISelectionModel Selection
+        {
+            get => base.Selection;
+            set => base.Selection = value;
+        }
+
         /// <summary>
         /// Gets or sets the selection mode.
         /// </summary>
@@ -95,12 +110,12 @@ namespace Avalonia.Controls
         /// <summary>
         /// Selects all items in the <see cref="ListBox"/>.
         /// </summary>
-        public new void SelectAll() => base.SelectAll();
+        public void SelectAll() => Selection.SelectAll();
 
         /// <summary>
         /// Deselects all items in the <see cref="ListBox"/>.
         /// </summary>
-        public new void UnselectAll() => base.UnselectAll();
+        public void UnselectAll() => Selection.ClearSelection();
 
         /// <inheritdoc/>
         protected override IItemContainerGenerator CreateItemContainerGenerator()

+ 1 - 1
src/Avalonia.Controls/Presenters/IItemsPresenter.cs

@@ -11,6 +11,6 @@ namespace Avalonia.Controls.Presenters
 
         void ItemsChanged(NotifyCollectionChangedEventArgs e);
 
-        void ScrollIntoView(object item);
+        void ScrollIntoView(int index);
     }
 }

+ 2 - 2
src/Avalonia.Controls/Presenters/ItemVirtualizer.cs

@@ -275,8 +275,8 @@ namespace Avalonia.Controls.Presenters
         /// <summary>
         /// Scrolls the specified item into view.
         /// </summary>
-        /// <param name="item">The item.</param>
-        public virtual void ScrollIntoView(object item)
+        /// <param name="index">The index of the item.</param>
+        public virtual void ScrollIntoView(int index)
         {
         }
 

+ 5 - 10
src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs

@@ -64,18 +64,13 @@ namespace Avalonia.Controls.Presenters
         /// <summary>
         /// Scrolls the specified item into view.
         /// </summary>
-        /// <param name="item">The item.</param>
-        public override void ScrollIntoView(object item)
+        /// <param name="index">The index of the item.</param>
+        public override void ScrollIntoView(int index)
         {
-            if (Items != null)
+            if (index != -1)
             {
-                var index = Items.IndexOf(item);
-
-                if (index != -1)
-                {
-                    var container = Owner.ItemContainerGenerator.ContainerFromIndex(index);
-                    container?.BringIntoView();
-                }
+                var container = Owner.ItemContainerGenerator.ContainerFromIndex(index);
+                container?.BringIntoView();
             }
         }
 

+ 5 - 10
src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs

@@ -286,20 +286,15 @@ namespace Avalonia.Controls.Presenters
                     break;
             }
 
-            return ScrollIntoView(newItemIndex);
+            return ScrollIntoViewCore(newItemIndex);
         }
 
         /// <inheritdoc/>
-        public override void ScrollIntoView(object item)
+        public override void ScrollIntoView(int index)
         {
-            if (Items != null)
+            if (index != -1)
             {
-                var index = Items.IndexOf(item);
-
-                if (index != -1)
-                {
-                    ScrollIntoView(index);
-                }
+                ScrollIntoViewCore(index);
             }
         }
 
@@ -511,7 +506,7 @@ namespace Avalonia.Controls.Presenters
         /// </summary>
         /// <param name="index">The item index.</param>
         /// <returns>The container that was brought into view.</returns>
-        private IControl ScrollIntoView(int index)
+        private IControl ScrollIntoViewCore(int index)
         {
             var panel = VirtualizingPanel;
             var generator = Owner.ItemContainerGenerator;

+ 2 - 2
src/Avalonia.Controls/Presenters/ItemsPresenter.cs

@@ -128,9 +128,9 @@ namespace Avalonia.Controls.Presenters
             _scrollInvalidated?.Invoke(this, e);
         }
 
-        public override void ScrollIntoView(object item)
+        public override void ScrollIntoView(int index)
         {
-            Virtualizer?.ScrollIntoView(item);
+            Virtualizer?.ScrollIntoView(index);
         }
 
         /// <inheritdoc/>

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

@@ -139,7 +139,7 @@ namespace Avalonia.Controls.Presenters
         }
 
         /// <inheritdoc/>
-        public virtual void ScrollIntoView(object item)
+        public virtual void ScrollIntoView(int index)
         {
         }
 

+ 246 - 593
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@@ -2,15 +2,15 @@ using System;
 using System.Collections;
 using System.Collections.Generic;
 using System.Collections.Specialized;
+using System.ComponentModel;
 using System.Diagnostics;
 using System.Linq;
-using Avalonia.Collections;
 using Avalonia.Controls.Generators;
+using Avalonia.Controls.Utils;
 using Avalonia.Data;
 using Avalonia.Input;
 using Avalonia.Input.Platform;
 using Avalonia.Interactivity;
-using Avalonia.Logging;
 using Avalonia.VisualTree;
 
 namespace Avalonia.Controls.Primitives
@@ -23,9 +23,9 @@ namespace Avalonia.Controls.Primitives
     /// <see cref="SelectingItemsControl"/> provides a base class for <see cref="ItemsControl"/>s
     /// that maintain a selection (single or multiple). By default only its 
     /// <see cref="SelectedIndex"/> and <see cref="SelectedItem"/> properties are visible; the
-    /// current multiple selection <see cref="SelectedItems"/> together with the 
-    /// <see cref="SelectionMode"/> properties are protected, however a derived  class can expose 
-    /// these if it wishes to support multiple selection.
+    /// current multiple <see cref="Selection"/> and <see cref="SelectedItems"/> together with the
+    /// <see cref="SelectionMode"/> and properties are protected, however a derived  class can
+    /// expose these if it wishes to support multiple selection.
     /// </para>
     /// <para>
     /// <see cref="SelectingItemsControl"/> maintains a selection respecting the current 
@@ -74,6 +74,15 @@ namespace Avalonia.Controls.Primitives
                 o => o.SelectedItems,
                 (o, v) => o.SelectedItems = v);
 
+        /// <summary>
+        /// Defines the <see cref="Selection"/> property.
+        /// </summary>
+        public static readonly DirectProperty<SelectingItemsControl, ISelectionModel> SelectionProperty =
+            AvaloniaProperty.RegisterDirect<SelectingItemsControl, ISelectionModel>(
+                nameof(Selection),
+                o => o.Selection,
+                (o, v) => o.Selection = v);
+
         /// <summary>
         /// Defines the <see cref="SelectionMode"/> property.
         /// </summary>
@@ -100,17 +109,22 @@ namespace Avalonia.Controls.Primitives
                 RoutingStrategies.Bubble);
 
         private static readonly IList Empty = Array.Empty<object>();
-
-        private readonly Selection _selection = new Selection();
+        private readonly SelectedItemsSync _selectedItems;
+        private ISelectionModel _selection;
         private int _selectedIndex = -1;
         private object _selectedItem;
-        private IList _selectedItems;
         private bool _ignoreContainerSelectionChanged;
-        private bool _syncingSelectedItems;
         private int _updateCount;
         private int _updateSelectedIndex;
         private object _updateSelectedItem;
 
+        public SelectingItemsControl()
+        {
+            // Setting Selection to null causes a default SelectionModel to be created.
+            Selection = null;
+            _selectedItems = new SelectedItemsSync(Selection);
+        }
+
         /// <summary>
         /// Initializes static members of the <see cref="SelectingItemsControl"/> class.
         /// </summary>
@@ -142,17 +156,15 @@ namespace Avalonia.Controls.Primitives
         /// </summary>
         public int SelectedIndex
         {
-            get
-            {
-                return _selectedIndex;
-            }
-
+            get => Selection.SelectedIndex != default ? Selection.SelectedIndex.GetAt(0) : -1;
             set
             {
                 if (_updateCount == 0)
                 {
-                    var effective = (value >= 0 && value < ItemCount) ? value : -1;
-                    UpdateSelectedItem(effective);
+                    if (value != SelectedIndex)
+                    {
+                        Selection.SelectedIndex = new IndexPath(value);
+                    }
                 }
                 else
                 {
@@ -167,16 +179,12 @@ namespace Avalonia.Controls.Primitives
         /// </summary>
         public object SelectedItem
         {
-            get
-            {
-                return _selectedItem;
-            }
-
+            get => Selection.SelectedItem;
             set
             {
                 if (_updateCount == 0)
                 {
-                    UpdateSelectedItem(IndexOf(Items, value));
+                    SelectedIndex = IndexOf(Items, value);
                 }
                 else
                 {
@@ -187,32 +195,110 @@ namespace Avalonia.Controls.Primitives
         }
 
         /// <summary>
-        /// Gets the selected items.
+        /// Gets or sets the selected items.
         /// </summary>
         protected IList SelectedItems
         {
-            get
-            {
-                if (_selectedItems == null)
-                {
-                    _selectedItems = new AvaloniaList<object>();
-                    SubscribeToSelectedItems();
-                }
-
-                return _selectedItems;
-            }
+            get => _selectedItems.GetOrCreateItems();
+            set => _selectedItems.SetItems(value);
+        }
 
+        /// <summary>
+        /// Gets or sets a model holding the current selection.
+        /// </summary>
+        protected ISelectionModel Selection 
+        {
+            get => _selection;
             set
             {
-                if (value?.IsFixedSize == true || value?.IsReadOnly == true)
+                value ??= new SelectionModel
                 {
-                    throw new NotSupportedException(
-                        "Cannot use a fixed size or read-only collection as SelectedItems.");
-                }
+                    SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple),
+                    AutoSelect = SelectionMode.HasFlagCustom(SelectionMode.AlwaysSelected),
+                    RetainSelectionOnReset = true,
+                };
+
+                if (_selection != value)
+                {
+                    if (value == null)
+                    {
+                        throw new ArgumentNullException(nameof(value), "Cannot set Selection to null.");
+                    }
+                    else if (value.Source != null && value.Source != Items)
+                    {
+                        throw new ArgumentException("Selection has invalid Source.");
+                    }
+
+                    List<object> oldSelection = null;
+
+                    if (_selection != null)
+                    {
+                        oldSelection = Selection.SelectedItems.ToList();
+                        _selection.PropertyChanged -= OnSelectionModelPropertyChanged;
+                        _selection.SelectionChanged -= OnSelectionModelSelectionChanged;
+                        MarkContainersUnselected();
+                    }
+
+                    _selection = value;
+
+                    if (oldSelection?.Count > 0)
+                    {
+                        RaiseEvent(new SelectionChangedEventArgs(
+                            SelectionChangedEvent,
+                            oldSelection,
+                            Array.Empty<object>()));
+                    }
+
+                    if (_selection != null)
+                    {
+                        _selection.Source = Items;
+                        _selection.PropertyChanged += OnSelectionModelPropertyChanged;
+                        _selection.SelectionChanged += OnSelectionModelSelectionChanged;
+
+                        if (_selection.SingleSelect)
+                        {
+                            SelectionMode &= ~SelectionMode.Multiple;
+                        }
+                        else
+                        {
+                            SelectionMode |= SelectionMode.Multiple;
+                        }
+
+                        if (_selection.AutoSelect)
+                        {
+                            SelectionMode |= SelectionMode.AlwaysSelected;
+                        }
+                        else
+                        {
+                            SelectionMode &= ~SelectionMode.AlwaysSelected;
+                        }
+
+                        UpdateContainerSelection();
+
+                        var selectedIndex = SelectedIndex;
+                        var selectedItem = SelectedItem;
 
-                UnsubscribeFromSelectedItems();
-                _selectedItems = value ?? new AvaloniaList<object>();
-                SubscribeToSelectedItems();
+                        if (_selectedIndex != selectedIndex)
+                        {
+                            RaisePropertyChanged(SelectedIndexProperty, _selectedIndex, selectedIndex);
+                            _selectedIndex = selectedIndex;
+                        }
+
+                        if (_selectedItem != selectedItem)
+                        {
+                            RaisePropertyChanged(SelectedItemProperty, _selectedItem, selectedItem);
+                            _selectedItem = selectedItem;
+                        }
+                        
+                        if (selectedIndex != -1)
+                        {
+                            RaiseEvent(new SelectionChangedEventArgs(
+                                SelectionChangedEvent,
+                                Array.Empty<object>(),
+                                Selection.SelectedItems.ToList()));
+                        }
+                    }
+                }
             }
         }
 
@@ -250,11 +336,17 @@ namespace Avalonia.Controls.Primitives
             base.EndInit();
         }
 
+        /// <summary>
+        /// Scrolls the specified item into view.
+        /// </summary>
+        /// <param name="index">The index of the item.</param>
+        public void ScrollIntoView(int index) => Presenter?.ScrollIntoView(index);
+
         /// <summary>
         /// Scrolls the specified item into view.
         /// </summary>
         /// <param name="item">The item.</param>
-        public void ScrollIntoView(object item) => Presenter?.ScrollIntoView(item);
+        public void ScrollIntoView(object item) => ScrollIntoView(IndexOf(Items, item));
 
         /// <summary>
         /// Tries to get the container that was the source of an event.
@@ -282,81 +374,18 @@ namespace Avalonia.Controls.Primitives
         /// <inheritdoc/>
         protected override void ItemsChanged(AvaloniaPropertyChangedEventArgs e)
         {
-            base.ItemsChanged(e);
-
             if (_updateCount == 0)
             {
-                var newIndex = -1;
-
-                if (SelectedIndex != -1)
-                {
-                    newIndex = IndexOf((IEnumerable)e.NewValue, SelectedItem);
-                }
-
-                if (AlwaysSelected && Items != null && Items.Cast<object>().Any())
-                {
-                    newIndex = 0;
-                }
-
-                SelectedIndex = newIndex;
+                Selection.Source = e.NewValue;
             }
+
+            base.ItemsChanged(e);
         }
 
         /// <inheritdoc/>
         protected override void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
         {
-            if (_updateCount > 0)
-            {
-                base.ItemsCollectionChanged(sender, e);
-                return;
-            }
-
-            switch (e.Action)
-            {
-                case NotifyCollectionChangedAction.Add:
-                    _selection.ItemsInserted(e.NewStartingIndex, e.NewItems.Count);
-                    break;
-                case NotifyCollectionChangedAction.Remove:
-                    _selection.ItemsRemoved(e.OldStartingIndex, e.OldItems.Count);
-                    break;
-            }
-
             base.ItemsCollectionChanged(sender, e);
-
-            switch (e.Action)
-            {
-                case NotifyCollectionChangedAction.Add:
-                    if (AlwaysSelected && SelectedIndex == -1)
-                    {
-                        SelectedIndex = 0;
-                    }
-                    else
-                    {
-                        UpdateSelectedItem(_selection.First(), false);
-                    }
-
-                    break;
-
-                case NotifyCollectionChangedAction.Remove:
-                    UpdateSelectedItem(_selection.First(), false);
-                    ResetSelectedItems();
-                    break;
-
-                case NotifyCollectionChangedAction.Replace:
-                    UpdateSelectedItem(SelectedIndex, false);
-                    ResetSelectedItems();
-                    break;
-
-                case NotifyCollectionChangedAction.Move:
-                case NotifyCollectionChangedAction.Reset:
-                    SelectedIndex = IndexOf(Items, SelectedItem);
-
-                    if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0)
-                    {
-                        SelectedIndex = 0;
-                    }
-                    break;
-            }
         }
 
         /// <inheritdoc/>
@@ -364,36 +393,18 @@ namespace Avalonia.Controls.Primitives
         {
             base.OnContainersMaterialized(e);
 
-            var resetSelectedItems = false;
-
             foreach (var container in e.Containers)
             {
                 if ((container.ContainerControl as ISelectable)?.IsSelected == true)
                 {
-                    if (SelectionMode.HasFlag(SelectionMode.Multiple))
-                    {
-                        if (_selection.Add(container.Index))
-                        {
-                            resetSelectedItems = true;
-                        }
-                    }
-                    else
-                    {
-                        SelectedIndex = container.Index;
-                    }
-
+                    Selection.Select(container.Index);
                     MarkContainerSelected(container.ContainerControl, true);
                 }
-                else if (_selection.Contains(container.Index))
+                else if (Selection.IsSelected(container.Index) == true)
                 {
                     MarkContainerSelected(container.ContainerControl, true);
                 }
             }
-
-            if (resetSelectedItems)
-            {
-                ResetSelectedItems();
-            }
         }
 
         /// <inheritdoc/>
@@ -422,7 +433,7 @@ namespace Avalonia.Controls.Primitives
             {
                 if (i.ContainerControl != null && i.Item != null)
                 {
-                    bool selected = _selection.Contains(i.Index);
+                    bool selected = Selection.IsSelected(i.Index) == true;
                     MarkContainerSelected(i.ContainerControl, selected);
                 }
             }
@@ -444,6 +455,18 @@ namespace Avalonia.Controls.Primitives
             InternalEndInit();
         }
 
+        protected override void OnPropertyChanged<T>(AvaloniaProperty<T> property, Optional<T> oldValue, BindingValue<T> newValue, BindingPriority priority)
+        {
+            base.OnPropertyChanged(property, oldValue, newValue, priority);
+
+            if (property == SelectionModeProperty)
+            {
+                var mode = newValue.GetValueOrDefault<SelectionMode>();
+                Selection.SingleSelect = !mode.HasFlagCustom(SelectionMode.Multiple);
+                Selection.AutoSelect = mode.HasFlagCustom(SelectionMode.AlwaysSelected);
+            }
+        }
+
         protected override void OnKeyDown(KeyEventArgs e)
         {
             base.OnKeyDown(e);
@@ -458,7 +481,7 @@ namespace Avalonia.Controls.Primitives
                     (((SelectionMode & SelectionMode.Multiple) != 0) ||
                       (SelectionMode & SelectionMode.Toggle) != 0))
                 {
-                    SelectAll();
+                    Selection.SelectAll();
                     e.Handled = true;
                 }
             }
@@ -500,36 +523,6 @@ namespace Avalonia.Controls.Primitives
             return false;
         }
 
-        /// <summary>
-        /// Selects all items in the control.
-        /// </summary>
-        protected void SelectAll()
-        {
-            UpdateSelectedItems(() =>
-            {
-                _selection.Clear();
-
-                for (var i = 0; i < ItemCount; ++i)
-                {
-                    _selection.Add(i);
-                }
-
-                UpdateSelectedItem(0, false);
-
-                foreach (var container in ItemContainerGenerator.Containers)
-                {
-                    MarkItemSelected(container.Index, true);
-                }
-
-                ResetSelectedItems();
-            });
-        }
-
-        /// <summary>
-        /// Deselects all items in the control.
-        /// </summary>
-        protected void UnselectAll() => UpdateSelectedItem(-1);
-
         /// <summary>
         /// Updates the selection for an item based on user interaction.
         /// </summary>
@@ -556,63 +549,35 @@ namespace Avalonia.Controls.Primitives
 
                     if (rightButton)
                     {
-                        if (!_selection.Contains(index))
+                        if (Selection.IsSelected(index) == false)
                         {
-                            UpdateSelectedItem(index);
+                            SelectedIndex = index;
                         }
                     }
                     else if (range)
                     {
-                        UpdateSelectedItems(() =>
-                        {
-                            var start = SelectedIndex != -1 ? SelectedIndex : 0;
-                            var step = start < index ? 1 : -1;
-
-                            _selection.Clear();
-
-                            for (var i = start; i != index; i += step)
-                            {
-                                _selection.Add(i);
-                            }
-
-                            _selection.Add(index);
+                        using var operation = Selection.Update();
+                        var anchor = Selection.AnchorIndex;
 
-                            var first = Math.Min(start, index);
-                            var last = Math.Max(start, index);
-
-                            foreach (var container in ItemContainerGenerator.Containers)
-                            {
-                                MarkItemSelected(
-                                    container.Index,
-                                    container.Index >= first && container.Index <= last);
-                            }
+                        if (anchor.GetSize() == 0)
+                        {
+                            anchor = new IndexPath(0);
+                        }
 
-                            ResetSelectedItems();
-                        });
+                        Selection.ClearSelection();
+                        Selection.AnchorIndex = anchor;
+                        Selection.SelectRangeFromAnchor(index);
                     }
                     else if (multi && toggle)
                     {
-                        UpdateSelectedItems(() =>
+                        if (Selection.IsSelected(index) == true)
                         {
-                            if (!_selection.Contains(index))
-                            {
-                                _selection.Add(index);
-                                MarkItemSelected(index, true);
-                                SelectedItems.Add(ElementAt(Items, index));
-                            }
-                            else
-                            {
-                                _selection.Remove(index);
-                                MarkItemSelected(index, false);
-
-                                if (index == _selectedIndex)
-                                {
-                                    UpdateSelectedItem(_selection.First(), false);
-                                }
-
-                                SelectedItems.Remove(ElementAt(Items, index));
-                            }
-                        });
+                            Selection.Deselect(index);
+                        }
+                        else
+                        {
+                            Selection.Select(index);
+                        }
                     }
                     else if (toggle)
                     {
@@ -620,7 +585,9 @@ namespace Avalonia.Controls.Primitives
                     }
                     else
                     {
-                        UpdateSelectedItem(index);
+                        using var operation = Selection.Update();
+                        Selection.ClearSelection();
+                        Selection.Select(index);
                     }
 
                     if (Presenter?.Panel != null)
@@ -693,25 +660,68 @@ namespace Avalonia.Controls.Primitives
         }
 
         /// <summary>
-        /// Gets a range of items from an IEnumerable.
+        /// Called when <see cref="SelectionModel.PropertyChanged"/> is raised.
         /// </summary>
-        /// <param name="items">The items.</param>
-        /// <param name="first">The index of the first item.</param>
-        /// <param name="last">The index of the last item.</param>
-        /// <returns>The items.</returns>
-        private static List<object> GetRange(IEnumerable items, int first, int last)
+        /// <param name="sender">The sender.</param>
+        /// <param name="e">The event args.</param>
+        private void OnSelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e)
         {
-            var list = (items as IList) ?? items.Cast<object>().ToList();
-            var step = first > last ? -1 : 1;
-            var result = new List<object>();
+            if (e.PropertyName == nameof(SelectionModel.AnchorIndex) && AutoScrollToSelectedItem)
+            {
+                if (Selection.AnchorIndex.GetSize() > 0)
+                {
+                    ScrollIntoView(Selection.AnchorIndex.GetAt(0));
+                }
+            }
+        }
+
+        /// <summary>
+        /// Called when <see cref="SelectionModel.SelectionChanged"/> is raised.
+        /// </summary>
+        /// <param name="sender">The sender.</param>
+        /// <param name="e">The event args.</param>
+        private void OnSelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e)
+        {
+            void Mark(int index, bool selected)
+            {
+                var container = ItemContainerGenerator.ContainerFromIndex(index);
+
+                if (container != null)
+                {
+                    MarkContainerSelected(container, selected);
+                }
+            }
+
+            foreach (var i in e.SelectedIndices)
+            {
+                Mark(i.GetAt(0), true);
+            }
+
+            foreach (var i in e.DeselectedIndices)
+            {
+                Mark(i.GetAt(0), false);
+            }
+
+            var newSelectedIndex = SelectedIndex;
+            var newSelectedItem = SelectedItem;
 
-            for (int i = first; i != last; i += step)
+            if (newSelectedIndex != _selectedIndex)
             {
-                result.Add(list[i]);
+                RaisePropertyChanged(SelectedIndexProperty, _selectedIndex, newSelectedIndex);
+                _selectedIndex = newSelectedIndex;
             }
 
-            result.Add(list[last]);
-            return result;
+            if (newSelectedItem != _selectedItem)
+            {
+                RaisePropertyChanged(SelectedItemProperty, _selectedItem, newSelectedItem);
+                _selectedItem = newSelectedItem;
+            }
+
+            var ev = new SelectionChangedEventArgs(
+                SelectionChangedEvent,
+                e.DeselectedItems.ToList(),
+                e.SelectedItems.ToList());
+            RaiseEvent(ev);
         }
 
         /// <summary>
@@ -791,301 +801,43 @@ namespace Avalonia.Controls.Primitives
             }
         }
 
-        /// <summary>
-        /// Sets an item container's 'selected' class or <see cref="ISelectable.IsSelected"/>.
-        /// </summary>
-        /// <param name="index">The index of the item.</param>
-        /// <param name="selected">Whether the item should be selected or deselected.</param>
-        private void MarkItemSelected(int index, bool selected)
+        private void MarkContainersUnselected()
         {
-            var container = ItemContainerGenerator?.ContainerFromIndex(index);
-
-            if (container != null)
+            foreach (var container in ItemContainerGenerator.Containers)
             {
-                MarkContainerSelected(container, selected);
+                MarkContainerSelected(container.ContainerControl, false);
             }
         }
 
-        /// <summary>
-        /// Sets an item container's 'selected' class or <see cref="ISelectable.IsSelected"/>.
-        /// </summary>
-        /// <param name="item">The item.</param>
-        /// <param name="selected">Whether the item should be selected or deselected.</param>
-        private int MarkItemSelected(object item, bool selected)
+        private void UpdateContainerSelection()
         {
-            var index = IndexOf(Items, item);
-
-            if (index != -1)
+            foreach (var container in ItemContainerGenerator.Containers)
             {
-                MarkItemSelected(index, selected);
+                MarkContainerSelected(
+                    container.ContainerControl,
+                    Selection.IsSelected(container.Index) != false);
             }
-
-            return index;
-        }
-
-        private void ResetSelectedItems()
-        {
-            UpdateSelectedItems(() =>
-            {
-                SelectedItems.Clear();
-
-                foreach (var i in _selection)
-                {
-                    SelectedItems.Add(ElementAt(Items, i));
-                }
-            });
         }
 
         /// <summary>
-        /// Called when the <see cref="SelectedItems"/> CollectionChanged event is raised.
-        /// </summary>
-        /// <param name="sender">The event sender.</param>
-        /// <param name="e">The event args.</param>
-        private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
-        {
-            if (_syncingSelectedItems)
-            {
-                return;
-            }
-
-            void Add(IList newItems, IList addedItems = null)
-            {
-                foreach (var item in newItems)
-                {
-                    var index = MarkItemSelected(item, true);
-
-                    if (index != -1 && _selection.Add(index) && addedItems != null)
-                    {
-                        addedItems.Add(item);
-                    }
-                }
-            }
-
-            void UpdateSelection()
-            {
-                if ((SelectedIndex != -1 && !_selection.Contains(SelectedIndex)) ||
-                    (SelectedIndex == -1 && _selection.HasItems))
-                {
-                    _selectedIndex = _selection.First();
-                    _selectedItem = ElementAt(Items, _selectedIndex);
-                    RaisePropertyChanged(SelectedIndexProperty, -1, _selectedIndex, BindingPriority.LocalValue);
-                    RaisePropertyChanged(SelectedItemProperty, null, _selectedItem, BindingPriority.LocalValue);
-                }
-            }
-
-            IList added = null;
-            IList removed = null;
-
-            switch (e.Action)
-            {
-                case NotifyCollectionChangedAction.Add:
-                    {
-                        Add(e.NewItems);
-                        UpdateSelection();
-                        added = e.NewItems;
-                    }
-
-                    break;
-
-                case NotifyCollectionChangedAction.Remove:
-                    if (SelectedItems.Count == 0)
-                    {
-                        SelectedIndex = -1;
-                    }
-
-                    foreach (var item in e.OldItems)
-                    {
-                        var index = MarkItemSelected(item, false);
-                        _selection.Remove(index);
-                    }
-
-                    removed = e.OldItems;
-                    break;
-
-                case NotifyCollectionChangedAction.Replace:
-                    throw new NotSupportedException("Replacing items in a SelectedItems collection is not supported.");
-
-                case NotifyCollectionChangedAction.Move:
-                    throw new NotSupportedException("Moving items in a SelectedItems collection is not supported.");
-
-                case NotifyCollectionChangedAction.Reset:
-                    {
-                        removed = new List<object>();
-                        added = new List<object>();
-
-                        foreach (var index in _selection.ToList())
-                        {
-                            var item = ElementAt(Items, index);
-
-                            if (!SelectedItems.Contains(item))
-                            {
-                                MarkItemSelected(index, false);
-                                removed.Add(item);
-                                _selection.Remove(index);
-                            }
-                        }
-
-                        Add(SelectedItems, added);
-                        UpdateSelection();
-                    }
-
-                    break;
-            }
-
-            if (added?.Count > 0 || removed?.Count > 0)
-            {
-                var changed = new SelectionChangedEventArgs(
-                    SelectionChangedEvent,
-                    removed ?? Empty,
-                    added ?? Empty);
-                RaiseEvent(changed);
-            }
-        }
-
-        /// <summary>
-        /// Subscribes to the <see cref="SelectedItems"/> CollectionChanged event, if any.
-        /// </summary>
-        private void SubscribeToSelectedItems()
-        {
-            var incc = _selectedItems as INotifyCollectionChanged;
-
-            if (incc != null)
-            {
-                incc.CollectionChanged += SelectedItemsCollectionChanged;
-            }
-
-            SelectedItemsCollectionChanged(
-                _selectedItems,
-                new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
-        }
-
-        /// <summary>
-        /// Unsubscribes from the <see cref="SelectedItems"/> CollectionChanged event, if any.
-        /// </summary>
-        private void UnsubscribeFromSelectedItems()
-        {
-            var incc = _selectedItems as INotifyCollectionChanged;
-
-            if (incc != null)
-            {
-                incc.CollectionChanged -= SelectedItemsCollectionChanged;
-            }
-        }
-
-        /// <summary>
-        /// Updates the selection due to a change to <see cref="SelectedIndex"/> or
-        /// <see cref="SelectedItem"/>.
+        /// Sets an item container's 'selected' class or <see cref="ISelectable.IsSelected"/>.
         /// </summary>
-        /// <param name="index">The new selected index.</param>
-        /// <param name="clear">Whether to clear existing selection.</param>
-        private void UpdateSelectedItem(int index, bool clear = true)
+        /// <param name="index">The index of the item.</param>
+        /// <param name="selected">Whether the item should be selected or deselected.</param>
+        private void MarkItemSelected(int index, bool selected)
         {
-            var oldIndex = _selectedIndex;
-            var oldItem = _selectedItem;
-
-            if (index == -1 && AlwaysSelected)
-            {
-                index = Math.Min(SelectedIndex, ItemCount - 1);
-            }
-
-            var item = ElementAt(Items, index);
-            var itemChanged = !Equals(item, oldItem);
-            var added = -1;
-            HashSet<int> removed = null;
-
-            _selectedIndex = index;
-            _selectedItem = item;
-
-            if (oldIndex != index || itemChanged || _selection.HasMultiple)
-            {
-                if (clear)
-                {
-                    removed = _selection.Clear();
-                }
-
-                if (index != -1)
-                {
-                    if (_selection.Add(index))
-                    {
-                        added = index;
-                    }
-
-                    if (removed?.Contains(index) == true)
-                    {
-                        removed.Remove(index);
-                        added = -1;
-                    }
-                }
-
-                if (removed != null)
-                {
-                    foreach (var i in removed)
-                    {
-                        MarkItemSelected(i, false);
-                    }
-                }
-
-                MarkItemSelected(index, true);
-
-                RaisePropertyChanged(
-                    SelectedIndexProperty,
-                    oldIndex,
-                    index);
-            }
-
-            if (itemChanged)
-            {
-                RaisePropertyChanged(
-                    SelectedItemProperty,
-                    oldItem,
-                    item);
-            }
-
-            if (removed != null && index != -1)
-            {
-                removed.Remove(index);
-            }
-
-            if (added != -1 || removed?.Count > 0)
-            {
-                ResetSelectedItems();
-
-                var e = new SelectionChangedEventArgs(
-                    SelectionChangedEvent,
-                    removed?.Select(x => ElementAt(Items, x)).ToArray() ?? Array.Empty<object>(),
-                    added != -1 ? new[] { ElementAt(Items, added) } : Array.Empty<object>());
-                RaiseEvent(e);
-            }
-
-            if (AutoScrollToSelectedItem && _selectedIndex != -1)
-            {
-                ScrollIntoView(_selectedItem);
-            }
-        }
+            var container = ItemContainerGenerator?.ContainerFromIndex(index);
 
-        private void UpdateSelectedItems(Action action)
-        {
-            try
-            {
-                _syncingSelectedItems = true;
-                action();
-            }
-            catch (Exception ex)
-            {
-                Logger.TryGet(LogEventLevel.Error)?.Log(
-                    LogArea.Property,
-                    this,
-                    "Error thrown updating SelectedItems: {Error}",
-                    ex);
-            }
-            finally
+            if (container != null)
             {
-                _syncingSelectedItems = false;
+                MarkContainerSelected(container, selected);
             }
         }
 
         private void UpdateFinished()
         {
+            Selection.Source = Items;
+
             if (_updateSelectedItem != null)
             {
                 SelectedItem = _updateSelectedItem;
@@ -1130,104 +882,5 @@ namespace Avalonia.Controls.Primitives
                 UpdateFinished();
             }
         }
-
-        private class Selection : IEnumerable<int>
-        {
-            private readonly List<int> _list = new List<int>();
-            private HashSet<int> _set = new HashSet<int>();
-
-            public bool HasItems => _set.Count > 0;
-            public bool HasMultiple => _set.Count > 1;
-
-            public bool Add(int index)
-            {
-                if (index == -1)
-                {
-                    throw new ArgumentException("Invalid index", "index");
-                }
-
-                if (_set.Add(index))
-                {
-                    _list.Add(index);
-                    return true;
-                }
-
-                return false;
-            }
-
-            public bool Remove(int index)
-            {
-                if (_set.Remove(index))
-                {
-                    _list.RemoveAll(x => x == index);
-                    return true;
-                }
-
-                return false;
-            }
-
-            public HashSet<int> Clear()
-            {
-                var result = _set;
-                _list.Clear();
-                _set = new HashSet<int>();
-                return result;
-            }
-
-            public void ItemsInserted(int index, int count)
-            {
-                _set = new HashSet<int>();
-
-                for (var i = 0; i < _list.Count; ++i)
-                {
-                    var ix = _list[i];
-
-                    if (ix >= index)
-                    {
-                        var newIndex = ix + count;
-                        _list[i] = newIndex;
-                        _set.Add(newIndex);
-                    }
-                    else
-                    {
-                        _set.Add(ix);
-                    }
-                }
-            }
-
-            public void ItemsRemoved(int index, int count)
-            {
-                var last = (index + count) - 1;
-
-                _set = new HashSet<int>();
-
-                for (var i = 0; i < _list.Count; ++i)
-                {
-                    var ix = _list[i];
-
-                    if (ix >= index && ix <= last)
-                    {
-                        _list.RemoveAt(i--);
-                    }
-                    else if (ix > last)
-                    {
-                        var newIndex = ix - count;
-                        _list[i] = newIndex;
-                        _set.Add(newIndex);
-                    }
-                    else
-                    {
-                        _set.Add(ix);
-                    }
-                }
-            }
-
-            public bool Contains(int index) => _set.Contains(index);
-
-            public int First() => HasItems ? _list[0] : -1;
-
-            public IEnumerator<int> GetEnumerator() => _set.GetEnumerator();
-            IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
-        }
     }
 }

+ 2 - 0
src/Avalonia.Controls/Repeater/ItemsSourceView.cs

@@ -96,6 +96,8 @@ namespace Avalonia.Controls
         /// <returns>the item.</returns>
         public object GetAt(int index) => _inner[index];
 
+        public int IndexOf(object item) => _inner.IndexOf(item);
+
         /// <summary>
         /// Retrieves the index of the item that has the specified unique identifier (key).
         /// </summary>

+ 49 - 0
src/Avalonia.Controls/SelectedItems.cs

@@ -0,0 +1,49 @@
+// 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;
+using System.Collections.Generic;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    public interface ISelectedItemInfo
+    {
+        public IndexPath Path { get; }
+    }
+
+    internal class SelectedItems<TValue, Tinfo> : IReadOnlyList<TValue>
+        where Tinfo : ISelectedItemInfo
+    {
+        private readonly List<Tinfo> _infos;
+        private readonly Func<List<Tinfo>, int, TValue> _getAtImpl;
+
+        public SelectedItems(
+            List<Tinfo> infos,
+            int count,
+            Func<List<Tinfo>, int, TValue> getAtImpl)
+        {
+            _infos = infos;
+            _getAtImpl = getAtImpl;
+            Count = count;
+        }
+
+        public TValue this[int index] => _getAtImpl(_infos, index);
+
+        public int Count { get; }
+
+        public IEnumerator<TValue> GetEnumerator()
+        {
+            for (var i = 0; i < Count; ++i)
+            {
+                yield return this[i];
+            }
+        }
+
+        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+    }
+}

+ 848 - 0
src/Avalonia.Controls/SelectionModel.cs

@@ -0,0 +1,848 @@
+// 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;
+using System.ComponentModel;
+using System.Linq;
+using System.Reactive.Linq;
+using Avalonia.Controls.Utils;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    public class SelectionModel : ISelectionModel, IDisposable
+    {
+        private readonly SelectionNode _rootNode;
+        private bool _singleSelect;
+        private bool _autoSelect;
+        private int _operationCount;
+        private IReadOnlyList<IndexPath>? _selectedIndicesCached;
+        private IReadOnlyList<object?>? _selectedItemsCached;
+        private SelectionModelChildrenRequestedEventArgs? _childrenRequestedEventArgs;
+
+        public event EventHandler<SelectionModelChildrenRequestedEventArgs>? ChildrenRequested;
+        public event PropertyChangedEventHandler? PropertyChanged;
+        public event EventHandler<SelectionModelSelectionChangedEventArgs>? SelectionChanged;
+
+        public SelectionModel()
+        {
+            _rootNode = new SelectionNode(this, null);
+            SharedLeafNode = new SelectionNode(this, null);
+        }
+
+        public object? Source
+        {
+            get => _rootNode.Source;
+            set
+            {
+                if (_rootNode.Source != value)
+                {
+                    var raiseChanged = _rootNode.Source == null && SelectedIndices.Count > 0;
+
+                    if (_rootNode.Source != null)
+                    {
+                        if (_rootNode.Source != null)
+                        {
+                            using (var operation = new Operation(this))
+                            {
+                                ClearSelection(resetAnchor: true);
+                            }
+                        }
+                    }
+
+                    _rootNode.Source = value;
+                    ApplyAutoSelect();
+
+                    RaisePropertyChanged("Source");
+
+                    if (raiseChanged)
+                    {
+                        var e = new SelectionModelSelectionChangedEventArgs(
+                            null,
+                            SelectedIndices,
+                            null,
+                            SelectedItems);
+                        OnSelectionChanged(e);
+                    }
+                }
+            }
+        }
+
+        public bool SingleSelect
+        {
+            get => _singleSelect;
+            set
+            {
+                if (_singleSelect != value)
+                {
+                    _singleSelect = value;
+                    var selectedIndices = SelectedIndices;
+
+                    if (value && selectedIndices != null && selectedIndices.Count > 0)
+                    {
+                        using var operation = new Operation(this);
+
+                        // We want to be single select, so make sure there is only 
+                        // one selected item.
+                        var firstSelectionIndexPath = selectedIndices[0];
+                        ClearSelection(resetAnchor: true);
+                        SelectWithPathImpl(firstSelectionIndexPath, select: true);
+                        SelectedIndex = firstSelectionIndexPath;
+                    }
+
+                    RaisePropertyChanged("SingleSelect");
+                }
+            }
+        }
+
+        public bool RetainSelectionOnReset
+        {
+            get => _rootNode.RetainSelectionOnReset;
+            set => _rootNode.RetainSelectionOnReset = value;
+        }
+
+        public bool AutoSelect 
+        {
+            get => _autoSelect;
+            set
+            {
+                if (_autoSelect != value)
+                {
+                    _autoSelect = value;
+                    ApplyAutoSelect();
+                }
+            }
+        }
+
+        public IndexPath AnchorIndex
+        {
+            get
+            {
+                IndexPath anchor = default;
+
+                if (_rootNode.AnchorIndex >= 0)
+                {
+                    var path = new List<int>();
+                    SelectionNode? current = _rootNode;
+
+                    while (current?.AnchorIndex >= 0)
+                    {
+                        path.Add(current.AnchorIndex);
+                        current = current.GetAt(current.AnchorIndex, false);
+                    }
+
+                    anchor = new IndexPath(path);
+                }
+
+                return anchor;
+            }
+            set
+            {
+                if (value != null)
+                {
+                    SelectionTreeHelper.TraverseIndexPath(
+                        _rootNode,
+                        value,
+                        realizeChildren: true,
+                        (currentNode, path, depth, childIndex) => currentNode.AnchorIndex = path.GetAt(depth));
+                }
+                else
+                {
+                    _rootNode.AnchorIndex = -1;
+                }
+
+                RaisePropertyChanged("AnchorIndex");
+            }
+        }
+
+        public IndexPath SelectedIndex
+        {
+            get
+            {
+                IndexPath selectedIndex = default;
+                var selectedIndices = SelectedIndices;
+
+                if (selectedIndices?.Count > 0)
+                {
+                    selectedIndex = selectedIndices[0];
+                }
+
+                return selectedIndex;
+            }
+            set
+            {
+                var isSelected = IsSelectedWithPartialAt(value);
+
+                if (!IsSelectedAt(value) || SelectedItems.Count > 1)
+                {
+                    using var operation = new Operation(this);
+                    ClearSelection(resetAnchor: true);
+                    SelectWithPathImpl(value, select: true);
+                    ApplyAutoSelect();
+                }
+            }
+        }
+
+        public object? SelectedItem
+        {
+            get
+            {
+                object? item = null;
+                var selectedItems = SelectedItems;
+
+                if (selectedItems?.Count > 0)
+                {
+                    item = selectedItems[0];
+                }
+
+                return item;
+            }
+        }
+
+        public IReadOnlyList<object?> SelectedItems
+        {
+            get
+            {
+                if (_selectedItemsCached == null)
+                {
+                    var selectedInfos = new List<SelectedItemInfo>();
+                    var count = 0;
+
+                    if (_rootNode.Source != null)
+                    {
+                        SelectionTreeHelper.Traverse(
+                            _rootNode,
+                            realizeChildren: false,
+                            currentInfo =>
+                            {
+                                if (currentInfo.Node.SelectedCount > 0)
+                                {
+                                    selectedInfos.Add(new SelectedItemInfo(currentInfo.Node, currentInfo.Path));
+                                    count += currentInfo.Node.SelectedCount;
+                                }
+                            });
+                    }
+
+                    // Instead of creating a dumb vector that takes up the space for all the selected items,
+                    // we create a custom IReadOnlyList implementation that calls back using a delegate to find 
+                    // the selected item at a particular index. This avoid having to create the storage and copying
+                    // needed in a dumb vector. This also allows us to expose a tree of selected nodes into an 
+                    // easier to consume flat vector view of objects.
+                    var selectedItems = new SelectedItems<object?, SelectedItemInfo> (
+                        selectedInfos,
+                        count,
+                        (infos, index) =>
+                        {
+                            var currentIndex = 0;
+                            object? item = null;
+
+                            foreach (var info in infos)
+                            {
+                                var node = info.Node;
+
+                                if (node != null)
+                                {
+                                    var currentCount = node.SelectedCount;
+
+                                    if (index >= currentIndex && index < currentIndex + currentCount)
+                                    {
+                                        var targetIndex = node.SelectedIndices[index - currentIndex];
+                                        item = node.ItemsSourceView!.GetAt(targetIndex);
+                                        break;
+                                    }
+
+                                    currentIndex += currentCount;
+                                }
+                                else
+                                {
+                                    throw new InvalidOperationException(
+                                        "Selection has changed since SelectedItems property was read.");
+                                }
+                            }
+
+                            return item;
+                        });
+
+                    _selectedItemsCached = selectedItems;
+                }
+
+                return _selectedItemsCached;
+            }
+        }
+
+        public IReadOnlyList<IndexPath> SelectedIndices
+        {
+            get
+            {
+                if (_selectedIndicesCached == null)
+                {
+                    var selectedInfos = new List<SelectedItemInfo>();
+                    var count = 0;
+
+                    SelectionTreeHelper.Traverse(
+                        _rootNode,
+                        false,
+                        currentInfo =>
+                        {
+                            if (currentInfo.Node.SelectedCount > 0)
+                            {
+                                selectedInfos.Add(new SelectedItemInfo(currentInfo.Node, currentInfo.Path));
+                                count += currentInfo.Node.SelectedCount;
+                            }
+                        });
+
+                    // Instead of creating a dumb vector that takes up the space for all the selected indices,
+                    // we create a custom VectorView implimentation that calls back using a delegate to find 
+                    // the IndexPath at a particular index. This avoid having to create the storage and copying
+                    // needed in a dumb vector. This also allows us to expose a tree of selected nodes into an 
+                    // easier to consume flat vector view of IndexPaths.
+                    var indices = new SelectedItems<IndexPath, SelectedItemInfo>(
+                        selectedInfos,
+                        count,
+                        (infos, index) => // callback for GetAt(index)
+                        {
+                            var currentIndex = 0;
+                            IndexPath path = default;
+
+                            foreach (var info in infos)
+                            {
+                                var node = info.Node;
+
+                                if (node != null)
+                                {
+                                    var currentCount = node.SelectedCount;
+                                    if (index >= currentIndex && index < currentIndex + currentCount)
+                                    {
+                                        int targetIndex = node.SelectedIndices[index - currentIndex];
+                                        path = info.Path.CloneWithChildIndex(targetIndex);
+                                        break;
+                                    }
+
+                                    currentIndex += currentCount;
+                                }
+                                else
+                                {
+                                    throw new InvalidOperationException(
+                                        "Selection has changed since SelectedIndices property was read.");
+                                }
+                            }
+
+                            return path;
+                        });
+
+                    _selectedIndicesCached = indices;
+                }
+
+                return _selectedIndicesCached;
+            }
+        }
+
+        internal SelectionNode SharedLeafNode { get; private set; }
+
+        public void Dispose()
+        {
+            ClearSelection(resetAnchor: false);
+            _rootNode.Cleanup();
+            _rootNode.Dispose();
+            _selectedIndicesCached = null;
+            _selectedItemsCached = null;
+        }
+
+        public void SetAnchorIndex(int index) => AnchorIndex = new IndexPath(index);
+
+        public void SetAnchorIndex(int groupIndex, int index) => AnchorIndex = new IndexPath(groupIndex, index);
+
+        public void Select(int index)
+        {
+            using var operation = new Operation(this);
+            SelectImpl(index, select: true);
+        }
+
+        public void Select(int groupIndex, int itemIndex)
+        {
+            using var operation = new Operation(this);
+            SelectWithGroupImpl(groupIndex, itemIndex, select: true);
+        }
+
+        public void SelectAt(IndexPath index)
+        {
+            using var operation = new Operation(this);
+            SelectWithPathImpl(index, select: true);
+        }
+
+        public void Deselect(int index)
+        {
+            using var operation = new Operation(this);
+            SelectImpl(index, select: false);
+            ApplyAutoSelect();
+        }
+
+        public void Deselect(int groupIndex, int itemIndex)
+        {
+            using var operation = new Operation(this);
+            SelectWithGroupImpl(groupIndex, itemIndex, select: false);
+            ApplyAutoSelect();
+        }
+
+        public void DeselectAt(IndexPath index)
+        {
+            using var operation = new Operation(this);
+            SelectWithPathImpl(index, select: false);
+            ApplyAutoSelect();
+        }
+
+        public bool IsSelected(int index) => _rootNode.IsSelected(index);
+
+        public bool IsSelected(int grouIndex, int itemIndex)
+        {
+            return IsSelectedAt(new IndexPath(grouIndex, itemIndex));
+        }
+
+        public bool IsSelectedAt(IndexPath index)
+        {
+            var path = index;
+            SelectionNode? node = _rootNode;
+
+            for (int i = 0; i < path.GetSize() - 1; i++)
+            {
+                var childIndex = path.GetAt(i);
+                node = node.GetAt(childIndex, realizeChild: false);
+
+                if (node == null)
+                {
+                    return false;
+                }
+            }
+
+            return node.IsSelected(index.GetAt(index.GetSize() - 1));
+        }
+
+        public bool? IsSelectedWithPartial(int index)
+        {
+            if (index < 0)
+            {
+                throw new ArgumentException("Index must be >= 0", nameof(index));
+            }
+
+            var isSelected = _rootNode.IsSelectedWithPartial(index);
+            return isSelected;
+        }
+
+        public bool? IsSelectedWithPartial(int groupIndex, int itemIndex)
+        {
+            if (groupIndex < 0)
+            {
+                throw new ArgumentException("Group index must be >= 0", nameof(groupIndex));
+            }
+
+            if (itemIndex < 0)
+            {
+                throw new ArgumentException("Item index must be >= 0", nameof(itemIndex));
+            }
+
+            var isSelected = (bool?)false;
+            var childNode = _rootNode.GetAt(groupIndex, realizeChild: false);
+
+            if (childNode != null)
+            {
+                isSelected = childNode.IsSelectedWithPartial(itemIndex);
+            }
+
+            return isSelected;
+        }
+
+        public bool? IsSelectedWithPartialAt(IndexPath index)
+        {
+            var path = index;
+            var isRealized = true;
+            SelectionNode? node = _rootNode;
+
+            for (int i = 0; i < path.GetSize() - 1; i++)
+            {
+                var childIndex = path.GetAt(i);
+                node = node.GetAt(childIndex, realizeChild: false);
+
+                if (node == null)
+                {
+                    isRealized = false;
+                    break;
+                }
+            }
+
+            var isSelected = (bool?)false;
+
+            if (isRealized)
+            {
+                var size = path.GetSize();
+                if (size == 0)
+                {
+                    isSelected = SelectionNode.ConvertToNullableBool(node!.EvaluateIsSelectedBasedOnChildrenNodes());
+                }
+                else
+                {
+                    isSelected = node!.IsSelectedWithPartial(path.GetAt(size - 1));
+                }
+            }
+
+            return isSelected;
+        }
+
+        public void SelectRangeFromAnchor(int index)
+        {
+            using var operation = new Operation(this);
+            SelectRangeFromAnchorImpl(index, select: true);
+        }
+
+        public void SelectRangeFromAnchor(int endGroupIndex, int endItemIndex)
+        {
+            using var operation = new Operation(this);
+            SelectRangeFromAnchorWithGroupImpl(endGroupIndex, endItemIndex, select: true);
+        }
+
+        public void SelectRangeFromAnchorTo(IndexPath index)
+        {
+            using var operation = new Operation(this);
+            SelectRangeImpl(AnchorIndex, index, select: true);
+        }
+
+        public void DeselectRangeFromAnchor(int index)
+        {
+            using var operation = new Operation(this);
+            SelectRangeFromAnchorImpl(index, select: false);
+        }
+
+        public void DeselectRangeFromAnchor(int endGroupIndex, int endItemIndex)
+        {
+            using var operation = new Operation(this);
+            SelectRangeFromAnchorWithGroupImpl(endGroupIndex, endItemIndex, false /* select */);
+        }
+
+        public void DeselectRangeFromAnchorTo(IndexPath index)
+        {
+            using var operation = new Operation(this);
+            SelectRangeImpl(AnchorIndex, index, select: false);
+        }
+
+        public void SelectRange(IndexPath start, IndexPath end)
+        {
+            using var operation = new Operation(this);
+            SelectRangeImpl(start, end, select: true);
+        }
+
+        public void DeselectRange(IndexPath start, IndexPath end)
+        {
+            using var operation = new Operation(this);
+            SelectRangeImpl(start, end, select: false);
+        }
+
+        public void SelectAll()
+        {
+            using var operation = new Operation(this);
+
+            SelectionTreeHelper.Traverse(
+                _rootNode,
+                realizeChildren: true,
+                info =>
+                {
+                    if (info.Node.DataCount > 0)
+                    {
+                        info.Node.SelectAll();
+                    }
+                });
+        }
+
+        public void ClearSelection()
+        {
+            using var operation = new Operation(this);
+            ClearSelection(resetAnchor: true);
+            ApplyAutoSelect();
+        }
+
+        public IDisposable Update() => new Operation(this);
+
+        protected void OnPropertyChanged(string propertyName)
+        {
+            RaisePropertyChanged(propertyName);
+        }
+
+        private void RaisePropertyChanged(string propertyName)
+        {
+            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+        }
+
+        public void OnSelectionInvalidatedDueToCollectionChange(
+            bool selectionInvalidated,
+            IReadOnlyList<object?>? removedItems)
+        {
+            SelectionModelSelectionChangedEventArgs? e = null;
+
+            if (selectionInvalidated)
+            {
+                e = new SelectionModelSelectionChangedEventArgs(null, null, removedItems, null);
+            }
+
+            OnSelectionChanged(e);
+            ApplyAutoSelect();
+        }
+
+        internal IObservable<object?>? ResolvePath(object data, IndexPath dataIndexPath)
+        {
+            IObservable<object?>? resolved = null;
+
+            // Raise ChildrenRequested event if there is a handler
+            if (ChildrenRequested != null)
+            {
+                if (_childrenRequestedEventArgs == null)
+                {
+                    _childrenRequestedEventArgs = new SelectionModelChildrenRequestedEventArgs(data, dataIndexPath, false);
+                }
+                else
+                {
+                    _childrenRequestedEventArgs.Initialize(data, dataIndexPath, false);
+                }
+
+                ChildrenRequested(this, _childrenRequestedEventArgs);
+                resolved = _childrenRequestedEventArgs.Children;
+
+                // Clear out the values in the args so that it cannot be used after the event handler call.
+                _childrenRequestedEventArgs.Initialize(null, default, true);
+            }
+
+            return resolved;
+        }
+
+        private void ClearSelection(bool resetAnchor)
+        {
+            SelectionTreeHelper.Traverse(
+                _rootNode,
+                realizeChildren: false,
+                info => info.Node.Clear());
+
+            if (resetAnchor)
+            {
+                AnchorIndex = default;
+            }
+        }
+
+        private void OnSelectionChanged(SelectionModelSelectionChangedEventArgs? e = null)
+        {
+            _selectedIndicesCached = null;
+            _selectedItemsCached = null;
+
+            // Raise SelectionChanged event
+            if (e != null)
+            {
+                SelectionChanged?.Invoke(this, e);
+            }
+
+            RaisePropertyChanged(nameof(SelectedIndex));
+            RaisePropertyChanged(nameof(SelectedIndices));
+
+            if (_rootNode.Source != null)
+            {
+                RaisePropertyChanged(nameof(SelectedItem));
+                RaisePropertyChanged(nameof(SelectedItems));
+            }
+        }
+
+        private void SelectImpl(int index, bool select)
+        {
+            if (_singleSelect)
+            {
+                ClearSelection(resetAnchor: true);
+            }
+
+            var selected = _rootNode.Select(index, select);
+
+            if (selected)
+            {
+                AnchorIndex = new IndexPath(index);
+            }
+        }
+
+        private void SelectWithGroupImpl(int groupIndex, int itemIndex, bool select)
+        {
+            if (_singleSelect)
+            {
+                ClearSelection(resetAnchor: true);
+            }
+
+            var childNode = _rootNode.GetAt(groupIndex, realizeChild: true);
+            var selected = childNode!.Select(itemIndex, select);
+
+            if (selected)
+            {
+                AnchorIndex = new IndexPath(groupIndex, itemIndex);
+            }
+        }
+
+        private void SelectWithPathImpl(IndexPath index, bool select)
+        {
+            bool selected = false;
+
+            if (_singleSelect)
+            {
+                ClearSelection(resetAnchor: true);
+            }
+
+            SelectionTreeHelper.TraverseIndexPath(
+                _rootNode,
+                index,
+                true,
+                (currentNode, path, depth, childIndex) =>
+                {
+                    if (depth == path.GetSize() - 1)
+                    {
+                        selected = currentNode.Select(childIndex, select);
+                    }
+                }
+            );
+
+            if (selected)
+            {
+                AnchorIndex = index;
+            }
+        }
+
+        private void SelectRangeFromAnchorImpl(int index, bool select)
+        {
+            int anchorIndex = 0;
+            var anchor = AnchorIndex;
+
+            if (anchor != null)
+            {
+                anchorIndex = anchor.GetAt(0);
+            }
+
+            _rootNode.SelectRange(new IndexRange(anchorIndex, index), select);
+        }
+
+        private void SelectRangeFromAnchorWithGroupImpl(int endGroupIndex, int endItemIndex, bool select)
+        {
+            var startGroupIndex = 0;
+            var startItemIndex = 0;
+            var anchorIndex = AnchorIndex;
+
+            if (anchorIndex != null)
+            {
+                startGroupIndex = anchorIndex.GetAt(0);
+                startItemIndex = anchorIndex.GetAt(1);
+            }
+
+            // Make sure start > end
+            if (startGroupIndex > endGroupIndex ||
+                (startGroupIndex == endGroupIndex && startItemIndex > endItemIndex))
+            {
+                int temp = startGroupIndex;
+                startGroupIndex = endGroupIndex;
+                endGroupIndex = temp;
+                temp = startItemIndex;
+                startItemIndex = endItemIndex;
+                endItemIndex = temp;
+            }
+
+            for (int groupIdx = startGroupIndex; groupIdx <= endGroupIndex; groupIdx++)
+            {
+                var groupNode = _rootNode.GetAt(groupIdx, realizeChild: true)!;
+                int startIndex = groupIdx == startGroupIndex ? startItemIndex : 0;
+                int endIndex = groupIdx == endGroupIndex ? endItemIndex : groupNode.DataCount - 1;
+                groupNode.SelectRange(new IndexRange(startIndex, endIndex), select);
+            }
+        }
+
+        private void SelectRangeImpl(IndexPath start, IndexPath end, bool select)
+        {
+            var winrtStart = start;
+            var winrtEnd = end;
+
+            // Make sure start <= end 
+            if (winrtEnd.CompareTo(winrtStart) == -1)
+            {
+                var temp = winrtStart;
+                winrtStart = winrtEnd;
+                winrtEnd = temp;
+            }
+
+            // Note: Since we do not know the depth of the tree, we have to walk to each leaf
+            SelectionTreeHelper.TraverseRangeRealizeChildren(
+                _rootNode,
+                winrtStart,
+                winrtEnd,
+                info =>
+                {
+                    info.ParentNode!.Select(info.Path.GetAt(info.Path.GetSize() - 1), select);
+                });
+        }
+
+        private void BeginOperation()
+        {
+            if (_operationCount++ == 0)
+            {
+                _rootNode.BeginOperation();
+            }
+        }
+
+        private void EndOperation()
+        {
+            if (_operationCount == 0)
+            {
+                throw new AvaloniaInternalException("No selection operation in progress.");
+            }
+
+            SelectionModelSelectionChangedEventArgs? e = null;
+
+            if (--_operationCount == 0)
+            {
+                var changes = new List<SelectionNodeOperation>();
+                _rootNode.EndOperation(changes);
+
+                if (changes.Count > 0)
+                {
+                    var changeSet = new SelectionModelChangeSet(changes);
+                    e = changeSet.CreateEventArgs();
+                }
+            }
+
+            OnSelectionChanged(e);
+            _rootNode.Cleanup();
+        }
+
+        private void ApplyAutoSelect()
+        {
+            if (AutoSelect)
+            {
+                _selectedIndicesCached = null;
+
+                if (SelectedIndex == default && _rootNode.ItemsSourceView?.Count > 0)
+                {
+                    using var operation = new Operation(this);
+                    SelectImpl(0, true);
+                }
+            }
+        }
+
+        internal class SelectedItemInfo : ISelectedItemInfo
+        {
+            public SelectedItemInfo(SelectionNode node, IndexPath path)
+            {
+                Node = node;
+                Path = path;
+            }
+
+            public SelectionNode Node { get; }
+            public IndexPath Path { get; }
+            public int Count => Node.SelectedCount;
+        }
+
+        private struct Operation : IDisposable
+        {
+            private readonly SelectionModel _manager;
+            public Operation(SelectionModel manager) => (_manager = manager).BeginOperation();
+            public void Dispose() => _manager.EndOperation();
+        }
+    }
+}

+ 170 - 0
src/Avalonia.Controls/SelectionModelChangeSet.cs

@@ -0,0 +1,170 @@
+using System;
+using System.Collections.Generic;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    internal class SelectionModelChangeSet
+    {
+        private readonly List<SelectionNodeOperation> _changes;
+
+        public SelectionModelChangeSet(List<SelectionNodeOperation> changes)
+        {
+            _changes = changes;
+        }
+
+        public SelectionModelSelectionChangedEventArgs CreateEventArgs()
+        {
+            var deselectedIndexCount = 0;
+            var selectedIndexCount = 0;
+            var deselectedItemCount = 0;
+            var selectedItemCount = 0;
+
+            foreach (var change in _changes)
+            {
+                deselectedIndexCount += change.DeselectedCount;
+                selectedIndexCount += change.SelectedCount;
+
+                if (change.Items != null)
+                {
+                    deselectedItemCount += change.DeselectedCount;
+                    selectedItemCount += change.SelectedCount;
+                }
+            }
+
+            var deselectedIndices = new SelectedItems<IndexPath, SelectionNodeOperation>(
+                _changes,
+                deselectedIndexCount,
+                GetDeselectedIndexAt);
+            var selectedIndices = new SelectedItems<IndexPath, SelectionNodeOperation>(
+                _changes,
+                selectedIndexCount,
+                GetSelectedIndexAt);
+            var deselectedItems = new SelectedItems<object?, SelectionNodeOperation>(
+                _changes,
+                deselectedItemCount,
+                GetDeselectedItemAt);
+            var selectedItems = new SelectedItems<object?, SelectionNodeOperation>(
+                _changes,
+                selectedItemCount,
+                GetSelectedItemAt);
+
+            return new SelectionModelSelectionChangedEventArgs(
+                deselectedIndices,
+                selectedIndices,
+                deselectedItems,
+                selectedItems);
+        }
+
+        private IndexPath GetDeselectedIndexAt(
+            List<SelectionNodeOperation> infos,
+            int index)
+        {
+            static int GetCount(SelectionNodeOperation info) => info.DeselectedCount;
+            static List<IndexRange>? GetRanges(SelectionNodeOperation info) => info.DeselectedRanges;
+            return GetIndexAt(infos, index, x => GetCount(x), x => GetRanges(x));
+        }
+
+        private IndexPath GetSelectedIndexAt(
+            List<SelectionNodeOperation> infos,
+            int index)
+        {
+            static int GetCount(SelectionNodeOperation info) => info.SelectedCount;
+            static List<IndexRange>? GetRanges(SelectionNodeOperation info) => info.SelectedRanges;
+            return GetIndexAt(infos, index, x => GetCount(x), x => GetRanges(x));
+        }
+
+        private object? GetDeselectedItemAt(
+            List<SelectionNodeOperation> infos,
+            int index)
+        {
+            static int GetCount(SelectionNodeOperation info) => info.Items != null ? info.DeselectedCount : 0;
+            static List<IndexRange>? GetRanges(SelectionNodeOperation info) => info.DeselectedRanges;
+            return GetItemAt(infos, index, x => GetCount(x), x => GetRanges(x));
+        }
+
+        private object? GetSelectedItemAt(
+            List<SelectionNodeOperation> infos,
+            int index)
+        {
+            static int GetCount(SelectionNodeOperation info) => info.Items != null ? info.SelectedCount : 0;
+            static List<IndexRange>? GetRanges(SelectionNodeOperation info) => info.SelectedRanges;
+            return GetItemAt(infos, index, x => GetCount(x), x => GetRanges(x));
+        }
+
+        private IndexPath GetIndexAt(
+            List<SelectionNodeOperation> infos,
+            int index,
+            Func<SelectionNodeOperation, int> getCount,
+            Func<SelectionNodeOperation, List<IndexRange>?> getRanges)
+        {
+            var currentIndex = 0;
+            IndexPath path = default;
+
+            foreach (var info in infos)
+            {
+                var currentCount = getCount(info);
+
+                if (index >= currentIndex && index < currentIndex + currentCount)
+                {
+                    int targetIndex = GetIndexAt(getRanges(info), index - currentIndex);
+                    path = info.Path.CloneWithChildIndex(targetIndex);
+                    break;
+                }
+
+                currentIndex += currentCount;
+            }
+
+            return path;
+        }
+
+        private object? GetItemAt(
+            List<SelectionNodeOperation> infos,
+            int index,
+            Func<SelectionNodeOperation, int> getCount,
+            Func<SelectionNodeOperation, List<IndexRange>?> getRanges)
+        {
+            var currentIndex = 0;
+            object? item = null;
+
+            foreach (var info in infos)
+            {
+                var currentCount = getCount(info);
+
+                if (index >= currentIndex && index < currentIndex + currentCount)
+                {
+                    int targetIndex = GetIndexAt(getRanges(info), index - currentIndex);
+                    item = info.Items?.GetAt(targetIndex);
+                    break;
+                }
+
+                currentIndex += currentCount;
+            }
+
+            return item;
+        }
+
+        private int GetIndexAt(List<IndexRange>? ranges, int index)
+        {
+            var currentIndex = 0;
+
+            if (ranges != null)
+            {
+                foreach (var range in ranges)
+                {
+                    var currentCount = (range.End - range.Begin) + 1;
+
+                    if (index >= currentIndex && index < currentIndex + currentCount)
+                    {
+                        return range.Begin + (index - currentIndex);
+                    }
+
+                    currentIndex += currentCount;
+                }
+            }
+
+            throw new IndexOutOfRangeException();
+        }
+    }
+}

+ 83 - 0
src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs

@@ -0,0 +1,83 @@
+// 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;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Provides data for the <see cref="SelectionModel.ChildrenRequested"/> event.
+    /// </summary>
+    public class SelectionModelChildrenRequestedEventArgs : EventArgs
+    {
+        private object? _source;
+        private IndexPath _sourceIndexPath;
+        private bool _throwOnAccess;
+        
+        internal SelectionModelChildrenRequestedEventArgs(
+            object source,
+            IndexPath sourceIndexPath,
+            bool throwOnAccess)
+        {
+            source = source ?? throw new ArgumentNullException(nameof(source));
+            Initialize(source, sourceIndexPath, throwOnAccess);
+        }
+
+        /// <summary>
+        /// Gets or sets an observable which produces the children of the <see cref="Source"/>
+        /// object.
+        /// </summary>
+        public IObservable<object?>? Children { get; set; }
+
+        /// <summary>
+        /// Gets the object whose children are being requested.
+        /// </summary>        
+        public object Source
+        {
+            get
+            {
+                if (_throwOnAccess)
+                {
+                    throw new ObjectDisposedException(nameof(SelectionModelChildrenRequestedEventArgs));
+                }
+
+                return _source!;
+            }
+        }
+
+        /// <summary>
+        /// Gets the index of the object whose children are being requested.
+        /// </summary>        
+        public IndexPath SourceIndex
+        {
+            get
+            {
+                if (_throwOnAccess)
+                {
+                    throw new ObjectDisposedException(nameof(SelectionModelChildrenRequestedEventArgs));
+                }
+
+                return _sourceIndexPath;
+            }
+        }
+
+        internal void Initialize(
+            object? source,
+            IndexPath sourceIndexPath,
+            bool throwOnAccess)
+        {
+            if (!throwOnAccess && source == null)
+            {
+                throw new ArgumentNullException(nameof(source));
+            }
+
+            _source = source;
+            _sourceIndexPath = sourceIndexPath;
+            _throwOnAccess = throwOnAccess;
+        }
+    }
+}

+ 47 - 0
src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs

@@ -0,0 +1,47 @@
+// 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
+{
+    public class SelectionModelSelectionChangedEventArgs : EventArgs
+    {
+        public SelectionModelSelectionChangedEventArgs(
+            IReadOnlyList<IndexPath>? deselectedIndices,
+            IReadOnlyList<IndexPath>? selectedIndices,
+            IReadOnlyList<object?>? deselectedItems,
+            IReadOnlyList<object?>? selectedItems)
+        {
+            DeselectedIndices = deselectedIndices ?? Array.Empty<IndexPath>();
+            SelectedIndices = selectedIndices ?? Array.Empty<IndexPath>();
+            DeselectedItems = deselectedItems ?? Array.Empty<object?>();
+            SelectedItems= selectedItems ?? Array.Empty<object?>();
+        }
+
+        /// <summary>
+        /// Gets the indices of the items that were removed from the selection.
+        /// </summary>
+        public IReadOnlyList<IndexPath> DeselectedIndices { get; }
+
+        /// <summary>
+        /// Gets the indices of the items that were added to the selection.
+        /// </summary>
+        public IReadOnlyList<IndexPath> SelectedIndices { get; }
+
+        /// <summary>
+        /// Gets the items that were removed from the selection.
+        /// </summary>
+        public IReadOnlyList<object?> DeselectedItems { get; }
+
+        /// <summary>
+        /// Gets the items that were added to the selection.
+        /// </summary>
+        public IReadOnlyList<object?> SelectedItems { get; }
+    }
+}

+ 966 - 0
src/Avalonia.Controls/SelectionNode.cs

@@ -0,0 +1,966 @@
+// 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;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Linq;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Tracks nested selection.
+    /// </summary>
+    /// <remarks>
+    /// SelectionNode is the internal tree data structure that we keep track of for selection in 
+    /// a nested scenario. This would map to one ItemsSourceView/Collection. This node reacts to
+    /// collection changes and keeps the selected indices up to date. This can either be a leaf
+    /// node or a non leaf node.
+    /// </remarks>
+    internal class SelectionNode : IDisposable
+    {
+        private readonly SelectionModel _manager;
+        private readonly List<SelectionNode?> _childrenNodes = new List<SelectionNode?>();
+        private readonly SelectionNode? _parent;
+        private readonly List<IndexRange> _selected = new List<IndexRange>();
+        private readonly List<int> _selectedIndicesCached = new List<int>();
+        private IDisposable? _childrenSubscription;
+        private SelectionNodeOperation? _operation;
+        private object? _source;
+        private bool _selectedIndicesCacheIsValid;
+        private bool _retainSelectionOnReset;
+        private List<object?>? _selectedItems;
+
+        public SelectionNode(SelectionModel manager, SelectionNode? parent)
+        {
+            _manager = manager;
+            _parent = parent;
+        }
+
+        public int AnchorIndex { get; set; } = -1;
+
+        public bool RetainSelectionOnReset 
+        {
+            get => _retainSelectionOnReset;
+            set
+            {
+                if (_retainSelectionOnReset != value)
+                {
+                    _retainSelectionOnReset = value;
+
+                    if (_retainSelectionOnReset)
+                    {
+                        _selectedItems = new List<object?>();
+                        PopulateSelectedItemsFromSelectedIndices();
+                    }
+                    else
+                    {
+                        _selectedItems = null;
+                    }
+
+                    foreach (var child in _childrenNodes)
+                    {
+                        if (child != null)
+                        {
+                            child.RetainSelectionOnReset = value;
+                        }
+                    }
+                }
+            }
+        }
+
+        public object? Source
+        {
+            get => _source;
+            set
+            {
+                if (_source != value)
+                {
+                    if (_source != null)
+                    {
+                        ClearSelection();
+                        ClearChildNodes();
+                        UnhookCollectionChangedHandler();
+                    }
+
+                    _source = value;
+
+                    // Setup ItemsSourceView
+                    var newDataSource = value as ItemsSourceView;
+
+                    if (value != null && newDataSource == null)
+                    {
+                        newDataSource = new ItemsSourceView((IEnumerable)value);
+                    }
+
+                    ItemsSourceView = newDataSource;
+
+                    PopulateSelectedItemsFromSelectedIndices();
+                    HookupCollectionChangedHandler();
+                    OnSelectionChanged();
+                }
+            }
+        }
+
+        public ItemsSourceView? ItemsSourceView { get; private set; }
+        public int DataCount => ItemsSourceView?.Count ?? 0;
+        public int ChildrenNodeCount => _childrenNodes.Count;
+        public int RealizedChildrenNodeCount { get; private set; }
+
+        public IndexPath IndexPath
+        {
+            get
+            {
+                var path = new List<int>(); ;
+                var parent = _parent;
+                var child = this;
+                
+                while (parent != null)
+                {
+                    var childNodes = parent._childrenNodes;
+                    var index = childNodes.IndexOf(child);
+
+                    // We are walking up to the parent, so the path will be backwards
+                    path.Insert(0, index);
+                    child = parent;
+                    parent = parent._parent;
+                }
+
+                return new IndexPath(path);
+            }
+        }
+
+        // For a genuine tree view, we dont know which node is leaf until we 
+        // actually walk to it, so currently the tree builds up to the leaf. I don't 
+        // create a bunch of leaf node instances - instead i use the same instance m_leafNode to avoid 
+        // an explosion of node objects. However, I'm still creating the m_childrenNodes 
+        // collection unfortunately.
+        public SelectionNode? GetAt(int index, bool realizeChild)
+        {
+            SelectionNode? child = null;
+            
+            if (realizeChild)
+            {
+                if (ItemsSourceView == null || index < 0 || index >= ItemsSourceView.Count)
+                {
+                    throw new IndexOutOfRangeException();
+                }
+
+                if (_childrenNodes.Count == 0)
+                {
+                    if (ItemsSourceView != null)
+                    {
+                        for (int i = 0; i < ItemsSourceView.Count; i++)
+                        {
+                            _childrenNodes.Add(null);
+                        }
+                    }
+                }
+
+                if (_childrenNodes[index] == null)
+                {
+                    var childData = ItemsSourceView!.GetAt(index);
+                    IObservable<object?>? resolver = null;
+                    
+                    if (childData != null)
+                    {
+                        var childDataIndexPath = IndexPath.CloneWithChildIndex(index);
+                        resolver = _manager.ResolvePath(childData, childDataIndexPath);
+                    }
+
+                    if (resolver != null)
+                    {
+                        child = new SelectionNode(_manager, parent: this);
+                        child.SetChildrenObservable(resolver);
+                    }
+                    else if (childData is IEnumerable<object> || childData is IList)
+                    {
+                        child = new SelectionNode(_manager, parent: this);
+                        child.Source = childData;
+                    }
+                    else
+                    { 
+                        child = _manager.SharedLeafNode;
+                    }
+
+                    if (_operation != null && child != _manager.SharedLeafNode)
+                    {
+                        child.BeginOperation();
+                    }
+
+                    _childrenNodes[index] = child;
+                    RealizedChildrenNodeCount++;
+                }
+                else
+                {
+                    child = _childrenNodes[index];
+                }
+            }
+            else
+            {
+                if (_childrenNodes.Count > 0)
+                {
+                    child = _childrenNodes[index];
+                }
+            }
+
+            return child;
+        }
+
+        public void SetChildrenObservable(IObservable<object?> resolver)
+        {
+            _childrenSubscription = resolver.Subscribe(x => Source = x);
+        }
+
+        public int SelectedCount { get; private set; }
+
+        public bool IsSelected(int index)
+        {
+            var isSelected = false;
+
+            foreach (var range in _selected)
+            {
+                if (range.Contains(index))
+                {
+                    isSelected = true;
+                    break;
+                }
+            }
+
+            return isSelected;
+        }
+
+        // True  -> Selected
+        // False -> Not Selected
+        // Null  -> Some descendents are selected and some are not
+        public bool? IsSelectedWithPartial()
+        {
+            var isSelected = (bool?)false;
+
+            if (_parent != null)
+            {
+                var parentsChildren = _parent._childrenNodes;
+
+                var myIndexInParent = parentsChildren.IndexOf(this);
+                
+                if (myIndexInParent != -1)
+                {
+                    isSelected = _parent.IsSelectedWithPartial(myIndexInParent);
+                }
+            }
+
+            return isSelected;
+        }
+
+        // True  -> Selected
+        // False -> Not Selected
+        // Null  -> Some descendents are selected and some are not
+        public bool? IsSelectedWithPartial(int index)
+        {
+            SelectionState selectionState;
+
+            if (_childrenNodes.Count == 0 || // no nodes realized
+                _childrenNodes.Count <= index || // target node is not realized 
+                _childrenNodes[index] == null || // target node is not realized
+                _childrenNodes[index] == _manager.SharedLeafNode)  // target node is a leaf node.
+            {
+                // Ask parent if the target node is selected.
+                selectionState = IsSelected(index) ? SelectionState.Selected : SelectionState.NotSelected;
+            }
+            else
+            {
+                // targetNode is the node representing the index. This node is the parent. 
+                // targetNode is a non-leaf node, containing one or many children nodes. Evaluate 
+                // based on children of targetNode.
+                var targetNode = _childrenNodes[index];
+                selectionState = targetNode!.EvaluateIsSelectedBasedOnChildrenNodes();
+            }
+
+            return ConvertToNullableBool(selectionState);
+        }
+
+        public int SelectedIndex
+        {
+            get => SelectedCount > 0 ? SelectedIndices[0] : -1;
+            set
+            {
+                if (IsValidIndex(value) && (SelectedCount != 1 || !IsSelected(value)))
+                {
+                    ClearSelection();
+
+                    if (value != -1)
+                    {
+                        Select(value, true);
+                    }
+                }
+            }
+        }
+
+        public List<int> SelectedIndices
+        {
+            get
+            {
+                if (!_selectedIndicesCacheIsValid)
+                {
+                    _selectedIndicesCacheIsValid = true;
+                    
+                    foreach (var range in _selected)
+                    {
+                        for (int index = range.Begin; index <= range.End; index++)
+                        {
+                            // Avoid duplicates
+                            if (!_selectedIndicesCached.Contains(index))
+                            {
+                                _selectedIndicesCached.Add(index);
+                            }
+                        }
+                    }
+
+                    // Sort the list for easy consumption
+                    _selectedIndicesCached.Sort();
+                }
+
+                return _selectedIndicesCached;
+            }
+        }
+
+        public IEnumerable<object> SelectedItems
+        {
+            get => SelectedIndices.Select(x => ItemsSourceView!.GetAt(x));
+        }
+
+        public void Dispose()
+        {
+            _childrenSubscription?.Dispose();
+            ItemsSourceView?.Dispose();
+            ClearChildNodes();
+            UnhookCollectionChangedHandler();
+        }
+
+        public void BeginOperation()
+        {
+            if (_operation != null)
+            {
+                throw new AvaloniaInternalException("Selection operation already in progress.");
+            }
+
+            _operation = new SelectionNodeOperation(this);
+
+            for (var i = 0; i < _childrenNodes.Count; ++i)
+            {
+                var child = _childrenNodes[i];
+
+                if (child != null && child != _manager.SharedLeafNode)
+                {
+                    child.BeginOperation();
+                }
+            }
+        }
+
+        public void EndOperation(List<SelectionNodeOperation> changes)
+        {
+            if (_operation == null)
+            {
+                throw new AvaloniaInternalException("No selection operation in progress.");
+            }
+
+            if (_operation.HasChanges)
+            {
+                changes.Add(_operation);
+            }
+
+            _operation = null;
+
+            for (var i = 0; i < _childrenNodes.Count; ++i)
+            {
+                var child = _childrenNodes[i];
+
+                if (child != null && child != _manager.SharedLeafNode)
+                {
+                    child.EndOperation(changes);
+                }
+            }
+        }
+
+        public bool Cleanup()
+        {
+            var result = SelectedCount == 0;
+
+            for (var i = 0; i < _childrenNodes.Count; ++i)
+            {
+                var child = _childrenNodes[i];
+
+                if (child != null)
+                {
+                    if (child.Cleanup())
+                    {
+                        child.Dispose();
+                        _childrenNodes[i] = null;
+                    }
+                    else
+                    {
+                        result = false;
+                    }
+                }
+            }
+
+            return result;
+        }
+
+        public bool Select(int index, bool select)
+        {
+            return Select(index, select, raiseOnSelectionChanged: true);
+        }
+
+        public bool ToggleSelect(int index)
+        {
+            return Select(index, !IsSelected(index));
+        }
+
+        public void SelectAll()
+        {
+            if (ItemsSourceView != null)
+            {
+                var size = ItemsSourceView.Count;
+                
+                if (size > 0)
+                {
+                    SelectRange(new IndexRange(0, size - 1), select: true);
+                }
+            }
+        }
+
+        public void Clear() => ClearSelection();
+
+        public bool SelectRange(IndexRange range, bool select)
+        {
+            if (IsValidIndex(range.Begin) && IsValidIndex(range.End))
+            {
+                if (select)
+                {
+                    AddRange(range, raiseOnSelectionChanged: true);
+                }
+                else
+                {
+                    RemoveRange(range, raiseOnSelectionChanged: true);
+                }
+
+                return true;
+            }
+
+            return false;
+        }
+
+        private void HookupCollectionChangedHandler()
+        {
+            if (ItemsSourceView != null)
+            {
+                ItemsSourceView.CollectionChanged += OnSourceListChanged;
+            }
+        }
+
+        private void UnhookCollectionChangedHandler()
+        {
+            if (ItemsSourceView != null)
+            {
+                ItemsSourceView.CollectionChanged -= OnSourceListChanged;
+            }
+        }
+
+        private bool IsValidIndex(int index)
+        {
+            return ItemsSourceView == null || (index >= 0 && index < ItemsSourceView.Count);
+        }
+
+        private void AddRange(IndexRange addRange, bool raiseOnSelectionChanged)
+        {
+            var selected = new List<IndexRange>();
+
+            SelectedCount += IndexRange.Add(_selected, addRange, selected);
+
+            if (selected.Count > 0)
+            {
+                _operation?.Selected(selected);
+
+                if (_selectedItems != null && ItemsSourceView != null)
+                {
+                    for (var i = addRange.Begin; i <= addRange.End; ++i)
+                    {
+                        _selectedItems.Add(ItemsSourceView!.GetAt(i));
+                    }
+                }
+
+                if (raiseOnSelectionChanged)
+                {
+                    OnSelectionChanged();
+                }
+            }
+        }
+
+        private void RemoveRange(IndexRange removeRange, bool raiseOnSelectionChanged)
+        {
+            var removed = new List<IndexRange>();
+
+            SelectedCount -= IndexRange.Remove(_selected, removeRange, removed);
+
+            if (removed.Count > 0)
+            {
+                _operation?.Deselected(removed);
+
+                if (_selectedItems != null)
+                {
+                    for (var i = removeRange.Begin; i <= removeRange.End; ++i)
+                    {
+                        _selectedItems.Remove(ItemsSourceView!.GetAt(i));
+                    }
+                }
+
+                if (raiseOnSelectionChanged)
+                {
+                    OnSelectionChanged();
+                }
+            }
+        }
+
+        private void ClearSelection()
+        {
+            // Deselect all items
+            if (_selected.Count > 0)
+            {
+                _operation?.Deselected(_selected);
+                _selected.Clear();
+                OnSelectionChanged();
+            }
+
+            _selectedItems?.Clear();
+            SelectedCount = 0;
+            AnchorIndex = -1;
+        }
+
+        private void ClearChildNodes()
+        {
+            foreach (var child in _childrenNodes)
+            {
+                if (child != null && child != _manager.SharedLeafNode)
+                {
+                    child.Dispose();
+                }
+            }
+
+            RealizedChildrenNodeCount = 0;
+        }
+
+        private bool Select(int index, bool select, bool raiseOnSelectionChanged)
+        {
+            if (IsValidIndex(index))
+            {
+                // Ignore duplicate selection calls
+                if (IsSelected(index) == select)
+                {
+                    return true;
+                }
+
+                var range = new IndexRange(index, index);
+
+                if (select)
+                {
+                    AddRange(range, raiseOnSelectionChanged);
+                }
+                else
+                {
+                    RemoveRange(range, raiseOnSelectionChanged);
+                }
+
+                return true;
+            }
+
+            return false;
+        }
+
+        private void OnSourceListChanged(object dataSource, NotifyCollectionChangedEventArgs args)
+        {
+            bool selectionInvalidated = false;
+            List<object?>? removed = null;
+
+            switch (args.Action)
+            {
+                case NotifyCollectionChangedAction.Add:
+                {
+                    selectionInvalidated = OnItemsAdded(args.NewStartingIndex, args.NewItems.Count);
+                    break;
+                }
+
+                case NotifyCollectionChangedAction.Remove:
+                {
+                    (selectionInvalidated, removed) = OnItemsRemoved(args.OldStartingIndex, args.OldItems);
+                    break;
+                }
+
+                case NotifyCollectionChangedAction.Reset:
+                    {
+                        if (_selectedItems == null)
+                        {
+                            ClearSelection();
+                        }
+                        else
+                        {
+                            removed = RecreateSelectionFromSelectedItems();
+                        }
+
+                        selectionInvalidated = true;
+                        break;
+                    }
+
+                case NotifyCollectionChangedAction.Replace:
+                {
+                    (selectionInvalidated, removed) = OnItemsRemoved(args.OldStartingIndex, args.OldItems);
+                    selectionInvalidated |= OnItemsAdded(args.NewStartingIndex, args.NewItems.Count);
+                    break;
+                }
+            }
+
+            if (selectionInvalidated)
+            {
+                OnSelectionChanged();
+            }
+
+            _manager.OnSelectionInvalidatedDueToCollectionChange(selectionInvalidated, removed);
+        }
+
+        private bool OnItemsAdded(int index, int count)
+        {
+            var selectionInvalidated = false;
+            
+            // Update ranges for leaf items
+            var toAdd = new List<IndexRange>();
+
+            for (int i = 0; i < _selected.Count; i++)
+            {
+                var range = _selected[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.Add(before);
+                        begin = index;
+                    }
+
+                    // Shift the range to the right
+                    _selected[i] = new IndexRange(begin + count, range.End + count);
+                    selectionInvalidated = true;
+                }
+            }
+
+            // Add the left sides of the split ranges
+            _selected.AddRange(toAdd);
+
+            // Update for non-leaf if we are tracking non-leaf nodes
+            if (_childrenNodes.Count > 0)
+            {
+                selectionInvalidated = true;
+                for (int i = 0; i < count; i++)
+                {
+                    _childrenNodes.Insert(index, null);
+                }
+            }
+
+            // Adjust the anchor
+            if (AnchorIndex >= index)
+            {
+                AnchorIndex += count;
+            }
+
+            // Check if adding a node invalidated an ancestors
+            // selection state. For example if parent was selected before
+            // adding a new item makes the parent partially selected now.
+            if (!selectionInvalidated)
+            {
+                var parent = _parent;
+                
+                while (parent != null)
+                {
+                    var isSelected = parent.IsSelectedWithPartial();
+                    
+                    // If a parent is selected, then it will become partially selected.
+                    // If it is not selected or partially selected - there is no change.
+                    if (isSelected == true)
+                    {
+                        selectionInvalidated = true;
+                        break;
+                    }
+
+                    parent = parent._parent;
+                }
+            }
+
+            return selectionInvalidated;
+        }
+
+        private (bool, List<object?>) OnItemsRemoved(int index, IList items)
+        {
+            var selectionInvalidated = false;
+            var removed = new List<object?>();
+            var count = items.Count;
+            
+            // Remove the items from the selection for leaf
+            if (ItemsSourceView!.Count > 0)
+            {
+                bool isSelected = false;
+
+                for (int i = 0; i <= count - 1; i++)
+                {
+                    if (IsSelected(index + i))
+                    {
+                        isSelected = true;
+                        removed.Add(items[i]);
+                    }
+                }
+
+                if (isSelected)
+                {
+                    var removeRange = new IndexRange(index, index + count - 1);
+                    SelectedCount -= IndexRange.Remove(_selected, removeRange);
+                    selectionInvalidated = true;
+
+                    if (_selectedItems != null)
+                    {
+                        foreach (var i in items)
+                        {
+                            _selectedItems.Remove(i);
+                        }
+                    }
+                }
+
+                for (int i = 0; i < _selected.Count; i++)
+                {
+                    var range = _selected[i];
+
+                    // The range is after the removed items, need to shift the range left
+                    if (range.End > index)
+                    {
+                        // Shift the range to the left
+                        _selected[i] = new IndexRange(range.Begin - count, range.End - count);
+                        selectionInvalidated = true;
+                    }
+                }
+
+                // Update for non-leaf if we are tracking non-leaf nodes
+                if (_childrenNodes.Count > 0)
+                {
+                    selectionInvalidated = true;
+                    for (int i = 0; i < count; i++)
+                    {
+                        if (_childrenNodes[index] != null)
+                        {
+                            removed.AddRange(_childrenNodes[index]!.SelectedItems);
+                            RealizedChildrenNodeCount--;
+                            _childrenNodes[index]!.Dispose();
+                        }
+                        _childrenNodes.RemoveAt(index);
+                    }
+                }
+
+                //Adjust the anchor
+                if (AnchorIndex >= index)
+                {
+                    AnchorIndex -= count;
+                }
+            }
+            else
+            {
+                // No more items in the list, clear
+                ClearSelection();
+                RealizedChildrenNodeCount = 0;
+                selectionInvalidated = true;
+            }
+
+            // Check if removing a node invalidated an ancestors
+            // selection state. For example if parent was partially selected before
+            // removing an item, it could be selected now.
+            if (!selectionInvalidated)
+            {
+                var parent = _parent;
+                
+                while (parent != null)
+                {
+                    var isSelected = parent.IsSelectedWithPartial();
+                    // If a parent is partially selected, then it will become selected.
+                    // If it is selected or not selected - there is no change.
+                    if (!isSelected.HasValue)
+                    {
+                        selectionInvalidated = true;
+                        break;
+                    }
+
+                    parent = parent._parent;
+                }
+            }
+
+            return (selectionInvalidated, removed);
+        }
+
+        private void OnSelectionChanged()
+        {
+            _selectedIndicesCacheIsValid = false;
+            _selectedIndicesCached.Clear();
+        }
+
+        public static bool? ConvertToNullableBool(SelectionState isSelected)
+        {
+            bool? result = null; // PartialySelected
+
+            if (isSelected == SelectionState.Selected)
+            {
+                result = true;
+            }
+            else if (isSelected == SelectionState.NotSelected)
+            {
+                result = false;
+            }
+
+            return result;
+        }
+
+        public SelectionState EvaluateIsSelectedBasedOnChildrenNodes()
+        {
+            var selectionState = SelectionState.NotSelected;
+            int realizedChildrenNodeCount = RealizedChildrenNodeCount;
+            int selectedCount = SelectedCount;
+
+            if (realizedChildrenNodeCount != 0 || selectedCount != 0)
+            {
+                // There are realized children or some selected leaves.
+                int dataCount = DataCount;
+                if (realizedChildrenNodeCount == 0 && selectedCount > 0)
+                {
+                    // All nodes are leaves under it - we didn't create children nodes as an optimization.
+                    // See if all/some or none of the leaves are selected.
+                    selectionState = dataCount != selectedCount ?
+                        SelectionState.PartiallySelected :
+                        dataCount == selectedCount ? SelectionState.Selected : SelectionState.NotSelected;
+                }
+                else
+                {
+                    // There are child nodes, walk them individually and evaluate based on each child
+                    // being selected/not selected or partially selected.
+                    selectedCount = 0;
+                    int notSelectedCount = 0;
+                    for (int i = 0; i < ChildrenNodeCount; i++)
+                    {
+                        var child = GetAt(i, realizeChild: false);
+
+                        if (child != null)
+                        {
+                            // child is realized, ask it.
+                            var isChildSelected = IsSelectedWithPartial(i);
+                            if (isChildSelected == null)
+                            {
+                                selectionState = SelectionState.PartiallySelected;
+                                break;
+                            }
+                            else if (isChildSelected == true)
+                            {
+                                selectedCount++;
+                            }
+                            else
+                            {
+                                notSelectedCount++;
+                            }
+                        }
+                        else
+                        {
+                            // not realized.
+                            if (IsSelected(i))
+                            {
+                                selectedCount++;
+                            }
+                            else
+                            {
+                                notSelectedCount++;
+                            }
+                        }
+
+                        if (selectedCount > 0 && notSelectedCount > 0)
+                        {
+                            selectionState = SelectionState.PartiallySelected;
+                            break;
+                        }
+                    }
+
+                    if (selectionState != SelectionState.PartiallySelected)
+                    {
+                        if (selectedCount != 0 && selectedCount != dataCount)
+                        {
+                            selectionState = SelectionState.PartiallySelected;
+                        }
+                        else
+                        {
+                            selectionState = selectedCount == dataCount ? SelectionState.Selected : SelectionState.NotSelected;
+                        }
+                    }
+                }
+            }
+
+            return selectionState;
+        }
+
+        private void PopulateSelectedItemsFromSelectedIndices()
+        {
+            if (_selectedItems != null)
+            {
+                _selectedItems.Clear();
+
+                foreach (var i in SelectedIndices)
+                {
+                    _selectedItems.Add(ItemsSourceView!.GetAt(i));
+                }
+            }
+        }
+
+        private List<object?> RecreateSelectionFromSelectedItems()
+        {
+            var removed = new List<object?>();
+
+            _selected.Clear();
+            SelectedCount = 0;
+
+            for (var i = 0; i < _selectedItems!.Count; ++i)
+            {
+                var item = _selectedItems[i];
+                var index = ItemsSourceView!.IndexOf(item);
+
+                if (index != -1)
+                {
+                    IndexRange.Add(_selected, new IndexRange(index, index));
+                    ++SelectedCount;
+                }
+                else
+                {
+                    removed.Add(item);
+                    _selectedItems.RemoveAt(i--);
+                }
+            }
+
+            return removed;
+        }
+
+        public enum SelectionState
+        {
+            Selected,
+            NotSelected,
+            PartiallySelected
+        }
+    }
+}

+ 110 - 0
src/Avalonia.Controls/SelectionNodeOperation.cs

@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    internal class SelectionNodeOperation : ISelectedItemInfo
+    {
+        private readonly SelectionNode _owner;
+        private List<IndexRange>? _selected;
+        private List<IndexRange>? _deselected;
+        private int _selectedCount = -1;
+        private int _deselectedCount = -1;
+
+        public SelectionNodeOperation(SelectionNode owner)
+        {
+            _owner = owner;
+        }
+
+        public bool HasChanges => _selected?.Count > 0 || _deselected?.Count > 0;
+        public List<IndexRange>? SelectedRanges => _selected;
+        public List<IndexRange>? DeselectedRanges => _deselected;
+        public IndexPath Path => _owner.IndexPath;
+        public ItemsSourceView? Items => _owner.ItemsSourceView;
+
+        public int SelectedCount
+        {
+            get
+            {
+                if (_selectedCount == -1)
+                {
+                    _selectedCount = (_selected != null) ? IndexRange.GetCount(_selected) : 0;
+                }
+
+                return _selectedCount;
+            }
+        }
+
+        public int DeselectedCount
+        {
+            get
+            {
+                if (_deselectedCount == -1)
+                {
+                    _deselectedCount = (_deselected != null) ? IndexRange.GetCount(_deselected) : 0;
+                }
+
+                return _deselectedCount;
+            }
+        }
+
+        public void Selected(IndexRange range)
+        {
+            Add(range, ref _selected, _deselected);
+            _selectedCount = -1;
+        }
+
+        public void Selected(IEnumerable<IndexRange> ranges)
+        {
+            foreach (var range in ranges)
+            {
+                Selected(range);
+            }
+        }
+
+        public void Deselected(IndexRange range)
+        {
+            Add(range, ref _deselected, _selected);
+            _deselectedCount = -1;
+        }
+
+        public void Deselected(IEnumerable<IndexRange> ranges)
+        {
+            foreach (var range in ranges)
+            {
+                Deselected(range);
+            }
+        }
+
+        private static void Add(
+            IndexRange range,
+            ref List<IndexRange>? add,
+            List<IndexRange>? remove)
+        {
+            if (remove != null)
+            {
+                var removed = new List<IndexRange>();
+                IndexRange.Remove(remove, range, removed);
+                var selected = IndexRange.Subtract(range, removed);
+
+                if (selected.Any())
+                {
+                    add ??= new List<IndexRange>();
+
+                    foreach (var r in selected)
+                    {
+                        IndexRange.Add(add, r);
+                    }
+                }
+            }
+            else
+            {
+                add ??= new List<IndexRange>();
+                IndexRange.Add(add, range);
+            }
+        }
+    }
+}

+ 298 - 367
src/Avalonia.Controls/TreeView.cs

@@ -2,11 +2,12 @@
 using System;
 using System.Collections;
 using System.Collections.Generic;
-using System.Collections.Specialized;
+using System.ComponentModel;
 using System.Linq;
-using Avalonia.Collections;
 using Avalonia.Controls.Generators;
 using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Utils;
+using Avalonia.Data;
 using Avalonia.Input;
 using Avalonia.Input.Platform;
 using Avalonia.Interactivity;
@@ -42,15 +43,29 @@ namespace Avalonia.Controls
                 o => o.SelectedItems,
                 (o, v) => o.SelectedItems = v);
 
+        /// <summary>
+        /// Defines the <see cref="Selection"/> property.
+        /// </summary>
+        public static readonly DirectProperty<TreeView, ISelectionModel> SelectionProperty =
+            SelectingItemsControl.SelectionProperty.AddOwner<TreeView>(
+                o => o.Selection,
+                (o, v) => o.Selection = v);
+
         /// <summary>
         /// Defines the <see cref="SelectionMode"/> property.
         /// </summary>
         public static readonly StyledProperty<SelectionMode> SelectionModeProperty =
             ListBox.SelectionModeProperty.AddOwner<TreeView>();
 
-        private static readonly IList Empty = Array.Empty<object>();
+        /// <summary>
+        /// Defines the <see cref="SelectionChanged"/> property.
+        /// </summary>
+        public static RoutedEvent<SelectionChangedEventArgs> SelectionChangedEvent =
+            SelectingItemsControl.SelectionChangedEvent;
+
         private object _selectedItem;
-        private IList _selectedItems;
+        private ISelectionModel _selection;
+        private readonly SelectedItemsSync _selectedItems;
 
         /// <summary>
         /// Initializes static members of the <see cref="TreeView"/> class.
@@ -60,6 +75,13 @@ namespace Avalonia.Controls
             // HACK: Needed or SelectedItem property will not be found in Release build.
         }
 
+        public TreeView()
+        {
+            // Setting Selection to null causes a default SelectionModel to be created.
+            Selection = null;
+            _selectedItems = new SelectedItemsSync(Selection);
+        }
+
         /// <summary>
         /// Occurs when the control's selection changes.
         /// </summary>
@@ -84,8 +106,6 @@ namespace Avalonia.Controls
             set => SetValue(AutoScrollToSelectedItemProperty, value);
         }
 
-        private bool _syncingSelectedItems;
-
         /// <summary>
         /// Gets or sets the selection mode.
         /// </summary>
@@ -95,61 +115,102 @@ namespace Avalonia.Controls
             set => SetValue(SelectionModeProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets the selected item.
+        /// </summary>
         /// <summary>
         /// Gets or sets the selected item.
         /// </summary>
         public object SelectedItem
         {
-            get => _selectedItem;
-            set
-            {
-                var selectedItems = SelectedItems;
-
-                SetAndRaise(SelectedItemProperty, ref _selectedItem, value);
+            get => Selection.SelectedItem;
+            set => Selection.SelectedIndex = IndexFromItem(value);
+        }
 
-                if (value != null)
-                {
-                    if (selectedItems.Count != 1 || selectedItems[0] != value)
-                    {
-                        _syncingSelectedItems = true;
-                        SelectSingleItem(value);
-                        _syncingSelectedItems = false;
-                    }
-                }
-                else if (SelectedItems.Count > 0)
-                {
-                    SelectedItems.Clear();
-                }
-            }
+        /// <summary>
+        /// Gets or sets the selected items.
+        /// </summary>
+        protected IList SelectedItems
+        {
+            get => _selectedItems.GetOrCreateItems();
+            set => _selectedItems.SetItems(value);
         }
 
         /// <summary>
-        /// Gets the selected items.
+        /// Gets or sets a model holding the current selection.
         /// </summary>
-        public IList SelectedItems
+        public ISelectionModel Selection
         {
-            get
+            get => _selection;
+            set
             {
-                if (_selectedItems == null)
+                value ??= new SelectionModel
                 {
-                    _selectedItems = new AvaloniaList<object>();
-                    SubscribeToSelectedItems();
-                }
-
-                return _selectedItems;
-            }
+                    SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple),
+                    AutoSelect = SelectionMode.HasFlagCustom(SelectionMode.AlwaysSelected),
+                    RetainSelectionOnReset = true,
+                };
 
-            set
-            {
-                if (value?.IsFixedSize == true || value?.IsReadOnly == true)
+                if (_selection != value)
                 {
-                    throw new NotSupportedException(
-                        "Cannot use a fixed size or read-only collection as SelectedItems.");
-                }
+                    if (value == null)
+                    {
+                        throw new ArgumentNullException(nameof(value), "Cannot set Selection to null.");
+                    }
+                    else if (value.Source != null && value.Source != Items)
+                    {
+                        throw new ArgumentException("Selection has invalid Source.");
+                    }
+
+                    List<object> oldSelection = null;
+
+                    if (_selection != null)
+                    {
+                        oldSelection = Selection.SelectedItems.ToList();
+                        _selection.PropertyChanged -= OnSelectionModelPropertyChanged;
+                        _selection.SelectionChanged -= OnSelectionModelSelectionChanged;
+                        _selection.ChildrenRequested -= OnSelectionModelChildrenRequested;
+                        MarkContainersUnselected();
+                    }
+
+                    _selection = value;
+
+                    if (_selection != null)
+                    {
+                        _selection.Source = Items;
+                        _selection.PropertyChanged += OnSelectionModelPropertyChanged;
+                        _selection.SelectionChanged += OnSelectionModelSelectionChanged;
+                        _selection.ChildrenRequested += OnSelectionModelChildrenRequested;
 
-                UnsubscribeFromSelectedItems();
-                _selectedItems = value ?? new AvaloniaList<object>();
-                SubscribeToSelectedItems();
+                        if (_selection.SingleSelect)
+                        {
+                            SelectionMode &= ~SelectionMode.Multiple;
+                        }
+                        else
+                        {
+                            SelectionMode |= SelectionMode.Multiple;
+                        }
+
+                        if (_selection.AutoSelect)
+                        {
+                            SelectionMode |= SelectionMode.AlwaysSelected;
+                        }
+                        else
+                        {
+                            SelectionMode &= ~SelectionMode.AlwaysSelected;
+                        }
+
+                        UpdateContainerSelection();
+
+                        var selectedItem = SelectedItem;
+
+                        if (_selectedItem != selectedItem)
+                        {
+                            RaisePropertyChanged(SelectedItemProperty, _selectedItem, selectedItem);
+                            _selectedItem = selectedItem;
+                        }
+                    }
+                }
             }
         }
 
@@ -182,186 +243,12 @@ namespace Avalonia.Controls
         /// Note that this method only selects nodes currently visible due to their parent nodes
         /// being expanded: it does not expand nodes.
         /// </remarks>
-        public void SelectAll()
-        {
-            SynchronizeItems(SelectedItems, ItemContainerGenerator.Index.Items);
-        }
+        public void SelectAll() => Selection.SelectAll();
 
         /// <summary>
         /// Deselects all items in the <see cref="TreeView"/>.
         /// </summary>
-        public void UnselectAll()
-        {
-            SelectedItems.Clear();
-        }
-
-        /// <summary>
-        /// Subscribes to the <see cref="SelectedItems"/> CollectionChanged event, if any.
-        /// </summary>
-        private void SubscribeToSelectedItems()
-        {
-            if (_selectedItems is INotifyCollectionChanged incc)
-            {
-                incc.CollectionChanged += SelectedItemsCollectionChanged;
-            }
-
-            SelectedItemsCollectionChanged(
-                _selectedItems,
-                new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
-        }
-
-        private void SelectSingleItem(object item)
-        {
-            SelectedItems.Clear();
-            SelectedItems.Add(item);
-        }
-
-        /// <summary>
-        /// Called when the <see cref="SelectedItems"/> CollectionChanged event is raised.
-        /// </summary>
-        /// <param name="sender">The event sender.</param>
-        /// <param name="e">The event args.</param>
-        private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
-        {
-            IList added = null;
-            IList removed = null;
-
-            switch (e.Action)
-            {
-                case NotifyCollectionChangedAction.Add:
-
-                    SelectedItemsAdded(e.NewItems.Cast<object>().ToArray());
-
-                    if (AutoScrollToSelectedItem)
-                    {
-                        var container = (TreeViewItem)ItemContainerGenerator.Index.ContainerFromItem(e.NewItems[0]);
-
-                        container?.BringIntoView();
-                    }
-
-                    added = e.NewItems;
-
-                    break;
-                case NotifyCollectionChangedAction.Remove:
-
-                    if (!_syncingSelectedItems)
-                    {
-                        if (SelectedItems.Count == 0)
-                        {
-                            SelectedItem = null;
-                        }
-                        else
-                        {
-                            var selectedIndex = SelectedItems.IndexOf(_selectedItem);
-
-                            if (selectedIndex == -1)
-                            {
-                                var old = _selectedItem;
-                                _selectedItem = SelectedItems[0];
-
-                                RaisePropertyChanged(SelectedItemProperty, old, _selectedItem);
-                            }
-                        }
-                    }
-
-                    foreach (var item in e.OldItems)
-                    {
-                        MarkItemSelected(item, false);
-                    }
-
-                    removed = e.OldItems;
-
-                    break;
-                case NotifyCollectionChangedAction.Reset:
-
-                    foreach (IControl container in ItemContainerGenerator.Index.Containers)
-                    {
-                        MarkContainerSelected(container, false);
-                    }
-
-                    if (SelectedItems.Count > 0)
-                    {
-                        SelectedItemsAdded(SelectedItems);
-
-                        added = SelectedItems;
-                    }
-                    else if (!_syncingSelectedItems)
-                    {
-                        SelectedItem = null;
-                    }
-
-                    break;
-                case NotifyCollectionChangedAction.Replace:
-
-                    foreach (var item in e.OldItems)
-                    {
-                        MarkItemSelected(item, false);
-                    }
-
-                    foreach (var item in e.NewItems)
-                    {
-                        MarkItemSelected(item, true);
-                    }
-
-                    if (SelectedItem != SelectedItems[0] && !_syncingSelectedItems)
-                    {
-                        var oldItem = SelectedItem;
-                        var item = SelectedItems[0];
-                        _selectedItem = item;
-                        RaisePropertyChanged(SelectedItemProperty, oldItem, item);
-                    }
-
-                    added = e.NewItems;
-                    removed = e.OldItems;
-
-                    break;
-            }
-
-            if (added?.Count > 0 || removed?.Count > 0)
-            {
-                var changed = new SelectionChangedEventArgs(
-                    SelectingItemsControl.SelectionChangedEvent,
-                    removed ?? Empty,
-                    added ?? Empty);
-                RaiseEvent(changed);
-            }
-        }
-
-        private void MarkItemSelected(object item, bool selected)
-        {
-            var container = ItemContainerGenerator.Index.ContainerFromItem(item);
-
-            MarkContainerSelected(container, selected);
-        }
-
-        private void SelectedItemsAdded(IList items)
-        {
-            if (items.Count == 0)
-            {
-                return;
-            }
-
-            foreach (object item in items)
-            {
-                MarkItemSelected(item, true);
-            }
-
-            if (SelectedItem == null && !_syncingSelectedItems)
-            {
-                SetAndRaise(SelectedItemProperty, ref _selectedItem, items[0]);
-            }
-        }
-
-        /// <summary>
-        /// Unsubscribes from the <see cref="SelectedItems"/> CollectionChanged event, if any.
-        /// </summary>
-        private void UnsubscribeFromSelectedItems()
-        {
-            if (_selectedItems is INotifyCollectionChanged incc)
-            {
-                incc.CollectionChanged -= SelectedItemsCollectionChanged;
-            }
-        }
+        public void UnselectAll() => Selection.ClearSelection();
 
         (bool handled, IInputElement next) ICustomKeyboardNavigation.GetNext(IInputElement element,
             NavigationDirection direction)
@@ -403,7 +290,7 @@ namespace Avalonia.Controls
                 e.Handled = UpdateSelectionFromEventSource(
                     e.Source,
                     true,
-                    (e.KeyModifiers & KeyModifiers.Shift) != 0);
+                    (e.InputModifiers & InputModifiers.Shift) != 0);
             }
         }
 
@@ -445,6 +332,72 @@ namespace Avalonia.Controls
             }
         }
 
+        /// <summary>
+        /// Called when <see cref="SelectionModel.PropertyChanged"/> is raised.
+        /// </summary>
+        /// <param name="sender">The sender.</param>
+        /// <param name="e">The event args.</param>
+        private void OnSelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e)
+        {
+            if (e.PropertyName == nameof(SelectionModel.AnchorIndex) && AutoScrollToSelectedItem)
+            {
+                var container = ContainerFromIndex(Selection.AnchorIndex);
+
+                if (container != null)
+                {
+                    container.BringIntoView();
+                }
+            }
+        }
+
+        /// <summary>
+        /// Called when <see cref="SelectionModel.SelectionChanged"/> is raised.
+        /// </summary>
+        /// <param name="sender">The sender.</param>
+        /// <param name="e">The event args.</param>
+        private void OnSelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e)
+        {
+            void Mark(IndexPath index, bool selected)
+            {
+                var container = ContainerFromIndex(index);
+
+                if (container != null)
+                {
+                    MarkContainerSelected(container, selected);
+                }
+            }
+
+            foreach (var i in e.SelectedIndices)
+            {
+                Mark(i, true);
+            }
+
+            foreach (var i in e.DeselectedIndices)
+            {
+                Mark(i, false);
+            }
+
+            var newSelectedItem = SelectedItem;
+
+            if (newSelectedItem != _selectedItem)
+            {
+                RaisePropertyChanged(SelectedItemProperty, _selectedItem, newSelectedItem);
+                _selectedItem = newSelectedItem;
+            }
+
+            var ev = new SelectionChangedEventArgs(
+                SelectionChangedEvent,
+                e.DeselectedItems.ToList(),
+                e.SelectedItems.ToList());
+            RaiseEvent(ev);
+        }
+
+        private void OnSelectionModelChildrenRequested(object sender, SelectionModelChildrenRequestedEventArgs e)
+        {
+            var container = ItemContainerGenerator.Index.ContainerFromItem(e.Source) as ItemsControl;
+            e.Children = container.GetObservable(ItemsProperty);
+        }
+
         private TreeViewItem GetContainerInDirection(
             TreeViewItem from,
             NavigationDirection direction,
@@ -498,6 +451,12 @@ namespace Avalonia.Controls
             return result;
         }
 
+        protected override void ItemsChanged(AvaloniaPropertyChangedEventArgs e)
+        {
+            Selection.Source = Items;
+            base.ItemsChanged(e);
+        }
+
         /// <inheritdoc/>
         protected override void OnPointerPressed(PointerPressedEventArgs e)
         {
@@ -519,6 +478,18 @@ namespace Avalonia.Controls
             }
         }
 
+        protected override void OnPropertyChanged<T>(AvaloniaProperty<T> property, Optional<T> oldValue, BindingValue<T> newValue, BindingPriority priority)
+        {
+            base.OnPropertyChanged(property, oldValue, newValue, priority);
+
+            if (property == SelectionModeProperty)
+            {
+                var mode = newValue.GetValueOrDefault<SelectionMode>();
+                Selection.SingleSelect = !mode.HasFlagCustom(SelectionMode.Multiple);
+                Selection.AutoSelect = mode.HasFlagCustom(SelectionMode.AlwaysSelected);
+            }
+        }
+
         /// <summary>
         /// Updates the selection for an item based on user interaction.
         /// </summary>
@@ -534,9 +505,9 @@ namespace Avalonia.Controls
             bool toggleModifier = false,
             bool rightButton = false)
         {
-            var item = ItemContainerGenerator.Index.ItemFromContainer(container);
+            var index = IndexFromContainer((TreeViewItem)container);
 
-            if (item == null)
+            if (index.GetSize() == 0)
             {
                 return;
             }
@@ -553,41 +524,48 @@ namespace Avalonia.Controls
             var multi = (mode & SelectionMode.Multiple) != 0;
             var range = multi && selectedContainer != null && rangeModifier;
 
-            if (rightButton)
+            if (!select)
+            {
+                Selection.DeselectAt(index);
+            }
+            else if (rightButton)
             {
-                if (!SelectedItems.Contains(item))
+                if (!Selection.IsSelectedAt(index))
                 {
-                    SelectSingleItem(item);
+                    Selection.SelectedIndex = index;
                 }
             }
             else if (!toggle && !range)
             {
-                SelectSingleItem(item);
+                Selection.SelectedIndex = index;
             }
             else if (multi && range)
             {
-                SynchronizeItems(
-                    SelectedItems,
-                    GetItemsInRange(selectedContainer as TreeViewItem, container as TreeViewItem));
+                using var operation = Selection.Update();
+                var anchor = Selection.AnchorIndex;
+
+                if (anchor.GetSize() == 0)
+                {
+                    anchor = new IndexPath(0);
+                }
+
+                Selection.ClearSelection();
+                Selection.AnchorIndex = anchor;
+                Selection.SelectRangeFromAnchorTo(index);
             }
             else
             {
-                var i = SelectedItems.IndexOf(item);
-
-                if (i != -1)
+                if (Selection.IsSelectedAt(index))
                 {
-                    SelectedItems.Remove(item);
+                    Selection.DeselectAt(index);
+                }
+                else if (multi)
+                {
+                    Selection.SelectAt(index);
                 }
                 else
                 {
-                    if (multi)
-                    {
-                        SelectedItems.Add(item);
-                    }
-                    else
-                    {
-                        SelectedItem = item;
-                    }
+                    Selection.SelectedIndex = index;
                 }
             }
         }
@@ -610,117 +588,6 @@ namespace Avalonia.Controls
             }
         }
 
-        /// <summary>
-        /// Find which node is first in hierarchy.
-        /// </summary>
-        /// <param name="treeView">Search root.</param>
-        /// <param name="nodeA">Nodes to find.</param>
-        /// <param name="nodeB">Node to find.</param>
-        /// <returns>Found first node.</returns>
-        private static TreeViewItem FindFirstNode(TreeView treeView, TreeViewItem nodeA, TreeViewItem nodeB)
-        {
-            return FindInContainers(treeView.ItemContainerGenerator, nodeA, nodeB);
-        }
-
-        private static TreeViewItem FindInContainers(ITreeItemContainerGenerator containerGenerator,
-            TreeViewItem nodeA,
-            TreeViewItem nodeB)
-        {
-            IEnumerable<ItemContainerInfo> containers = containerGenerator.Containers;
-
-            foreach (ItemContainerInfo container in containers)
-            {
-                TreeViewItem node = FindFirstNode(container.ContainerControl as TreeViewItem, nodeA, nodeB);
-
-                if (node != null)
-                {
-                    return node;
-                }
-            }
-
-            return null;
-        }
-
-        private static TreeViewItem FindFirstNode(TreeViewItem node, TreeViewItem nodeA, TreeViewItem nodeB)
-        {
-            if (node == null)
-            {
-                return null;
-            }
-
-            TreeViewItem match = node == nodeA ? nodeA : node == nodeB ? nodeB : null;
-
-            if (match != null)
-            {
-                return match;
-            }
-
-            return FindInContainers(node.ItemContainerGenerator, nodeA, nodeB);
-        }
-
-        /// <summary>
-        /// Returns all items that belong to containers between <paramref name="from"/> and <paramref name="to"/>.
-        /// The range is inclusive.
-        /// </summary>
-        /// <param name="from">From container.</param>
-        /// <param name="to">To container.</param>
-        private List<object> GetItemsInRange(TreeViewItem from, TreeViewItem to)
-        {
-            var items = new List<object>();
-
-            if (from == null || to == null)
-            {
-                return items;
-            }
-
-            TreeViewItem firstItem = FindFirstNode(this, from, to);
-
-            if (firstItem == null)
-            {
-                return items;
-            }
-
-            bool wasReversed = false;
-
-            if (firstItem == to)
-            {
-                var temp = from;
-
-                from = to;
-                to = temp;
-
-                wasReversed = true;
-            }
-
-            TreeViewItem node = from;
-
-            while (node != to)
-            {
-                var item = ItemContainerGenerator.Index.ItemFromContainer(node);
-
-                if (item != null)
-                {
-                    items.Add(item);
-                }
-
-                node = GetContainerInDirection(node, NavigationDirection.Down, true);
-            }
-
-            var toItem = ItemContainerGenerator.Index.ItemFromContainer(to);
-
-            if (toItem != null)
-            {
-                items.Add(toItem);
-            }
-
-            if (wasReversed)
-            {
-                items.Reverse();
-            }
-
-            return items;
-        }
-
         /// <summary>
         /// Updates the selection based on an event that may have originated in a container that 
         /// belongs to the control.
@@ -826,26 +693,90 @@ namespace Avalonia.Controls
             }
         }
 
-        /// <summary>
-        /// Makes a list of objects equal another (though doesn't preserve order).
-        /// </summary>
-        /// <param name="items">The items collection.</param>
-        /// <param name="desired">The desired items.</param>
-        private static void SynchronizeItems(IList items, IEnumerable<object> desired)
+        private void MarkContainersUnselected()
         {
-            var list = items.Cast<object>().ToList();
-            var toRemove = list.Except(desired).ToList();
-            var toAdd = desired.Except(list).ToList();
+            foreach (var container in ItemContainerGenerator.Index.Containers)
+            {
+                MarkContainerSelected(container, false);
+            }
+        }
+
+        private void UpdateContainerSelection()
+        {
+            var index = ItemContainerGenerator.Index;
+
+            foreach (var container in index.Containers)
+            {
+                var i = IndexFromContainer((TreeViewItem)container);
+
+                MarkContainerSelected(
+                    container,
+                    Selection.IsSelectedAt(i) != false);
+            }
+        }
+
+        private static IndexPath IndexFromContainer(TreeViewItem container)
+        {
+            var result = new List<int>();
+
+            while (true)
+            {
+                if (container.Level == 0)
+                {
+                    var treeView = container.FindAncestorOfType<TreeView>();
+
+                    if (treeView == null)
+                    {
+                        return default;
+                    }
+
+                    result.Add(treeView.ItemContainerGenerator.IndexFromContainer(container));
+                    result.Reverse();
+                    return new IndexPath(result);
+                }
+                else
+                {
+                    var parent = container.FindAncestorOfType<TreeViewItem>();
+
+                    if (parent == null)
+                    {
+                        return default;
+                    }
+
+                    result.Add(parent.ItemContainerGenerator.IndexFromContainer(container));
+                    container = parent;
+                }
+            }
+        }
+
+        private IndexPath IndexFromItem(object item)
+        {
+            var container = ItemContainerGenerator.Index.ContainerFromItem(item) as TreeViewItem;
 
-            foreach (var i in toRemove)
+            if (container != null)
             {
-                items.Remove(i);
+                return IndexFromContainer(container);
             }
 
-            foreach (var i in toAdd)
+            return default;
+        }
+
+        private TreeViewItem ContainerFromIndex(IndexPath index)
+        {
+            TreeViewItem treeViewItem = null;
+
+            for (var i = 0; i < index.GetSize(); ++i)
             {
-                items.Add(i);
+                var generator = treeViewItem?.ItemContainerGenerator ?? ItemContainerGenerator;
+                treeViewItem = generator.ContainerFromIndex(index.GetAt(i)) as TreeViewItem;
+
+                if (treeViewItem == null)
+                {
+                    return null;
+                }
             }
+
+            return treeViewItem;
         }
     }
 }

+ 227 - 0
src/Avalonia.Controls/Utils/SelectedItemsSync.cs

@@ -0,0 +1,227 @@
+using System;
+using System.Collections;
+using System.Collections.Specialized;
+using System.Linq;
+using Avalonia.Collections;
+
+#nullable enable
+
+namespace Avalonia.Controls.Utils
+{
+    /// <summary>
+    /// Synchronizes an <see cref="ISelectionModel"/> with a list of SelectedItems.
+    /// </summary>
+    internal class SelectedItemsSync
+    {
+        private IList? _items;
+        private bool _updatingItems;
+        private bool _updatingModel;
+
+        public SelectedItemsSync(ISelectionModel model)
+        {
+            model = model ?? throw new ArgumentNullException(nameof(model));
+            Model = model;
+        }
+
+        public ISelectionModel Model { get; private set; }
+
+        public IList GetOrCreateItems()
+        {
+            if (_items == null)
+            {
+                var items = new AvaloniaList<object>(Model.SelectedItems);
+                items.CollectionChanged += ItemsCollectionChanged;
+                Model.SelectionChanged += SelectionModelSelectionChanged;
+                _items = items;
+            }
+
+            return _items;
+        }
+
+        public void SetItems(IList? items)
+        {
+            items ??= new AvaloniaList<object>();
+
+            if (items.IsFixedSize)
+            {
+                throw new NotSupportedException(
+                    "Cannot assign fixed size selection to SelectedItems.");
+            }
+
+            if (_items is INotifyCollectionChanged incc)
+            {
+                incc.CollectionChanged -= ItemsCollectionChanged;
+            }
+
+            if (_items == null)
+            {
+                Model.SelectionChanged += SelectionModelSelectionChanged;
+            }
+
+            try
+            {
+                _updatingModel = true;
+                _items = items;
+
+                using (Model.Update())
+                {
+                    Model.ClearSelection();
+                    Add(items);
+                }
+
+                if (_items is INotifyCollectionChanged incc2)
+                {
+                    incc2.CollectionChanged += ItemsCollectionChanged;
+                }
+            }
+            finally
+            {
+                _updatingModel = false;
+            }
+        }
+
+        public void SetModel(ISelectionModel model)
+        {
+            model = model ?? throw new ArgumentNullException(nameof(model));
+
+            if (_items != null)
+            {
+                Model.SelectionChanged -= SelectionModelSelectionChanged;
+                Model = model;
+                Model.SelectionChanged += SelectionModelSelectionChanged;
+
+                try
+                {
+                    _updatingItems = true;
+                    _items.Clear();
+
+                    foreach (var i in model.SelectedItems)
+                    {
+                        _items.Add(i);
+                    }
+                }
+                finally
+                {
+                    _updatingItems = false;
+                }
+            }
+        }
+
+        private void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+        {
+            if (_updatingItems)
+            {
+                return;
+            }
+
+            if (_items == null)
+            {
+                throw new AvaloniaInternalException("CollectionChanged raised but we don't have items.");
+            }
+
+            void Remove()
+            {
+                foreach (var i in e.OldItems)
+                {
+                    var index = IndexOf(Model.Source, i);
+
+                    if (index != -1)
+                    {
+                        Model.Deselect(index);
+                    }
+                }
+            }
+
+            try
+            {
+                using var operation = Model.Update();
+
+                _updatingModel = true;
+
+                switch (e.Action)
+                {
+                    case NotifyCollectionChangedAction.Add:
+                        Add(e.NewItems);
+                        break;
+                    case NotifyCollectionChangedAction.Remove:
+                        Remove();
+                        break;
+                    case NotifyCollectionChangedAction.Replace:
+                        Remove();
+                        Add(e.NewItems);
+                        break;
+                    case NotifyCollectionChangedAction.Reset:
+                        Model.ClearSelection();
+                        Add(_items);
+                        break;
+                }
+            }
+            finally
+            {
+                _updatingModel = false;
+            }
+        }
+
+        private void Add(IList newItems)
+        {
+            foreach (var i in newItems)
+            {
+                var index = IndexOf(Model.Source, i);
+
+                if (index != -1)
+                {
+                    Model.Select(index);
+                }
+            }
+        }
+
+        private void SelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e)
+        {
+            if (_updatingModel)
+            {
+                return;
+            }
+
+            if (_items == null)
+            {
+                throw new AvaloniaInternalException("SelectionModelChanged raised but we don't have items.");
+            }
+
+            try
+            {
+                var deselected = e.DeselectedItems.ToList();
+                var selected = e.SelectedItems.ToList();
+
+                _updatingItems = true;
+
+                foreach (var i in deselected)
+                {
+                    _items.Remove(i);
+                }
+
+                foreach (var i in selected)
+                {
+                    _items.Add(i);
+                }
+            }
+            finally
+            {
+                _updatingItems = false;
+            }
+        }
+
+        private static int IndexOf(object source, object item)
+        {
+            if (source is IList l)
+            {
+                return l.IndexOf(item);
+            }
+            else if (source is ItemsSourceView v)
+            {
+                return v.IndexOf(item);
+            }
+
+            return -1;
+        }
+    }
+}

+ 189 - 0
src/Avalonia.Controls/Utils/SelectionTreeHelper.cs

@@ -0,0 +1,189 @@
+// 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;
+using System.Linq;
+
+#nullable enable
+
+namespace Avalonia.Controls.Utils
+{
+    internal static class SelectionTreeHelper
+    {
+        public static void TraverseIndexPath(
+            SelectionNode root,
+            IndexPath path,
+            bool realizeChildren,
+            Action<SelectionNode, IndexPath, int, int> nodeAction)
+        {
+            var node = root;
+
+            for (int depth = 0; depth < path.GetSize(); depth++)
+            {
+                int childIndex = path.GetAt(depth);
+                nodeAction(node, path, depth, childIndex);
+
+                if (depth < path.GetSize() - 1)
+                {
+                    node = node.GetAt(childIndex, realizeChildren)!;
+                }
+            }
+        }
+
+        public static void Traverse(
+            SelectionNode root,
+            bool realizeChildren,
+            Action<TreeWalkNodeInfo> nodeAction)
+        {
+            var pendingNodes = new List<TreeWalkNodeInfo>();
+            var current = new IndexPath(null);
+
+            pendingNodes.Add(new TreeWalkNodeInfo(root, current));
+
+            while (pendingNodes.Count > 0)
+            {
+                var nextNode = pendingNodes.Last();
+                pendingNodes.RemoveAt(pendingNodes.Count - 1);
+                int count = realizeChildren ? nextNode.Node.DataCount : nextNode.Node.ChildrenNodeCount;
+                for (int i = count - 1; i >= 0; i--)
+                {
+                    var child = nextNode.Node.GetAt(i, realizeChildren);
+                    var childPath = nextNode.Path.CloneWithChildIndex(i);
+                    if (child != null)
+                    {
+                        pendingNodes.Add(new TreeWalkNodeInfo(child, childPath, nextNode.Node));
+                    }
+                }
+
+                // Queue the children first and then perform the action. This way
+                // the action can remove the children in the action if necessary
+                nodeAction(nextNode);
+            }
+        }
+
+        public static void TraverseRangeRealizeChildren(
+            SelectionNode root,
+            IndexPath start,
+            IndexPath end,
+            Action<TreeWalkNodeInfo> nodeAction)
+        {
+            var pendingNodes = new List<TreeWalkNodeInfo>();
+            var current = start;
+
+            // Build up the stack to account for the depth first walk up to the 
+            // start index path.
+            TraverseIndexPath(
+                root,
+                start,
+                true,
+                (node, path, depth, childIndex) =>
+                {
+                    var currentPath = StartPath(path, depth);
+                    bool isStartPath = IsSubSet(start, currentPath);
+                    bool isEndPath = IsSubSet(end, currentPath);
+
+                    int startIndex = depth < start.GetSize() && isStartPath ? start.GetAt(depth) : 0;
+                    int endIndex = depth < end.GetSize() && isEndPath ? end.GetAt(depth) : node.DataCount - 1;
+
+                    for (int i = endIndex; i >= startIndex; i--)
+                    {
+                        var child = node.GetAt(i, realizeChild: true);
+                        if (child != null)
+                        {
+                            var childPath = currentPath.CloneWithChildIndex(i);
+                            pendingNodes.Add(new TreeWalkNodeInfo(child, childPath, node));
+                        }
+                    }
+                });
+
+            // From the start index path, do a depth first walk as long as the
+            // current path is less than the end path.
+            while (pendingNodes.Count > 0)
+            {
+                var info = pendingNodes.Last();
+                pendingNodes.RemoveAt(pendingNodes.Count - 1);
+                int depth = info.Path.GetSize();
+                bool isStartPath = IsSubSet(start, info.Path);
+                bool isEndPath = IsSubSet(end, info.Path);
+                int startIndex = depth < start.GetSize() && isStartPath ? start.GetAt(depth) : 0;
+                int endIndex = depth < end.GetSize() && isEndPath ? end.GetAt(depth) : info.Node.DataCount - 1;
+                for (int i = endIndex; i >= startIndex; i--)
+                {
+                    var child = info.Node.GetAt(i, realizeChild: true);
+                    if (child != null)
+                    {
+                        var childPath = info.Path.CloneWithChildIndex(i);
+                        pendingNodes.Add(new TreeWalkNodeInfo(child, childPath, info.Node));
+                    }
+                }
+
+                nodeAction(info);
+
+                if (info.Path.CompareTo(end) == 0)
+                {
+                    // We reached the end index path. stop iterating.
+                    break;
+                }
+            }
+        }
+
+        private static bool IsSubSet(IndexPath path, IndexPath subset)
+        {
+            var subsetSize = subset.GetSize();
+            if (path.GetSize() < subsetSize)
+            {
+                return false;
+            }
+
+            for (int i = 0; i < subsetSize; i++)
+            {
+                if (path.GetAt(i) != subset.GetAt(i))
+                {
+                    return false;
+                }
+            }
+
+            return true;
+        }
+
+        private static IndexPath StartPath(IndexPath path, int length)
+        {
+            var subPath = new List<int>();
+            for (int i = 0; i < length; i++)
+            {
+                subPath.Add(path.GetAt(i));
+            }
+
+            return new IndexPath(subPath);
+        }
+
+        public struct TreeWalkNodeInfo
+        {
+            public TreeWalkNodeInfo(SelectionNode node, IndexPath indexPath, SelectionNode? parent)
+            {
+                node = node ?? throw new ArgumentNullException(nameof(node));
+
+                Node = node;
+                Path = indexPath;
+                ParentNode = parent;
+            }
+
+            public TreeWalkNodeInfo(SelectionNode node, IndexPath indexPath)
+            {
+                node = node ?? throw new ArgumentNullException(nameof(node));
+
+                Node = node;
+                Path = indexPath;
+                ParentNode = null;
+            }
+
+            public SelectionNode Node { get; }
+            public IndexPath Path { get; }
+            public SelectionNode? ParentNode { get; }
+        };
+
+    }
+}

+ 1 - 1
src/Avalonia.Dialogs/ManagedFileChooser.xaml.cs

@@ -81,7 +81,7 @@ namespace Avalonia.Dialogs
 
             if (indexOfPreselected > 1)
             {
-                _filesView.ScrollIntoView(model.Items[indexOfPreselected - 1]);
+                _filesView.ScrollIntoView(indexOfPreselected - 1);
             }
         }
     }

+ 3 - 8
tests/Avalonia.Controls.UnitTests/AppBuilderTests.cs

@@ -1,12 +1,6 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using Xunit;
-using Avalonia.Controls.UnitTests;
+using Avalonia.Controls.UnitTests;
 using Avalonia.Platform;
-using Avalonia.UnitTests;
+using Xunit;
 
 [assembly: ExportAvaloniaModule("DefaultModule", typeof(AppBuilderTests.DefaultModule))]
 [assembly: ExportAvaloniaModule("RenderingModule", typeof(AppBuilderTests.Direct2DModule), ForRenderingSubsystem = "Direct2D1")]
@@ -16,6 +10,7 @@ using Avalonia.UnitTests;
 
 namespace Avalonia.Controls.UnitTests
 {
+    using AppBuilder = Avalonia.UnitTests.AppBuilder;
 
     public class AppBuilderTests
     {

+ 1 - 0
tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj

@@ -21,6 +21,7 @@
     <ProjectReference Include="..\..\src\Avalonia.Input\Avalonia.Input.csproj" />
     <ProjectReference Include="..\..\src\Avalonia.Interactivity\Avalonia.Interactivity.csproj" />
     <ProjectReference Include="..\..\src\Avalonia.Layout\Avalonia.Layout.csproj" />
+    <ProjectReference Include="..\..\src\Avalonia.ReactiveUI\Avalonia.ReactiveUI.csproj" />
     <ProjectReference Include="..\..\src\Avalonia.Visuals\Avalonia.Visuals.csproj" />
     <ProjectReference Include="..\..\src\Avalonia.Styling\Avalonia.Styling.csproj" />
     <ProjectReference Include="..\Avalonia.UnitTests\Avalonia.UnitTests.csproj" />

+ 1 - 0
tests/Avalonia.Controls.UnitTests/ButtonTests.cs

@@ -9,6 +9,7 @@ using Avalonia.UnitTests;
 using Avalonia.VisualTree;
 using Moq;
 using Xunit;
+using MouseButton = Avalonia.Input.MouseButton;
 
 namespace Avalonia.Controls.UnitTests
 {

+ 3 - 3
tests/Avalonia.Controls.UnitTests/CarouselTests.cs

@@ -265,7 +265,7 @@ namespace Avalonia.Controls.UnitTests
         }
 
         [Fact]
-        public void Selected_Item_Changes_To_NextAvailable_Item_If_SelectedItem_Is_Removed_From_Middle()
+        public void Selected_Item_Changes_To_First_Item_If_SelectedItem_Is_Removed_From_Middle()
         {
             var items = new ObservableCollection<string>
             {
@@ -288,8 +288,8 @@ namespace Avalonia.Controls.UnitTests
 
             items.RemoveAt(1);
 
-            Assert.Equal(1, target.SelectedIndex);
-            Assert.Equal("FooBar", target.SelectedItem);
+            Assert.Equal(0, target.SelectedIndex);
+            Assert.Equal("Foo", target.SelectedItem);
         }
 
         private Control CreateTemplate(Carousel control, INameScope scope)

+ 95 - 0
tests/Avalonia.Controls.UnitTests/IndexPathTests.cs

@@ -0,0 +1,95 @@
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests
+{
+    public class IndexPathTests
+    {
+        [Fact]
+        public void Simple_Index()
+        {
+            var a = new IndexPath(1);
+
+            Assert.Equal(1, a.GetSize());
+            Assert.Equal(1, a.GetAt(0));
+        }
+
+        [Fact]
+        public void Equal_Paths()
+        {
+            var a = new IndexPath(1);
+            var b = new IndexPath(1);
+
+            Assert.True(a == b);
+            Assert.False(a != b);
+            Assert.True(a.Equals(b));
+            Assert.Equal(0, a.CompareTo(b));
+            Assert.Equal(a.GetHashCode(), b.GetHashCode());
+        }
+
+        [Fact]
+        public void Unequal_Paths()
+        {
+            var a = new IndexPath(1);
+            var b = new IndexPath(2);
+
+            Assert.False(a == b);
+            Assert.True(a != b);
+            Assert.False(a.Equals(b));
+            Assert.Equal(-1, a.CompareTo(b));
+            Assert.NotEqual(a.GetHashCode(), b.GetHashCode());
+        }
+
+        [Fact]
+        public void Equal_Null_Path()
+        {
+            var a = new IndexPath(null);
+            var b = new IndexPath(null);
+
+            Assert.True(a == b);
+            Assert.False(a != b);
+            Assert.True(a.Equals(b));
+            Assert.Equal(0, a.CompareTo(b));
+            Assert.Equal(a.GetHashCode(), b.GetHashCode());
+        }
+
+        [Fact]
+        public void Unequal_Null_Path()
+        {
+            var a = new IndexPath(null);
+            var b = new IndexPath(2);
+
+            Assert.False(a == b);
+            Assert.True(a != b);
+            Assert.False(a.Equals(b));
+            Assert.Equal(-1, a.CompareTo(b));
+            Assert.NotEqual(a.GetHashCode(), b.GetHashCode());
+        }
+
+        [Fact]
+        public void Default_Is_Null_Path()
+        {
+            var a = new IndexPath(null);
+            var b = default(IndexPath);
+
+            Assert.True(a == b);
+            Assert.False(a != b);
+            Assert.True(a.Equals(b));
+            Assert.Equal(0, a.CompareTo(b));
+            Assert.Equal(a.GetHashCode(), b.GetHashCode());
+        }
+
+        [Fact]
+        public void Null_Equality()
+        {
+            var a = new IndexPath(null);
+            var b = new IndexPath(1);
+
+            // Implementing operator == on a struct automatically implements an operator which
+            // accepts null, so make sure this does something useful.
+            Assert.True(a == null);
+            Assert.False(a != null);
+            Assert.False(b == null);
+            Assert.True(b != null);
+        }
+    }
+}

+ 307 - 0
tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs

@@ -0,0 +1,307 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests
+{
+    public class IndexRangeTests
+    {
+        [Fact]
+        public void Add_Should_Add_Range_To_Empty_List()
+        {
+            var ranges = new List<IndexRange>();
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(0, 4), selected);
+
+            Assert.Equal(5, result);
+            Assert.Equal(new[] { new IndexRange(0, 4) }, ranges);
+            Assert.Equal(new[] { new IndexRange(0, 4) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Add_Non_Intersecting_Range_At_End()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(0, 4) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(8, 10), selected);
+
+            Assert.Equal(3, result);
+            Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10) }, ranges);
+            Assert.Equal(new[] { new IndexRange(8, 10) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Add_Non_Intersecting_Range_At_Beginning()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(0, 4), selected);
+
+            Assert.Equal(5, result);
+            Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10) }, ranges);
+            Assert.Equal(new[] { new IndexRange(0, 4) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Add_Non_Intersecting_Range_In_Middle()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(0, 4), new IndexRange(14, 16) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(8, 10), selected);
+
+            Assert.Equal(3, result);
+            Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10), new IndexRange(14, 16) }, ranges);
+            Assert.Equal(new[] { new IndexRange(8, 10) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Add_Intersecting_Range_Start()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(6, 9), selected);
+
+            Assert.Equal(2, result);
+            Assert.Equal(new[] { new IndexRange(6, 10) }, ranges);
+            Assert.Equal(new[] { new IndexRange(6, 7) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Add_Intersecting_Range_End()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(9, 12), selected);
+
+            Assert.Equal(2, result);
+            Assert.Equal(new[] { new IndexRange(8, 12) }, ranges);
+            Assert.Equal(new[] { new IndexRange(11, 12) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Add_Intersecting_Range_Both()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(6, 12), selected);
+
+            Assert.Equal(4, result);
+            Assert.Equal(new[] { new IndexRange(6, 12) }, ranges);
+            Assert.Equal(new[] { new IndexRange(6, 7), new IndexRange(11, 12) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Join_Two_Intersecting_Ranges()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10), new IndexRange(12, 14) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(8, 14), selected);
+
+            Assert.Equal(1, result);
+            Assert.Equal(new[] { new IndexRange(8, 14) }, ranges);
+            Assert.Equal(new[] { new IndexRange(11, 11) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Join_Two_Intersecting_Ranges_And_Add_Ranges()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10), new IndexRange(12, 14) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(6, 18), selected);
+
+            Assert.Equal(7, result);
+            Assert.Equal(new[] { new IndexRange(6, 18) }, ranges);
+            Assert.Equal(new[] { new IndexRange(6, 7), new IndexRange(11, 11), new IndexRange(15, 18) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Not_Add_Already_Selected_Range()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(9, 10), selected);
+
+            Assert.Equal(0, result);
+            Assert.Equal(new[] { new IndexRange(8, 10) }, ranges);
+            Assert.Empty(selected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Entire_Range()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(8, 10), deselected);
+
+            Assert.Equal(3, result);
+            Assert.Empty(ranges);
+            Assert.Equal(new[] { new IndexRange(8, 10) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Start_Of_Range()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 12) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(8, 10), deselected);
+
+            Assert.Equal(3, result);
+            Assert.Equal(new[] { new IndexRange(11, 12) }, ranges);
+            Assert.Equal(new[] { new IndexRange(8, 10) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_End_Of_Range()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 12) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(10, 12), deselected);
+
+            Assert.Equal(3, result);
+            Assert.Equal(new[] { new IndexRange(8, 9) }, ranges);
+            Assert.Equal(new[] { new IndexRange(10, 12) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Overlapping_End_Of_Range()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 12) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(10, 14), deselected);
+
+            Assert.Equal(3, result);
+            Assert.Equal(new[] { new IndexRange(8, 9) }, ranges);
+            Assert.Equal(new[] { new IndexRange(10, 12) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Middle_Of_Range()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(10, 20) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(12, 16), deselected);
+
+            Assert.Equal(5, result);
+            Assert.Equal(new[] { new IndexRange(10, 11), new IndexRange(17, 20) }, ranges);
+            Assert.Equal(new[] { new IndexRange(12, 16) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Multiple_Ranges()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(6, 15), deselected);
+
+            Assert.Equal(6, result);
+            Assert.Equal(new[] { new IndexRange(16, 18) }, ranges);
+            Assert.Equal(new[] { new IndexRange(8, 10), new IndexRange(12, 14) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Multiple_And_Partial_Ranges_1()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(9, 15), deselected);
+
+            Assert.Equal(5, result);
+            Assert.Equal(new[] { new IndexRange(8, 8), new IndexRange(16, 18) }, ranges);
+            Assert.Equal(new[] { new IndexRange(9, 10), new IndexRange(12, 14) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Multiple_And_Partial_Ranges_2()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(8, 13), deselected);
+
+            Assert.Equal(5, result);
+            Assert.Equal(new[] { new IndexRange(14, 14), new IndexRange(16, 18) }, ranges);
+            Assert.Equal(new[] { new IndexRange(8, 10), new IndexRange(12, 13) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Multiple_And_Partial_Ranges_3()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(9, 13), deselected);
+
+            Assert.Equal(4, result);
+            Assert.Equal(new[] { new IndexRange(8, 8), new IndexRange(14, 14), new IndexRange(16, 18) }, ranges);
+            Assert.Equal(new[] { new IndexRange(9, 10), new IndexRange(12, 13) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Do_Nothing_For_Unselected_Range()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(2, 4), deselected);
+
+            Assert.Equal(0, result);
+            Assert.Equal(new[] { new IndexRange(8, 10) }, ranges);
+            Assert.Empty(deselected);
+        }
+
+        [Fact]
+        public void Stress_Test()
+        {
+            const int iterations = 100;
+            var random = new Random(0);
+            var selection = new List<IndexRange>();
+            var expected = new List<int>();
+
+            IndexRange Generate()
+            {
+                var start = random.Next(100);
+                return new IndexRange(start, start + random.Next(20));
+            }
+
+            for (var i = 0; i < iterations; ++i)
+            {
+                var toAdd = random.Next(5);
+
+                for (var j = 0; j < toAdd; ++j)
+                {
+                    var range = Generate();
+                    IndexRange.Add(selection, range);
+
+                    for (var k = range.Begin; k <= range.End; ++k)
+                    {
+                        if (!expected.Contains(k))
+                        {
+                            expected.Add(k);
+                        }
+                    }
+
+                    var actual = IndexRange.EnumerateIndices(selection).ToList();
+                    expected.Sort();
+                    Assert.Equal(expected, actual);
+                }
+
+                var toRemove = random.Next(5);
+
+                for (var j = 0; j < toRemove; ++j)
+                {
+                    var range = Generate();
+                    IndexRange.Remove(selection, range);
+
+                    for (var k = range.Begin; k <= range.End; ++k)
+                    {
+                        expected.Remove(k);
+                    }
+
+                    var actual = IndexRange.EnumerateIndices(selection).ToList();
+                    Assert.Equal(expected, actual);
+                }
+
+                selection.Clear();
+                expected.Clear();
+            }
+        }
+    }
+}

+ 4 - 4
tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization_Simple.cs

@@ -573,7 +573,7 @@ namespace Avalonia.Controls.UnitTests.Presenters
                 target.Arrange(Rect.Empty);
 
                 // Check for issue #591: this should not throw.
-                target.ScrollIntoView(items[0]);
+                target.ScrollIntoView(0);
             }
         }
 
@@ -727,7 +727,7 @@ namespace Avalonia.Controls.UnitTests.Presenters
 
             var last = (target.Items as IList)[10];
 
-            target.ScrollIntoView(last);
+            target.ScrollIntoView(10);
 
             Assert.Equal(new Vector(0, 1), ((ILogicalScrollable)target).Offset);
             Assert.Same(target.Panel.Children[9].DataContext, last);
@@ -744,12 +744,12 @@ namespace Avalonia.Controls.UnitTests.Presenters
 
             var last = (target.Items as IList)[10];
 
-            target.ScrollIntoView(last);
+            target.ScrollIntoView(10);
 
             Assert.Equal(new Vector(0, 1), ((ILogicalScrollable)target).Offset);
             Assert.Same(target.Panel.Children[9].DataContext, last);
 
-            target.ScrollIntoView(last);
+            target.ScrollIntoView(10);
 
             Assert.Equal(new Vector(0, 1), ((ILogicalScrollable)target).Offset);
             Assert.Same(target.Panel.Children[9].DataContext, last);

+ 11 - 29
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs

@@ -536,37 +536,19 @@ namespace Avalonia.Controls.UnitTests.Primitives
             Assert.Equal(items[1], target.SelectedItem);
             Assert.Equal(1, target.SelectedIndex);
 
-            items.RemoveAt(1);
-
-            Assert.Null(target.SelectedItem);
-            Assert.Equal(-1, target.SelectedIndex);
-        }
-
-        [Fact]
-        public void Moving_Selected_Item_Should_Update_Selection()
-        {
-            var items = new AvaloniaList<Item>
-            {
-                new Item(),
-                new Item(),
-            };
-
-            var target = new SelectingItemsControl
-            {
-                Items = items,
-                Template = Template(),
-            };
+            SelectionChangedEventArgs receivedArgs = null;
 
-            target.ApplyTemplate();
-            target.SelectedIndex = 0;
+            target.SelectionChanged += (_, args) => receivedArgs = args;
 
-            Assert.Equal(items[0], target.SelectedItem);
-            Assert.Equal(0, target.SelectedIndex);
+            var removed = items[1];
 
-            items.Move(0, 1);
+            items.RemoveAt(1);
 
-            Assert.Equal(items[1], target.SelectedItem);
-            Assert.Equal(1, target.SelectedIndex);
+            Assert.Null(target.SelectedItem);
+            Assert.Equal(-1, target.SelectedIndex);
+            Assert.NotNull(receivedArgs);
+            Assert.Empty(receivedArgs.AddedItems);
+            Assert.Equal(new[] { removed }, receivedArgs.RemovedItems);
         }
 
         [Fact]
@@ -1089,8 +1071,8 @@ namespace Avalonia.Controls.UnitTests.Primitives
 
             items[1] = "Qux";
 
-            Assert.Equal(1, target.SelectedIndex);
-            Assert.Equal("Qux", target.SelectedItem);
+            Assert.Equal(-1, target.SelectedIndex);
+            Assert.Null(target.SelectedItem);
         }
 
         [Fact]

+ 2 - 2
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs

@@ -75,8 +75,8 @@ namespace Avalonia.Controls.UnitTests.Primitives
             target.SelectedIndex = 2;
             items.RemoveAt(2);
 
-            Assert.Equal(2, target.SelectedIndex);
-            Assert.Equal("qux", target.SelectedItem);
+            Assert.Equal(0, target.SelectedIndex);
+            Assert.Equal("foo", target.SelectedItem);
         }
 
         [Fact]

+ 325 - 7
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs

@@ -70,8 +70,6 @@ namespace Avalonia.Controls.UnitTests.Primitives
         [Fact]
         public void Assigning_Multiple_SelectedItems_Should_Set_SelectedIndex()
         {
-            // Note that we don't need SelectionMode = Multiple here. Multiple selections can always
-            // be made in code.
             var target = new TestSelector
             {
                 Items = new[] { "foo", "bar", "baz" },
@@ -337,7 +335,6 @@ namespace Avalonia.Controls.UnitTests.Primitives
                     "qiz",
                     "lol",
                 },
-                SelectionMode = SelectionMode.Multiple,
                 Template = Template(),
             };
 
@@ -370,7 +367,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
             target.SelectedIndex = 3;
             target.SelectRange(1);
 
-            Assert.Equal(new[] { "qux", "baz", "bar" }, target.SelectedItems.Cast<object>().ToList());
+            Assert.Equal(new[] { "bar", "baz", "qux" }, target.SelectedItems.Cast<object>().ToList());
         }
 
         [Fact]
@@ -680,6 +677,57 @@ namespace Avalonia.Controls.UnitTests.Primitives
             Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target));
         }
 
+        [Fact]
+        public void Ctrl_Selecting_Raises_SelectionChanged_Events()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz", "Qux" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            SelectionChangedEventArgs receivedArgs = null;
+
+            target.SelectionChanged += (_, args) => receivedArgs = args;
+
+            void VerifyAdded(string selection)
+            {
+                Assert.NotNull(receivedArgs);
+                Assert.Equal(new[] { selection }, receivedArgs.AddedItems);
+                Assert.Empty(receivedArgs.RemovedItems);
+            }
+
+            void VerifyRemoved(string selection)
+            {
+                Assert.NotNull(receivedArgs);
+                Assert.Equal(new[] { selection }, receivedArgs.RemovedItems);
+                Assert.Empty(receivedArgs.AddedItems);
+            }
+
+            _helper.Click((Interactive)target.Presenter.Panel.Children[1]);
+
+            VerifyAdded("Bar");
+
+            receivedArgs = null;
+            _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Control);
+
+            VerifyAdded("Baz");
+
+            receivedArgs = null;
+            _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: InputModifiers.Control);
+
+            VerifyAdded("Qux");
+
+            receivedArgs = null;
+            _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: InputModifiers.Control);
+
+            VerifyRemoved("Bar");
+        }
+
         [Fact]
         public void Ctrl_Selecting_SelectedItem_With_Multiple_Selection_Active_Sets_SelectedItem_To_Next_Selection()
         {
@@ -794,6 +842,52 @@ namespace Avalonia.Controls.UnitTests.Primitives
             Assert.Equal(new[] { 0, 1, 2, 3, 4, 5 }, SelectedContainers(target));
         }
 
+        [Fact]
+        public void Shift_Selecting_Raises_SelectionChanged_Events()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz", "Qux" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            SelectionChangedEventArgs receivedArgs = null;
+
+            target.SelectionChanged += (_, args) => receivedArgs = args;
+
+            void VerifyAdded(params string[] selection)
+            {
+                Assert.NotNull(receivedArgs);
+                Assert.Equal(selection, receivedArgs.AddedItems);
+                Assert.Empty(receivedArgs.RemovedItems);
+            }
+
+            void VerifyRemoved(string selection)
+            {
+                Assert.NotNull(receivedArgs);
+                Assert.Equal(new[] { selection }, receivedArgs.RemovedItems);
+                Assert.Empty(receivedArgs.AddedItems);
+            }
+
+            _helper.Click((Interactive)target.Presenter.Panel.Children[1]);
+
+            VerifyAdded("Bar");
+
+            receivedArgs = null;
+            _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: InputModifiers.Shift);
+
+            VerifyAdded("Baz" ,"Qux");
+
+            receivedArgs = null;
+            _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Shift);
+
+            VerifyRemoved("Qux");
+        }
+
         [Fact]
         public void Duplicate_Items_Are_Added_To_SelectedItems_In_Order()
         {
@@ -842,6 +936,30 @@ namespace Avalonia.Controls.UnitTests.Primitives
             Assert.Equal("Foo", target.SelectedItem);
         }
 
+        [Fact]
+        public void SelectAll_Raises_SelectionChanged_Event()
+        {
+            var target = new TestSelector
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            SelectionChangedEventArgs receivedArgs = null;
+
+            target.SelectionChanged += (_, args) => receivedArgs = args;
+
+            target.SelectAll();
+
+            Assert.NotNull(receivedArgs);
+            Assert.Equal(target.Items, receivedArgs.AddedItems);
+            Assert.Empty(receivedArgs.RemovedItems);
+        }
+
         [Fact]
         public void UnselectAll_Clears_SelectedIndex_And_SelectedItem()
         {
@@ -993,7 +1111,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
             target.SelectAll();
             items[1] = "Qux";
 
-            Assert.Equal(new[] { "Foo", "Qux", "Baz" }, target.SelectedItems);
+            Assert.Equal(new[] { "Foo", "Baz" }, target.SelectedItems);
         }
 
         [Fact]
@@ -1131,6 +1249,195 @@ namespace Avalonia.Controls.UnitTests.Primitives
             Assert.Equal(1, target.SelectedItems.Count);
         }
 
+        [Fact]
+        public void Adding_To_Selection_Should_Set_SelectedIndex()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar" },
+                Template = Template(),
+            };
+
+            target.ApplyTemplate();
+            target.Selection.Select(1);
+
+            Assert.Equal(1, target.SelectedIndex);
+        }
+
+        [Fact]
+        public void Assigning_Null_To_Selection_Should_Create_New_SelectionModel()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar" },
+                Template = Template(),
+            };
+
+            var oldSelection = target.Selection;
+
+            target.Selection = null;
+
+            Assert.NotNull(target.Selection);
+            Assert.NotSame(oldSelection, target.Selection);
+        }
+
+        [Fact]
+        public void Assigning_SelectionModel_With_Different_Source_To_Selection_Should_Fail()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar" },
+                Template = Template(),
+            };
+
+            var selection = new SelectionModel { Source = new[] { "baz" } };
+            Assert.Throws<ArgumentException>(() => target.Selection = selection);
+        }
+
+        [Fact]
+        public void Assigning_SelectionModel_With_Null_Source_To_Selection_Should_Set_Source()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar" },
+                Template = Template(),
+            };
+
+            var selection = new SelectionModel();
+            target.Selection = selection;
+
+            Assert.Same(target.Items, selection.Source);
+        }
+
+        [Fact]
+        public void Assigning_Single_Selected_Item_To_Selection_Should_Set_SelectedIndex()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar" },
+                Template = Template(),
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            var selection = new SelectionModel { Source = target.Items };
+            selection.Select(1);
+            target.Selection = selection;
+
+            Assert.Equal(1, target.SelectedIndex);
+            Assert.Equal(new[] { "bar" }, target.Selection.SelectedItems);
+            Assert.Equal(new[] { 1 }, SelectedContainers(target));
+        }
+
+        [Fact]
+        public void Assigning_Multiple_Selected_Items_To_Selection_Should_Set_SelectedIndex()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar", "baz" },
+                Template = Template(),
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            var selection = new SelectionModel { Source = target.Items };
+            selection.SelectRange(new IndexPath(0), new IndexPath(2));
+            target.Selection = selection;
+
+            Assert.Equal(0, target.SelectedIndex);
+            Assert.Equal(new[] { "foo", "bar", "baz" }, target.Selection.SelectedItems);
+            Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target));
+        }
+
+        [Fact]
+        public void Reassigning_Selection_Should_Clear_Selection()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar" },
+                Template = Template(),
+            };
+
+            target.ApplyTemplate();
+            target.Selection.Select(1);
+            target.Selection = new SelectionModel();
+
+            Assert.Equal(-1, target.SelectedIndex);
+            Assert.Null(target.SelectedItem);
+        }
+
+        [Fact]
+        public void Assigning_Selection_Should_Set_Item_IsSelected()
+        {
+            var items = new[]
+            {
+                new ListBoxItem(),
+                new ListBoxItem(),
+                new ListBoxItem(),
+            };
+
+            var target = new TestSelector
+            {
+                Items = items,
+                Template = Template(),
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            var selection = new SelectionModel { Source = items };
+            selection.SelectRange(new IndexPath(0), new IndexPath(1));
+            target.Selection = selection;
+
+            Assert.True(items[0].IsSelected);
+            Assert.True(items[1].IsSelected);
+            Assert.False(items[2].IsSelected);
+        }
+
+        [Fact]
+        public void Assigning_Selection_Should_Raise_SelectionChanged()
+        {
+            var items = new[] { "foo", "bar", "baz" };
+
+            var target = new TestSelector
+            {
+                Items = items,
+                Template = Template(),
+                SelectedItem = "bar",
+            };
+
+            var raised = 0;
+
+            target.SelectionChanged += (s, e) =>
+            {
+                if (raised == 0)
+                {
+                    Assert.Empty(e.AddedItems.Cast<object>());
+                    Assert.Equal(new[] { "bar" }, e.RemovedItems.Cast<object>());
+                }
+                else
+                {
+                    Assert.Equal(new[] { "foo", "baz" }, e.AddedItems.Cast<object>());
+                    Assert.Empty(e.RemovedItems.Cast<object>());
+                }
+
+                ++raised;
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+
+            var selection = new SelectionModel { Source = items };
+            selection.Select(0);
+            selection.Select(2);
+            target.Selection = selection;
+
+            Assert.Equal(2, raised);
+        }
+
         private IEnumerable<int> SelectedContainers(SelectingItemsControl target)
         {
             return target.Presenter.Panel.Children
@@ -1154,20 +1461,31 @@ namespace Avalonia.Controls.UnitTests.Primitives
             public static readonly new AvaloniaProperty<IList> SelectedItemsProperty = 
                 SelectingItemsControl.SelectedItemsProperty;
 
+            public TestSelector()
+            {
+                SelectionMode = SelectionMode.Multiple;
+            }
+
             public new IList SelectedItems
             {
                 get { return base.SelectedItems; }
                 set { base.SelectedItems = value; }
             }
 
+            public new ISelectionModel Selection
+            {
+                get => base.Selection;
+                set => base.Selection = value;
+            }
+
             public new SelectionMode SelectionMode
             {
                 get { return base.SelectionMode; }
                 set { base.SelectionMode = value; }
             }
 
-            public new void SelectAll() => base.SelectAll();
-            public new void UnselectAll() => base.UnselectAll();
+            public void SelectAll() => Selection.SelectAll();
+            public void UnselectAll() => Selection.ClearSelection();
             public void SelectRange(int index) => UpdateSelection(index, true, true);
             public void Toggle(int index) => UpdateSelection(index, true, false, true);
         }

+ 4 - 5
tests/Avalonia.Controls.UnitTests/Primitives/TabStripTests.cs

@@ -67,7 +67,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
         }
 
         [Fact]
-        public void Removing_Selected_Should_Select_Next()
+        public void Removing_Selected_Should_Select_First()
         {
             var items = new ObservableCollection<TabItem>()
             {
@@ -96,10 +96,9 @@ namespace Avalonia.Controls.UnitTests.Primitives
             Assert.Same(items[1], target.SelectedItem);
             items.RemoveAt(1);
 
-            // Assert for former element [2] now [1] == "3rd"
-            Assert.Equal(1, target.SelectedIndex);
-            Assert.Same(items[1], target.SelectedItem);
-            Assert.Same("3rd", ((TabItem)target.SelectedItem).Name);
+            Assert.Equal(0, target.SelectedIndex);
+            Assert.Same(items[0], target.SelectedItem);
+            Assert.Same("first", ((TabItem)target.SelectedItem).Name);
         }
 
         private Control CreateTabStripTemplate(TabStrip parent, INameScope scope)

+ 2384 - 0
tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs

@@ -0,0 +1,2384 @@
+// 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;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Linq;
+using System.Reactive.Linq;
+using Avalonia.Collections;
+using Avalonia.Diagnostics;
+using ReactiveUI;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Avalonia.Controls.UnitTests
+{
+    public class SelectionModelTests
+    {
+        private readonly ITestOutputHelper _output;
+
+        public SelectionModelTests(ITestOutputHelper output)
+        {
+            _output = output;
+        }
+
+        [Fact]
+        public void ValidateOneLevelSingleSelectionNoSource()
+        {
+            SelectionModel selectionModel = new SelectionModel() { SingleSelect = true };
+            _output.WriteLine("No source set.");
+            Select(selectionModel, 4, true);
+            ValidateSelection(selectionModel, new List<IndexPath>() { Path(4) });
+            Select(selectionModel, 4, false);
+            ValidateSelection(selectionModel, new List<IndexPath>() { });
+        }
+
+        [Fact]
+        public void ValidateOneLevelSingleSelection()
+        {
+            SelectionModel selectionModel = new SelectionModel() { SingleSelect = true };
+            _output.WriteLine("Set the source to 10 items");
+            selectionModel.Source = Enumerable.Range(0, 10).ToList();
+            Select(selectionModel, 3, true);
+            ValidateSelection(selectionModel, new List<IndexPath>() { Path(3) }, new List<IndexPath>() { Path() });
+            Select(selectionModel, 3, false);
+            ValidateSelection(selectionModel, new List<IndexPath>() { });
+        }
+
+        [Fact]
+        public void ValidateSelectionChangedEvent()
+        {
+            SelectionModel selectionModel = new SelectionModel();
+            selectionModel.Source = Enumerable.Range(0, 10).ToList();
+
+            int selectionChangedFiredCount = 0;
+            selectionModel.SelectionChanged += delegate (object sender, SelectionModelSelectionChangedEventArgs args)
+            {
+                selectionChangedFiredCount++;
+                ValidateSelection(selectionModel, new List<IndexPath>() { Path(4) }, new List<IndexPath>() { Path() });
+            };
+
+            Select(selectionModel, 4, true);
+            ValidateSelection(selectionModel, new List<IndexPath>() { Path(4) }, new List<IndexPath>() { Path() });
+            Assert.Equal(1, selectionChangedFiredCount);
+        }
+
+        [Fact]
+        public void ValidateCanSetSelectedIndex()
+        {
+            var model = new SelectionModel();
+            var ip = IndexPath.CreateFrom(34);
+            model.SelectedIndex = ip;
+            Assert.Equal(0, ip.CompareTo(model.SelectedIndex));
+        }
+
+        [Fact]
+        public void ValidateOneLevelMultipleSelection()
+        {
+            SelectionModel selectionModel = new SelectionModel();
+            selectionModel.Source = Enumerable.Range(0, 10).ToList();
+
+            Select(selectionModel, 4, true);
+            ValidateSelection(selectionModel, new List<IndexPath>() { Path(4) }, new List<IndexPath>() { Path() });
+            SelectRangeFromAnchor(selectionModel, 8, true /* select */);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(4),
+                    Path(5),
+                    Path(6),
+                    Path(7),
+                    Path(8)
+                },
+                new List<IndexPath>() { Path() });
+
+            ClearSelection(selectionModel);
+            SetAnchorIndex(selectionModel, 6);
+            SelectRangeFromAnchor(selectionModel, 3, true /* select */);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(3),
+                    Path(4),
+                    Path(5),
+                    Path(6)
+                },
+                new List<IndexPath>() { Path() });
+
+            SetAnchorIndex(selectionModel, 4);
+            SelectRangeFromAnchor(selectionModel, 5, false /* select */);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(3),
+                    Path(6)
+                },
+                new List<IndexPath>() { Path() });
+        }
+
+        [Fact]
+        public void ValidateTwoLevelSingleSelection()
+        {
+            SelectionModel selectionModel = new SelectionModel();
+            _output.WriteLine("Setting the source");
+            selectionModel.Source = CreateNestedData(1 /* levels */ , 2 /* groupsAtLevel */, 2 /* countAtLeaf */);
+            Select(selectionModel, 1, 1, true);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>() { Path(1, 1) }, new List<IndexPath>() { Path(), Path(1) });
+            Select(selectionModel, 1, 1, false);
+            ValidateSelection(selectionModel, new List<IndexPath>() { });
+        }
+
+        [Fact]
+        public void ValidateTwoLevelMultipleSelection()
+        {
+            SelectionModel selectionModel = new SelectionModel();
+            _output.WriteLine("Setting the source");
+            selectionModel.Source = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */);
+
+            Select(selectionModel, 1, 2, true);
+            ValidateSelection(selectionModel, new List<IndexPath>() { Path(1, 2) }, new List<IndexPath>() { Path(), Path(1) });
+            SelectRangeFromAnchor(selectionModel, 2, 2, true /* select */);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(1, 2),
+                    Path(2), // Inner node should be selected since everything 2.* is selected
+                    Path(2, 0),
+                    Path(2, 1),
+                    Path(2, 2)
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(1)
+                },
+                1 /* selectedInnerNodes */);
+
+            ClearSelection(selectionModel);
+            SetAnchorIndex(selectionModel, 2, 1);
+            SelectRangeFromAnchor(selectionModel, 0, 1, true /* select */);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(0, 1),
+                    Path(0, 2),
+                    Path(1, 0),
+                    Path(1, 1),
+                    Path(1, 2),
+                    Path(1),
+                    Path(2, 0),
+                    Path(2, 1)
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(0),
+                    Path(2),
+                },
+                1 /* selectedInnerNodes */);
+
+            SetAnchorIndex(selectionModel, 1, 1);
+            SelectRangeFromAnchor(selectionModel, 2, 0, false /* select */);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(0, 1),
+                    Path(0, 2),
+                    Path(1, 0),
+                    Path(2, 1)
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(1),
+                    Path(0),
+                    Path(2),
+                },
+                0 /* selectedInnerNodes */);
+
+            ClearSelection(selectionModel);
+            ValidateSelection(selectionModel, new List<IndexPath>() { });
+        }
+
+        [Fact]
+        public void ValidateNestedSingleSelection()
+        {
+            SelectionModel selectionModel = new SelectionModel() { SingleSelect = true };
+            _output.WriteLine("Setting the source");
+            selectionModel.Source = CreateNestedData(3 /* levels */ , 2 /* groupsAtLevel */, 2 /* countAtLeaf */);
+            var path = Path(1, 0, 1, 1);
+            Select(selectionModel, path, true);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>() { path },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(1),
+                    Path(1, 0),
+                    Path(1, 0, 1),
+                });
+            Select(selectionModel, Path(0, 0, 1, 0), true);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(0, 0, 1, 0)
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(0),
+                    Path(0, 0),
+                    Path(0, 0, 1)
+                });
+            Select(selectionModel, Path(0, 0, 1, 0), false);
+            ValidateSelection(selectionModel, new List<IndexPath>() { });
+        }
+
+        [Theory]
+        [InlineData(true)]
+        [InlineData(false)]
+        public void ValidateNestedMultipleSelection(bool handleChildrenRequested)
+        {
+            SelectionModel selectionModel = new SelectionModel();
+            List<IndexPath> sourcePaths = new List<IndexPath>();
+
+            _output.WriteLine("Setting the source");
+            selectionModel.Source = CreateNestedData(3 /* levels */ , 2 /* groupsAtLevel */, 4 /* countAtLeaf */);
+            if (handleChildrenRequested)
+            {
+                selectionModel.ChildrenRequested += (object sender, SelectionModelChildrenRequestedEventArgs args) =>
+                {
+                    _output.WriteLine("ChildrenRequestedIndexPath:" + args.SourceIndex);
+                    sourcePaths.Add(args.SourceIndex);
+                    args.Children = Observable.Return(args.Source as IEnumerable);
+                };
+            }
+
+            var startPath = Path(1, 0, 1, 0);
+            Select(selectionModel, startPath, true);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>() { startPath },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(1),
+                    Path(1, 0),
+                    Path(1, 0, 1)
+                });
+
+            var endPath = Path(1, 1, 1, 0);
+            SelectRangeFromAnchor(selectionModel, endPath, true /* select */);
+
+            if (handleChildrenRequested)
+            {
+                // Validate SourceIndices.
+                var expectedSourceIndices = new List<IndexPath>()
+                {
+                    Path(1),
+                    Path(1, 0),
+                    Path(1, 0, 1),
+                    Path(1, 1),
+                    Path(1, 0, 1, 3),
+                    Path(1, 0, 1, 2),
+                    Path(1, 0, 1, 1),
+                    Path(1, 0, 1, 0),
+                    Path(1, 1, 1),
+                    Path(1, 1, 0),
+                    Path(1, 1, 0, 3),
+                    Path(1, 1, 0, 2),
+                    Path(1, 1, 0, 1),
+                    Path(1, 1, 0, 0),
+                    Path(1, 1, 1, 0)
+                };
+
+                Assert.Equal(expectedSourceIndices.Count, sourcePaths.Count);
+                for (int i = 0; i < expectedSourceIndices.Count; i++)
+                {
+                    Assert.True(AreEqual(expectedSourceIndices[i], sourcePaths[i]));
+                }
+            }
+
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(1, 0),
+                    Path(1, 1),
+                    Path(1, 0, 1),
+                    Path(1, 0, 1, 0),
+                    Path(1, 0, 1, 1),
+                    Path(1, 0, 1, 2),
+                    Path(1, 0, 1, 3),
+                    Path(1, 1, 0),
+                    Path(1, 1, 1),
+                    Path(1, 1, 0, 0),
+                    Path(1, 1, 0, 1),
+                    Path(1, 1, 0, 2),
+                    Path(1, 1, 0, 3),
+                    Path(1, 1, 1, 0),
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(1),
+                    Path(1, 0),
+                    Path(1, 1),
+                    Path(1, 1, 1),
+                });
+
+            ClearSelection(selectionModel);
+            ValidateSelection(selectionModel, new List<IndexPath>() { });
+
+            startPath = Path(0, 1, 0, 2);
+            SetAnchorIndex(selectionModel, startPath);
+            endPath = Path(0, 0, 0, 2);
+            SelectRangeFromAnchor(selectionModel, endPath, true /* select */);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(0, 0),
+                    Path(0, 1),
+                    Path(0, 0, 0),
+                    Path(0, 0, 1),
+                    Path(0, 0, 0, 2),
+                    Path(0, 0, 0, 3),
+                    Path(0, 0, 1, 0),
+                    Path(0, 0, 1, 1),
+                    Path(0, 0, 1, 2),
+                    Path(0, 0, 1, 3),
+                    Path(0, 1, 0),
+                    Path(0, 1, 0, 0),
+                    Path(0, 1, 0, 1),
+                    Path(0, 1, 0, 2),
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(0),
+                    Path(0, 0),
+                    Path(0, 0, 0),
+                    Path(0, 1),
+                    Path(0, 1, 0),
+                });
+
+            startPath = Path(0, 1, 0, 2);
+            SetAnchorIndex(selectionModel, startPath);
+            endPath = Path(0, 0, 0, 2);
+            SelectRangeFromAnchor(selectionModel, endPath, false /* select */);
+            ValidateSelection(selectionModel, new List<IndexPath>() { });
+        }
+
+        [Fact]
+        public void ValidateInserts()
+        {
+            var data = new ObservableCollection<int>(Enumerable.Range(0, 10));
+            var selectionModel = new SelectionModel();
+            selectionModel.Source = data;
+
+            selectionModel.Select(3);
+            selectionModel.Select(4);
+            selectionModel.Select(5);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(3),
+                    Path(4),
+                    Path(5),
+                },
+                new List<IndexPath>()
+                {
+                    Path()
+                });
+
+            _output.WriteLine("Insert in selected range: Inserting 3 items at index 4");
+            data.Insert(4, 41);
+            data.Insert(4, 42);
+            data.Insert(4, 43);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(3),
+                    Path(7),
+                    Path(8),
+                },
+                new List<IndexPath>()
+                {
+                    Path()
+                });
+
+            _output.WriteLine("Insert before selected range: Inserting 3 items at index 0");
+            data.Insert(0, 100);
+            data.Insert(0, 101);
+            data.Insert(0, 102);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(6),
+                    Path(10),
+                    Path(11),
+                },
+                new List<IndexPath>()
+                {
+                    Path()
+                });
+
+            _output.WriteLine("Insert after selected range: Inserting 3 items at index 12");
+            data.Insert(12, 1000);
+            data.Insert(12, 1001);
+            data.Insert(12, 1002);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(6),
+                    Path(10),
+                    Path(11),
+                },
+                new List<IndexPath>()
+                {
+                    Path()
+                });
+        }
+
+        [Fact]
+        public void ValidateGroupInserts()
+        {
+            var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */);
+            var selectionModel = new SelectionModel();
+            selectionModel.Source = data;
+
+            selectionModel.Select(1, 1);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(1, 1),
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(1),
+                });
+
+            _output.WriteLine("Insert before selected range: Inserting item at group index 0");
+            data.Insert(0, 100);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(2, 1)
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(2),
+                });
+
+            _output.WriteLine("Insert after selected range: Inserting item at group index 3");
+            data.Insert(3, 1000);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(2, 1)
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(2),
+                });
+        }
+
+        [Fact]
+        public void ValidateRemoves()
+        {
+            var data = new ObservableCollection<int>(Enumerable.Range(0, 10));
+            var selectionModel = new SelectionModel();
+            selectionModel.Source = data;
+
+            selectionModel.Select(6);
+            selectionModel.Select(7);
+            selectionModel.Select(8);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(6),
+                    Path(7),
+                    Path(8)
+                },
+                new List<IndexPath>()
+                {
+                    Path()
+                });
+
+            _output.WriteLine("Remove before selected range: Removing item at index 0");
+            data.RemoveAt(0);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(5),
+                    Path(6),
+                    Path(7)
+                },
+                new List<IndexPath>()
+                {
+                    Path()
+                });
+
+            _output.WriteLine("Remove from before to middle of selected range: Removing items at index 3, 4, 5");
+            data.RemoveAt(3);
+            data.RemoveAt(3);
+            data.RemoveAt(3);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(3),
+                    Path(4)
+                },
+                new List<IndexPath>()
+                {
+                    Path()
+                });
+
+            _output.WriteLine("Remove after selected range: Removing item at index 5");
+            data.RemoveAt(5);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(3),
+                    Path(4)
+                },
+                new List<IndexPath>()
+                {
+                    Path()
+                });
+        }
+
+        [Fact]
+        public void ValidateGroupRemoves()
+        {
+            var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */);
+            var selectionModel = new SelectionModel();
+            selectionModel.Source = data;
+
+            selectionModel.Select(1, 1);
+            selectionModel.Select(1, 2);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(1, 1),
+                    Path(1, 2)
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(1),
+                });
+
+            _output.WriteLine("Remove before selected range: Removing item at group index 0");
+            data.RemoveAt(0);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(0, 1),
+                    Path(0, 2)
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(0),
+                });
+
+            _output.WriteLine("Remove after selected range: Removing item at group index 1");
+            data.RemoveAt(1);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(0, 1),
+                    Path(0, 2)
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(0),
+                });
+
+            _output.WriteLine("Remove group containing selected items");
+            data.RemoveAt(0);
+            ValidateSelection(selectionModel, new List<IndexPath>());
+        }
+
+        [Fact]
+        public void CanReplaceItem()
+        {
+            var data = new ObservableCollection<int>(Enumerable.Range(0, 10));
+            var selectionModel = new SelectionModel();
+            selectionModel.Source = data;
+
+            selectionModel.Select(3);
+            selectionModel.Select(4);
+            selectionModel.Select(5);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(3),
+                    Path(4),
+                    Path(5),
+                },
+                new List<IndexPath>()
+                {
+                    Path()
+                });
+
+            data[3] = 300;
+            data[4] = 400;
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(5),
+                },
+                new List<IndexPath>()
+                {
+                    Path()
+                });
+        }
+
+        [Fact]
+        public void ValidateGroupReplaceLosesSelection()
+        {
+            var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */);
+            var selectionModel = new SelectionModel();
+            selectionModel.Source = data;
+
+            selectionModel.Select(1, 1);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(1, 1)
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(1)
+                });
+
+            data[1] = new ObservableCollection<int>(Enumerable.Range(0, 5));
+            ValidateSelection(selectionModel, new List<IndexPath>());
+        }
+
+        [Fact]
+        public void ValidateClear()
+        {
+            var data = new ObservableCollection<int>(Enumerable.Range(0, 10));
+            var selectionModel = new SelectionModel();
+            selectionModel.Source = data;
+
+            selectionModel.Select(3);
+            selectionModel.Select(4);
+            selectionModel.Select(5);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(3),
+                    Path(4),
+                    Path(5),
+                },
+                new List<IndexPath>()
+                {
+                    Path()
+                });
+
+            data.Clear();
+            ValidateSelection(selectionModel, new List<IndexPath>());
+        }
+
+        [Fact]
+        public void ValidateGroupClear()
+        {
+            var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */);
+            var selectionModel = new SelectionModel();
+            selectionModel.Source = data;
+
+            selectionModel.Select(1, 1);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(1, 1)
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(1)
+                });
+
+            (data[1] as IList).Clear();
+            ValidateSelection(selectionModel, new List<IndexPath>());
+        }
+
+        // In some cases the leaf node might get a collection change that affects an ancestors selection
+        // state. In this case we were not raising selection changed event. For example, if all elements 
+        // in a group are selected and a new item gets inserted - the parent goes from selected to partially 
+        // selected. In that case we need to raise the selection changed event so that the header containers 
+        // can show the correct visual.
+        [Fact]
+        public void ValidateEventWhenInnerNodeChangesSelectionState()
+        {
+            bool selectionChangedRaised = false;
+            var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */);
+            var selectionModel = new SelectionModel();
+            selectionModel.Source = data;
+            selectionModel.SelectionChanged += (sender, args) => { selectionChangedRaised = true; };
+
+            selectionModel.Select(1, 0);
+            selectionModel.Select(1, 1);
+            selectionModel.Select(1, 2);
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(1, 0),
+                    Path(1, 1),
+                    Path(1, 2),
+                    Path(1)
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                },
+                1 /* selectedInnerNodes */);
+
+            _output.WriteLine("Inserting 1.0");
+            selectionChangedRaised = false;
+            (data[1] as AvaloniaList<object>).Insert(0, 100);
+            Assert.True(selectionChangedRaised, "SelectionChanged event was not raised");
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(1, 1),
+                    Path(1, 2),
+                    Path(1, 3),
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(1),
+                });
+
+            _output.WriteLine("Removing 1.0");
+            selectionChangedRaised = false;
+            (data[1] as AvaloniaList<object>).RemoveAt(0);
+            Assert.True(selectionChangedRaised, "SelectionChanged event was not raised");
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(1, 0),
+                    Path(1, 1),
+                    Path(1, 2),
+                    Path(1)
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                },
+                1 /* selectedInnerNodes */);
+        }
+
+        [Fact]
+        public void ValidatePropertyChangedEventIsRaised()
+        {
+            var selectionModel = new SelectionModel();
+            _output.WriteLine("Set the source to 10 items");
+            selectionModel.Source = Enumerable.Range(0, 10).ToList();
+
+            bool selectedIndexChanged = false;
+            bool selectedIndicesChanged = false;
+            bool SelectedItemChanged = false;
+            bool SelectedItemsChanged = false;
+            bool AnchorIndexChanged = false;
+            selectionModel.PropertyChanged += (sender, args) =>
+            {
+                switch (args.PropertyName)
+                {
+                    case "SelectedIndex":
+                        selectedIndexChanged = true;
+                        break;
+                    case "SelectedIndices":
+                        selectedIndicesChanged = true;
+                        break;
+                    case "SelectedItem":
+                        SelectedItemChanged = true;
+                        break;
+                    case "SelectedItems":
+                        SelectedItemsChanged = true;
+                        break;
+                    case "AnchorIndex":
+                        AnchorIndexChanged = true;
+                        break;
+
+                    default:
+                        throw new InvalidOperationException();
+                }
+            };
+
+            Select(selectionModel, 3, true);
+
+            Assert.True(selectedIndexChanged);
+            Assert.True(selectedIndicesChanged);
+            Assert.True(SelectedItemChanged);
+            Assert.True(SelectedItemsChanged);
+            Assert.True(AnchorIndexChanged);
+        }
+
+        [Fact]
+        public void CanExtendSelectionModelINPC()
+        {
+            var selectionModel = new CustomSelectionModel();
+            bool intPropertyChanged = false;
+            selectionModel.PropertyChanged += (sender, args) =>
+            {
+                if (args.PropertyName == "IntProperty")
+                {
+                    intPropertyChanged = true;
+                }
+            };
+
+            selectionModel.IntProperty = 5;
+            Assert.True(intPropertyChanged);
+        }
+
+        [Fact]
+        public void SelectRangeRegressionTest()
+        {
+            var selectionModel = new SelectionModel()
+            {
+                Source = CreateNestedData(1, 2, 3)
+            };
+
+            // length of start smaller than end used to cause an out of range error.
+            selectionModel.SelectRange(IndexPath.CreateFrom(0), IndexPath.CreateFrom(1, 1));
+
+            ValidateSelection(selectionModel,
+                new List<IndexPath>()
+                {
+                    Path(0),
+                    Path(1),
+                    Path(0, 0),
+                    Path(0, 1),
+                    Path(0, 2),
+                    Path(1, 0),
+                    Path(1, 1)
+                },
+                new List<IndexPath>()
+                {
+                    Path(),
+                    Path(1)
+                });
+        }
+
+        [Fact]
+        public void Should_Listen_For_Changes_After_Deselect()
+        {
+            var target = new SelectionModel();
+            var data = CreateNestedData(1, 2, 3);
+
+            target.Source = data;
+            target.Select(1, 0);
+            target.Deselect(1, 0);
+            target.Select(1, 0);
+            ((AvaloniaList<object>)data[1]).Insert(0, "foo");
+
+            Assert.Equal(new IndexPath(1, 1), target.SelectedIndex);
+        }
+
+        [Fact]
+        public void Selecting_Item_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var raised = 0;
+
+            target.Source = Enumerable.Range(0, 10).ToList();
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Equal(new[] { new IndexPath(4) }, e.SelectedIndices);
+                Assert.Equal(new object[] { 4 }, e.SelectedItems);
+                ++raised;
+            };
+
+            target.Select(4);
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void Selecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var raised = 0;
+
+            target.Source = Enumerable.Range(0, 10).ToList();
+            target.Select(4);
+            target.SelectionChanged += (s, e) => ++raised;
+            target.Select(4);
+
+            Assert.Equal(0, raised);
+        }
+
+        [Fact]
+        public void SingleSelecting_Item_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel { SingleSelect = true };
+            var raised = 0;
+
+            target.Source = Enumerable.Range(0, 10).ToList();
+            target.Select(3);
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Equal(new[] { new IndexPath(3) }, e.DeselectedIndices);
+                Assert.Equal(new object[] { 3 }, e.DeselectedItems);
+                Assert.Equal(new[] { new IndexPath(4) }, e.SelectedIndices);
+                Assert.Equal(new object[] { 4 }, e.SelectedItems);
+                ++raised;
+            };
+
+            target.Select(4);
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void SingleSelecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged()
+        {
+            var target = new SelectionModel { SingleSelect = true };
+            var raised = 0;
+
+            target.Source = Enumerable.Range(0, 10).ToList();
+            target.Select(4);
+            target.SelectionChanged += (s, e) => ++raised;
+            target.Select(4);
+
+            Assert.Equal(0, raised);
+        }
+
+        [Fact]
+        public void Selecting_Item_With_Group_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var raised = 0;
+
+            target.Source = CreateNestedData(1, 2, 3);
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Equal(new[] { new IndexPath(1, 1) }, e.SelectedIndices);
+                Assert.Equal(new object[] { 4 }, e.SelectedItems);
+                ++raised;
+            };
+
+            target.Select(1, 1);
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void SelectAt_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var raised = 0;
+
+            target.Source = CreateNestedData(1, 2, 3);
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Equal(new[] { new IndexPath(1, 1) }, e.SelectedIndices);
+                Assert.Equal(new object[] { 4 }, e.SelectedItems);
+                ++raised;
+            };
+
+            target.SelectAt(new IndexPath(1, 1));
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void SelectAll_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel { SingleSelect = true };
+            var raised = 0;
+
+            target.Source = Enumerable.Range(0, 10).ToList();
+            target.SelectionChanged += (s, e) =>
+            {
+                var expected = Enumerable.Range(0, 10);
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Equal(expected.Select(x => new IndexPath(x)), e.SelectedIndices);
+                Assert.Equal(expected, e.SelectedItems.Cast<int>());
+                ++raised;
+            };
+
+            target.SelectAll();
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void SelectAll_With_Already_Selected_Items_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel { SingleSelect = true };
+            var raised = 0;
+
+            target.Source = Enumerable.Range(0, 10).ToList();
+            target.Select(4);
+
+            target.SelectionChanged += (s, e) =>
+            {
+                var expected = Enumerable.Range(0, 10).Except(new[] { 4 });
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Equal(expected.Select(x => new IndexPath(x)), e.SelectedIndices);
+                Assert.Equal(expected, e.SelectedItems.Cast<int>());
+                ++raised;
+            };
+
+            target.SelectAll();
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void SelectRangeFromAnchor_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var raised = 0;
+
+            target.Source = Enumerable.Range(0, 10).ToList();
+            target.SelectionChanged += (s, e) =>
+            {
+                var expected = Enumerable.Range(4, 3);
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Equal(expected.Select(x => new IndexPath(x)), e.SelectedIndices);
+                Assert.Equal(expected, e.SelectedItems.Cast<int>());
+                ++raised;
+            };
+
+            target.AnchorIndex = new IndexPath(4);
+            target.SelectRangeFromAnchor(6);
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void SelectRangeFromAnchor_With_Group_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var raised = 0;
+
+            target.Source = CreateNestedData(1, 2, 10);
+            target.SelectionChanged += (s, e) =>
+            {
+                var expected = Enumerable.Range(11, 6);
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Equal(expected.Select(x => new IndexPath(x / 10, x % 10)), e.SelectedIndices);
+                Assert.Equal(expected, e.SelectedItems.Cast<int>());
+                ++raised;
+            };
+
+            target.AnchorIndex = new IndexPath(1, 1);
+            target.SelectRangeFromAnchor(1, 6);
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void SelectRangeFromAnchorTo_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var raised = 0;
+
+            target.Source = CreateNestedData(1, 2, 10);
+            target.SelectionChanged += (s, e) =>
+            {
+                var expected = Enumerable.Range(11, 6);
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Equal(expected.Select(x => new IndexPath(x / 10, x % 10)), e.SelectedIndices);
+                Assert.Equal(expected, e.SelectedItems.Cast<int>());
+                ++raised;
+            };
+
+            target.AnchorIndex = new IndexPath(1, 1);
+            target.SelectRangeFromAnchorTo(new IndexPath(1, 6));
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void ClearSelection_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var raised = 0;
+
+            target.Source = Enumerable.Range(0, 10).ToList();
+            target.Select(4);
+            target.Select(5);
+
+            target.SelectionChanged += (s, e) =>
+            {
+                var expected = Enumerable.Range(4, 2);
+                Assert.Equal(expected.Select(x => new IndexPath(x)), e.DeselectedIndices);
+                Assert.Equal(expected, e.DeselectedItems.Cast<int>());
+                Assert.Empty(e.SelectedIndices);
+                Assert.Empty(e.SelectedItems);
+                ++raised;
+            };
+
+            target.ClearSelection();
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void Clearing_Nested_Selection_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var raised = 0;
+
+            target.Source = CreateNestedData(1, 2, 3);
+            target.Select(1, 1);
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Equal(new[] { new IndexPath(1, 1) }, e.DeselectedIndices);
+                Assert.Equal(new object[] { 4 }, e.DeselectedItems);
+                Assert.Empty(e.SelectedIndices);
+                Assert.Empty(e.SelectedItems);
+                ++raised;
+            };
+
+            target.ClearSelection();
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void Changing_Source_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var raised = 0;
+
+            target.Source = Enumerable.Range(0, 10).ToList();
+            target.Select(4);
+            target.Select(5);
+
+            target.SelectionChanged += (s, e) =>
+            {
+                var expected = Enumerable.Range(4, 2);
+                Assert.Equal(expected.Select(x => new IndexPath(x)), e.DeselectedIndices);
+                Assert.Equal(expected, e.DeselectedItems.Cast<int>());
+                Assert.Empty(e.SelectedIndices);
+                Assert.Empty(e.SelectedItems);
+                ++raised;
+            };
+
+            target.Source = Enumerable.Range(20, 10).ToList();
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void Setting_SelectedIndex_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var raised = 0;
+
+            target.Source = Enumerable.Range(0, 10).ToList();
+            target.Select(4);
+            target.Select(5);
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Equal(new[] { new IndexPath(4), new IndexPath(5) }, e.DeselectedIndices);
+                Assert.Equal(new object[] { 4, 5 }, e.DeselectedItems);
+                Assert.Equal(new[] { new IndexPath(6) }, e.SelectedIndices);
+                Assert.Equal(new object[] { 6 }, e.SelectedItems);
+                ++raised;
+            };
+
+            target.SelectedIndex = new IndexPath(6);
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void Removing_Selected_Item_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var data = new ObservableCollection<int>(Enumerable.Range(0, 10));
+            var raised = 0;
+
+            target.Source = data;
+            target.Select(4);
+            target.Select(5);
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Equal(new object[] { 4 }, e.DeselectedItems);
+                Assert.Empty(e.SelectedIndices);
+                Assert.Empty(e.SelectedItems);
+                ++raised;
+            };
+
+            data.Remove(4);
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void Removing_Selected_Child_Item_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var data = CreateNestedData(1, 2, 3);
+            var raised = 0;
+
+            target.Source = data;
+            target.SelectRange(new IndexPath(0), new IndexPath(1, 1));
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Equal(new object[] { 1}, e.DeselectedItems);
+                Assert.Empty(e.SelectedIndices);
+                Assert.Empty(e.SelectedItems);
+                ++raised;
+            };
+
+            ((AvaloniaList<object>)data[0]).RemoveAt(1);
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void Removing_Selected_Item_With_Children_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var data = CreateNestedData(1, 2, 3);
+            var raised = 0;
+
+            target.Source = data;
+            target.SelectRange(new IndexPath(0), new IndexPath(1, 1));
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Equal(new object[] { new AvaloniaList<int> { 0, 1, 2 }, 0, 1, 2 }, e.DeselectedItems);
+                Assert.Empty(e.SelectedIndices);
+                Assert.Empty(e.SelectedItems);
+                ++raised;
+            };
+
+            data.RemoveAt(0);
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void Removing_Unselected_Item_Before_Selected_Item_Raises_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var data = new ObservableCollection<int>(Enumerable.Range(0, 10));
+            var raised = 0;
+
+            target.Source = data;
+            target.Select(8);
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Empty(e.SelectedIndices);
+                Assert.Empty(e.SelectedItems);
+                ++raised;
+            };
+
+            data.Remove(6);
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void Removing_Unselected_Item_After_Selected_Item_Doesnt_Raise_SelectionChanged()
+        {
+            var target = new SelectionModel();
+            var data = new ObservableCollection<int>(Enumerable.Range(0, 10));
+            var raised = 0;
+
+            target.Source = data;
+            target.Select(4);
+
+            target.SelectionChanged += (s, e) => ++raised;
+
+            data.Remove(6);
+
+            Assert.Equal(0, raised); 
+        }
+
+        [Fact]
+        public void Disposing_Unhooks_CollectionChanged_Handlers()
+        {
+            var data = CreateNestedData(2, 2, 2);
+            var target = new SelectionModel { Source = data };
+
+            target.SelectAll();
+            VerifyCollectionChangedHandlers(1, data);
+
+            target.Dispose();
+
+            VerifyCollectionChangedHandlers(0, data);
+        }
+
+        [Fact]
+        public void Clearing_Selection_Unhooks_CollectionChanged_Handlers()
+        {
+            var data = CreateNestedData(2, 2, 2);
+            var target = new SelectionModel { Source = data };
+
+            target.SelectAll();
+            VerifyCollectionChangedHandlers(1, data);
+
+            target.ClearSelection();
+
+            // Root subscription not unhooked until SelectionModel is disposed.
+            Assert.Equal(1, GetSubscriberCount(data));
+
+            foreach (AvaloniaList<object> i in data)
+            {
+                VerifyCollectionChangedHandlers(0, i);
+            }
+        }
+
+        [Fact]
+        public void Removing_Item_Unhooks_CollectionChanged_Handlers()
+        {
+            var data = CreateNestedData(2, 2, 2);
+            var target = new SelectionModel { Source = data };
+
+            target.SelectAll();
+
+            var toRemove = (AvaloniaList<object>)data[1];
+            data.Remove(toRemove);
+
+            Assert.Equal(0, GetSubscriberCount(toRemove));
+        }
+
+        [Fact]
+        public void SelectRange_Behaves_The_Same_As_Multiple_Selects()
+        {
+            var data = new[] { 1, 2, 3 };
+            var target = new SelectionModel { Source = data };
+
+            target.Select(1);
+
+            Assert.Equal(new[] { IndexPath.CreateFrom(1) }, target.SelectedIndices);
+
+            target.ClearSelection();
+            target.SelectRange(new IndexPath(1), new IndexPath(1));
+
+            Assert.Equal(new[] { IndexPath.CreateFrom(1) }, target.SelectedIndices);
+        }
+
+        [Fact]
+        public void SelectRange_Behaves_The_Same_As_Multiple_Selects_Nested()
+        {
+            var data = CreateNestedData(3, 2, 2);
+            var target = new SelectionModel { Source = data };
+
+            target.Select(1);
+
+            Assert.Equal(new[] { IndexPath.CreateFrom(1) }, target.SelectedIndices);
+
+            target.ClearSelection();
+            target.SelectRange(new IndexPath(1), new IndexPath(1));
+
+            Assert.Equal(new[] { IndexPath.CreateFrom(1) }, target.SelectedIndices);
+        }
+
+        [Fact]
+        public void Should_Not_Treat_Strings_As_Nested_Selections()
+        {
+            var data = new[] { "foo", "bar", "baz" };
+            var target = new SelectionModel { Source = data };
+
+            target.SelectAll();
+
+            Assert.Equal(3, target.SelectedItems.Count);
+        }
+
+        [Fact]
+        public void Not_Enumerating_Changes_Does_Not_Prevent_Further_Operations()
+        {
+            var data = new[] { "foo", "bar", "baz" };
+            var target = new SelectionModel { Source = data };
+
+            target.SelectionChanged += (s, e) => { };
+
+            target.SelectAll();
+            target.ClearSelection();
+        }
+
+        [Fact]
+        public void Can_Change_Selection_From_SelectionChanged()
+        {
+            var data = new[] { "foo", "bar", "baz" };
+            var target = new SelectionModel { Source = data };
+            var raised = 0;
+
+            target.SelectionChanged += (s, e) => 
+            {
+                if (raised++ == 0)
+                {
+                    target.ClearSelection();
+                }
+            };
+
+            target.SelectAll();
+
+            Assert.Equal(2, raised);
+        }
+
+        [Fact]
+        public void Raises_SelectionChanged_With_No_Source()
+        {
+            var target = new SelectionModel();
+            var raised = 0;
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Equal(new[] { new IndexPath(1) }, e.SelectedIndices);
+                Assert.Empty(e.SelectedItems);
+                ++raised;
+            };
+
+            target.Select(1);
+
+            Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices);
+            Assert.Empty(target.SelectedItems);
+        }
+
+        [Fact]
+        public void Raises_SelectionChanged_With_Items_After_Source_Is_Set()
+        {
+            var target = new SelectionModel();
+            var raised = 0;
+
+            target.Select(1);
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Equal(new[] { new IndexPath(1) }, e.SelectedIndices);
+                Assert.Equal(new[] { "bar" }, e.SelectedItems);
+                ++raised;
+            };
+
+            target.Source = new[] { "foo", "bar", "baz" };
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void RetainSelectionOnReset_Retains_Selection_On_Reset()
+        {
+            var data = new ResettingList<string> { "foo", "bar", "baz" };
+            var target = new SelectionModel { Source = data, RetainSelectionOnReset = true };
+
+            target.SelectRange(new IndexPath(1), new IndexPath(2));
+            data.Reset();
+
+            Assert.Equal(new[] { new IndexPath(1), new IndexPath(2) }, target.SelectedIndices);
+            Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void RetainSelectionOnReset_Retains_Correct_Selection_After_Deselect()
+        {
+            var data = new ResettingList<string> { "foo", "bar", "baz" };
+            var target = new SelectionModel { Source = data, RetainSelectionOnReset = true };
+
+            target.SelectRange(new IndexPath(1), new IndexPath(2));
+            target.Deselect(2);
+            data.Reset();
+
+            Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices);
+            Assert.Equal(new[] { "bar" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void RetainSelectionOnReset_Retains_Correct_Selection_After_Remove_1()
+        {
+            var data = new ResettingList<string> { "foo", "bar", "baz" };
+            var target = new SelectionModel { Source = data, RetainSelectionOnReset = true };
+
+            target.SelectRange(new IndexPath(1), new IndexPath(2));
+            data.RemoveAt(2);
+            data.Reset(new[] { "foo", "bar", "baz" });
+
+            Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices);
+            Assert.Equal(new[] { "bar" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void RetainSelectionOnReset_Retains_Correct_Selection_After_Remove_2()
+        {
+            var data = new ResettingList<string> { "foo", "bar", "baz" };
+            var target = new SelectionModel { Source = data, RetainSelectionOnReset = true };
+
+            target.SelectRange(new IndexPath(1), new IndexPath(2));
+            data.RemoveAt(0);
+            data.Reset(new[] { "foo", "bar", "baz" });
+
+            Assert.Equal(new[] { new IndexPath(1), new IndexPath(2) }, target.SelectedIndices);
+            Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void RetainSelectionOnReset_Retains_No_Selection_After_Clear()
+        {
+            var data = new ResettingList<string> { "foo", "bar", "baz" };
+            var target = new SelectionModel { Source = data, RetainSelectionOnReset = true };
+
+            target.SelectRange(new IndexPath(1), new IndexPath(2));
+            target.ClearSelection();
+            data.Reset();
+
+            Assert.Empty(target.SelectedIndices);
+            Assert.Empty(target.SelectedItems);
+        }
+
+        [Fact]
+        public void RetainSelectionOnReset_Retains_Correct_Selection_After_Two_Resets()
+        {
+            var data = new ResettingList<string> { "foo", "bar", "baz" };
+            var target = new SelectionModel { Source = data, RetainSelectionOnReset = true };
+
+            target.SelectRange(new IndexPath(1), new IndexPath(2));
+            data.Reset(new[] { "foo", "bar" });
+            data.Reset(new[] { "foo", "bar", "baz" });
+
+            Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices);
+            Assert.Equal(new[] { "bar", }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void RetainSelectionOnReset_Raises_Empty_SelectionChanged_On_Reset_With_No_Changes()
+        {
+            var data = new ResettingList<string> { "foo", "bar", "baz" };
+            var target = new SelectionModel { Source = data, RetainSelectionOnReset = true };
+            var raised = 0;
+
+            target.SelectRange(new IndexPath(1), new IndexPath(2));
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Empty(e.SelectedIndices);
+                Assert.Empty(e.SelectedItems);
+                ++raised;
+            };
+
+            data.Reset();
+        }
+
+        [Fact]
+        public void RetainSelectionOnReset_Raises_SelectionChanged_On_Reset_With_Removed_Items()
+        {
+            var data = new ResettingList<string> { "foo", "bar", "baz" };
+            var target = new SelectionModel { Source = data, RetainSelectionOnReset = true };
+            var raised = 0;
+
+            target.SelectRange(new IndexPath(1), new IndexPath(2));
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Equal(new[] { "bar" }, e.DeselectedItems);
+                Assert.Empty(e.SelectedIndices);
+                Assert.Empty(e.SelectedItems);
+                ++raised;
+            };
+
+            data.Reset(new[] { "foo", "baz" });
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void RetainSelectionOnReset_Handles_Null_Source()
+        {
+            var data = new ResettingList<string> { "foo", "bar", "baz" };
+            var target = new SelectionModel { RetainSelectionOnReset = true };
+            var raised = 0;
+
+            target.SelectionChanged += (s, e) =>
+            {
+                if (raised == 0)
+                {
+                    Assert.Empty(e.DeselectedIndices);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Equal(new[] { new IndexPath(1) }, e.SelectedIndices);
+                    Assert.Empty(e.SelectedItems);
+                }
+                else if (raised == 1)
+                {
+                    Assert.Empty(e.DeselectedIndices);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Equal(new[] { new IndexPath(1) }, e.SelectedIndices);
+                    Assert.Equal(new[] { "bar" }, e.SelectedItems);
+                }
+                else if (raised == 3)
+                {
+                    Assert.Empty(e.DeselectedIndices);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndices);
+                    Assert.Empty(e.SelectedItems);
+                }
+
+                ++raised;
+            };
+
+            target.Select(1);
+            Assert.Equal(1, raised);
+
+            target.Source = data;
+            Assert.Equal(2, raised);
+            Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices);
+
+            data.Reset(new[] { "qux", "foo", "bar", "baz" });
+            Assert.Equal(3, raised);
+            Assert.Equal(new[] { new IndexPath(2) }, target.SelectedIndices);
+        }
+        
+        [Fact]
+        public void Can_Batch_Update()
+        {
+            var target = new SelectionModel();
+            var raised = 0;
+
+            target.Source = Enumerable.Range(0, 10).ToList();
+            target.Select(1);
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Equal(new[] { new IndexPath(1) }, e.DeselectedIndices);
+                Assert.Equal(new object[] { 1 }, e.DeselectedItems);
+                Assert.Equal(new[] { new IndexPath(4) }, e.SelectedIndices);
+                Assert.Equal(new object[] { 4 }, e.SelectedItems);
+                ++raised;
+            };
+
+            using (target.Update())
+            {
+                target.Deselect(1);
+                target.Select(4);
+            }
+
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void AutoSelect_Selects_When_Enabled()
+        {
+            var data = new[] { "foo", "bar", "baz" };
+            var target = new SelectionModel { Source = data };
+            var raised = 0;
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Equal(new[] { new IndexPath(0) }, e.SelectedIndices);
+                Assert.Equal(new[] { "foo" }, e.SelectedItems);
+                ++raised;
+            };
+
+            target.AutoSelect = true;
+
+            Assert.Equal(new IndexPath(0), target.SelectedIndex);
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void AutoSelect_Selects_When_Source_Assigned()
+        {
+            var data = new[] { "foo", "bar", "baz" };
+            var target = new SelectionModel { AutoSelect = true };
+            var raised = 0;
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Equal(new[] { new IndexPath(0) }, e.SelectedIndices);
+                Assert.Equal(new[] { "foo" }, e.SelectedItems);
+                ++raised;
+            };
+
+            target.Source = data;
+            
+            Assert.Equal(new IndexPath(0), target.SelectedIndex);
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void AutoSelect_Selects_When_New_Source_Assigned_And_Old_Source_Has_Selection()
+        {
+            var data = new[] { "foo", "bar", "baz" };
+            var target = new SelectionModel { AutoSelect = true, Source = data };
+            var raised = 0;
+
+            target.SelectionChanged += (s, e) =>
+            {
+                if (raised == 0)
+                {
+                    Assert.Equal(new[] { new IndexPath(0) }, e.DeselectedIndices);
+                    Assert.Equal(new[] { "foo" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndices);
+                    Assert.Empty(e.SelectedItems);
+                }
+                else
+                {
+                    Assert.Empty(e.DeselectedIndices);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Equal(new[] { new IndexPath(0) }, e.SelectedIndices);
+                    Assert.Equal(new[] { "newfoo" }, e.SelectedItems);
+                }
+                ++raised;
+            };
+
+            target.Source = new[] { "newfoo" };
+
+            Assert.Equal(new IndexPath(0), target.SelectedIndex);
+            Assert.Equal(2, raised);
+        }
+
+        [Fact]
+        public void AutoSelect_Selects_When_First_Item_Added()
+        {
+            var data = new ObservableCollection<string>();
+            var target = new SelectionModel { AutoSelect = true , Source = data };
+            var raised = 0;
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Empty(e.DeselectedIndices);
+                Assert.Empty(e.DeselectedItems);
+                Assert.Equal(new[] { new IndexPath(0) }, e.SelectedIndices);
+                Assert.Equal(new[] { "foo" }, e.SelectedItems);
+                ++raised;
+            };
+
+            data.Add("foo");
+
+            Assert.Equal(new IndexPath(0), target.SelectedIndex);
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void AutoSelect_Selects_When_Selected_Item_Removed()
+        {
+            var data = new ObservableCollection<string> { "foo", "bar", "baz" };
+            var target = new SelectionModel { AutoSelect = true, Source = data };
+            var raised = 0;
+
+            target.SelectedIndex = new IndexPath(2);
+
+            target.SelectionChanged += (s, e) =>
+            {
+                if (raised == 0)
+                {
+                    Assert.Empty(e.DeselectedIndices);
+                    Assert.Equal(new[] { "baz" }, e.DeselectedItems);
+                    Assert.Empty(e.SelectedIndices);
+                    Assert.Empty(e.SelectedItems);
+                }
+                else
+                {
+                    Assert.Empty(e.DeselectedIndices);
+                    Assert.Empty(e.DeselectedItems);
+                    Assert.Equal(new[] { new IndexPath(0) }, e.SelectedIndices);
+                    Assert.Equal(new[] { "foo" }, e.SelectedItems);
+                }
+
+                ++raised;
+            };
+
+            data.RemoveAt(2);
+
+            Assert.Equal(new IndexPath(0), target.SelectedIndex);
+            Assert.Equal(2, raised);
+        }
+
+        [Fact]
+        public void AutoSelect_Selects_On_Deselection()
+        {
+            var data = new[] { "foo", "bar", "baz" };
+            var target = new SelectionModel { AutoSelect = true, Source = data };
+            var raised = 0;
+
+            target.SelectedIndex = new IndexPath(2);
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Equal(new[] { new IndexPath(2) }, e.DeselectedIndices);
+                Assert.Equal(new[] { "baz" }, e.DeselectedItems);
+                Assert.Equal(new[] { new IndexPath(0) }, e.SelectedIndices);
+                Assert.Equal(new[] { "foo" }, e.SelectedItems);
+                ++raised;
+            };
+
+            target.Deselect(2);
+
+            Assert.Equal(new IndexPath(0), target.SelectedIndex);
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void AutoSelect_Selects_On_ClearSelection()
+        {
+            var data = new[] { "foo", "bar", "baz" };
+            var target = new SelectionModel { AutoSelect = true, Source = data };
+            var raised = 0;
+
+            target.SelectedIndex = new IndexPath(2);
+
+            target.SelectionChanged += (s, e) =>
+            {
+                Assert.Equal(new[] { new IndexPath(2) }, e.DeselectedIndices);
+                Assert.Equal(new[] { "baz" }, e.DeselectedItems);
+                Assert.Equal(new[] { new IndexPath(0) }, e.SelectedIndices);
+                Assert.Equal(new[] { "foo" }, e.SelectedItems);
+                ++raised;
+            };
+
+            target.ClearSelection();
+
+            Assert.Equal(new IndexPath(0), target.SelectedIndex);
+            Assert.Equal(1, raised);
+        }
+
+        [Fact]
+        public void AutoSelect_Overrides_Deselecting_First_Item()
+        {
+            var data = new[] { "foo", "bar", "baz" };
+            var target = new SelectionModel { AutoSelect = true, Source = data };
+            var raised = 0;
+
+            target.Select(0);
+
+            target.SelectionChanged += (s, e) =>
+            {
+                ++raised;
+            };
+
+            target.Deselect(0);
+
+            Assert.Equal(new IndexPath(0), target.SelectedIndex);
+            Assert.Equal(0, raised);
+        }
+
+        [Fact]
+        public void Can_Replace_Children_Collection()
+        {
+            var root = new Node("Root");
+            var target = new SelectionModel { Source = new[] { root } };
+            target.ChildrenRequested += (s, e) => e.Children = ((Node)e.Source).WhenAnyValue(x => x.Children);
+
+            target.Select(0, 9);
+
+            Assert.Equal("Child 9", ((Node)target.SelectedItem).Header);
+
+            root.ReplaceChildren();
+
+            Assert.Null(target.SelectedItem);
+        }
+
+        [Fact]
+        public void Child_Resolver_Is_Unsubscribed_When_Source_Changed()
+        {
+            var root = new Node("Root");
+            var target = new SelectionModel { Source = new[] { root } };
+            target.ChildrenRequested += (s, e) => e.Children = ((Node)e.Source).WhenAnyValue(x => x.Children);
+
+            target.Select(0, 9);
+
+            Assert.Equal(1, root.PropertyChangedSubscriptions);
+
+            target.Source = null;
+
+            Assert.Equal(0, root.PropertyChangedSubscriptions);
+        }
+
+        [Fact]
+        public void Child_Resolver_Is_Unsubscribed_When_Parent_Removed()
+        {
+            var root = new Node("Root");
+            var target = new SelectionModel { Source = new[] { root } };
+            var node = root.Children[1];
+            var path = new IndexPath(new[] { 0, 1, 1 });
+
+            target.ChildrenRequested += (s, e) => e.Children = ((Node)e.Source).WhenAnyValue(x => x.Children);
+
+            target.SelectAt(path);
+
+            Assert.Equal(1, node.PropertyChangedSubscriptions);
+
+            root.ReplaceChildren();
+
+            Assert.Equal(0, node.PropertyChangedSubscriptions);
+        }
+
+        private class Node : INotifyPropertyChanged
+        {
+            private ObservableCollection<Node> _children;
+            private PropertyChangedEventHandler _propertyChanged;
+
+            public Node(string header)
+            {
+                Header = header;
+            }
+
+            public string Header { get; }
+
+            public ObservableCollection<Node> Children
+            {
+                get => _children ??= CreateChildren(10);
+                private set
+                {
+                    _children = value;
+                    _propertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Children)));
+                }
+            }
+
+            public event PropertyChangedEventHandler PropertyChanged
+            {
+                add
+                {
+                    _propertyChanged += value;
+                    ++PropertyChangedSubscriptions;
+                }
+
+                remove
+                {
+                    _propertyChanged -= value;
+                    --PropertyChangedSubscriptions;
+                }
+            }
+
+            public int PropertyChangedSubscriptions { get; private set; }
+
+            public void ReplaceChildren()
+            {
+                Children = CreateChildren(5);
+            }
+
+            private ObservableCollection<Node> CreateChildren(int count)
+            {
+                return new ObservableCollection<Node>(
+                    Enumerable.Range(0, count).Select(x => new Node("Child " + x)));
+            }
+        }
+
+        private int GetSubscriberCount(AvaloniaList<object> list)
+        {
+            return ((INotifyCollectionChangedDebug)list).GetCollectionChangedSubscribers()?.Length ?? 0;
+        }
+
+        private void VerifyCollectionChangedHandlers(int expected, AvaloniaList<object> list)
+        {
+            var count = GetSubscriberCount(list);
+            
+            Assert.Equal(expected, count);
+
+            foreach (var i in list)
+            {
+                if (i is AvaloniaList<object> l)
+                {
+                    VerifyCollectionChangedHandlers(expected, l);
+                }
+            }
+        }
+
+        private void Select(SelectionModel manager, int index, bool select)
+        {
+            _output.WriteLine((select ? "Selecting " : "DeSelecting ") + index);
+            if (select)
+            {
+                manager.Select(index);
+            }
+            else
+            {
+                manager.Deselect(index);
+            }
+        }
+
+        private void Select(SelectionModel manager, int groupIndex, int itemIndex, bool select)
+        {
+            _output.WriteLine((select ? "Selecting " : "DeSelecting ") + groupIndex + "." + itemIndex);
+            if (select)
+            {
+                manager.Select(groupIndex, itemIndex);
+            }
+            else
+            {
+                manager.Deselect(groupIndex, itemIndex);
+            }
+        }
+
+        private void Select(SelectionModel manager, IndexPath index, bool select)
+        {
+            _output.WriteLine((select ? "Selecting " : "DeSelecting ") + index);
+            if (select)
+            {
+                manager.SelectAt(index);
+            }
+            else
+            {
+                manager.DeselectAt(index);
+            }
+        }
+
+        private void SelectRangeFromAnchor(SelectionModel manager, int index, bool select)
+        {
+            _output.WriteLine("SelectRangeFromAnchor " + index + " select: " + select.ToString());
+            if (select)
+            {
+                manager.SelectRangeFromAnchor(index);
+            }
+            else
+            {
+                manager.DeselectRangeFromAnchor(index);
+            }
+        }
+
+        private void SelectRangeFromAnchor(SelectionModel manager, int groupIndex, int itemIndex, bool select)
+        {
+            _output.WriteLine("SelectRangeFromAnchor " + groupIndex + "." + itemIndex + " select:" + select.ToString());
+            if (select)
+            {
+                manager.SelectRangeFromAnchor(groupIndex, itemIndex);
+            }
+            else
+            {
+                manager.DeselectRangeFromAnchor(groupIndex, itemIndex);
+            }
+        }
+
+        private void SelectRangeFromAnchor(SelectionModel manager, IndexPath index, bool select)
+        {
+            _output.WriteLine("SelectRangeFromAnchor " + index + " select: " + select.ToString());
+            if (select)
+            {
+                manager.SelectRangeFromAnchorTo(index);
+            }
+            else
+            {
+                manager.DeselectRangeFromAnchorTo(index);
+            }
+        }
+
+        private void ClearSelection(SelectionModel manager)
+        {
+            _output.WriteLine("ClearSelection");
+            manager.ClearSelection();
+        }
+
+        private void SetAnchorIndex(SelectionModel manager, int index)
+        {
+            _output.WriteLine("SetAnchorIndex " + index);
+            manager.SetAnchorIndex(index);
+        }
+
+        private void SetAnchorIndex(SelectionModel manager, int groupIndex, int itemIndex)
+        {
+            _output.WriteLine("SetAnchor " + groupIndex + "." + itemIndex);
+            manager.SetAnchorIndex(groupIndex, itemIndex);
+        }
+
+        private void SetAnchorIndex(SelectionModel manager, IndexPath index)
+        {
+            _output.WriteLine("SetAnchor " + index);
+            manager.AnchorIndex = index;
+        }
+
+        private void ValidateSelection(
+            SelectionModel selectionModel,
+            List<IndexPath> expectedSelected,
+            List<IndexPath> expectedPartialSelected = null,
+            int selectedInnerNodes = 0)
+        {
+            _output.WriteLine("Validating Selection...");
+
+            _output.WriteLine("Selection contains indices:");
+            foreach (var index in selectionModel.SelectedIndices)
+            {
+                _output.WriteLine(" " + index.ToString());
+            }
+
+            _output.WriteLine("Selection contains items:");
+            foreach (var item in selectionModel.SelectedItems)
+            {
+                _output.WriteLine(" " + item.ToString());
+            }
+
+            if (selectionModel.Source != null)
+            {
+                List<IndexPath> allIndices = GetIndexPathsInSource(selectionModel.Source);
+                foreach (var index in allIndices)
+                {
+                    bool? isSelected = selectionModel.IsSelectedWithPartialAt(index);
+                    if (Contains(expectedSelected, index) && !Contains(expectedPartialSelected, index))
+                    {
+                        Assert.True(isSelected.Value, index + " is Selected");
+                    }
+                    else if (expectedPartialSelected != null && Contains(expectedPartialSelected, index))
+                    {
+                        Assert.True(isSelected == null, index + " is partially Selected");
+                    }
+                    else
+                    {
+                        if (isSelected == null)
+                        {
+                            _output.WriteLine("*************" + index + " is null");
+                            Assert.True(false, "Expected false but got null");;
+                        }
+                        else
+                        {
+                            Assert.False(isSelected.Value, index + " is not Selected");
+                        }
+                    }
+                }
+            }
+            else
+            {
+                foreach (var index in expectedSelected)
+                {
+                    Assert.True(selectionModel.IsSelectedWithPartialAt(index), index + " is Selected");
+                }
+            }
+            if (expectedSelected.Count > 0)
+            {
+                _output.WriteLine("SelectedIndex is " + selectionModel.SelectedIndex);
+                Assert.Equal(expectedSelected[0], selectionModel.SelectedIndex);
+                if (selectionModel.Source != null)
+                {
+                    Assert.Equal(selectionModel.SelectedItem, GetData(selectionModel, expectedSelected[0]));
+                }
+
+                int itemsCount = selectionModel.SelectedItems.Count();
+                Assert.Equal(selectionModel.Source != null ? expectedSelected.Count - selectedInnerNodes : 0, itemsCount);
+                int indicesCount = selectionModel.SelectedIndices.Count();
+                Assert.Equal(expectedSelected.Count - selectedInnerNodes, indicesCount);
+            }
+
+            _output.WriteLine("Validating Selection... done");
+        }
+
+        private object GetData(SelectionModel selectionModel, IndexPath indexPath)
+        {
+            var data = selectionModel.Source;
+            for (int i = 0; i < indexPath.GetSize(); i++)
+            {
+                var listData = data as IList;
+                data = listData[indexPath.GetAt(i)];
+            }
+
+            return data;
+        }
+
+        private bool AreEqual(IndexPath a, IndexPath b)
+        {
+            if (a.GetSize() != b.GetSize())
+            {
+                return false;
+            }
+
+            for (int i = 0; i < a.GetSize(); i++)
+            {
+                if (a.GetAt(i) != b.GetAt(i))
+                {
+                    return false;
+                }
+            }
+
+            return true;
+        }
+
+        private List<IndexPath> GetIndexPathsInSource(object source)
+        {
+            List<IndexPath> paths = new List<IndexPath>();
+            Traverse(source, (TreeWalkNodeInfo node) =>
+            {
+                if (!paths.Contains(node.Path))
+                {
+                    paths.Add(node.Path);
+                }
+            });
+
+            _output.WriteLine("All Paths in source..");
+            foreach (var path in paths)
+            {
+                _output.WriteLine(path.ToString());
+            }
+            _output.WriteLine("done.");
+
+            return paths;
+        }
+
+        private static void Traverse(object root, Action<TreeWalkNodeInfo> nodeAction)
+        {
+            var pendingNodes = new Stack<TreeWalkNodeInfo>();
+            IndexPath current = Path(null);
+            pendingNodes.Push(new TreeWalkNodeInfo() { Current = root, Path = current });
+
+            while (pendingNodes.Count > 0)
+            {
+                var currentNode = pendingNodes.Pop();
+                var currentObject = currentNode.Current as IList;
+
+                if (currentObject != null)
+                {
+                    for (int i = currentObject.Count - 1; i >= 0; i--)
+                    {
+                        var child = currentObject[i];
+                        List<int> path = new List<int>();
+                        for (int idx = 0; idx < currentNode.Path.GetSize(); idx++)
+                        {
+                            path.Add(currentNode.Path.GetAt(idx));
+                        }
+
+                        path.Add(i);
+                        var childPath = IndexPath.CreateFromIndices(path);
+                        if (child != null)
+                        {
+                            pendingNodes.Push(new TreeWalkNodeInfo() { Current = child, Path = childPath });
+                        }
+                    }
+                }
+
+                nodeAction(currentNode);
+            }
+        }
+
+        private bool Contains(List<IndexPath> list, IndexPath index)
+        {
+            bool contains = false;
+            foreach (var item in list)
+            {
+                if (item.CompareTo(index) == 0)
+                {
+                    contains = true;
+                    break;
+                }
+            }
+
+            return contains;
+        }
+
+        public static AvaloniaList<object> CreateNestedData(int levels = 3, int groupsAtLevel = 5, int countAtLeaf = 10)
+        {
+            var nextData = 0;
+            return CreateNestedData(levels, groupsAtLevel, countAtLeaf, ref nextData);
+        }
+
+        public static AvaloniaList<object> CreateNestedData(
+            int levels,
+            int groupsAtLevel,
+            int countAtLeaf,
+            ref int nextData)
+        {
+            var data = new AvaloniaList<object>();
+            if (levels != 0)
+            {
+                for (int i = 0; i < groupsAtLevel; i++)
+                {
+                    data.Add(CreateNestedData(levels - 1, groupsAtLevel, countAtLeaf, ref nextData));
+                }
+            }
+            else
+            {
+                for (int i = 0; i < countAtLeaf; i++)
+                {
+                    data.Add(nextData++);
+                }
+            }
+
+            return data;
+        }
+
+        static IndexPath Path(params int[] path)
+        {
+            return IndexPath.CreateFromIndices(path);
+        }
+
+        private static int _nextData = 0;
+        private struct TreeWalkNodeInfo
+        {
+            public object Current { get; set; }
+
+            public IndexPath Path { get; set; }
+        }
+
+        private class ResettingList<T> : List<object>, INotifyCollectionChanged
+        {
+            public event NotifyCollectionChangedEventHandler CollectionChanged;
+
+            public new void RemoveAt(int index)
+            {
+                var item = this[index];
+                base.RemoveAt(index);
+                CollectionChanged?.Invoke(
+                    this,
+                    new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, new[] { item }, index));
+            }
+
+            public void Reset(IEnumerable<object> items = null)
+            {
+                if (items != null)
+                {
+                    Clear();
+                    AddRange(items);
+                }
+
+                CollectionChanged?.Invoke(
+                    this, 
+                    new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+            }
+        }
+    }
+
+    class CustomSelectionModel : SelectionModel
+    {
+        public int IntProperty
+        {
+            get { return _intProperty; }
+            set
+            {
+                _intProperty = value;
+                OnPropertyChanged("IntProperty");
+            }
+        }
+
+        private int _intProperty;
+    }
+}

+ 2 - 4
tests/Avalonia.Controls.UnitTests/TabControlTests.cs

@@ -92,7 +92,7 @@ namespace Avalonia.Controls.UnitTests
         }
 
         [Fact]
-        public void Removal_Should_Set_Next_Tab()
+        public void Removal_Should_Set_First_Tab()
         {
             var collection = new ObservableCollection<TabItem>()
             {
@@ -123,11 +123,9 @@ namespace Avalonia.Controls.UnitTests
             target.SelectedItem = collection[1];
             collection.RemoveAt(1);
 
-            // compare with former [2] now [1] == "3rd"
-            Assert.Same(collection[1], target.SelectedItem);
+            Assert.Same(collection[0], target.SelectedItem);
         }
 
-
         [Fact]
         public void TabItem_Templates_Should_Be_Set_Before_TabItem_ApplyTemplate()
         {

+ 8 - 9
tests/Avalonia.Controls.UnitTests/TreeViewTests.cs

@@ -7,7 +7,6 @@ using Avalonia.Controls.Presenters;
 using Avalonia.Controls.Templates;
 using Avalonia.Data;
 using Avalonia.Data.Core;
-using Avalonia.Diagnostics;
 using Avalonia.Input;
 using Avalonia.Input.Platform;
 using Avalonia.Interactivity;
@@ -241,12 +240,12 @@ namespace Avalonia.Controls.UnitTests
             ClickContainer(item2Container, InputModifiers.Control);
             Assert.True(item2Container.IsSelected);
 
-            Assert.Equal(new[] {item1, item2}, target.SelectedItems.OfType<Node>());
+            Assert.Equal(new[] {item1, item2}, target.Selection.SelectedItems.OfType<Node>());
 
             ClickContainer(item1Container, InputModifiers.Control);
             Assert.False(item1Container.IsSelected);
 
-            Assert.DoesNotContain(item1, target.SelectedItems.OfType<Node>());
+            Assert.DoesNotContain(item1, target.Selection.SelectedItems.OfType<Node>());
         }
 
         [Fact]
@@ -746,11 +745,11 @@ namespace Avalonia.Controls.UnitTests
             target.SelectAll();
 
             AssertChildrenSelected(target, tree[0]);
-            Assert.Equal(5, target.SelectedItems.Count);
+            Assert.Equal(5, target.Selection.SelectedItems.Count);
 
             _mouse.Click((Interactive)target.Presenter.Panel.Children[0], MouseButton.Right);
 
-            Assert.Equal(5, target.SelectedItems.Count);
+            Assert.Equal(5, target.Selection.SelectedItems.Count);
         }
 
         [Fact]
@@ -782,11 +781,11 @@ namespace Avalonia.Controls.UnitTests
             ClickContainer(fromContainer, InputModifiers.None);
             ClickContainer(toContainer, InputModifiers.Shift);
 
-            Assert.Equal(2, target.SelectedItems.Count);
+            Assert.Equal(2, target.Selection.SelectedItems.Count);
 
             _mouse.Click(thenContainer, MouseButton.Right);
 
-            Assert.Equal(1, target.SelectedItems.Count);
+            Assert.Equal(1, target.Selection.SelectedItems.Count);
         }
 
         [Fact]
@@ -816,7 +815,7 @@ namespace Avalonia.Controls.UnitTests
             _mouse.Click(fromContainer);
             _mouse.Click(toContainer, MouseButton.Right, modifiers: InputModifiers.Shift);
 
-            Assert.Equal(1, target.SelectedItems.Count);
+            Assert.Equal(1, target.Selection.SelectedItems.Count);
         }
 
         [Fact]
@@ -846,7 +845,7 @@ namespace Avalonia.Controls.UnitTests
             _mouse.Click(fromContainer);
             _mouse.Click(toContainer, MouseButton.Right, modifiers: InputModifiers.Control);
 
-            Assert.Equal(1, target.SelectedItems.Count);
+            Assert.Equal(1, target.Selection.SelectedItems.Count);
         }
 
         [Fact]

+ 223 - 0
tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs

@@ -0,0 +1,223 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using Avalonia.Collections;
+using Avalonia.Controls.Utils;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests.Utils
+{
+    public class SelectedItemsSyncTests
+    {
+        [Fact]
+        public void Initial_Items_Are_From_Model()
+        {
+            var target = CreateTarget();
+            var items = target.GetOrCreateItems();
+
+            Assert.Equal(new[] { "bar", "baz" }, items);
+        }
+
+        [Fact]
+        public void Selecting_On_Model_Adds_Item()
+        {
+            var target = CreateTarget();
+            var items = target.GetOrCreateItems();
+
+            target.Model.Select(0);
+
+            Assert.Equal(new[] { "bar", "baz", "foo" }, items);
+        }
+
+        [Fact]
+        public void Selecting_Duplicate_On_Model_Adds_Item()
+        {
+            var target = CreateTarget(new[] { "foo", "bar", "baz", "foo", "bar", "baz" });
+            var items = target.GetOrCreateItems();
+
+            target.Model.Select(4);
+
+            Assert.Equal(new[] { "bar", "baz", "bar" }, items);
+        }
+
+        [Fact]
+        public void Deselecting_On_Model_Removes_Item()
+        {
+            var target = CreateTarget();
+            var items = target.GetOrCreateItems();
+
+            target.Model.Deselect(1);
+
+            Assert.Equal(new[] { "baz" }, items);
+        }
+
+        [Fact]
+        public void Deselecting_Duplicate_On_Model_Removes_Item()
+        {
+            var target = CreateTarget(new[] { "foo", "bar", "baz", "foo", "bar", "baz" });
+            var items = target.GetOrCreateItems();
+
+            target.Model.Select(4);
+            target.Model.Deselect(4);
+
+            Assert.Equal(new[] { "baz", "bar" }, items);
+        }
+
+        [Fact]
+        public void Reassigning_Model_Resets_Items()
+        {
+            var target = CreateTarget();
+            var items = target.GetOrCreateItems();
+
+            var newModel = new SelectionModel { Source = target.Model.Source };
+            newModel.Select(0);
+            newModel.Select(1);
+
+            target.SetModel(newModel);
+
+            Assert.Equal(new[] { "foo", "bar" }, items);
+        }
+
+        [Fact]
+        public void Reassigning_Model_Tracks_New_Model()
+        {
+            var target = CreateTarget();
+            var items = target.GetOrCreateItems();
+
+            var newModel = new SelectionModel { Source = target.Model.Source };
+            target.SetModel(newModel);
+
+            newModel.Select(0);
+            newModel.Select(1);
+
+            Assert.Equal(new[] { "foo", "bar" }, items);
+        }
+
+        [Fact]
+        public void Adding_To_Items_Selects_On_Model()
+        {
+            var target = CreateTarget();
+            var items = target.GetOrCreateItems();
+
+            items.Add("foo");
+
+            Assert.Equal(
+                new[] { new IndexPath(0), new IndexPath(1), new IndexPath(2) },
+                target.Model.SelectedIndices);
+            Assert.Equal(new[] { "bar", "baz", "foo" }, items);
+        }
+
+        [Fact]
+        public void Removing_From_Items_Deselects_On_Model()
+        {
+            var target = CreateTarget();
+            var items = target.GetOrCreateItems();
+
+            items.Remove("baz");
+
+            Assert.Equal(new[] { new IndexPath(1) }, target.Model.SelectedIndices);
+            Assert.Equal(new[] { "bar" }, items);
+        }
+
+        [Fact]
+        public void Replacing_Item_Updates_Model()
+        {
+            var target = CreateTarget();
+            var items = target.GetOrCreateItems();
+
+            items[0] = "foo";
+
+            Assert.Equal(new[] { new IndexPath(0), new IndexPath(2) }, target.Model.SelectedIndices);
+            Assert.Equal(new[] { "foo", "baz" }, items);
+        }
+
+        [Fact]
+        public void Clearing_Items_Updates_Model()
+        {
+            var target = CreateTarget();
+            var items = target.GetOrCreateItems();
+
+            items.Clear();
+
+            Assert.Empty(target.Model.SelectedIndices);
+        }
+
+        [Fact]
+        public void Setting_Items_Updates_Model()
+        {
+            var target = CreateTarget();
+            var oldItems = target.GetOrCreateItems();
+
+            var newItems = new AvaloniaList<string> { "foo", "baz" };
+            target.SetItems(newItems);
+
+            Assert.Equal(new[] { new IndexPath(0), new IndexPath(2) }, target.Model.SelectedIndices);
+            Assert.Same(newItems, target.GetOrCreateItems());
+            Assert.NotSame(oldItems, target.GetOrCreateItems());
+            Assert.Equal(new[] { "foo", "baz" }, newItems);
+        }
+
+        [Fact]
+        public void Setting_Items_Subscribes_To_Model()
+        {
+            var target = CreateTarget();
+            var items = new AvaloniaList<string> { "foo", "baz" };
+
+            target.SetItems(items);
+            target.Model.Select(1);
+
+            Assert.Equal(new[] { "foo", "baz", "bar" }, items);
+        }
+
+        [Fact]
+        public void Setting_Items_To_Null_Creates_Empty_Items()
+        {
+            var target = CreateTarget();
+            var oldItems = target.GetOrCreateItems();
+
+            target.SetItems(null);
+
+            var newItems = Assert.IsType<AvaloniaList<object>>(target.GetOrCreateItems());
+
+            Assert.NotSame(oldItems, newItems);
+        }
+
+        [Fact]
+        public void Handles_Null_Model_Source()
+        {
+            var model = new SelectionModel();
+            model.Select(1);
+
+            var target = new SelectedItemsSync(model);
+            var items = target.GetOrCreateItems();
+
+            Assert.Empty(items);
+
+            model.Select(2);
+            model.Source = new[] { "foo", "bar", "baz" };
+
+            Assert.Equal(new[] { "bar", "baz" }, items);
+        }
+
+        [Fact]
+        public void Does_Not_Accept_Fixed_Size_Items()
+        {
+            var target = CreateTarget();
+
+            Assert.Throws<NotSupportedException>(() =>
+                target.SetItems(new[] { "foo", "bar", "baz" }));
+        }
+
+        private static SelectedItemsSync CreateTarget(
+            IEnumerable<string> items = null)
+        {
+            items ??= new[] { "foo", "bar", "baz" };
+
+            var model = new SelectionModel { Source = items };
+            model.SelectRange(new IndexPath(1), new IndexPath(2));
+
+            var target = new SelectedItemsSync(model);
+            return target;
+        }
+    }
+}