Browse Source

Fix SelectedItemsControl initialization property order (#13800)

* Add SelectingItemsControl property init order tests

* Property order in SelectedItemsControl doesn't matter on init

* Fix SelectedItemsControl properties during init when Selection is set

* Fixed SelectedItemsControl.AnchorIndex after init
Julien Lebosquain 1 year ago
parent
commit
7e81b5d8c2

+ 55 - 60
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@@ -1,6 +1,5 @@
 using System;
 using System.Collections;
-using System.Collections.Generic;
 using System.Collections.Specialized;
 using System.ComponentModel;
 using System.Diagnostics.CodeAnalysis;
@@ -8,7 +7,6 @@ using System.Linq;
 using Avalonia.Controls.Selection;
 using Avalonia.Data;
 using Avalonia.Input;
-using Avalonia.Input.Platform;
 using Avalonia.Interactivity;
 using Avalonia.Metadata;
 using Avalonia.Threading;
@@ -187,14 +185,21 @@ namespace Avalonia.Controls.Primitives
         /// </summary>
         public int SelectedIndex
         {
-            get =>
+            get
+            {
                 // When a Begin/EndInit/DataContext update is in place we return the value to be
                 // updated here, even though it's not yet active and the property changed notification
                 // has not yet been raised. If we don't do this then the old value will be written back
                 // to the source when two-way bound, and the update value will be lost.
-                _updateState?.SelectedIndex.HasValue == true ?
-                    _updateState.SelectedIndex.Value :
-                    Selection.SelectedIndex;
+                if (_updateState is not null)
+                {
+                    return _updateState.SelectedIndex.HasValue ?
+                        _updateState.SelectedIndex.Value :
+                        TryGetExistingSelection()?.SelectedIndex ?? -1;
+                }
+
+                return Selection.SelectedIndex;
+            }
             set
             {
                 if (_updateState is object)
@@ -213,11 +218,18 @@ namespace Avalonia.Controls.Primitives
         /// </summary>
         public object? SelectedItem
         {
-            get =>
-                // See SelectedIndex setter for more information.
-                _updateState?.SelectedItem.HasValue == true ?
-                    _updateState.SelectedItem.Value :
-                    Selection.SelectedItem;
+            get
+            {
+                // See SelectedIndex getter for more information.
+                if (_updateState is not null)
+                {
+                    return _updateState.SelectedItem.HasValue ?
+                        _updateState.SelectedItem.Value :
+                        TryGetExistingSelection()?.SelectedItem;
+                }
+
+                return Selection.SelectedItem;
+            }
             set
             {
                 if (_updateState is object)
@@ -270,6 +282,7 @@ namespace Avalonia.Controls.Primitives
                 {
                     return _updateState.SelectedItems.Value;
                 }
+
                 else if (Selection is InternalSelectionModel ism)
                 {
                     var result = ism.WritableSelectedItems;
@@ -456,10 +469,8 @@ namespace Avalonia.Controls.Primitives
         protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
         {
             base.OnAttachedToVisualTree(e);
-            if (Selection?.AnchorIndex is int index)
-            {
-                AutoScrollToSelectedItemIfNecessary(index);
-            }
+
+            AutoScrollToSelectedItemIfNecessary(GetAnchorIndex());
         }
 
         /// <inheritdoc />
@@ -470,10 +481,8 @@ namespace Avalonia.Controls.Primitives
             void ExecuteScrollWhenLayoutUpdated(object? sender, EventArgs e)
             {
                 LayoutUpdated -= ExecuteScrollWhenLayoutUpdated;
-                if (Selection?.AnchorIndex is int index)
-                {
-                    AutoScrollToSelectedItemIfNecessary(index);
-                }
+
+                AutoScrollToSelectedItemIfNecessary(GetAnchorIndex());
             }
 
             if (AutoScrollToSelectedItem)
@@ -482,6 +491,15 @@ namespace Avalonia.Controls.Primitives
             }
         }
 
+        internal int GetAnchorIndex()
+        {
+            var selection = _updateState is not null ? TryGetExistingSelection() : Selection;
+            return selection?.AnchorIndex ?? -1;
+        }
+
+        private ISelectionModel? TryGetExistingSelection()
+            => _updateState?.Selection.HasValue == true ? _updateState.Selection.Value : _selection;
+
         protected internal override void PrepareContainerForItemOverride(Control container, object? item, int index)
         {
             // Ensure that the selection model is created at this point so that accessing it in 
@@ -634,10 +652,7 @@ namespace Avalonia.Controls.Primitives
 
             if (change.Property == AutoScrollToSelectedItemProperty)
             {
-                if (Selection?.AnchorIndex is int index)
-                {
-                    AutoScrollToSelectedItemIfNecessary(index);
-                }
+                AutoScrollToSelectedItemIfNecessary(GetAnchorIndex());
             }
             else if (change.Property == SelectionModeProperty && _selection is object)
             {
@@ -671,7 +686,7 @@ namespace Avalonia.Controls.Primitives
                     return;
                 }
 
-                var value = change.GetNewValue<IBinding>();
+                var value = change.GetNewValue<IBinding?>();
                 if (value is null)
                 {
                     // Clearing SelectedValueBinding makes the SelectedValue the item itself
@@ -921,11 +936,10 @@ namespace Avalonia.Controls.Primitives
             if (e.PropertyName == nameof(ISelectionModel.AnchorIndex))
             {
                 _hasScrolledToSelectedItem = false;
-                if (Selection?.AnchorIndex is int index)
-                {
-                    KeyboardNavigation.SetTabOnceActiveElement(this, ContainerFromIndex(index));
-                    AutoScrollToSelectedItemIfNecessary(index);
-                }
+
+                var anchorIndex = GetAnchorIndex();
+                KeyboardNavigation.SetTabOnceActiveElement(this, ContainerFromIndex(anchorIndex));
+                AutoScrollToSelectedItemIfNecessary(anchorIndex);
             }
             else if (e.PropertyName == nameof(ISelectionModel.SelectedIndex) && _oldSelectedIndex != SelectedIndex)
             {
@@ -1279,9 +1293,17 @@ namespace Avalonia.Controls.Primitives
                         state.SelectedItem = item;
                 }
 
+                // SelectedIndex vs SelectedItem:
+                // - If only one has a value, use it
+                // - If both have a value, prefer the one having a "non-empty" value, e.g. not -1 nor null
+                // - If both have a "non-empty" value, prefer the index
                 if (state.SelectedIndex.HasValue)
                 {
-                    SelectedIndex = state.SelectedIndex.Value;
+                    var selectedIndex = state.SelectedIndex.Value;
+                    if (selectedIndex >= 0 || !state.SelectedItem.HasValue)
+                        SelectedIndex = selectedIndex;
+                    else
+                        SelectedItem = state.SelectedItem.Value;
                 }
                 else if (state.SelectedItem.HasValue)
                 {
@@ -1338,39 +1360,12 @@ namespace Avalonia.Controls.Primitives
         // - Both the old and new SelectionModels have the incorrect Source
         private class UpdateState
         {
-            private Optional<int> _selectedIndex;
-            private Optional<object?> _selectedItem;
-            private Optional<object?> _selectedValue;
-
             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;
-                }
-            }
-
-            public Optional<object?> SelectedValue
-            {
-                get => _selectedValue;
-                set => _selectedValue = value;
-            }
+            public Optional<int> SelectedIndex { get; set; }
+            public Optional<object?> SelectedItem { get; set; }
+            public Optional<object?> SelectedValue { get; set; }
         }
 
         /// <summary>

+ 24 - 2
tests/Avalonia.Controls.UnitTests/EnumerableExtensions.cs

@@ -1,8 +1,6 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
 
 namespace Avalonia.Controls.UnitTests
 {
@@ -16,5 +14,29 @@ namespace Avalonia.Controls.UnitTests
                 yield return i;
             }
         }
+
+        public static IEnumerable<T[]> Permutations<T>(this IEnumerable<T> source)
+        {
+            var sourceArray = source.ToArray();
+            var results = new List<T[]>();
+            Permute(sourceArray, 0, sourceArray.Length - 1);
+            return results;
+
+            void Permute(T[] elements, int depth, int maxDepth)
+            {
+                if (depth == maxDepth)
+                {
+                    results.Add(elements.ToArray());
+                    return;
+                }
+
+                for (var i = depth; i <= maxDepth; i++)
+                {
+                    (elements[depth], elements[i]) = (elements[i], elements[depth]);
+                    Permute(elements, depth + 1, maxDepth);
+                    (elements[depth], elements[i]) = (elements[i], elements[depth]);
+                }
+            }
+        }
     }
 }

+ 187 - 2
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs

@@ -5,7 +5,6 @@ using System.Collections.ObjectModel;
 using System.Collections.Specialized;
 using System.ComponentModel;
 using System.Linq;
-using System.Reactive.Disposables;
 using System.Threading.Tasks;
 using Avalonia.Collections;
 using Avalonia.Controls.Presenters;
@@ -18,7 +17,6 @@ using Avalonia.Input.Platform;
 using Avalonia.Interactivity;
 using Avalonia.Layout;
 using Avalonia.Markup.Data;
-using Avalonia.Platform;
 using Avalonia.Styling;
 using Avalonia.Threading;
 using Avalonia.UnitTests;
@@ -2294,6 +2292,134 @@ namespace Avalonia.Controls.UnitTests.Primitives
 
         }
 
+        [Theory]
+        [MemberData(nameof(GetSelectionFieldPermutationParameters))]
+        public void SelectedItem_And_Selection_Properties_Work_In_Any_Order_When_Initializing(SelectionField[] fields)
+            => TestSelectionFields(vm => vm.SelectedItem = vm.Items[2], fields);
+
+        [Theory]
+        [MemberData(nameof(GetSelectionFieldPermutationParameters))]
+        public void SelectedIndex_And_Selection_Properties_Work_In_Any_Order_When_Initializing(SelectionField[] fields)
+            => TestSelectionFields(vm => vm.SelectedIndex = 2, fields);
+
+        [Theory]
+        [MemberData(nameof(GetSelectionFieldPermutationParameters))]
+        public void SelectedValue_And_Selection_Properties_Work_In_Any_Order_When_Initializing(SelectionField[] fields)
+            => TestSelectionFields(vm => vm.SelectedValue = 12, fields);
+
+        private void TestSelectionFields(Action<FullSelectionViewModel> setItem2, SelectionField[] fields)
+        {
+            using var _ = Start();
+
+            var vm = new FullSelectionViewModel
+            {
+                Items =
+                {
+                    new ItemModel { Id = 10, Name = "Item0" },
+                    new ItemModel { Id = 11, Name = "Item1" },
+                    new ItemModel { Id = 12, Name = "Item2" },
+                    new ItemModel { Id = 13, Name = "Item3" }
+                }
+            };
+
+            setItem2(vm);
+
+            var root = new TestRoot
+            {
+                Width = 100,
+                Height = 100
+            };
+
+            // Match the Begin/EndInit sequence emitted by the XAML compiler
+            root.BeginInit();
+            var target = new ListBox();
+            target.BeginInit();
+            root.Child = target;
+            target.DataContext = vm;
+
+            foreach (var field in fields)
+            {
+                switch (field)
+                {
+                    case SelectionField.ItemsSource:
+                        target.Bind(ItemsControl.ItemsSourceProperty, new Binding(nameof(FullSelectionViewModel.Items)));
+                        break;
+                    case SelectionField.SelectedItem:
+                        target.Bind(SelectingItemsControl.SelectedItemProperty, new Binding(nameof(FullSelectionViewModel.SelectedItem)));
+                        break;
+                    case SelectionField.SelectedIndex:
+                        target.Bind(SelectingItemsControl.SelectedIndexProperty, new Binding(nameof(FullSelectionViewModel.SelectedIndex)));
+                        break;
+                    case SelectionField.SelectedValue:
+                        target.Bind(SelectingItemsControl.SelectedValueProperty, new Binding(nameof(FullSelectionViewModel.SelectedValue)));
+                        break;
+                    case SelectionField.SelectedValueBinding:
+                        target.SelectedValueBinding = new Binding(nameof(ItemModel.Id));
+                        break;
+                    default:
+                        throw new InvalidOperationException($"Unkown field {field}");
+                }
+            }
+
+            target.EndInit();
+            root.EndInit();
+
+            Assert.Equal(vm.Items[2], target.SelectedItem);
+            Assert.Equal(2, target.SelectedIndex);
+            Assert.Equal(12, target.SelectedValue);
+
+            Assert.Equal(vm.Items[2], vm.SelectedItem);
+            Assert.Equal(2, vm.SelectedIndex);
+            Assert.Equal(12, vm.SelectedValue);
+        }
+
+        [Fact]
+        public void SelectedItem_Can_Access_Selection_DuringInit()
+        {
+            using var _ = Start();
+
+            var target = new ListBox();
+            target.BeginInit();
+
+            var item = new ItemModel();
+
+            target.Selection = new SelectionModel<ItemModel> {
+                SelectedItem = item
+            };
+
+            Assert.Equal(item, target.SelectedItem);
+        }
+
+        [Fact]
+        public void SelectedIndex_Can_Access_Selection_DuringInit()
+        {
+            using var _ = Start();
+
+            var target = new ListBox();
+            target.BeginInit();
+
+            target.Selection = new SelectionModel<ItemModel> {
+                SelectedIndex = 42
+            };
+
+            Assert.Equal(42, target.SelectedIndex);
+        }
+
+        [Fact]
+        public void AnchorIndex_Can_Access_Selection_DuringInit()
+        {
+            using var _ = Start();
+
+            var target = new ListBox();
+            target.BeginInit();
+
+            target.Selection = new SelectionModel<ItemModel> {
+                AnchorIndex = 42
+            };
+
+            Assert.Equal(42, target.GetAnchorIndex());
+        }
+
         private static IDisposable Start()
         {
             return UnitTestApplication.Start(TestServices.StyledWindow);
@@ -2336,6 +2462,9 @@ namespace Avalonia.Controls.UnitTests.Primitives
                 }.RegisterInNameScope(scope));
         }
 
