Jelajahi Sumber

Merge pull request #10589 from AvaloniaUI/refactor/selection-model

Port SelectionModel changes from TreeDataGrid.
Steven Kirk 2 tahun lalu
induk
melakukan
ef129ec0bc

+ 1 - 0
.gitignore

@@ -102,6 +102,7 @@ csx
 AppPackages/
 
 # NCrunch
+.NCrunch_*/
 _NCrunch_*/
 *.ncrunchsolution.user
 nCrunchTemp_*

+ 22 - 1
src/Avalonia.Controls/ItemsSourceView.cs

@@ -27,6 +27,7 @@ namespace Avalonia.Controls
 
         private readonly IList _inner;
         private NotifyCollectionChangedEventHandler? _collectionChanged;
+        private NotifyCollectionChangedEventHandler? _preCollectionChanged;
         private NotifyCollectionChangedEventHandler? _postCollectionChanged;
         private bool _listening;
 
@@ -70,7 +71,7 @@ namespace Avalonia.Controls
         /// Gets a value that indicates whether the items source can provide a unique key for each item.
         /// </summary>
         /// <remarks>
-        /// TODO: Not yet implemented in Avalonia.
+        /// Not implemented in Avalonia, preserved here for ItemsRepeater's usage.
         /// </remarks>
         internal bool HasKeyIndexMapping => false;
 
@@ -92,6 +93,25 @@ namespace Avalonia.Controls
             }
         }
 
+        /// <summary>
+        /// Occurs when a collection has finished changing and all <see cref="CollectionChanged"/>
+        /// event handlers have been notified.
+        /// </summary>
+        internal event NotifyCollectionChangedEventHandler? PreCollectionChanged
+        {
+            add
+            {
+                AddListenerIfNecessary();
+                _preCollectionChanged += value;
+            }
+
+            remove
+            {
+                _preCollectionChanged -= value;
+                RemoveListenerIfNecessary();
+            }
+        }
+
         /// <summary>
         /// Occurs when a collection has finished changing and all <see cref="CollectionChanged"/>
         /// event handlers have been notified.
@@ -229,6 +249,7 @@ namespace Avalonia.Controls
 
         void ICollectionChangedListener.PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)
         {
+            _preCollectionChanged?.Invoke(this, e);
         }
 
         void ICollectionChangedListener.Changed(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)

+ 1 - 1
src/Avalonia.Controls/Selection/InternalSelectionModel.cs

@@ -203,7 +203,7 @@ namespace Avalonia.Controls.Selection
             }
         }
 
