Skip to content

Commit 2d0b49a

Browse files
authored
Keep SelectingItemsControl selection values until ItemsSource is set (#18634)
* Add failing tests for SelectedItem/SelectedIndex without an ItemsSource * Keep SelectedItem/SelectedIndex until ItemsSource is set * Add failing tests for setting SelectedValue without an ItemsSource * Keep SelectedValue until ItemsSource is set
1 parent b8e4be7 commit 2d0b49a

File tree

5 files changed

+177
-19
lines changed

5 files changed

+177
-19
lines changed

src/Avalonia.Controls/ItemCollection.cs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,10 @@ namespace Avalonia.Controls
1111
/// </summary>
1212
public class ItemCollection : ItemsSourceView, IList
1313
{
14-
// Suppress "Avoid zero-length array allocations": This is a sentinel value and must be unique.
15-
#pragma warning disable CA1825
16-
private static readonly object?[] s_uninitialized = new object?[0];
17-
#pragma warning restore CA1825
18-
1914
private Mode _mode;
2015

2116
internal ItemCollection()
22-
: base(s_uninitialized)
17+
: base(UninitializedSource)
2318
{
2419
}
2520

@@ -100,7 +95,7 @@ private IList WritableSource
10095
{
10196
if (IsReadOnly)
10297
ThrowIsItemsSource();
103-
if (Source == s_uninitialized)
98+
if (Source == UninitializedSource)
10499
SetSource(CreateDefaultCollection());
105100
return Source;
106101
}

src/Avalonia.Controls/ItemsSourceView.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ public class ItemsSourceView : IReadOnlyList<object?>,
2727
/// </summary>
2828
public static ItemsSourceView Empty { get; } = new ItemsSourceView(Array.Empty<object?>());
2929

30+
/// <summary>
31+
/// Gets an instance representing an uninitialized source.
32+
/// </summary>
33+
[SuppressMessage("Performance", "CA1825:Avoid zero-length array allocations", Justification = "This is a sentinel value and must be unique.")]
34+
[SuppressMessage("ReSharper", "UseCollectionExpression", Justification = "This is a sentinel value and must be unique.")]
35+
internal static object?[] UninitializedSource { get; } = new object?[0];
36+
3037
private IList _source;
3138
private NotifyCollectionChangedEventHandler? _collectionChanged;
3239
private NotifyCollectionChangedEventHandler? _preCollectionChanged;
@@ -49,6 +56,9 @@ public class ItemsSourceView : IReadOnlyList<object?>,
4956
/// </summary>
5057
public IList Source => _source;
5158

59+
internal IList? TryGetInitializedSource()
60+
=> _source == UninitializedSource ? null : _source;
61+
5262
/// <summary>
5363
/// Retrieves the item at the specified index.
5464
/// </summary>

src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -592,10 +592,7 @@ protected override void OnInitialized()
592592
{
593593
base.OnInitialized();
594594

595-
if (_selection is object)
596-
{
597-
_selection.Source = ItemsView.Source;
598-
}
595+
TryInitializeSelectionSource(_selection, _updateState is null);
599596
}
600597

601598
/// <inheritdoc />
@@ -896,8 +893,8 @@ private ISelectionModel GetOrCreateSelectionModel()
896893

897894
private void OnItemsViewSourceChanged(object? sender, EventArgs e)
898895
{
899-
if (_selection is not null && _updateState is null)
900-
_selection.Source = ItemsView.Source;
896+
if (_updateState is null)
897+
TryInitializeSelectionSource(_selection, true);
901898
}
902899

903900
/// <summary>
@@ -1202,7 +1199,7 @@ private void InitializeSelectionModel(ISelectionModel model)
12021199
{
12031200
if (_updateState is null)
12041201
{
1205-
model.Source = ItemsView.Source;
1202+
TryInitializeSelectionSource(model, false);
12061203
}
12071204

12081205
model.PropertyChanged += OnSelectionModelPropertyChanged;
@@ -1237,6 +1234,32 @@ private void InitializeSelectionModel(ISelectionModel model)
12371234
}
12381235
}
12391236

1237+
private void TryInitializeSelectionSource(ISelectionModel? selection, bool shouldSelectItemFromSelectedValue)
1238+
{
1239+
if (selection is not null && ItemsView.TryGetInitializedSource() is { } source)
1240+
{
1241+
// InternalSelectionModel keeps the SelectedIndex and SelectedItem values before the ItemsSource is set.
1242+
// However, SelectedValue isn't part of that model, so we have to set the SelectedItem from
1243+
// SelectedValue manually now that we have a source.
1244+
//
1245+
// While this works, this is messy: we effectively have "lazy selection initialization" in 3 places:
1246+
// - UpdateState (all selection properties, for BeginInit/EndInit)
1247+
// - InternalSelectionModel (SelectedIndex/SelectedItem)
1248+
// - SelectedItemsControl (SelectedValue)
1249+
//
1250+
// There's the opportunity to have a single place responsible for this logic.
1251+
// TODO12 (or 13): refactor this.
1252+
if (shouldSelectItemFromSelectedValue && selection.SelectedIndex == -1 && selection.SelectedItem is null)
1253+
{
1254+
var item = FindItemWithValue(SelectedValue);
1255+
if (item != AvaloniaProperty.UnsetValue)
1256+
selection.SelectedItem = item;
1257+
}
1258+
1259+
selection.Source = source;
1260+
}
1261+
}
1262+
12401263
private void DeinitializeSelectionModel(ISelectionModel? model)
12411264
{
12421265
if (model is object)
@@ -1266,7 +1289,7 @@ private void EndUpdating()
12661289

12671290
if (_selection is InternalSelectionModel s)
12681291
{
1269-
s.Update(ItemsView.Source, state.SelectedItems);
1292+
s.Update(ItemsView.TryGetInitializedSource(), state.SelectedItems);
12701293
}
12711294
else
12721295
{
@@ -1275,7 +1298,7 @@ private void EndUpdating()
12751298
SelectedItems = state.SelectedItems.Value;
12761299
}
12771300

1278-
Selection.Source = ItemsView.Source;
1301+
TryInitializeSelectionSource(Selection, false);
12791302
}
12801303

12811304
if (state.SelectedValue.HasValue)

tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ public void Setting_SelectedIndex_Should_Set_SelectedItem()
441441
}
442442

