Jelajahi Sumber

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.
Steven Kirk 1 tahun lalu
induk
melakukan
62314a010e

+ 11 - 0
src/Avalonia.Controls/ItemsControl.cs

@@ -528,6 +528,17 @@ namespace Avalonia.Controls
             _itemsPresenter = e.NameScope.Find<ItemsPresenter>("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);
+        }
+
         /// <summary>
         /// Handles directional navigation within the <see cref="ItemsControl"/>.
         /// </summary>

+ 3 - 0
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@@ -513,6 +513,9 @@ namespace Avalonia.Controls.Primitives
                 var containerIsSelected = GetIsSelected(container);
                 UpdateSelection(index, containerIsSelected, toggleModifier: true);
             }
+
+            if (Selection.AnchorIndex == index)
+                KeyboardNavigation.SetTabOnceActiveElement(this, container);
         }
 
         /// <inheritdoc />

+ 23 - 11
src/Avalonia.Controls/VirtualizingStackPanel.cs

@@ -265,6 +265,16 @@ namespace Avalonia.Controls
             }
         }
 
+        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;
@@ -378,7 +388,7 @@ namespace Avalonia.Controls
                 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) :
@@ -661,6 +671,7 @@ namespace Avalonia.Controls
 
         private void RecycleElement(Control element, int index)
         {
+            Debug.Assert(ItemsControl is not null);
             Debug.Assert(ItemContainerGenerator is not null);
             
             _scrollAnchorProvider?.UnregisterAnchorCandidate(element);
@@ -675,11 +686,10 @@ namespace Avalonia.Controls
             {
                 element.IsVisible = false;
             }
-            else if (element.IsKeyboardFocusWithin)
+            else if (KeyboardNavigation.GetTabOnceActiveElement(ItemsControl) == element)
             {
                 _focusedElement = element;
                 _focusedIndex = index;
-                _focusedElement.LostFocus += OnUnrealizedFocusedElementLostFocus;
             }
             else
             {
@@ -746,15 +756,17 @@ namespace Avalonia.Controls
             }
         }
 
-        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<IInputElement?>() == _focusedElement)
+            {
+                // TabOnceActiveElement has moved away from _focusedElement so we can recycle it.
+                RecycleElement(_focusedElement, _focusedIndex);
+                _focusedElement = null;
+                _focusedIndex = -1;
+            }
         }
 
         /// <inheritdoc/>

+ 3 - 0
tests/Avalonia.Controls.UnitTests/ListBoxTests.cs

@@ -217,6 +217,9 @@ namespace Avalonia.Controls.UnitTests
                 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);

+ 19 - 0
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs

@@ -1226,6 +1226,25 @@ namespace Avalonia.Controls.UnitTests.Primitives
             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()
         {

+ 1 - 1
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs

@@ -1110,7 +1110,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
             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);

+ 5 - 0
tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

@@ -8,6 +8,7 @@ using Avalonia.Collections;
 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 @@ namespace Avalonia.Controls.UnitTests
         {
             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));
         }