瀏覽代碼

Reafactor SelectingItemsControl selection.

- Remove `SelectedItemsSync` and store `SelectedItems` in a new `InternalSelectionModel`
- Store transient `SelectingItemsControl` state in an `UpdateState` object

Fixes #4272
Steven Kirk 5 年之前
父節點
當前提交
b73ba99077

+ 178 - 47
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@@ -6,7 +6,6 @@ using System.ComponentModel;
 using System.Linq;
 using System.Linq;
 using Avalonia.Controls.Generators;
 using Avalonia.Controls.Generators;
 using Avalonia.Controls.Selection;
 using Avalonia.Controls.Selection;
-using Avalonia.Controls.Utils;
 using Avalonia.Data;
 using Avalonia.Data;
 using Avalonia.Input;
 using Avalonia.Input;
 using Avalonia.Input.Platform;
 using Avalonia.Input.Platform;
@@ -70,8 +69,8 @@ namespace Avalonia.Controls.Primitives
         /// <summary>
         /// <summary>
         /// Defines the <see cref="SelectedItems"/> property.
         /// Defines the <see cref="SelectedItems"/> property.
         /// </summary>
         /// </summary>
-        protected static readonly DirectProperty<SelectingItemsControl, IList> SelectedItemsProperty =
-            AvaloniaProperty.RegisterDirect<SelectingItemsControl, IList>(
+        protected static readonly DirectProperty<SelectingItemsControl, IList?> SelectedItemsProperty =
+            AvaloniaProperty.RegisterDirect<SelectingItemsControl, IList?>(
                 nameof(SelectedItems),
                 nameof(SelectedItems),
                 o => o.SelectedItems,
                 o => o.SelectedItems,
                 (o, v) => o.SelectedItems = v);
                 (o, v) => o.SelectedItems = v);
@@ -111,12 +110,11 @@ namespace Avalonia.Controls.Primitives
                 RoutingStrategies.Bubble);
                 RoutingStrategies.Bubble);
 
 
         private static readonly IList Empty = Array.Empty<object>();
         private static readonly IList Empty = Array.Empty<object>();
-        private SelectedItemsSync? _selectedItemsSync;
         private ISelectionModel? _selection;
         private ISelectionModel? _selection;
         private int _oldSelectedIndex;
         private int _oldSelectedIndex;
         private object? _oldSelectedItem;
         private object? _oldSelectedItem;
-        private int _initializing;
         private bool _ignoreContainerSelectionChanged;
         private bool _ignoreContainerSelectionChanged;
+        private UpdateState? _updateState;
 
 
         /// <summary>
         /// <summary>
         /// Initializes static members of the <see cref="SelectingItemsControl"/> class.
         /// Initializes static members of the <see cref="SelectingItemsControl"/> class.
@@ -149,8 +147,23 @@ namespace Avalonia.Controls.Primitives
         /// </summary>
         /// </summary>
         public int SelectedIndex
         public int SelectedIndex
         {
         {
-            get => Selection.SelectedIndex;
-            set => Selection.SelectedIndex = value;
+            get
+            {
+                return _updateState?.SelectedIndex.HasValue == true ?
+                    _updateState.SelectedIndex.Value :
+                    Selection.SelectedIndex;
+            }
+            set
+            {
+                if (_updateState is object)
+                {
+                    _updateState.SelectedIndex = value;
+                }
+                else
+                {
+                    Selection.SelectedIndex = value;
+                }
+            }
         }
         }
 
 
         /// <summary>
         /// <summary>
@@ -158,17 +171,56 @@ namespace Avalonia.Controls.Primitives
         /// </summary>
         /// </summary>
         public object? SelectedItem
         public object? SelectedItem
         {
         {
-            get => Selection.SelectedItem;
-            set => Selection.SelectedItem = value;
+            get
+            {
+                return _updateState?.SelectedItem.HasValue == true ?
+                    _updateState.SelectedItem.Value :
+                    Selection.SelectedItem;
+            }
+            set
+            {
+                if (_updateState is object)
+                {
+                    _updateState.SelectedItem = value;
+                }
+                else
+                {
+                    Selection.SelectedItem = value;
+                }
+            }
         }
         }
 
 
         /// <summary>
         /// <summary>
         /// Gets or sets the selected items.
         /// Gets or sets the selected items.
         /// </summary>
         /// </summary>
-        protected IList SelectedItems
+        /// <remarks>
+        /// By default returns a collection that can be modified in order to manipulate the control
+        /// selection, however this property will return null if <see cref="Selection"/> is
+        /// re-assigned; you should only use _either_ Selection or SelectedItems.
+        /// </remarks>
+        protected IList? SelectedItems
         {
         {
-            get => SelectedItemsSync.SelectedItems;
-            set => SelectedItemsSync.SelectedItems = value;
+            get
+            {
+                return _updateState?.SelectedItems.HasValue == true ?
+                    _updateState.SelectedItems.Value :
+                    (Selection as InternalSelectionModel)?.SelectedItems;
+            }
+            set
+            {
+                if (_updateState is object)
+                {
+                    _updateState.SelectedItems = new Optional<IList?>(value);
+                }
+                else if (Selection is InternalSelectionModel i)
+                {
+                    i.SelectedItems = value;
+                }
+                else
+                {
+                    throw new InvalidOperationException("Cannot set both Selection and SelectedItems.");
+                }
+            }
         }
         }
 
 
         /// <summary>
         /// <summary>
