Skip to content

Commit 62314a0

Browse files
authored
Fix some issues with tabbing into virtualized list (#13826)
* Add failing unit test for scenario 1 in #11878. * Set TabOnceActiveElement on realized container. Fixes scenario 1 in #11878. * Use TabOnceActiveElement to decide focused element. Fixes scenario #3 in #11878.
1 parent 5e31b36 commit 62314a0

File tree

7 files changed

+65
-12
lines changed

7 files changed

+65
-12
lines changed

src/Avalonia.Controls/ItemsControl.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,17 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
528528
_itemsPresenter = e.NameScope.Find<ItemsPresenter>("PART_ItemsPresenter");
529529
}
530530

531+
protected override void OnGotFocus(GotFocusEventArgs e)
532+
{
533+
base.OnGotFocus(e);
534+
535+
// If the focus is coming from a child control, set the tab once active element to
536+
// the focused control. This ensures that tabbing back into the control will focus
537+
// the last focused control when TabNavigationMode == Once.
538+
if (e.Source != this && e.Source is IInputElement ie)
539+
KeyboardNavigation.SetTabOnceActiveElement(this, ie);
540+
}
541+
531542
/// <summary>
532543
/// Handles directional navigation within the <see cref="ItemsControl"/>.
533544
/// </summary>

src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,9 @@ protected internal override void ContainerForItemPreparedOverride(Control contai
513513
var containerIsSelected = GetIsSelected(container);
514514
UpdateSelection(index, containerIsSelected, toggleModifier: true);
515515
}
516+
517+
if (Selection.AnchorIndex == index)
518+
KeyboardNavigation.SetTabOnceActiveElement(this, container);
516519
}
517520

518521
/// <inheritdoc />

src/Avalonia.Controls/VirtualizingStackPanel.cs

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,16 @@ protected override void OnItemsChanged(IReadOnlyList<object?> items, NotifyColle
265265
}
266266
}
267267

268+
protected override void OnItemsControlChanged(ItemsControl? oldValue)
269+
{
270+
base.OnItemsControlChanged(oldValue);
271+
272+
if (oldValue is not null)
273+
oldValue.PropertyChanged -= OnItemsControlPropertyChanged;
274+
if (ItemsControl is not null)
275+
ItemsControl.PropertyChanged += OnItemsControlPropertyChanged;
276+
}
277+
268278
protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap)
269279
{
270280
var count = Items.Count;
@@ -378,7 +388,7 @@ protected internal override int IndexFromContainer(Control container)
378388
var scrollToElement = GetOrCreateElement(items, index);
379389
scrollToElement.Measure(Size.Infinity);
380390

381-
// Get the expected position of the elment and put it in place.
391+
// Get the expected position of the element and put it in place.
382392
var anchorU = _realizedElements.GetOrEstimateElementU(index, ref _lastEstimatedElementSizeU);
383393
var rect = Orientation == Orientation.Horizontal ?
384394
new Rect(anchorU, 0, scrollToElement.DesiredSize.Width, scrollToElement.DesiredSize.Height) :
@@ -661,6 +671,7 @@ private Control CreateElement(object? item, int index, object? recycleKey)
661671

662672
private void RecycleElement(Control element, int index)
663673
{
674+
Debug.Assert(ItemsControl is not null);
664675
Debug.Assert(ItemContainerGenerator is not null);
665676

666677
_scrollAnchorProvider?.UnregisterAnchorCandidate(element);
@@ -675,11 +686,10 @@ private void RecycleElement(Control element, int index)
675686
{
676687
element.IsVisible = false;
677688
}
678-
else if (element.IsKeyboardFocusWithin)
689+
else if (KeyboardNavigation.GetTabOnceActiveElement(ItemsControl) == element)
679690
{
680691
_focusedElement = element;
681692
_focusedIndex = index;
682-
_focusedElement.LostFocus += OnUnrealizedFocusedElementLostFocus;
683693
}
684694
else
685695
{
@@ -746,15 +756,17 @@ private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChanged
746756
}
747757
}
748758

749-
private void OnUnrealizedFocusedElementLostFocus(object? sender, RoutedEventArgs e)
759+
private void OnItemsControlPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
750760
{
751-
if (_focusedElement is null || sender != _focusedElement)
752-
return;
753-
754-
_focusedElement.LostFocus -= OnUnrealizedFocusedElementLostFocus;
755-
RecycleElement(_focusedElement, _focusedIndex);
756-
_focusedElement = null;
757-
_focusedIndex = -1;
761+
if (_focusedElement is not null &&
762+
e.Property == KeyboardNavigation.TabOnceActiveElementProperty &&
763+
e.GetOldValue<IInputElement?>() == _focusedElement)
764+
{
765+
// TabOnceActiveElement has moved away from _focusedElement so we can recycle it.
766+
RecycleElement(_focusedElement, _focusedIndex);
767+
_focusedElement = null;
768+
_focusedIndex = -1;
769+
}
758770
}
759771

760772
/// <inheritdoc/>

tests/Avalonia.Controls.UnitTests/ListBoxTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,9 @@ public void Selection_Should_Be_Cleared_On_Recycled_Items()
217217
Assert.Equal(10, target.Presenter.Panel.Children.Count);
218218
Assert.True(((ListBoxItem)target.Presenter.Panel.Children[0]).IsSelected);
219219

220+
// The selected item must not be the anchor, otherwise it won't get recycled.
221+
target.Selection.AnchorIndex = -1;
222+
220223
// Scroll down a page.
221224
target.Scroll.Offset = new Vector(0, 10);
222225
Layout(target);

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,6 +1226,25 @@ public void Nested_ListBox_Does_Not_Change_Parent_SelectedIndex()
12261226
Assert.Equal(0, root.SelectedIndex);
12271227
}
12281228