-        private protected override void OnSourceCollectionChanged(NotifyCollectionChangedEventArgs e)
+        protected override void OnSourceCollectionChanged(NotifyCollectionChangedEventArgs e)
         {
             if (e.Action == NotifyCollectionChangedAction.Reset)
             {

+ 5 - 12
src/Avalonia.Controls/Selection/SelectedItems.cs

@@ -5,7 +5,7 @@ using System.Diagnostics.CodeAnalysis;
 
 namespace Avalonia.Controls.Selection
 {
-    internal class SelectedItems<T> : IReadOnlyList<T>
+    internal class SelectedItems<T> : IReadOnlyList<T?>
     {
         private readonly SelectionModel<T>? _owner;
         private readonly ItemsSourceView<T>? _items;
@@ -19,12 +19,9 @@ namespace Avalonia.Controls.Selection
             _items = items;
         }
 
-        [MaybeNull]
-        public T this[int index]
+        public T? this[int index]
         {
-#pragma warning disable CS8766
             get
-#pragma warning restore CS8766
             {
                 if (index >= Count)
                 {
@@ -64,15 +61,13 @@ namespace Avalonia.Controls.Selection
         private ItemsSourceView<T>? Items => _items ?? _owner?.ItemsView;
         private IReadOnlyList<IndexRange>? Ranges => _ranges ?? _owner!.Ranges;
 
-        public IEnumerator<T> GetEnumerator()
+        public IEnumerator<T?> GetEnumerator()
         {
             if (_owner?.SingleSelect == true)
             {
                 if (_owner.SelectedIndex >= 0)
                 {
-#pragma warning disable CS8603
                     yield return _owner.SelectedItem;
-#pragma warning restore CS8603
                 }
             }
             else
@@ -83,9 +78,7 @@ namespace Avalonia.Controls.Selection
                 {
                     for (var i = range.Begin; i <= range.End; ++i)
                     {
-#pragma warning disable CS8603
                         yield return items is object ? items[i] : default;
-#pragma warning restore CS8603
                     }
                 }
             }
@@ -102,8 +95,8 @@ namespace Avalonia.Controls.Selection
 
         public class Untyped : IReadOnlyList<object?>
         {
-            private readonly IReadOnlyList<T> _source;
-            public Untyped(IReadOnlyList<T> source) => _source = source;
+            private readonly IReadOnlyList<T?> _source;
+            public Untyped(IReadOnlyList<T?> source) => _source = source;
             public object? this[int index] => _source[index];
             public int Count => _source.Count;
             IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

+ 52 - 39
src/Avalonia.Controls/Selection/SelectionModel.cs

@@ -19,6 +19,7 @@ namespace Avalonia.Controls.Selection
         private SelectedItems<T>.Untyped? _selectedItemsUntyped;
         private EventHandler<SelectionModelSelectionChangedEventArgs>? _untypedSelectionChanged;
         private IList? _initSelectedItems;
+        private bool _isSourceCollectionChanging;
 
         public SelectionModel()
         {
@@ -55,7 +56,7 @@ namespace Avalonia.Controls.Selection
 
                     if (RangesEnabled && _selectedIndex >= 0)
                     {
-                        CommitSelect(new IndexRange(_selectedIndex));
+                        CommitSelect(_selectedIndex, _selectedIndex);
                     }
 
                     RaisePropertyChanged(nameof(SingleSelect));
@@ -80,7 +81,7 @@ namespace Avalonia.Controls.Selection
         {
             get
             {
-                if (ItemsView is object)
+                if (ItemsView is not null)
                 {
                     return GetItemAt(_selectedIndex);
                 }
@@ -93,21 +94,19 @@ namespace Avalonia.Controls.Selection
             }
             set
             {
-                if (ItemsView is object)
+                if (ItemsView is not null)
                 {
                     SelectedIndex = ItemsView.IndexOf(value!);
                 }
                 else
                 {
                     Clear();
-#pragma warning disable CS8601
-                    SetInitSelectedItems(new T[] { value });
-#pragma warning restore CS8601
+                    SetInitSelectedItems(new T[] { value! });
                 }
             }
         }
 
-        public IReadOnlyList<T> SelectedItems
+        public IReadOnlyList<T?> SelectedItems
         {
             get
             {
@@ -206,7 +205,7 @@ namespace Avalonia.Controls.Selection
             {
                 // If the collection is currently changing, commit the update when the
                 // collection change finishes.
-                if (!IsSourceCollectionChanging)
+                if (!_isSourceCollectionChanging)
                 {
                     CommitOperation(_operation);
                 }
@@ -278,7 +277,7 @@ namespace Avalonia.Controls.Selection
         {
             if (base.Source != value)
             {
-                if (_operation is object)
+                if (_operation is not null)
                 {
                     throw new InvalidOperationException("Cannot change source while update is in progress.");
                 }
@@ -296,7 +295,7 @@ namespace Avalonia.Controls.Selection
                 {
                     update.Operation.IsSourceUpdate = true;
 
-                    if (_initSelectedItems is object && ItemsView is object)
+                    if (_initSelectedItems is object && ItemsView is not null)
                     {
                         foreach (T i in _initSelectedItems)
                         {
@@ -315,17 +314,23 @@ namespace Avalonia.Controls.Selection
             }
         }
 
-        private protected override void OnIndexesChanged(int shiftIndex, int shiftDelta)
+        protected override void OnIndexesChanged(int shiftIndex, int shiftDelta)
         {
             IndexesChanged?.Invoke(this, new SelectionModelIndexesChangedEventArgs(shiftIndex, shiftDelta));
         }
 
-        private protected override void OnSourceReset()
+        protected override void OnSourceCollectionChangeStarted()
+        {
+            base.OnSourceCollectionChangeStarted();
+            _isSourceCollectionChanging = true;
+        }
+
+        protected override void OnSourceReset()
         {
             _selectedIndex = _anchorIndex = -1;
-            CommitDeselect(new IndexRange(0, int.MaxValue));
+            CommitDeselect(0, int.MaxValue);
 
-            if (SourceReset is object)
+            if (SourceReset is not null)
             {
                 SourceReset.Invoke(this, EventArgs.Empty);
             }
@@ -339,7 +344,7 @@ namespace Avalonia.Controls.Selection
             }
         }
 
-        private protected override void OnSelectionChanged(IReadOnlyList<T> deselectedItems)
+        protected override void OnSelectionRemoved(int index, int count, IReadOnlyList<T> deselectedItems)
         {
             // Note: We're *not* putting this in a using scope. A collection update is still in progress
             // so the operation won't get committed by normal means: we have to commit it manually.
@@ -347,7 +352,7 @@ namespace Avalonia.Controls.Selection
 
             update.Operation.DeselectedItems = deselectedItems;
 
-            if (_selectedIndex == -1 && LostSelection is object)
+            if (_selectedIndex == -1 && LostSelection is not null)
             {
                 LostSelection(this, EventArgs.Empty);
             }
@@ -357,7 +362,7 @@ namespace Avalonia.Controls.Selection
             CommitOperation(update.Operation, raisePropertyChanged: false);
         }
 
-        private protected override CollectionChangeState OnItemsAdded(int index, IList items)
+        protected override CollectionChangeState OnItemsAdded(int index, IList items)
         {
             var count = items.Count;
             var shifted = SelectedIndex >= index;
@@ -420,7 +425,7 @@ namespace Avalonia.Controls.Selection
             };
         }
 
-        private protected override void OnSourceCollectionChanged(NotifyCollectionChangedEventArgs e)
+        protected override void OnSourceCollectionChanged(NotifyCollectionChangedEventArgs e)
         {
             if (_operation?.UpdateCount > 0)
             {
@@ -451,6 +456,16 @@ namespace Avalonia.Controls.Selection
             }
         }
 
+        private protected void SetInitSelectedItems(IList items)
+        {
+            if (Source is object)
+            {
+                throw new InvalidOperationException("Cannot set init selected items when Source is set.");
+            }
+
+            _initSelectedItems = items;
+        }
+
         private protected override bool IsValidCollectionChange(NotifyCollectionChangedEventArgs e)
         {
             if (!base.IsValidCollectionChange(e))
@@ -474,19 +489,11 @@ namespace Avalonia.Controls.Selection
             return true;
         }
 
-        private protected void SetInitSelectedItems(IList items)
-        {
-            if (Source is object)
-            {
-                throw new InvalidOperationException("Cannot set init selected items when Source is set.");
-            }
-
-            _initSelectedItems = items;
-        }
-
         protected override void OnSourceCollectionChangeFinished()
         {
-            if (_operation is object)
+            _isSourceCollectionChanging = false;
+
+            if (_operation is not null)
             {
                 CommitOperation(_operation);
             }
@@ -575,7 +582,7 @@ namespace Avalonia.Controls.Selection
         {
             index = Math.Max(index, -1);
 
-            if (ItemsView is object && index >= ItemsView.Count)
+            if (ItemsView is not null && index >= ItemsView.Count)
             {
                 index = -1;
             }
@@ -585,7 +592,7 @@ namespace Avalonia.Controls.Selection
 
         private IndexRange CoerceRange(int start, int end)
         {
-            var max = ItemsView is object ? ItemsView.Count - 1 : int.MaxValue;
+            var max = ItemsView is not null ? ItemsView.Count - 1 : int.MaxValue;
 
             if (start > max || (start < 0 && end < 0))
             {
@@ -643,7 +650,7 @@ namespace Avalonia.Controls.Selection
                 var oldSelectedIndex = _selectedIndex;
                 var indexesChanged = false;
 
-                if (operation.SelectedIndex == -1 && LostSelection is object && !operation.SkipLostSelection)
+                if (operation.SelectedIndex == -1 && LostSelection is not null && !operation.SkipLostSelection)
                 {
                     operation.UpdateCount++;
                     LostSelection?.Invoke(this, EventArgs.Empty);
@@ -652,17 +659,23 @@ namespace Avalonia.Controls.Selection
                 _selectedIndex = operation.SelectedIndex;
                 _anchorIndex = operation.AnchorIndex;
 
-                if (operation.SelectedRanges is object)
+                if (operation.SelectedRanges is not null)
                 {
-                    indexesChanged |= CommitSelect(operation.SelectedRanges) > 0;
+                    foreach (var range in operation.SelectedRanges)
+                    {
+                        indexesChanged |= CommitSelect(range.Begin, range.End) > 0;
+                    }
                 }
 
-                if (operation.DeselectedRanges is object)
+                if (operation.DeselectedRanges is not null)
                 {
-                    indexesChanged |= CommitDeselect(operation.DeselectedRanges) > 0;
+                    foreach (var range in operation.DeselectedRanges)
+                    {
+                        indexesChanged |= CommitDeselect(range.Begin, range.End) > 0;
+                    }
                 }
 
-                if (SelectionChanged is object || _untypedSelectionChanged is object)
+                if (SelectionChanged is not null || _untypedSelectionChanged is not null)
                 {
                     IReadOnlyList<IndexRange>? deselected = operation.DeselectedRanges;
                     IReadOnlyList<IndexRange>? selected = operation.SelectedRanges;
@@ -690,14 +703,14 @@ namespace Avalonia.Controls.Selection
                         // CollectionChanged event. LostFocus may have caused another item to have been
                         // selected, but it can't have caused a deselection (as it was called due to
                         // selection being lost) so we're ok to discard `deselected` here.
-                        var deselectedItems = operation.DeselectedItems ??
+                        var deselectedItems = (IReadOnlyList<T?>?)operation.DeselectedItems ??
                             SelectedItems<T>.Create(deselected, deselectedSource);
 
                         var e = new SelectionModelSelectionChangedEventArgs<T>(
                             SelectedIndexes<T>.Create(deselected),
                             SelectedIndexes<T>.Create(selected),
                             deselectedItems,
-                            SelectedItems<T>.Create(selected, ItemsView));
+                            SelectedItems<T>.Create(selected, Source is not null ? ItemsView : null));
                         SelectionChanged?.Invoke(this, e);
                         _untypedSelectionChanged?.Invoke(this, e);
                     }

+ 4 - 4
src/Avalonia.Controls/Selection/SelectionModelSelectionChangedEventArgs.cs

@@ -39,8 +39,8 @@ namespace Avalonia.Controls.Selection
         public SelectionModelSelectionChangedEventArgs(
             IReadOnlyList<int>? deselectedIndices = null,
             IReadOnlyList<int>? selectedIndices = null,
-            IReadOnlyList<T>? deselectedItems = null,
-            IReadOnlyList<T>? selectedItems = null)
+            IReadOnlyList<T?>? deselectedItems = null,
+            IReadOnlyList<T?>? selectedItems = null)
         {
             DeselectedIndexes = deselectedIndices ?? Array.Empty<int>();
             SelectedIndexes = selectedIndices ?? Array.Empty<int>();
@@ -61,12 +61,12 @@ namespace Avalonia.Controls.Selection
         /// <summary>
         /// Gets the items that were removed from the selection.
         /// </summary>
-        public new IReadOnlyList<T> DeselectedItems { get; }
+        public new IReadOnlyList<T?> DeselectedItems { get; }
 
         /// <summary>
         /// Gets the items that were added to the selection.
         /// </summary>
-        public new IReadOnlyList<T> SelectedItems { get; }
+        public new IReadOnlyList<T?> SelectedItems { get; }
 
         protected override IReadOnlyList<object?> GetUntypedDeselectedItems()
         {

+ 199 - 112
src/Avalonia.Controls/Selection/SelectionNodeBase.cs

@@ -2,37 +2,62 @@
 using System.Collections;
 using System.Collections.Generic;
 using System.Collections.Specialized;
-using System.Linq;
-using Avalonia.Controls.Utils;
 
 namespace Avalonia.Controls.Selection
 {
-    public abstract class SelectionNodeBase<T> : ICollectionChangedListener
+    /// <summary>
+    /// Base class for selection models.
+    /// </summary>
+    /// <typeparam name="T">The type of the element being selected.</typeparam>
+    public abstract class SelectionNodeBase<T>
     {
         private IEnumerable? _source;
         private bool _rangesEnabled;
         private List<IndexRange>? _ranges;
-        private int _collectionChanging;
 
+        /// <summary>
+        /// Gets or sets the source collection.
+        /// </summary>
         protected IEnumerable? Source
         {
             get => _source;
             set
             {
+                void OnPreChanged(object? sender, NotifyCollectionChangedEventArgs e) => OnSourceCollectionChangeStarted();
+                void OnChanged(object? sender, NotifyCollectionChangedEventArgs e) => OnSourceCollectionChanged(e);
+                void OnPostChanged(object? sender, NotifyCollectionChangedEventArgs e) => OnSourceCollectionChangeFinished();
+
                 if (_source != value)
                 {
-                    if (ItemsView?.Inner is INotifyCollectionChanged inccOld)
-                        CollectionChangedEventManager.Instance.RemoveListener(inccOld, this);
+                    if (ItemsView is not null)
+                    {
+                        ItemsView.PreCollectionChanged -= OnPreChanged;
+                        ItemsView.CollectionChanged -= OnChanged;
+                        ItemsView.PostCollectionChanged -= OnPostChanged;
+                    }
+
                     _source = value;
-                    ItemsView = value is object ? ItemsSourceView.GetOrCreate<T>(value) : null;
-                    if (ItemsView?.Inner is INotifyCollectionChanged inccNew)
-                        CollectionChangedEventManager.Instance.AddListener(inccNew, this);
+                    ItemsView = value is not null ? ItemsSourceView.GetOrCreate<T>(value) : null;
+
+                    if (ItemsView is not null)
+                    {
+                        ItemsView.PreCollectionChanged += OnPreChanged;
+                        ItemsView.CollectionChanged += OnChanged;
+                        ItemsView.PostCollectionChanged += OnPostChanged;
+                    }
                 }
             }
         }
 
-        protected bool IsSourceCollectionChanging => _collectionChanging > 0;
+        /// <summary>
+        /// Gets an <see cref="ItemsSourceView{T}"/> of the <see cref="Source"/>.
+        /// </summary>
+        protected internal ItemsSourceView<T>? ItemsView { get; set; }
 
+        /// <summary>
+        /// Gets or sets a value indicating whether range selection is currently enabled for
+        /// the selection node.
+        /// </summary>
         protected bool RangesEnabled
         {
             get => _rangesEnabled;
@@ -50,8 +75,6 @@ namespace Avalonia.Controls.Selection
             }
         }
 
-        internal ItemsSourceView<T>? ItemsView { get; set; }
-
         internal IReadOnlyList<IndexRange> Ranges
         {
             get
@@ -65,81 +88,170 @@ namespace Avalonia.Controls.Selection
             }
         }
 
-        void ICollectionChangedListener.PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)
+        /// <summary>
+        /// Called when the source collection starts changing.
+        /// </summary>
+        protected virtual void OnSourceCollectionChangeStarted()
         {
-            ++_collectionChanging;
         }
 
-        void ICollectionChangedListener.Changed(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)
+        /// <summary>
+        /// Called when the <see cref="Source"/> collection changes.
+        /// </summary>
+        /// <param name="e">The details of the collection change.</param>
+        /// <remarks>
+        /// The implementation in <see cref="SelectionNodeBase{T}"/> calls
+        /// <see cref="OnItemsAdded(int, IList)"/> and <see cref="OnItemsRemoved(int, IList)"/>
+        /// in order to calculate how the collection change affects the currently selected items.
+        /// It then calls <see cref="OnIndexesChanged(int, int)"/> and
+        /// <see cref="OnSelectionRemoved(int, int, IReadOnlyList{T})"/> if necessary, according
+        /// to the <see cref="CollectionChangeState"/> returned by those methods.
+        /// 
+        /// Override this method and <see cref="OnSourceCollectionChangeFinished"/> to provide
+        /// custom handling of source collection changes.
+        /// </remarks>
+        protected virtual void OnSourceCollectionChanged(NotifyCollectionChangedEventArgs e)
         {
-            OnSourceCollectionChanged(e);
-        }
+            var shiftDelta = 0;
+            var shiftIndex = -1;
+            List<T>? removed = null;
 
-        void ICollectionChangedListener.PostChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)
-        {
-            if (--_collectionChanging == 0)
+            if (!IsValidCollectionChange(e))
             {
-                OnSourceCollectionChangeFinished();
+                return;
             }
-        }
-
-        protected abstract void OnSourceCollectionChangeFinished();
-
-        private protected abstract void OnIndexesChanged(int shiftIndex, int shiftDelta);
-
-        private protected abstract void OnSourceReset();
 
-        private protected abstract void OnSelectionChanged(IReadOnlyList<T> deselectedItems);
-
-        private protected int CommitSelect(IndexRange range)
-        {
-            if (RangesEnabled)
+            switch (e.Action)
             {
-                _ranges ??= new List<IndexRange>();
-                return IndexRange.Add(_ranges, range);
+                case NotifyCollectionChangedAction.Add:
+                    {
+                        var change = OnItemsAdded(e.NewStartingIndex, e.NewItems!);
+                        shiftIndex = change.ShiftIndex;
+                        shiftDelta = change.ShiftDelta;
+                        break;
+                    }
+                case NotifyCollectionChangedAction.Remove:
+                    {
+                        var change = OnItemsRemoved(e.OldStartingIndex, e.OldItems!);
+                        shiftIndex = change.ShiftIndex;
+                        shiftDelta = change.ShiftDelta;
+                        removed = change.RemovedItems;
+                        break;
+                    }
+                case NotifyCollectionChangedAction.Replace:
+                case NotifyCollectionChangedAction.Move:
+                    {
+                        var removeChange = OnItemsRemoved(e.OldStartingIndex, e.OldItems!);
+                        var addChange = OnItemsAdded(e.NewStartingIndex, e.NewItems!);
+                        shiftIndex = removeChange.ShiftIndex;
+                        shiftDelta = removeChange.ShiftDelta + addChange.ShiftDelta;
+                        removed = removeChange.RemovedItems;
+                    }
+                    break;
+                case NotifyCollectionChangedAction.Reset:
+                    OnSourceReset();
+                    break;
             }
 
-            return 0;
+            if (shiftDelta != 0)
+                OnIndexesChanged(shiftIndex, shiftDelta);
+            if (removed is not null)
+                OnSelectionRemoved(shiftIndex, -shiftDelta, removed);
         }
 
-        private protected int CommitSelect(IReadOnlyList<IndexRange> ranges)
+        /// <summary>
+        /// Called when the source collection has finished changing, and all CollectionChanged
+        /// handlers have run.
+        /// </summary>
+        /// <remarks>
+        /// Override this method to respond to the end of a collection change instead of acting at
+        /// the end of <see cref="OnSourceCollectionChanged(NotifyCollectionChangedEventArgs)"/>
+        /// in order to ensure that all UI subscribers to the source collection change event have
+        /// had chance to run.
+        /// </remarks>
+        protected virtual void OnSourceCollectionChangeFinished()
         {
-            if (RangesEnabled)
-            {
-                _ranges ??= new List<IndexRange>();
-                return IndexRange.Add(_ranges, ranges);
-            }
+        }
 
-            return 0;
+        /// <summary>
+        /// Called by <see cref="OnSourceCollectionChanged(NotifyCollectionChangedEventArgs)"/>,
+        /// detailing the indexes changed by the collection changing.
+        /// </summary>
+        /// <param name="shiftIndex">The first index that was shifted.</param>
+        /// <param name="shiftDelta">
+        /// If positive, the number of items inserted, or if negative the number of items removed.
+        /// </param>
+        protected virtual void OnIndexesChanged(int shiftIndex, int shiftDelta)
+        {
+        }
+
+        /// <summary>
+        /// Called by <see cref="OnSourceCollectionChanged(NotifyCollectionChangedEventArgs)"/>,
+        /// on collection reset.
+        /// </summary>
+        protected abstract void OnSourceReset();
+
+        /// <summary>
+        /// Called by <see cref="OnSourceCollectionChanged(NotifyCollectionChangedEventArgs)"/>,
+        /// detailing the items removed by a collection change.
+        /// </summary>
+        protected virtual void OnSelectionRemoved(int index, int count, IReadOnlyList<T> deselectedItems)
+        {
         }
 
-        private protected int CommitDeselect(IndexRange range)
+        /// <summary>
+        /// If <see cref="RangesEnabled"/>, adds the specified range to the selection.
+        /// </summary>
+        /// <param name="begin">The inclusive index of the start of the range to select.</param>
+        /// <param name="end">The inclusive index of the end of the range to select.</param>
+        /// <returns>The number of items selected.</returns>
+        protected int CommitSelect(int begin, int end)
         {
             if (RangesEnabled)
             {
                 _ranges ??= new List<IndexRange>();
-                return IndexRange.Remove(_ranges, range);
+                return IndexRange.Add(_ranges, new IndexRange(begin, end));
             }
 
             return 0;
         }
 
-        private protected int CommitDeselect(IReadOnlyList<IndexRange> ranges)
+        /// <summary>
+        /// If <see cref="RangesEnabled"/>, removes the specified range from the selection.
+        /// </summary>
+        /// <param name="begin">The inclusive index of the start of the range to deselect.</param>
+        /// <param name="end">The inclusive index of the end of the range to deselect.</param>
+        /// <returns>The number of items selected.</returns>
+        protected int CommitDeselect(int begin, int end)
         {
-            if (RangesEnabled && _ranges is object)
+            if (RangesEnabled)
             {
-                return IndexRange.Remove(_ranges, ranges);
+                _ranges ??= new List<IndexRange>();
+                return IndexRange.Remove(_ranges, new IndexRange(begin, end));
             }
 
             return 0;
         }
 
-        private protected virtual CollectionChangeState OnItemsAdded(int index, IList items)
+        /// <summary>
+        /// Called by <see cref="OnSourceCollectionChanged(NotifyCollectionChangedEventArgs)"/>
+        /// when items are added to the source collection.
+        /// </summary>
+        /// <returns>
+        /// A <see cref="CollectionChangeState"/> struct containing the details of the adjusted
+        /// selection.
+        /// </returns>
+        /// <remarks>
+        /// The implementation in <see cref="SelectionNodeBase{T}"/> adjusts the selected ranges, 
+        /// assigning new indexes. Override this method to carry out additional computation when
+        /// items are added.
+        /// </remarks>
+        protected virtual CollectionChangeState OnItemsAdded(int index, IList items)
         {
             var count = items.Count;
             var shifted = false;
 
-            if (_ranges is object)
+            if (_ranges is not null)
             {
                 List<IndexRange>? toAdd = null;
 
@@ -150,7 +262,7 @@ namespace Avalonia.Controls.Selection
                     // The range is after the inserted items, need to shift the range right
                     if (range.End >= index)
                     {
-                        int begin = range.Begin;
+                        var begin = range.Begin;
 
                         // If the index left of newIndex is inside the range,
                         // Split the range and remember the left piece to add later
@@ -167,7 +279,7 @@ namespace Avalonia.Controls.Selection
                     }
                 }
 
-                if (toAdd is object)
+                if (toAdd is not null)
                 {
                     foreach (var range in toAdd)
                     {
@@ -183,14 +295,27 @@ namespace Avalonia.Controls.Selection
             };
         }
 
+        /// <summary>
+        /// Called by <see cref="OnSourceCollectionChanged(NotifyCollectionChangedEventArgs)"/>
+        /// when items are removed from the source collection.
+        /// </summary>
+        /// <returns>
+        /// A <see cref="CollectionChangeState"/> struct containing the details of the adjusted
+        /// selection.
+        /// </returns>
+        /// <remarks>
+        /// The implementation in <see cref="SelectionNodeBase{T}"/> adjusts the selected ranges, 
+        /// assigning new indexes. Override this method to carry out additional computation when
+        /// items are removed.
+        /// </remarks>
         private protected virtual CollectionChangeState OnItemsRemoved(int index, IList items)
         {
             var count = items.Count;
             var removedRange = new IndexRange(index, index + count - 1);
-            bool shifted = false;
+            var shifted = false;
             List<T>? removed = null;
 
-            if (_ranges is object)
+            if (_ranges is not null)
             {
                 var deselected = new List<IndexRange>();
 
@@ -227,60 +352,6 @@ namespace Avalonia.Controls.Selection
             };
         }
 
-        private protected virtual void OnSourceCollectionChanged(NotifyCollectionChangedEventArgs e)
-        {
-            var shiftDelta = 0;
-            var shiftIndex = -1;
-            List<T>? removed = null;
-
-            if (!IsValidCollectionChange(e))
-            {
-                return;
-            }
-
-            switch (e.Action)
-            {
-                case NotifyCollectionChangedAction.Add:
-                    {
-                        var change = OnItemsAdded(e.NewStartingIndex, e.NewItems!);
-                        shiftIndex = change.ShiftIndex;
-                        shiftDelta = change.ShiftDelta;
-                        break;
-                    }
-                case NotifyCollectionChangedAction.Remove:
-                    {
-                        var change = OnItemsRemoved(e.OldStartingIndex, e.OldItems!);
-                        shiftIndex = change.ShiftIndex;
-                        shiftDelta = change.ShiftDelta;
-                        removed = change.RemovedItems;
-                        break;
-                    }
-                case NotifyCollectionChangedAction.Replace:
-                case NotifyCollectionChangedAction.Move:
-                    {
-                        var removeChange = OnItemsRemoved(e.OldStartingIndex, e.OldItems!);
-                        var addChange = OnItemsAdded(e.NewStartingIndex, e.NewItems!);
-                        shiftIndex = removeChange.ShiftIndex;
-                        shiftDelta = removeChange.ShiftDelta + addChange.ShiftDelta;
-                        removed = removeChange.RemovedItems;
-                    }
-                    break;
-                case NotifyCollectionChangedAction.Reset:
-                    OnSourceReset();
-                    break;
-            }
-
-            if (shiftDelta != 0)
-            {
-                OnIndexesChanged(shiftIndex, shiftDelta);
-            }
-
-            if (removed is object)
-            {
-                OnSelectionChanged(removed);
-            }
-        }
-
         private protected virtual bool IsValidCollectionChange(NotifyCollectionChangedEventArgs e)
         {
             // If the selection is modified in a CollectionChanged handler before the selection
@@ -309,11 +380,27 @@ namespace Avalonia.Controls.Selection
             return true;
         }
 
-        private protected struct CollectionChangeState
+        /// <summary>
+        /// Details the results of a collection change on the current selection;
+        /// </summary>
+        protected class CollectionChangeState
         {
-            public int ShiftIndex;
-            public int ShiftDelta;
-            public List<T>? RemovedItems;
+            /// <summary>
+            /// Gets or sets the first index that was shifted as a result of the collection
+            /// changing.
+            /// </summary>
+            public int ShiftIndex { get; set; }
+
+            /// <summary>
+            /// Gets or sets a value indicating how the indexes after <see cref="ShiftIndex"/>
+            /// were shifted.
+            /// </summary>
+            public int ShiftDelta { get; set; }
+
+            /// <summary>
+            /// Gets or sets the items removed by the collection change, if any.
+            /// </summary>
+            public List<T>? RemovedItems { get; set; }
         }
     }
 }