diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 41e9e7c111b..0e4a3036fb3 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -515,6 +515,17 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) _itemsPresenter = e.NameScope.Find("PART_ItemsPresenter"); } + protected override void OnGotFocus(GotFocusEventArgs e) + { + base.OnGotFocus(e); + + // If the focus is coming from a child control, set the tab once active element to + // the focused control. This ensures that tabbing back into the control will focus + // the last focused control when TabNavigationMode == Once. + if (e.Source != this && e.Source is IInputElement ie) + KeyboardNavigation.SetTabOnceActiveElement(this, ie); + } + /// /// Handles directional navigation within the . /// diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index b354f06be46..beec0bdcb16 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -513,6 +513,9 @@ protected internal override void ContainerForItemPreparedOverride(Control contai var containerIsSelected = GetIsSelected(container); UpdateSelection(index, containerIsSelected, toggleModifier: true); } + + if (Selection.AnchorIndex == index) + KeyboardNavigation.SetTabOnceActiveElement(this, container); } /// diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index b2e6127baec..d7ff12ffe7d 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -266,6 +266,16 @@ protected override void OnItemsChanged(IReadOnlyList items, NotifyColle } } + protected override void OnItemsControlChanged(ItemsControl? oldValue) + { + base.OnItemsControlChanged(oldValue); + + if (oldValue is not null) + oldValue.PropertyChanged -= OnItemsControlPropertyChanged; + if (ItemsControl is not null) + ItemsControl.PropertyChanged += OnItemsControlPropertyChanged; + } + protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap) { var count = Items.Count; @@ -379,7 +389,7 @@ protected internal override int IndexFromContainer(Control container) var scrollToElement = GetOrCreateElement(items, index); scrollToElement.Measure(Size.Infinity); - // Get the expected position of the elment and put it in place. + // Get the expected position of the element and put it in place. var anchorU = _realizedElements.GetOrEstimateElementU(index, ref _lastEstimatedElementSizeU); var rect = Orientation == Orientation.Horizontal ? new Rect(anchorU, 0, scrollToElement.DesiredSize.Width, scrollToElement.DesiredSize.Height) : @@ -689,6 +699,7 @@ private Control CreateElement(object? item, int index, object? recycleKey) private void RecycleElement(Control element, int index) { + Debug.Assert(ItemsControl is not null); Debug.Assert(ItemContainerGenerator is not null); _scrollViewer?.UnregisterAnchorCandidate(element); @@ -703,11 +714,10 @@ private void RecycleElement(Control element, int index) { element.IsVisible = false; } - else if (element.IsKeyboardFocusWithin) + else if (KeyboardNavigation.GetTabOnceActiveElement(ItemsControl) == element) { _focusedElement = element; _focusedIndex = index; - _focusedElement.LostFocus += OnUnrealizedFocusedElementLostFocus; } else { @@ -774,15 +784,17 @@ private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChanged } } - private void OnUnrealizedFocusedElementLostFocus(object? sender, RoutedEventArgs e) + private void OnItemsControlPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { - if (_focusedElement is null || sender != _focusedElement) - return; - - _focusedElement.LostFocus -= OnUnrealizedFocusedElementLostFocus; - RecycleElement(_focusedElement, _focusedIndex); - _focusedElement = null; - _focusedIndex = -1; + if (_focusedElement is not null && + e.Property == KeyboardNavigation.TabOnceActiveElementProperty && + e.GetOldValue() == _focusedElement) + { + // TabOnceActiveElement has moved away from _focusedElement so we can recycle it. + RecycleElement(_focusedElement, _focusedIndex); + _focusedElement = null; + _focusedIndex = -1; + } } /// diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs index a6a2a024799..21389bc67e2 100644 --- a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs @@ -220,6 +220,9 @@ public void Selection_Should_Be_Cleared_On_Recycled_Items() Assert.Equal(10, target.Presenter.Panel.Children.Count); Assert.True(((ListBoxItem)target.Presenter.Panel.Children[0]).IsSelected); + // The selected item must not be the anchor, otherwise it won't get recycled. + target.Selection.AnchorIndex = -1; + // Scroll down a page. target.Scroll.Offset = new Vector(0, 10); Layout(target); diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index 41b60ef5b4d..20d782ac86b 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -1224,6 +1224,25 @@ public void Nested_ListBox_Does_Not_Change_Parent_SelectedIndex() Assert.Equal(0, root.SelectedIndex); } + [Fact] + public void TabOnceActiveElement_Should_Be_Initialized_With_SelectedItem() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + { + var target = new ListBox + { + Template = Template(), + ItemsSource = new[] { "Foo", "Bar", "Baz " }, + SelectedIndex = 1, + }; + + Prepare(target); + + var container = target.ContainerFromIndex(1)!; + Assert.Same(container, KeyboardNavigation.GetTabOnceActiveElement(target)); + } + } + [Fact] public void Setting_SelectedItem_With_Pointer_Should_Set_TabOnceActiveElement() { diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index 47fdb7cea7f..babcb6ff23d 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -1110,7 +1110,7 @@ public void Selection_Is_Not_Cleared_On_Recycling_Containers() Assert.Equal(19, panel.LastRealizedIndex); // The selection should be preserved. - Assert.Empty(SelectedContainers(target)); + Assert.Equal(new[] { 1 }, SelectedContainers(target)); Assert.Equal(1, target.SelectedIndex); Assert.Same(items[1], target.SelectedItem); Assert.Equal(new[] { 1 }, target.Selection.SelectedIndexes); diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs index bed8379d9c8..6091348f7c5 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -8,6 +8,7 @@ using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Styling; @@ -314,15 +315,19 @@ public void Removing_Item_Of_Focused_Element_Clears_Focus() { using var app = App(); var (target, scroll, itemsControl) = CreateTarget(); + var items = (IList)itemsControl.ItemsSource!; var focused = target.GetRealizedElements().First()!; focused.Focusable = true; focused.Focus(); Assert.True(focused.IsKeyboardFocusWithin); + Assert.Equal(focused, KeyboardNavigation.GetTabOnceActiveElement(itemsControl)); scroll.Offset = new Vector(0, 200); Layout(target); + items.RemoveAt(0); + Assert.All(target.GetRealizedElements(), x => Assert.False(x!.IsKeyboardFocusWithin)); Assert.All(target.GetRealizedElements(), x => Assert.NotSame(focused, x)); }