@@ -178,19 +230,30 @@ namespace Avalonia.Controls.Primitives
         {
         {
             get
             get
             {
             {
-                if (_selection is null)
+                if (_updateState?.Selection.HasValue == true)
                 {
                 {
-                    _selection = CreateDefaultSelectionModel();
-                    InitializeSelectionModel(_selection);
+                    return _updateState.Selection.Value;
                 }
                 }
+                else
+                {
+                    if (_selection is null)
+                    {
+                        _selection = CreateDefaultSelectionModel();
+                        InitializeSelectionModel(_selection);
+                    }
 
 
-                return _selection;
+                    return _selection;
+                }
             }
             }
             set
             set
             {
             {
                 value ??= CreateDefaultSelectionModel();
                 value ??= CreateDefaultSelectionModel();
 
 
-                if (_selection != value)
+                if (_updateState is object)
+                {
+                    _updateState.Selection = new Optional<ISelectionModel>(value);
+                }
+                else if (_selection != value)
                 {
                 {
                     if (value.Source != null && value.Source != Items)
                     if (value.Source != null && value.Source != Items)
                     {
                     {
@@ -234,20 +297,18 @@ namespace Avalonia.Controls.Primitives
         /// </summary>
         /// </summary>
         protected bool AlwaysSelected => (SelectionMode & SelectionMode.AlwaysSelected) != 0;
         protected bool AlwaysSelected => (SelectionMode & SelectionMode.AlwaysSelected) != 0;
 
 
-        private SelectedItemsSync SelectedItemsSync => _selectedItemsSync ??= new SelectedItemsSync(Selection);
-
         /// <inheritdoc/>
         /// <inheritdoc/>
         public override void BeginInit()
         public override void BeginInit()
         {
         {
             base.BeginInit();
             base.BeginInit();
-            ++_initializing;
+            BeginUpdating();
         }
         }
 
 
         /// <inheritdoc/>
         /// <inheritdoc/>
         public override void EndInit()
         public override void EndInit()
         {
         {
             base.EndInit();
             base.EndInit();
-            --_initializing;
+            EndUpdating();
         }
         }
 
 
         /// <summary>
         /// <summary>
@@ -351,30 +412,14 @@ namespace Avalonia.Controls.Primitives
         protected override void OnDataContextBeginUpdate()
         protected override void OnDataContextBeginUpdate()
         {
         {
             base.OnDataContextBeginUpdate();
             base.OnDataContextBeginUpdate();
-            ++_initializing;
-
-            if (_selection is object)
-            {
-                _selection.Source = null;
-            }
+            BeginUpdating();
         }
         }
 
 
         /// <inheritdoc/>
         /// <inheritdoc/>
         protected override void OnDataContextEndUpdate()
         protected override void OnDataContextEndUpdate()
         {
         {
             base.OnDataContextEndUpdate();
             base.OnDataContextEndUpdate();
-            --_initializing;
-
-            if (_selection is object && _initializing == 0)
-            {
-                _selection.Source = Items;
-
-                if (Items is null)
-                {
-                    _selection.Clear();
-                    _selectedItemsSync?.SelectedItems?.Clear();
-                }
-            }
+            EndUpdating();
         }
         }
 
 
         protected override void OnInitialized()
         protected override void OnInitialized()
@@ -411,9 +456,7 @@ namespace Avalonia.Controls.Primitives
         {
         {
             base.OnPropertyChanged(change);
             base.OnPropertyChanged(change);
 
 
-            if (change.Property == ItemsProperty &&
-                _initializing == 0 &&
-                _selection is object)
+            if (change.Property == ItemsProperty && _updateState is null && _selection is object)
             {
             {
                 var newValue = change.NewValue.GetValueOrDefault<IEnumerable>();
                 var newValue = change.NewValue.GetValueOrDefault<IEnumerable>();
                 _selection.Source = newValue;
                 _selection.Source = newValue;
@@ -789,7 +832,7 @@ namespace Avalonia.Controls.Primitives
 
 
         private ISelectionModel CreateDefaultSelectionModel()
         private ISelectionModel CreateDefaultSelectionModel()
         {
         {
-            return new SelectionModel<object>
+            return new InternalSelectionModel
             {
             {
                 SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple),
                 SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple),
             };
             };
@@ -797,7 +840,7 @@ namespace Avalonia.Controls.Primitives
 
 
         private void InitializeSelectionModel(ISelectionModel model)
         private void InitializeSelectionModel(ISelectionModel model)
         {
         {
-            if (_initializing == 0)
+            if (_updateState is null)
             {
             {
                 model.Source = Items;
                 model.Source = Items;
             }
             }
@@ -825,9 +868,6 @@ namespace Avalonia.Controls.Primitives
 
 
             UpdateContainerSelection();
             UpdateContainerSelection();
 
 
-            _selectedItemsSync ??= new SelectedItemsSync(model);
-            _selectedItemsSync.SelectionModel = model;
-
             if (SelectedIndex != -1)
             if (SelectedIndex != -1)
             {
             {
                 RaiseEvent(new SelectionChangedEventArgs(
                 RaiseEvent(new SelectionChangedEventArgs(
@@ -845,5 +885,96 @@ namespace Avalonia.Controls.Primitives
                 model.SelectionChanged -= OnSelectionModelSelectionChanged;
                 model.SelectionChanged -= OnSelectionModelSelectionChanged;
             }
             }
         }
         }
+
+        private void BeginUpdating()
+        {
+            _updateState ??= new UpdateState();
+            _updateState.UpdateCount++;
+        }
+
+        private void EndUpdating()
+        {
+            if (_updateState is object && --_updateState.UpdateCount == 0)
+            {
+                var state = _updateState;
+                _updateState = null;
+
+                if (state.Selection.HasValue)
+                {
+                    Selection = state.Selection.Value;
+                }
+
+                if (state.SelectedItems.HasValue)
+                {
+                    SelectedItems = state.SelectedItems.Value;
+                }
+
+                Selection.Source = Items;
+
+                if (Items is null)
+                {
+                    Selection.Clear();
+                }
+
+                if (state.SelectedIndex.HasValue)
+                {
+                    SelectedIndex = state.SelectedIndex.Value;
+                }
+                else if (state.SelectedItem.HasValue)
+                {
+                    SelectedItem = state.SelectedItem.Value;
+                }
+            }
+        }
+
+        // When in a BeginInit..EndInit block, or when the DataContext is updating, we need to
+        // defer changes to the selection model because we have no idea in which order properties
+        // will be set. Consider:
+        //
+        // - Both Items and SelectedItem are bound
+        // - The DataContext changes
+        // - The binding for SelectedItem updates first, producing an item
+        // - Items is searched to find the index of the new selected item
+        // - However Items isn't yet updated; the item is not found
+        // - SelectedIndex is incorrectly set to -1
+        //
+        // This logic cannot be encapsulated in SelectionModel because the selection model can also
+        // be bound, consider:
+        //
+        // - Both Items and Selection are bound
+        // - The DataContext changes
+        // - The binding for Items updates first
+        // - The new items are assigned to Selection.Source
+        // - The binding for Selection updates, producing a new SelectionModel
+        // - Both the old and new SelectionModels have the incorrect Source
+        private class UpdateState
+        {
+            private Optional<int> _selectedIndex;
+            private Optional<object?> _selectedItem;
+
+            public int UpdateCount { get; set; }
+            public Optional<ISelectionModel> Selection { get; set; }
+            public Optional<IList?> SelectedItems { get; set; }
+
+            public Optional<int> SelectedIndex 
+            {
+                get => _selectedIndex;
+                set
+                {
+                    _selectedIndex = value;
+                    _selectedItem = default;
+                }
+            }
+
+            public Optional<object?> SelectedItem
+            {
+                get => _selectedItem;
+                set
+                {
+                    _selectedItem = value;
+                    _selectedIndex = default;
+                }
+            }
+       }
     }
     }
 }
 }

+ 251 - 0
src/Avalonia.Controls/Selection/InternalSelectionModel.cs

@@ -0,0 +1,251 @@
+using System;
+using System.Collections;
+using System.Collections.Specialized;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Avalonia.Collections;
+
+#nullable enable
+
+namespace Avalonia.Controls.Selection
+{
+    internal class InternalSelectionModel : SelectionModel<object?>
+    {
+        private IList? _selectedItems;
+        private bool _ignoreModelChanges;
+        private bool _ignoreSelectedItemsChanges;
+
+        public InternalSelectionModel()
+        {
+            SelectionChanged += OnSelectionChanged;
+            SourceReset += OnSourceReset;
+        }
+
+        [AllowNull]
+        public new IList SelectedItems
+        {
+            get
+            {
+                if (_selectedItems is null)
+                {
+                    _selectedItems = new AvaloniaList<object?>();
+                    SubscribeToSelectedItems();
+                }
+
+                return _selectedItems;
+            }
+            set
+            {
+                value ??= new AvaloniaList<object?>();
+
+                if (value.IsFixedSize)
+                {
+                    throw new NotSupportedException("Cannot assign fixed size selection to SelectedItems.");
+                }
+
+                if (_selectedItems != value)
+                {
+                    UnsubscribeFromSelectedItems();
+                    _selectedItems = value;
+                    SyncFromSelectedItems();
+                    SubscribeToSelectedItems();
+                    
+                    if (ItemsView is null)
+                    {
+                        SetInitSelectedItems(value);
+                    }
+                }
+            }
+        }
+
+        private protected override void SetSource(IEnumerable? value)
+        {
+            try
+            {
+                _ignoreSelectedItemsChanges = true;
+                base.SetSource(value);
+            }
+            finally
+            {
+                _ignoreSelectedItemsChanges = false;
+            }
+
+            SyncToSelectedItems();
+        }
+
+        private void SyncToSelectedItems()
+        {
+            if (_selectedItems is object)
+            {
+                try
+                {
+                    _ignoreSelectedItemsChanges = true;
+                    _selectedItems.Clear();
+
+                    foreach (var i in base.SelectedItems)
+                    {
+                        _selectedItems.Add(i);
+                    }
+                }
+                finally
+                {
+                    _ignoreSelectedItemsChanges = false;
+                }
+            }
+        }
+
+        private void SyncFromSelectedItems()
+        {
+            if (Source is null || _selectedItems is null)
+            {
+                return;
+            }
+
+            try
+            {
+                _ignoreModelChanges = true;
+
+                using (BatchUpdate())
+                {
+                    Clear();
+                    Add(_selectedItems);
+                }
+            }
+            finally
+            {
+                _ignoreModelChanges = false;
+            }
+        }
+
+        private void SubscribeToSelectedItems()
+        {
+            if (_selectedItems is INotifyCollectionChanged incc)
+            {
+                incc.CollectionChanged += OnSelectedItemsCollectionChanged;
+            }
+        }
+
+        private void UnsubscribeFromSelectedItems()
+        {
+            if (_selectedItems is INotifyCollectionChanged incc)
+            {
+                incc.CollectionChanged += OnSelectedItemsCollectionChanged;
+            }
+        }
+
+        private void OnSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e)
+        {
+            if (_ignoreModelChanges)
+            {
+                return;
+            }
+
+            try
+            {
+                var items = SelectedItems;
+                var deselected = e.DeselectedItems.ToList();
+                var selected = e.SelectedItems.ToList();
+
+                _ignoreSelectedItemsChanges = true;
+
+                foreach (var i in deselected)
+                {
+                    items.Remove(i);
+                }
+
+                foreach (var i in selected)
+                {
+                    items.Add(i);
+                }
+            }
+            finally
+            {
+                _ignoreSelectedItemsChanges = false;
+            }
+        }
+
+        private void OnSourceReset(object sender, EventArgs e) => SyncFromSelectedItems();
+
+        private void OnSelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+        {
+            if (_ignoreSelectedItemsChanges)
+            {
+                return;
+            }
+
+            if (_selectedItems == null)
+            {
+                throw new AvaloniaInternalException("CollectionChanged raised but we don't have items.");
+            }
+
+            void Remove()
+            {
+                foreach (var i in e.OldItems)
+                {
+                    var index = IndexOf(Source, i);
+
+                    if (index != -1)
+                    {
+                        Deselect(index);
+                    }
+                }
+            }
+
+            try
+            {
+                using var operation = BatchUpdate();
+
+                _ignoreModelChanges = 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:
+                        Clear();
+                        Add(_selectedItems);
+                        break;
+                }
+            }
+            finally
+            {
+                _ignoreModelChanges = false;
+            }
+        }
+
+        private void Add(IList newItems)
+        {
+            foreach (var i in newItems)
+            {
+                var index = IndexOf(Source, i);
+
+                if (index != -1)
+                {
+                    Select(index);
+                }
+            }
+        }
+
+        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;
+        }
+    }
+}

+ 40 - 16
src/Avalonia.Controls/Selection/SelectionModel.cs

@@ -20,8 +20,7 @@ namespace Avalonia.Controls.Selection
         private SelectedItems<T>? _selectedItems;
         private SelectedItems<T>? _selectedItems;
         private SelectedItems<T>.Untyped? _selectedItemsUntyped;
         private SelectedItems<T>.Untyped? _selectedItemsUntyped;
         private EventHandler<SelectionModelSelectionChangedEventArgs>? _untypedSelectionChanged;
         private EventHandler<SelectionModelSelectionChangedEventArgs>? _untypedSelectionChanged;
-        [AllowNull] private T _initSelectedItem = default;
-        private bool _hasInitSelectedItem;
+        private IList? _initSelectedItems;
 
 
         public SelectionModel()
         public SelectionModel()
         {
         {
@@ -82,7 +81,19 @@ namespace Avalonia.Controls.Selection
         [MaybeNull, AllowNull]
         [MaybeNull, AllowNull]
         public T SelectedItem
         public T SelectedItem
         {
         {
-            get => ItemsView is object ? GetItemAt(_selectedIndex) : _initSelectedItem;
+            get
+            {
+                if (ItemsView is object)
+                {
+                    return GetItemAt(_selectedIndex);
+                }
+                else if (_initSelectedItems is object && _initSelectedItems.Count > 0)
+                {
+                    return (T)_initSelectedItems[0];
+                }
+
+                return default;
+            }
             set
             set
             {
             {
                 if (ItemsView is object)
                 if (ItemsView is object)
@@ -92,8 +103,9 @@ namespace Avalonia.Controls.Selection
                 else
                 else
                 {
                 {
                     Clear();
                     Clear();
-                    _initSelectedItem = value;
-                    _hasInitSelectedItem = true;
+#pragma warning disable CS8601
+                    SetInitSelectedItems(new T[] { value });
+#pragma warning restore CS8601
                 }
                 }
             }
             }
         }
         }
@@ -102,9 +114,10 @@ namespace Avalonia.Controls.Selection
         {
         {
             get
             get
             {
             {
-                if (ItemsView is null && _hasInitSelectedItem)
+                if (ItemsView is null && _initSelectedItems is object)
                 {
                 {
-                    return new[] { _initSelectedItem };
+                    return _initSelectedItems is IReadOnlyList<T> i ?
+                        i : _initSelectedItems.Cast<T>().ToList();
                 }
                 }
 
 
                 return _selectedItems ??= new SelectedItems<T>(this);
                 return _selectedItems ??= new SelectedItems<T>(this);
@@ -258,8 +271,7 @@ namespace Avalonia.Controls.Selection
                 o.SelectedIndex = -1;
                 o.SelectedIndex = -1;
             }
             }
 
 
-            _initSelectedItem = default;
-            _hasInitSelectedItem = false;
+            _initSelectedItems = null;
         }
         }
 
 
         public void SelectAll() => SelectRange(0, int.MaxValue);
         public void SelectAll() => SelectRange(0, int.MaxValue);
@@ -270,7 +282,7 @@ namespace Avalonia.Controls.Selection
             PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
             PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
         }
         }
 
 
-        private void SetSource(IEnumerable? value)
+        private protected virtual void SetSource(IEnumerable? value)
         {
         {
             if (base.Source != value)
             if (base.Source != value)
             {
             {
@@ -292,11 +304,14 @@ namespace Avalonia.Controls.Selection
                 {
                 {
                     update.Operation.IsSourceUpdate = true;
                     update.Operation.IsSourceUpdate = true;
 
 
-                    if (_hasInitSelectedItem)
+                    if (_initSelectedItems is object && ItemsView is object)
                     {
                     {
-                        SelectedItem = _initSelectedItem;
-                        _initSelectedItem = default;
-                        _hasInitSelectedItem = false;
+                        foreach (T i in _initSelectedItems)
+                        {
+                            Select(ItemsView.IndexOf(i));
+                        }
+
+                        _initSelectedItems = null;
                     }
                     }
                     else
                     else
                     {
                     {
@@ -466,6 +481,16 @@ namespace Avalonia.Controls.Selection
             return true;
             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()
         protected override void OnSourceCollectionChangeFinished()
         {
         {
             if (_operation is object)
             if (_operation is object)
@@ -539,8 +564,7 @@ namespace Avalonia.Controls.Selection
                 o.SelectedIndex = o.AnchorIndex = start;
                 o.SelectedIndex = o.AnchorIndex = start;
             }
             }
 
 
-            _initSelectedItem = default;
-            _hasInitSelectedItem = false;
+            _initSelectedItems = null;
         }
         }
 
 
         [return: MaybeNull]
         [return: MaybeNull]

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

@@ -1,283 +0,0 @@
-using System;
-using System.Collections;
-using System.Collections.Specialized;
-using System.ComponentModel;
-using System.Linq;
-using Avalonia.Collections;
-using Avalonia.Controls.Selection;
-
-#nullable enable
-
-namespace Avalonia.Controls.Utils
-{
-    /// <summary>
-    /// Synchronizes an <see cref="ISelectionModel"/> with a list of SelectedItems.
-    /// </summary>
-    internal class SelectedItemsSync : IDisposable
-    {
-        private ISelectionModel _selectionModel;
-        private IList _selectedItems;
-        private bool _updatingItems;
-        private bool _updatingModel;
-
-        public SelectedItemsSync(ISelectionModel model)
-        {
-            _selectionModel = model ?? throw new ArgumentNullException(nameof(model));
-            _selectedItems = new AvaloniaList<object?>();
-            SyncSelectedItemsWithSelectionModel();
-            SubscribeToSelectedItems(_selectedItems);
-            SubscribeToSelectionModel(model);
-        }
-
-        public ISelectionModel SelectionModel 
-        {
-            get => _selectionModel;
-            set
-            {
-                if (_selectionModel != value)
-                {
-                    value = value ?? throw new ArgumentNullException(nameof(value));
-                    UnsubscribeFromSelectionModel(_selectionModel);
-                    _selectionModel = value;
-                    SubscribeToSelectionModel(_selectionModel);
-                    SyncSelectedItemsWithSelectionModel();
-                }
-            }
-        }
-        
-        public IList SelectedItems 
-        {
-            get => _selectedItems;
-            set
-            {
-                value ??= new AvaloniaList<object?>();
-
-                if (_selectedItems != value)
-                {
-                    if (value.IsFixedSize)
-                    {
-                        throw new NotSupportedException(
-                            "Cannot assign fixed size selection to SelectedItems.");
-                    }
-
-                    UnsubscribeFromSelectedItems(_selectedItems);
-                    _selectedItems = value;
-                    SubscribeToSelectedItems(_selectedItems);
-                    SyncSelectionModelWithSelectedItems();
-                }
-            }
-        }
-
-        public void Dispose()
-        {
-            UnsubscribeFromSelectedItems(_selectedItems);
-            UnsubscribeFromSelectionModel(_selectionModel);
-        }
-
-        private void SyncSelectedItemsWithSelectionModel()
-        {
-            _updatingItems = true;
-
-            try
-            {
-                _selectedItems.Clear();
-
-                if (_selectionModel.Source is object)
-                {
-                    foreach (var i in _selectionModel.SelectedItems)
-                    {
-                        _selectedItems.Add(i);
-                    }
-                }
-            }
-            finally
-            {
-                _updatingItems = false;
-            }
-        }
-
-        private void SyncSelectionModelWithSelectedItems()
-        {
-            _updatingModel = true;
-
-            try
-            {
-                if (_selectionModel.Source is object)
-                {
-                    using (_selectionModel.BatchUpdate())
-                    {
-                        SelectionModel.Clear();
-                        Add(_selectedItems);
-                    }
-                }
-            }
-            finally
-            {
-                _updatingModel = false;
-            }
-        }
-
-        private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
-        {
-            if (_updatingItems)
-            {
-                return;
-            }
-
-            if (_selectedItems == null)
-            {
-                throw new AvaloniaInternalException("CollectionChanged raised but we don't have items.");
-            }
-
-            void Remove()
-            {
-                foreach (var i in e.OldItems)
-                {
-                    var index = IndexOf(SelectionModel.Source, i);
-
-                    if (index != -1)
-                    {
-                        SelectionModel.Deselect(index);
-                    }
-                }
-            }
-
-            try
-            {
-                using var operation = SelectionModel.BatchUpdate();
-
-                _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:
-                        SelectionModel.Clear();
-                        Add(_selectedItems);
-                        break;
-                }
-            }
-            finally
-            {
-                _updatingModel = false;
-            }
-        }
-
-        private void Add(IList newItems)
-        {
-            foreach (var i in newItems)
-            {
-                var index = IndexOf(SelectionModel.Source, i);
-
-                if (index != -1)
-                {
-                    SelectionModel.Select(index);
-                }
-            }
-        }
-
-        private void SelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e)
-        {
-            if (e.PropertyName == nameof(ISelectionModel.Source))
-            {
-                if (_selectedItems.Count > 0)
-                {
-                    SyncSelectionModelWithSelectedItems();
-                }
-                else
-                {
-                    SyncSelectedItemsWithSelectionModel();
-                }
-            }
-        }
-
-        private void SelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e)
-        {
-            if (_updatingModel || _selectionModel.Source is null)
-            {
-                return;
-            }
-
-            try
-            {
-                var deselected = e.DeselectedItems.ToList();
-                var selected = e.SelectedItems.ToList();
-
-                _updatingItems = true;
-
-                foreach (var i in deselected)
-                {
-                    _selectedItems.Remove(i);
-                }
-
-                foreach (var i in selected)
-                {
-                    _selectedItems.Add(i);
-                }
-            }
-            finally
-            {
-                _updatingItems = false;
-            }
-        }
-
-        private void SelectionModelSourceReset(object sender, EventArgs e)
-        {
-            SyncSelectionModelWithSelectedItems();
-        }
-
-
-        private void SubscribeToSelectedItems(IList selectedItems)
-        {
-            if (selectedItems is INotifyCollectionChanged incc)
-            {
-                incc.CollectionChanged += SelectedItemsCollectionChanged;
-            }
-        }
-
-        private void SubscribeToSelectionModel(ISelectionModel model)
-        {
-            model.PropertyChanged += SelectionModelPropertyChanged;
-            model.SelectionChanged += SelectionModelSelectionChanged;
-            model.SourceReset += SelectionModelSourceReset;
-        }
-
-        private void UnsubscribeFromSelectedItems(IList selectedItems)
-        {
-            if (selectedItems is INotifyCollectionChanged incc)
-            {
-                incc.CollectionChanged -= SelectedItemsCollectionChanged;
-            }
-        }
-
-        private void UnsubscribeFromSelectionModel(ISelectionModel model)
-        {
-            model.PropertyChanged -= SelectionModelPropertyChanged;
-            model.SelectionChanged -= SelectionModelSelectionChanged;
-            model.SourceReset -= SelectionModelSourceReset;
-        }
-
-        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;
-        }
-    }
-}

+ 243 - 0
tests/Avalonia.Controls.UnitTests/Selection/InternalSelectionModelTests.cs

@@ -0,0 +1,243 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using Avalonia.Collections;
+using Avalonia.Controls.Selection;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests.Selection
+{
+    public class InternalSelectionModelTests
+    {
+        [Fact]
+        public void Selecting_Item_Adds_To_SelectedItems()
+        {
+            var target = CreateTarget();
+
+            target.Select(0);
+
+            Assert.Equal(new[] { "foo" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void Selecting_Duplicate_On_Model_Adds_To_SelectedItems()
+        {
+            var target = CreateTarget(source: new[] { "foo", "bar", "baz", "foo", "bar", "baz" });
+
+            target.SelectRange(1, 4);
+
+            Assert.Equal(new[] { "bar", "baz", "foo", "bar" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void Deselecting_On_Model_Removes_SelectedItem()
+        {
+            var target = CreateTarget();
+
+            target.SelectRange(1, 2);
+            target.Deselect(1);
+
+            Assert.Equal(new[] { "baz" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void Deselecting_Duplicate_On_Model_Removes_SelectedItem()
+        {
+            var target = CreateTarget(source: new[] { "foo", "bar", "baz", "foo", "bar", "baz" });
+
+            target.SelectRange(1, 2);
+            target.Select(4);
+            target.Deselect(4);
+
+            Assert.Equal(new[] { "baz", "bar" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void Adding_To_SelectedItems_Selects_On_Model()
+        {
+            var target = CreateTarget();
+
+            target.SelectRange(1, 2);
+            target.SelectedItems.Add("foo");
+
+            Assert.Equal(new[] { 0, 1, 2 }, target.SelectedIndexes);
+            Assert.Equal(new[] { "bar", "baz", "foo" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void Removing_From_SelectedItems_Deselects_On_Model()
+        {
+            var target = CreateTarget();
+
+            target.SelectRange(1, 2);
+            target.SelectedItems.Remove("baz");
+
+            Assert.Equal(new[] { 1 }, target.SelectedIndexes);
+            Assert.Equal(new[] { "bar" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void Replacing_SelectedItem_Updates_Model()
+        {
+            var target = CreateTarget();
+
+            target.SelectRange(1, 2);
+            target.SelectedItems[0] = "foo";
+
+            Assert.Equal(new[] { 0, 2 }, target.SelectedIndexes);
+            Assert.Equal(new[] { "foo", "baz" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void Clearing_SelectedItems_Updates_Model()
+        {
+            var target = CreateTarget();
+
+            target.SelectedItems.Clear();
+
+            Assert.Empty(target.SelectedIndexes);
+        }
+
+        [Fact]
+        public void Setting_SelectedItems_Updates_Model()
+        {
+            var target = CreateTarget();
+            var oldItems = target.SelectedItems;
+
+            var newItems = new AvaloniaList<string> { "foo", "baz" };
+            target.SelectedItems = newItems;
+
+            Assert.Equal(new[] { 0, 2 }, target.SelectedIndexes);
+            Assert.Same(newItems, target.SelectedItems);
+            Assert.NotSame(oldItems, target.SelectedItems);
+            Assert.Equal(new[] { "foo", "baz" }, newItems);
+        }
+
+        [Fact]
+        public void Setting_Items_To_Null_Clears_Selection()
+        {
+            var target = CreateTarget();
+
+            target.SelectRange(1, 2);
+            target.SelectedItems = null;
+
+            Assert.Empty(target.SelectedIndexes);
+        }
+
+        [Fact]
+        public void Setting_Items_To_Null_Creates_Empty_Items()
+        {
+            var target = CreateTarget();
+            var oldItems = target.SelectedItems;
+
+            target.SelectedItems = null;
+
+            Assert.NotNull(target.SelectedItems);
+            Assert.NotSame(oldItems, target.SelectedItems);
+            Assert.IsType<AvaloniaList<object>>(target.SelectedItems);
+        }
+
+        [Fact]
+        public void Adds_Null_SelectedItems_When_Source_Is_Null()
+        {
+            var target = CreateTarget(nullSource: true);
+
+            target.SelectRange(1, 2);
+            Assert.Equal(new object[] { null, null }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void Updates_SelectedItems_When_Source_Changes_From_Null()
+        {
+            var target = CreateTarget(nullSource: true);
+
+            target.SelectRange(1, 2);
+            Assert.Equal(new object[] { null, null }, target.SelectedItems);
+
+            target.Source = new[] { "foo", "bar", "baz" };
+            Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void Updates_SelectedItems_When_Source_Changes_To_Null()
+        {
+            var target = CreateTarget();
+
+            target.SelectRange(1, 2);
+            Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems);
+
+            target.Source = null;
+            Assert.Equal(new object[] { null, null }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void SelectedItems_Can_Be_Set_Before_Source()
+        {
+            var target = CreateTarget(nullSource: true);
+            var items = new AvaloniaList<string> { "foo", "bar", "baz" };
+            var selectedItems = new AvaloniaList<string> { "bar" };
+
+            target.SelectedItems = selectedItems;
+            target.Source = items;
+
+            Assert.Equal(1, target.SelectedIndex);
+        }
+
+        [Fact]
+        public void Does_Not_Accept_Fixed_Size_Items()
+        {
+            var target = CreateTarget();
+
+            Assert.Throws<NotSupportedException>(() =>
+                target.SelectedItems = new[] { "foo", "bar", "baz" });
+        }
+
+        [Fact]
+        public void Restores_Selection_On_Items_Reset()
+        {
+            var items = new ResettingCollection(new[] { "foo", "bar", "baz" });
+            var target = CreateTarget(source: items);
+
+            target.SelectedIndex = 1;
+            items.Reset(new[] { "baz", "foo", "bar" });
+
+            Assert.Equal(2, target.SelectedIndex);
+        }
+
+        private static InternalSelectionModel CreateTarget(
+            bool singleSelect = false,
+            IList source = null,
+            bool nullSource = false)
+        {
+            source ??= !nullSource ? new[] { "foo", "bar", "baz" } : null;
+            
+            var result = new InternalSelectionModel
+            {
+                SingleSelect = singleSelect,
+            };
+
+            ((ISelectionModel)result).Source = source;
+            return result;
+        }
+
+        private class ResettingCollection : List<string>, INotifyCollectionChanged
+        {
+            public ResettingCollection(IEnumerable<string> items)
+            {
+                AddRange(items);
+            }
+
+            public void Reset(IEnumerable<string> items)
+            {
+                Clear();
+                AddRange(items);
+                CollectionChanged?.Invoke(
+                    this,
+                    new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+            }
+
+            public event NotifyCollectionChangedEventHandler CollectionChanged;
+        }
+    }
+}

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

@@ -1,278 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Collections.Specialized;
-using Avalonia.Collections;
-using Avalonia.Controls.Selection;
-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.SelectedItems;
-
-            Assert.Equal(new[] { "bar", "baz" }, items);
-        }
-
-        [Fact]
-        public void Selecting_On_Model_Adds_Item()
-        {
-            var target = CreateTarget();
-            var items = target.SelectedItems;
-
-            target.SelectionModel.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.SelectedItems;
-
-            target.SelectionModel.Select(4);
-
-            Assert.Equal(new[] { "bar", "baz", "bar" }, items);
-        }
-
-        [Fact]
-        public void Deselecting_On_Model_Removes_Item()
-        {
-            var target = CreateTarget();
-            var items = target.SelectedItems;
-
-            target.SelectionModel.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.SelectedItems;
-
-            target.SelectionModel.Select(4);
-            target.SelectionModel.Deselect(4);
-
-            Assert.Equal(new[] { "baz", "bar" }, items);
-        }
-
-        [Fact]
-        public void Reassigning_Model_Resets_Items()
-        {
-            var target = CreateTarget();
-            var items = target.SelectedItems;
-
-            var newModel = new SelectionModel<string> 
-            { 
-                Source = (string[])target.SelectionModel.Source,
-                SingleSelect = false 
-            };
-
-            newModel.Select(0);
-            newModel.Select(1);
-
-            target.SelectionModel = newModel;
-
-            Assert.Equal(new[] { "foo", "bar" }, items);
-        }
-
-        [Fact]
-        public void Reassigning_Model_Tracks_New_Model()
-        {
-            var target = CreateTarget();
-            var items = target.SelectedItems;
-
-            var newModel = new SelectionModel<string>
-            {
-                Source = (string[])target.SelectionModel.Source,
-                SingleSelect = false
-            };
-
-            target.SelectionModel = 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.SelectedItems;
-
-            items.Add("foo");
-
-            Assert.Equal(new[] { 0, 1, 2 }, target.SelectionModel.SelectedIndexes);
-            Assert.Equal(new[] { "bar", "baz", "foo" }, items);
-        }
-
-        [Fact]
-        public void Removing_From_Items_Deselects_On_Model()
-        {
-            var target = CreateTarget();
-            var items = target.SelectedItems;
-
-            items.Remove("baz");
-
-            Assert.Equal(new[] { 1 }, target.SelectionModel.SelectedIndexes);
-            Assert.Equal(new[] { "bar" }, items);
-        }
-
-        [Fact]
-        public void Replacing_Item_Updates_Model()
-        {
-            var target = CreateTarget();
-            var items = target.SelectedItems;
-
-            items[0] = "foo";
-
-            Assert.Equal(new[] { 0, 2 }, target.SelectionModel.SelectedIndexes);
-            Assert.Equal(new[] { "foo", "baz" }, items);
-        }
-
-        [Fact]
-        public void Clearing_Items_Updates_Model()
-        {
-            var target = CreateTarget();
-            var items = target.SelectedItems;
-
-            items.Clear();
-
-            Assert.Empty(target.SelectionModel.SelectedIndexes);
-        }
-
-        [Fact]
-        public void Setting_Items_Updates_Model()
-        {
-            var target = CreateTarget();
-            var oldItems = target.SelectedItems;
-
-            var newItems = new AvaloniaList<string> { "foo", "baz" };
-            target.SelectedItems = newItems;
-
-            Assert.Equal(new[] { 0, 2 }, target.SelectionModel.SelectedIndexes);
-            Assert.Same(newItems, target.SelectedItems);
-            Assert.NotSame(oldItems, target.SelectedItems);
-            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.SelectedItems = items;
-            target.SelectionModel.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.SelectedItems;
-
-            target.SelectedItems = null;
-
-            var newItems = Assert.IsType<AvaloniaList<object>>(target.SelectedItems);
-
-            Assert.NotSame(oldItems, newItems);
-        }
-
-        [Fact]
-        public void Handles_Null_Model_Source()
-        {
-            var model = new SelectionModel<string> { SingleSelect = false };
-            model.Select(1);
-
-            var target = new SelectedItemsSync(model);
-            var items = target.SelectedItems;
-
-            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.SelectedItems = new[] { "foo", "bar", "baz" });
-        }
-
-        [Fact]
-        public void Selected_Items_Can_Be_Set_Before_SelectionModel_Source()
-        {
-            var model = new SelectionModel<string>();
-            var target = new SelectedItemsSync(model);
-            var items = new AvaloniaList<string> { "foo", "bar", "baz" };
-            var selectedItems = new AvaloniaList<string> { "bar" };
-
-            target.SelectedItems = selectedItems;
-            model.Source = items;
-
-            Assert.Equal(1, model.SelectedIndex);
-        }
-
-        [Fact]
-        public void Restores_Selection_On_Items_Reset()
-        {
-            var items = new ResettingCollection(new[] { "foo", "bar", "baz" });
-            var model = new SelectionModel<string> { Source = items };
-            var target = new SelectedItemsSync(model);
-
-            model.SelectedIndex = 1;
-            items.Reset(new[] { "baz", "foo", "bar" });
-
-            Assert.Equal(2, model.SelectedIndex);
-        }
-
-        private static SelectedItemsSync CreateTarget(
-            IEnumerable<string> items = null)
-        {
-            items ??= new[] { "foo", "bar", "baz" };
-
-            var model = new SelectionModel<string> { Source = items, SingleSelect = false };
-            model.SelectRange(1, 2);
-
-            var target = new SelectedItemsSync(model);
-            return target;
-        }
-
-        private class ResettingCollection : List<string>, INotifyCollectionChanged
-        {
-            public ResettingCollection(IEnumerable<string> items)
-            {
-                AddRange(items);
-            }
-
-            public void Reset(IEnumerable<string> items)
-            {
-                Clear();
-                AddRange(items);
-                CollectionChanged?.Invoke(
-                    this,
-                    new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
-            }
-
-            public event NotifyCollectionChangedEventHandler CollectionChanged;
-        }
-    }
-}