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
pull/13976/head
Julien Lebosquain 2 years ago
committed by GitHub
parent
commit
7e81b5d8c2
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 115
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  2. 26
      tests/Avalonia.Controls.UnitTests/EnumerableExtensions.cs
  3. 189
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs
  4. 19
      tests/Avalonia.UnitTests/NotifyingBase.cs

115
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>

26
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]);
}
}
}
}
}

189
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
}
}
}

19
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;
}
}
}

Loading…
Cancel
Save