1229+
[Fact]
1230+
public void TabOnceActiveElement_Should_Be_Initialized_With_SelectedItem()
1231+
{
1232+
using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
1233+
{
1234+
var target = new ListBox
1235+
{
1236+
Template = Template(),
1237+
ItemsSource = new[] { "Foo", "Bar", "Baz " },
1238+
SelectedIndex = 1,
1239+
};
1240+
1241+
Prepare(target);
1242+
1243+
var container = target.ContainerFromIndex(1)!;
1244+
Assert.Same(container, KeyboardNavigation.GetTabOnceActiveElement(target));
1245+
}
1246+
}
1247+
12291248
[Fact]
12301249
public void Setting_SelectedItem_With_Pointer_Should_Set_TabOnceActiveElement()
12311250
{

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1110,7 +1110,7 @@ public void Selection_Is_Not_Cleared_On_Recycling_Containers()
11101110
Assert.Equal(19, panel.LastRealizedIndex);
11111111

11121112
// The selection should be preserved.
1113-
Assert.Empty(SelectedContainers(target));
1113+
Assert.Equal(new[] { 1 }, SelectedContainers(target));
11141114
Assert.Equal(1, target.SelectedIndex);
11151115
Assert.Same(items[1], target.SelectedItem);
11161116
Assert.Equal(new[] { 1 }, target.Selection.SelectedIndexes);

tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Avalonia.Controls.Presenters;
99
using Avalonia.Controls.Templates;
1010
using Avalonia.Data;
11+
using Avalonia.Input;
1112
using Avalonia.Layout;
1213
using Avalonia.Media;
1314
using Avalonia.Styling;
@@ -314,15 +315,19 @@ public void Removing_Item_Of_Focused_Element_Clears_Focus()
314315
{
315316
using var app = App();
316317
var (target, scroll, itemsControl) = CreateTarget();
318+
var items = (IList)itemsControl.ItemsSource!;
317319

318320
var focused = target.GetRealizedElements().First()!;
319321
focused.Focusable = true;
320322
focused.Focus();
321323
Assert.True(focused.IsKeyboardFocusWithin);
324+
Assert.Equal(focused, KeyboardNavigation.GetTabOnceActiveElement(itemsControl));
322325

323326
scroll.Offset = new Vector(0, 200);
324327
Layout(target);
325328

329+
items.RemoveAt(0);
330+
326331
Assert.All(target.GetRealizedElements(), x => Assert.False(x!.IsKeyboardFocusWithin));
327332
Assert.All(target.GetRealizedElements(), x => Assert.NotSame(focused, x));
328333
}

0 commit comments

Comments
 (0)