+        public static IEnumerable<object[]> GetSelectionFieldPermutationParameters()
+            => Enum.GetValues<SelectionField>().Permutations().Select(fields => new object[] { fields });
+
         private class Item : Control, ISelectable
         {
             public string Value { get; set; }
@@ -2467,5 +2596,61 @@ namespace Avalonia.Controls.UnitTests.Primitives
 
             public event NotifyCollectionChangedEventHandler CollectionChanged;
         }
+
+#nullable enable
+
+        private sealed class FullSelectionViewModel : NotifyingBase
+        {
+            private ItemModel? _selectedItem;
+            private int _selectedIndex = -1;
+            private int? _selectedValue;
+
+            public ObservableCollection<ItemModel> Items { get; } = new();
+
+            public ItemModel? SelectedItem
+            {
+                get => _selectedItem;
+                set => SetField(ref _selectedItem, value);
+            }
+
+            public int SelectedIndex
+            {
+                get => _selectedIndex;
+                set => SetField(ref _selectedIndex, value);
+            }
+
+            public int? SelectedValue
+            {
+                get => _selectedValue;
+                set => SetField(ref _selectedValue, value);
+            }
+        }
+
+        private sealed class ItemModel : NotifyingBase
+        {
+            private int _id;
+            private string? _name;
+
+            public int Id
+            {
+                get => _id;
+                set => SetField(ref _id, value);
+            }
+
+            public string? Name
+            {
+                get => _name;
+                set => SetField(ref _name, value);
+            }
+        }
+
+        public enum SelectionField
+        {
+            ItemsSource,
+            SelectedItem,
+            SelectedIndex,
+            SelectedValue,
+            SelectedValueBinding
+        }
     }
 }

+ 16 - 3
tests/Avalonia.UnitTests/NotifyingBase.cs

@@ -1,3 +1,6 @@
+#nullable enable
+
+using System.Collections.Generic;
 using System.ComponentModel;
 using System.Linq;
 using System.Runtime.CompilerServices;
@@ -6,9 +9,9 @@ namespace Avalonia.UnitTests
 {
     public class NotifyingBase : INotifyPropertyChanged
     {
-        private PropertyChangedEventHandler _propertyChanged;
+        private PropertyChangedEventHandler? _propertyChanged;
 
-        public event PropertyChangedEventHandler PropertyChanged
+        public event PropertyChangedEventHandler? PropertyChanged
         {
             add
             {
@@ -32,9 +35,19 @@ namespace Avalonia.UnitTests
             private set;
         }
 
-        public void RaisePropertyChanged([CallerMemberName] string propertyName = null)
+        public void RaisePropertyChanged([CallerMemberName] string? propertyName = null)
         {
             _propertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
         }
+
+        protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
+        {
+            if (EqualityComparer<T>.Default.Equals(field, value))
+                return false;
+
+            field = value;
+            RaisePropertyChanged(propertyName);
+            return true;
+        }
     }
 }