443443
[Fact]
444-
public void Setting_SelectedIndex_Out_Of_Bounds_Should_Clear_Selection()
444+
public void Setting_SelectedIndex_Out_Of_Bounds_With_ItemsSource_Should_Clear_Selection()
445445
{
446446
var items = new[]
447447
{
@@ -462,11 +462,50 @@ public void Setting_SelectedIndex_Out_Of_Bounds_Should_Clear_Selection()
462462
}
463463

464464
[Fact]
465-
public void Setting_SelectedItem_To_Non_Existent_Item_Should_Clear_Selection()
465+
public void Setting_SelectedIndex_Out_Of_Bounds_Without_ItemsSource_Should_Keep_Selection_Until_ItemsSource_Is_Set()
466466
{
467467
var target = new SelectingItemsControl
468468
{
469-
Template = Template(),
469+
Template = Template()
470+
};
471+
472+
target.ApplyTemplate();
473+
target.SelectedIndex = 2;
474+
475+
Assert.Equal(2, target.SelectedIndex);
476+
477+
target.ItemsSource = Array.Empty<Item>();
478+
479+
Assert.Equal(-1, target.SelectedIndex);
480+
}
481+
482+
[Fact]
483+
public void Setting_SelectedIndex_Without_ItemsSource_Should_Keep_Selection_If_Index_Exists_When_ItemsSource_IsSet()
484+
{
485+
var target = new SelectingItemsControl
486+
{
487+
Template = Template()
488+
};
489+
490+
target.ApplyTemplate();
491+
target.SelectedIndex = 2;
492+
493+
Assert.Equal(2, target.SelectedIndex);
494+
495+
var items = new Item[] { new(), new(), new(), new() };
496+
target.ItemsSource = items;
497+
498+
Assert.Equal(2, target.SelectedIndex);
499+
Assert.Same(items[2], target.SelectedItem);
500+
}
501+
502+
[Fact]
503+
public void Setting_SelectedItem_To_Non_Existent_Item_With_ItemsSource_Should_Clear_Selection()
504+
{
505+
var target = new SelectingItemsControl
506+
{
507+
ItemsSource = Array.Empty<Item>(),
508+
Template = Template()
470509
};
471510

472511
target.ApplyTemplate();
@@ -476,6 +515,50 @@ public void Setting_SelectedItem_To_Non_Existent_Item_Should_Clear_Selection()
476515
Assert.Null(target.SelectedItem);
477516
}
478517

518+
[Fact]
519+
public void Setting_SelectedItem_To_Non_Existent_Item_Without_ItemsSource_Should_Keep_Selection_Until_ItemsSource_Is_Set()
520+
{
521+
var item = new Item();
522+
523+
var target = new SelectingItemsControl
524+
{
525+
Template = Template()
526+
};
527+
528+
target.ApplyTemplate();
529+
target.SelectedItem = item;
530+
531+
Assert.Equal(-1, target.SelectedIndex);
532+
Assert.Same(item, target.SelectedItem);
533+
534+
target.ItemsSource = Array.Empty<Item>();
535+
536+
Assert.Equal(-1, target.SelectedIndex);
537+
Assert.Null(target.SelectedItem);
538+
}
539+
540+
[Fact]
541+
public void Setting_SelectedItem_Without_ItemsSource_Should_Keep_Selection_If_Item_Exists_When_ItemsSource_IsSet()
542+
{
543+
var item = new Item();
544+
545+
var target = new SelectingItemsControl
546+
{
547+
Template = Template()
548+
};
549+
550+
target.ApplyTemplate();
551+
target.SelectedItem = item;
552+
553+
Assert.Equal(-1, target.SelectedIndex);
554+
Assert.Same(item, target.SelectedItem);
555+
556+
target.ItemsSource = new[] { new(), new(), item, new() };
557+
558+
Assert.Equal(2, target.SelectedIndex);
559+
Assert.Same(item, target.SelectedItem);
560+
}
561+
479562
[Fact]
480563
public void Adding_Selected_Item_Should_Update_Selection()
481564
{

tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_SelectedValue.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,53 @@ public void Setting_SelectedValue_Before_Initialize_Should_Retain_Selection()
170170
Assert.Equal(items[2].Name, sic.SelectedValue);
171171
}
172172

173+
[Fact]
174+
public void Setting_SelectedValue_To_Non_Existent_Item_Without_ItemsSource_Should_Keep_Selection_Until_ItemsSource_Is_Set()
175+
{
176+
var target = new SelectingItemsControl
177+
{
178+
Template = Template(),
179+
SelectedValueBinding = new Binding("Name")
180+
};
181+
182+
target.ApplyTemplate();
183+
target.SelectedValue = "Item2";
184+
185+
Assert.Equal(-1, target.SelectedIndex);
186+
Assert.Null(target.SelectedItem);
187+
Assert.Same("Item2", target.SelectedValue);
188+
189+
target.ItemsSource = Array.Empty<TestClass>();
190+
191+
Assert.Equal(-1, target.SelectedIndex);
192+
Assert.Null(target.SelectedItem);
193+
Assert.Null(target.SelectedValue);
194+
}
195+
196+
[Fact]
197+
public void Setting_SelectedValue_Without_ItemsSource_Should_Keep_Selection_If_Item_Exists_When_ItemsSource_IsSet()
198+
{
199+
var target = new SelectingItemsControl
200+
{
201+
Template = Template(),
202+
SelectedValueBinding = new Binding("Name")
203+
};
204+
205+
target.ApplyTemplate();
206+
target.SelectedValue = "Item2";
207+
208+
Assert.Equal(-1, target.SelectedIndex);
209+
Assert.Null(target.SelectedItem);
210+
Assert.Same("Item2", target.SelectedValue);
211+
212+
var items = TestClass.GetItems();
213+
target.ItemsSource = items;
214+
215+
Assert.Equal(2, target.SelectedIndex);
216+
Assert.Same(items[2], target.SelectedItem);
217+
Assert.Equal("Item2", target.SelectedValue);
218+
}
219+
173220
[Fact]
174221
public void Setting_SelectedValue_During_Initialize_Should_Take_Priority_Over_Previous_Value()
175222
{

0 commit comments

Comments
 (0)