소스 검색

Merge pull request #11455 from AvaloniaUI/fixes/list-selection-interactions

Fix `ListBox` selection interactions
Max Katz 2 년 전
부모
커밋
aad728e90d

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

@@ -365,6 +365,30 @@ namespace Avalonia.Controls
         /// </summary>
         public IEnumerable<Control> GetRealizedContainers() => Presenter?.GetRealizedContainers() ?? Array.Empty<Control>();
 
+        /// <summary>
+        /// Returns the <see cref="ItemsControl"/> that owns the specified container control.
+        /// </summary>
+        /// <param name="container">The container.</param>
+        /// <returns>
+        /// The owning <see cref="ItemsControl"/> or null if the control is not an items container.
+        /// </returns>
+        public static ItemsControl? ItemsControlFromItemContaner(Control container)
+        {
+            var c = container.Parent as Control;
+
+            while (c is not null)
+            {
+                if (c is ItemsControl itemsControl)
+                {
+                    return itemsControl.IndexFromContainer(container) >= 0 ? itemsControl : null;
+                }
+
+                c = c.Parent as Control;
+            }
+
+            return null;
+        }
+
         /// <summary>
         /// Creates or a container that can be used to display an item.
         /// </summary>

+ 41 - 29
src/Avalonia.Controls/ListBox.cs

@@ -1,4 +1,8 @@
+using System;
 using System.Collections;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
 using Avalonia.Controls.Metadata;
 using Avalonia.Controls.Primitives;
 using Avalonia.Controls.Selection;
@@ -47,7 +51,7 @@ namespace Avalonia.Controls
         /// </summary>
         [System.Diagnostics.CodeAnalysis.SuppressMessage("AvaloniaProperty", "AVP1010",
             Justification = "This property is owned by SelectingItemsControl, but protected there. ListBox changes its visibility.")]
-        public static readonly new StyledProperty<SelectionMode> SelectionModeProperty = 
+        public static readonly new StyledProperty<SelectionMode> SelectionModeProperty =
             SelectingItemsControl.SelectionModeProperty;
 
         private IScrollable? _scroll;
@@ -121,41 +125,36 @@ namespace Avalonia.Controls
             return NeedsContainer<ListBoxItem>(item, out recycleKey);
         }
 
-        /// <inheritdoc/>
-        protected override void OnGotFocus(GotFocusEventArgs e)
+        protected override void OnKeyDown(KeyEventArgs e)
         {
-            base.OnGotFocus(e);
+            var hotkeys = AvaloniaLocator.Current.GetService<PlatformHotkeyConfiguration>();
+            var ctrl = hotkeys is not null && e.KeyModifiers.HasAllFlags(hotkeys.CommandModifiers);
 
-            if (e.NavigationMethod == NavigationMethod.Directional)
+            if (!ctrl &&
+                e.Key.ToNavigationDirection() is { } direction && 
+                direction.IsDirectional())
+            {
+                e.Handled |= MoveSelection(
+                    direction,
+                    WrapSelection,
+                    e.KeyModifiers.HasAllFlags(KeyModifiers.Shift));
+            }
+            else if (SelectionMode.HasAllFlags(SelectionMode.Multiple) &&
+                hotkeys is not null && hotkeys.SelectAll.Any(x => x.Matches(e)))
             {
-                e.Handled = UpdateSelectionFromEventSource(
+                Selection.SelectAll();
+                e.Handled = true;
+            }
+            else if (e.Key == Key.Space || e.Key == Key.Enter)
+            {
+                e.Handled |= UpdateSelectionFromEventSource(
                     e.Source,
                     true,
-                    e.KeyModifiers.HasAllFlags(KeyModifiers.Shift),
-                    e.KeyModifiers.HasAllFlags(KeyModifiers.Control),
-                    fromFocus: true);
+                    e.KeyModifiers.HasFlag(KeyModifiers.Shift),
+                    ctrl);
             }
-        }
-
-        /// <inheritdoc/>
-        protected override void OnPointerPressed(PointerPressedEventArgs e)
-        {
-            base.OnPointerPressed(e);
 
-            if (e.Source is Visual source)
-            {
-                var point = e.GetCurrentPoint(source);
-
-                if (point.Properties.IsLeftButtonPressed || point.Properties.IsRightButtonPressed)
-                {
-                    e.Handled = UpdateSelectionFromEventSource(
-                        e.Source,
-                        true,
-                        e.KeyModifiers.HasAllFlags(KeyModifiers.Shift),
-                        e.KeyModifiers.HasAllFlags(AvaloniaLocator.Current.GetRequiredService<PlatformHotkeyConfiguration>().CommandModifiers),
-                        point.Properties.IsRightButtonPressed);
-                }
-            }
+            base.OnKeyDown(e);
         }
 
         protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
@@ -163,5 +162,18 @@ namespace Avalonia.Controls
             base.OnApplyTemplate(e);
             Scroll = e.NameScope.Find<IScrollable>("PART_ScrollViewer");
         }
+
+        internal bool UpdateSelectionFromPointerEvent(Control source, PointerEventArgs e)
+        {
+            var hotkeys = AvaloniaLocator.Current.GetService<PlatformHotkeyConfiguration>();
+            var toggle = hotkeys is not null && e.KeyModifiers.HasAllFlags(hotkeys.CommandModifiers);
+
+            return UpdateSelectionFromEventSource(
+                source,
+                true,
+                e.KeyModifiers.HasAllFlags(KeyModifiers.Shift),
+                toggle,
+                e.GetCurrentPoint(source).Properties.IsRightButtonPressed);
+        }
     }
 }

+ 67 - 0
src/Avalonia.Controls/ListBoxItem.cs

@@ -2,6 +2,8 @@ using Avalonia.Automation.Peers;
 using Avalonia.Controls.Metadata;
 using Avalonia.Controls.Mixins;
 using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Platform;
 
 namespace Avalonia.Controls
 {
@@ -17,6 +19,9 @@ namespace Avalonia.Controls
         public static readonly StyledProperty<bool> IsSelectedProperty =
             SelectingItemsControl.IsSelectedProperty.AddOwner<ListBoxItem>();
 
+        private static readonly Point s_invalidPoint = new Point(double.NaN, double.NaN);
+        private Point _pointerDownPoint = s_invalidPoint;
+
         /// <summary>
         /// Initializes static members of the <see cref="ListBoxItem"/> class.
         /// </summary>
@@ -40,5 +45,67 @@ namespace Avalonia.Controls
         {
             return new ListItemAutomationPeer(this);
         }
+
+        protected override void OnPointerPressed(PointerPressedEventArgs e)
+        {
+            base.OnPointerPressed(e);
+
+            _pointerDownPoint = s_invalidPoint;
+
+            if (e.Handled)
+                return;
+
+            if (!e.Handled && ItemsControl.ItemsControlFromItemContaner(this) is ListBox owner)
+            {
+                var p = e.GetCurrentPoint(this);
+
+                if (p.Properties.PointerUpdateKind is PointerUpdateKind.LeftButtonPressed or 
+                    PointerUpdateKind.RightButtonPressed)
+                {
+                    if (p.Pointer.Type == PointerType.Mouse)
+                    {
+                        // If the pressed point comes from a mouse, perform the selection immediately.
+                        e.Handled = owner.UpdateSelectionFromPointerEvent(this, e);
+                    }
+                    else
+                    {
+                        // Otherwise perform the selection when the pointer is released as to not
+                        // interfere with gestures.
+                        _pointerDownPoint = p.Position;
+
+                        // Ideally we'd set handled here, but that would prevent the scroll gesture
+                        // recognizer from working.
+                        ////e.Handled = true;
+                    }
+                }
+            }
+        }
+
+        protected override void OnPointerReleased(PointerReleasedEventArgs e)
+        {
+            base.OnPointerReleased(e);
+
+            if (!e.Handled && 
+                !double.IsNaN(_pointerDownPoint.X) &&
+                e.InitialPressMouseButton is MouseButton.Left or MouseButton.Right)
+            {
+                var point = e.GetCurrentPoint(this);
+                var settings = AvaloniaLocator.Current.GetService<IPlatformSettings>();
+                var tapSize = settings?.GetTapSize(point.Pointer.Type) ?? new Size(4, 4);
+                var tapRect = new Rect(_pointerDownPoint, new Size())
+                    .Inflate(new Thickness(tapSize.Width, tapSize.Height));
+
+                if (new Rect(Bounds.Size).ContainsExclusive(point.Position) &&
+                    tapRect.ContainsExclusive(point.Position) &&
+                    ItemsControl.ItemsControlFromItemContaner(this) is ListBox owner)
+                {
+                    if (owner.UpdateSelectionFromPointerEvent(this, e))
+                        e.Handled = true;
+                }
+            }
+
+            _pointerDownPoint = s_invalidPoint;
+        }
+
     }
 }

+ 32 - 39
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@@ -618,38 +618,6 @@ namespace Avalonia.Controls.Primitives
             base.OnTextInput(e);
         }
 
-        /// <inheritdoc />
-        protected override void OnKeyDown(KeyEventArgs e)
-        {
-            base.OnKeyDown(e);
-
-            if (!e.Handled)
-            {
-                var keymap = AvaloniaLocator.Current.GetService<PlatformHotkeyConfiguration>();
-
-                if (keymap is null)
-                    return;
-
-                bool Match(List<KeyGesture> gestures) => gestures.Any(g => g.Matches(e));
-
-                if (ItemCount > 0 &&
-                    Match(keymap.SelectAll) &&
-                    SelectionMode.HasAllFlags(SelectionMode.Multiple))
-                {
-                    Selection.SelectAll();
-                    e.Handled = true;
-                }
-                else if (e.Key == Key.Space || e.Key == Key.Enter)
-                {
-                    UpdateSelectionFromEventSource(
-                          e.Source,
-                          true,
-                          e.KeyModifiers.HasFlag(KeyModifiers.Shift),
-                          e.KeyModifiers.HasFlag(KeyModifiers.Control));
-                }
-            }
-        }
-
         /// <inheritdoc />
         protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
         {
@@ -729,11 +697,16 @@ namespace Avalonia.Controls.Primitives
         /// </summary>
         /// <param name="direction">The direction to move.</param>
         /// <param name="wrap">Whether to wrap when the selection reaches the first or last item.</param>
+        /// <param name="rangeModifier">Whether the range modifier is enabled (i.e. shift key).</param>
         /// <returns>True if the selection was moved; otherwise false.</returns>
-        protected bool MoveSelection(NavigationDirection direction, bool wrap)
+        protected bool MoveSelection(
+            NavigationDirection direction,
+            bool wrap = false,
+            bool rangeModifier = false)
         {
-            var from = SelectedIndex != -1 ? ContainerFromIndex(SelectedIndex) : null;
-            return MoveSelection(from, direction, wrap);
+            var focused = FocusManager.GetFocusManager(this)?.GetFocusedElement();
+            var from = GetContainerFromEventSource(focused) ?? ContainerFromIndex(Selection.AnchorIndex);
+            return MoveSelection(from, direction, wrap, rangeModifier);
         }
 
         /// <summary>
@@ -742,17 +715,37 @@ namespace Avalonia.Controls.Primitives
         /// <param name="from">The container which serves as a starting point for the movement.</param>
         /// <param name="direction">The direction to move.</param>
         /// <param name="wrap">Whether to wrap when the selection reaches the first or last item.</param>
+        /// <param name="rangeModifier">Whether the range modifier is enabled (i.e. shift key).</param>
         /// <returns>True if the selection was moved; otherwise false.</returns>
-        protected bool MoveSelection(Control? from, NavigationDirection direction, bool wrap)
+        protected bool MoveSelection(
+            Control? from,
+            NavigationDirection direction,
+            bool wrap = false,
+            bool rangeModifier = false)
         {
-            if (Presenter?.Panel is INavigableContainer container &&
-                GetNextControl(container, direction, from, wrap) is Control next)
+            if (Presenter?.Panel is not INavigableContainer container)
+                return false;
+
+            if (from is null)
+            {
+                direction = direction switch
+                {
+                    NavigationDirection.Down => NavigationDirection.First,
+                    NavigationDirection.Up => NavigationDirection.Last,
+                    NavigationDirection.Right => NavigationDirection.First,
+                    NavigationDirection.Left => NavigationDirection.Last,
+                    _ => direction,
+                };
+            }
+
+            if (GetNextControl(container, direction, from, wrap) is Control next)
             {
                 var index = IndexFromContainer(next);
 
                 if (index != -1)
                 {
-                    SelectedIndex = index;
+                    UpdateSelection(index, true, rangeModifier);
+                    next.Focus();
                     return true;
                 }
             }

+ 70 - 50
src/Avalonia.Controls/VirtualizingStackPanel.cs

@@ -70,8 +70,8 @@ namespace Avalonia.Controls
         private ScrollViewer? _scrollViewer;
         private Rect _viewport = s_invalidViewport;
         private Dictionary<object, Stack<Control>>? _recyclePool;
-        private Control? _unrealizedFocusedElement;
-        private int _unrealizedFocusedIndex = -1;
+        private Control? _focusedElement;
+        private int _focusedIndex = -1;
 
         public VirtualizingStackPanel()
         {
@@ -265,12 +265,14 @@ namespace Avalonia.Controls
         protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap)
         {
             var count = Items.Count;
+            var fromControl = from as Control;
 
-            if (count == 0 || from is not Control fromControl)
+            if (count == 0 || 
+                fromControl is null && direction is not NavigationDirection.First or NavigationDirection.Last)
                 return null;
 
             var horiz = Orientation == Orientation.Horizontal;
-            var fromIndex = from != null ? IndexFromContainer(fromControl) : -1;
+            var fromIndex = fromControl != null ? IndexFromContainer(fromControl) : -1;
             var toIndex = fromIndex;
 
             switch (direction)
@@ -330,14 +332,25 @@ namespace Avalonia.Controls
         {
             if (index < 0 || index >= Items.Count)
                 return null;
-            if (_realizedElements?.GetElement(index) is { } realized)
+            if (_scrollToIndex == index)
+                return _scrollToElement;
+            if (_focusedIndex == index)
+                return _focusedElement;
+            if (GetRealizedElement(index) is { } realized)
                 return realized;
             if (Items[index] is Control c && c.GetValue(RecycleKeyProperty) == s_itemIsItsOwnContainer)
                 return c;
             return null;
         }
 
-        protected internal override int IndexFromContainer(Control container) => _realizedElements?.GetIndex(container) ?? -1;
+        protected internal override int IndexFromContainer(Control container)
+        {
+            if (container == _scrollToElement)
+                return _scrollToIndex;
+            if (container == _focusedElement)
+                return _focusedIndex;
+            return _realizedElements?.GetIndex(container) ?? -1;
+        }
 
         protected internal override Control? ScrollIntoView(int index)
         {
@@ -355,16 +368,19 @@ namespace Avalonia.Controls
             {
                 // Create and measure the element to be brought into view. Store it in a field so that
                 // it can be re-used in the layout pass.
-                _scrollToElement = GetOrCreateElement(items, index);
-                _scrollToElement.Measure(Size.Infinity);
-                _scrollToIndex = index;
+                var scrollToElement = GetOrCreateElement(items, index);
+                scrollToElement.Measure(Size.Infinity);
 
                 // Get the expected position of the elment 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) :
-                    new Rect(0, anchorU, _scrollToElement.DesiredSize.Width, _scrollToElement.DesiredSize.Height);
-                _scrollToElement.Arrange(rect);
+                    new Rect(anchorU, 0, scrollToElement.DesiredSize.Width, scrollToElement.DesiredSize.Height) :
+                    new Rect(0, anchorU, scrollToElement.DesiredSize.Width, scrollToElement.DesiredSize.Height);
+                scrollToElement.Arrange(rect);
+
+                // Store the element and index so that they can be used in the layout pass.
+                _scrollToElement = scrollToElement;
+                _scrollToIndex = index;
 
                 // If the item being brought into view was added since the last layout pass then
                 // our bounds won't be updated, so any containing scroll viewers will not have an
@@ -378,7 +394,7 @@ namespace Avalonia.Controls
                 }
 
                 // Try to bring the item into view.
-                _scrollToElement.BringIntoView();
+                scrollToElement.BringIntoView();
 
                 // If the viewport does not contain the item to scroll to, set _isWaitingForViewportUpdate:
                 // this should cause the following chain of events:
@@ -397,10 +413,9 @@ namespace Avalonia.Controls
                     root.LayoutManager.ExecuteLayoutPass();
                 }
 
-                var result = _scrollToElement;
                 _scrollToElement = null;
                 _scrollToIndex = -1;
-                return result;
+                return scrollToElement;
             }
 
             return null;
@@ -563,34 +578,48 @@ namespace Avalonia.Controls
         {
             Debug.Assert(ItemContainerGenerator is not null);
 
-            var e = GetRealizedElement(index);
+            if ((GetRealizedElement(index) ??
+                 GetRealizedElement(index, ref _focusedIndex, ref _focusedElement) ??
+                 GetRealizedElement(index, ref _scrollToIndex, ref _scrollToElement)) is { } realized)
+                return realized;
+
+            var item = items[index];
+            var generator = ItemContainerGenerator!;
 
-            if (e is null)
+            if (generator.NeedsContainer(item, index, out var recycleKey))
             {
-                var item = items[index];
-                var generator = ItemContainerGenerator!;
-
-                if (generator.NeedsContainer(item, index, out var recycleKey))
-                {
-                    e = GetRecycledElement(item, index, recycleKey) ??
-                        CreateElement(item, index, recycleKey);
-                }
-                else
-                {
-                    e = GetItemAsOwnContainer(item, index);
-                }
+                return GetRecycledElement(item, index, recycleKey) ??
+                       CreateElement(item, index, recycleKey);
+            }
+            else
+            {
+                return GetItemAsOwnContainer(item, index);
             }
-
-            return e;
         }
 
         private Control? GetRealizedElement(int index)
         {
-            if (_scrollToIndex == index)
-                return _scrollToElement;
             return _realizedElements?.GetElement(index);
         }
 
+        private static Control? GetRealizedElement(
+            int index,
+            ref int specialIndex,
+            ref Control? specialElement)
+        {
+            if (specialIndex == index)
+            {
+                Debug.Assert(specialElement is not null);
+
+                var result = specialElement;
+                specialIndex = -1;
+                specialElement = null;
+                return result;
+            }
+
+            return null;
+        }
+
         private Control GetItemAsOwnContainer(object? item, int index)
         {
             Debug.Assert(ItemContainerGenerator is not null);
@@ -619,15 +648,6 @@ namespace Avalonia.Controls
 
             var generator = ItemContainerGenerator!;
 
-            if (_unrealizedFocusedIndex == index && _unrealizedFocusedElement is not null)
-            {
-                var element = _unrealizedFocusedElement;
-                _unrealizedFocusedElement.LostFocus -= OnUnrealizedFocusedElementLostFocus;
-                _unrealizedFocusedElement = null;
-                _unrealizedFocusedIndex = -1;
-                return element;
-            }
-
             if (_recyclePool?.TryGetValue(recycleKey, out var recyclePool) == true && recyclePool.Count > 0)
             {
                 var recycled = recyclePool.Pop();
@@ -673,9 +693,9 @@ namespace Avalonia.Controls
             }
             else if (element.IsKeyboardFocusWithin)
             {
-                _unrealizedFocusedElement = element;
-                _unrealizedFocusedIndex = index;
-                _unrealizedFocusedElement.LostFocus += OnUnrealizedFocusedElementLostFocus;
+                _focusedElement = element;
+                _focusedIndex = index;
+                _focusedElement.LostFocus += OnUnrealizedFocusedElementLostFocus;
             }
             else
             {
@@ -744,13 +764,13 @@ namespace Avalonia.Controls
 
         private void OnUnrealizedFocusedElementLostFocus(object? sender, RoutedEventArgs e)
         {
-            if (_unrealizedFocusedElement is null || sender != _unrealizedFocusedElement)
+            if (_focusedElement is null || sender != _focusedElement)
                 return;
 
-            _unrealizedFocusedElement.LostFocus -= OnUnrealizedFocusedElementLostFocus;
-            RecycleElement(_unrealizedFocusedElement, _unrealizedFocusedIndex);
-            _unrealizedFocusedElement = null;
-            _unrealizedFocusedIndex = -1;
+            _focusedElement.LostFocus -= OnUnrealizedFocusedElementLostFocus;
+            RecycleElement(_focusedElement, _focusedIndex);
+            _focusedElement = null;
+            _focusedIndex = -1;
         }
 
         /// <inheritdoc/>

+ 177 - 5
tests/Avalonia.Controls.UnitTests/ListBoxTests.cs

@@ -12,6 +12,7 @@ using Avalonia.Data;
 using Avalonia.Input;
 using Avalonia.Layout;
 using Avalonia.LogicalTree;
+using Avalonia.Markup.Xaml.Templates;
 using Avalonia.Styling;
 using Avalonia.UnitTests;
 using Avalonia.VisualTree;
@@ -298,11 +299,11 @@ namespace Avalonia.Controls.UnitTests
 
                 Assert.Equal(false, item.IsSelected);
 
-                RaisePressedEvent(target, item, MouseButton.Left);
+                RaisePressedEvent(item, MouseButton.Left);
 
                 Assert.Equal(true, item.IsSelected);
 
-                RaisePressedEvent(target, item, MouseButton.Left);
+                RaisePressedEvent(item, MouseButton.Left);
 
                 Assert.Equal(false, item.IsSelected);
             }
@@ -328,9 +329,9 @@ namespace Avalonia.Controls.UnitTests
             }
         }
 
-        private void RaisePressedEvent(ListBox listBox, ListBoxItem item, MouseButton mouseButton)
+        private void RaisePressedEvent(ListBoxItem item, MouseButton mouseButton)
         {
-            _mouse.Click(listBox, item, mouseButton);
+            _mouse.Click(item, item, mouseButton);
         }
 
         [Fact]
@@ -752,6 +753,177 @@ namespace Avalonia.Controls.UnitTests
             Assert.Equal(Enumerable.Range(0, 10).Select(x => $"Item{10 - x}"), realized);
         }
 
+        [Fact]
+        public void Arrow_Keys_Should_Move_Selection_Vertical()
+        {
+            using var app = UnitTestApplication.Start(TestServices.RealFocus);
+            var items = Enumerable.Range(0, 10).Select(x => $"Item {x}").ToArray();
+            var target = new ListBox
+            {
+                Template = ListBoxTemplate(),
+                ItemsSource = items,
+                ItemTemplate = new FuncDataTemplate<string>((x, _) => new TextBlock { Height = 10 }),
+                SelectedIndex = 0,
+            };
+
+            Prepare(target);
+
+            RaiseKeyEvent(target, Key.Down);
+            Assert.Equal(1, target.SelectedIndex);
+
+            RaiseKeyEvent(target, Key.Down);
+            Assert.Equal(2, target.SelectedIndex);
+
+            RaiseKeyEvent(target, Key.Up);
+            Assert.Equal(1, target.SelectedIndex);
+        }
+
+        [Fact]
+        public void Arrow_Keys_Should_Move_Selection_Horizontal()
+        {
+            using var app = UnitTestApplication.Start(TestServices.RealFocus);
+            var items = Enumerable.Range(0, 10).Select(x => $"Item {x}").ToArray();
+            var target = new ListBox
+            {
+                Template = ListBoxTemplate(),
+                ItemsSource = items,
+                ItemsPanel = new FuncTemplate<Panel>(() => new VirtualizingStackPanel 
+                {
+                    Orientation = Orientation.Horizontal 
+                }),
+                ItemTemplate = new FuncDataTemplate<string>((x, _) => new TextBlock { Height = 10 }),
+                SelectedIndex = 0,
+            };
+
+            Prepare(target);
+
+            RaiseKeyEvent(target, Key.Right);
+            Assert.Equal(1, target.SelectedIndex);
+
+            RaiseKeyEvent(target, Key.Right);
+            Assert.Equal(2, target.SelectedIndex);
+
+            RaiseKeyEvent(target, Key.Left);
+            Assert.Equal(1, target.SelectedIndex);
+        }
+
+        [Fact]
+        public void Arrow_Keys_Should_Focus_Selection()
+        {
+            using var app = UnitTestApplication.Start(TestServices.RealFocus);
+            var items = Enumerable.Range(0, 10).Select(x => $"Item {x}").ToArray();
+            var target = new ListBox
+            {
+                Template = ListBoxTemplate(),
+                ItemsSource = items,
+                ItemTemplate = new FuncDataTemplate<string>((x, _) => new TextBlock { Height = 10 }),
+                SelectedIndex = 0,
+            };
+
+            Prepare(target);
+
+            RaiseKeyEvent(target, Key.Down);
+            Assert.True(target.ContainerFromIndex(1).IsFocused);
+
+            RaiseKeyEvent(target, Key.Down);
+            Assert.True(target.ContainerFromIndex(2).IsFocused);
+
+            RaiseKeyEvent(target, Key.Up);
+            Assert.True(target.ContainerFromIndex(1).IsFocused);
+        }
+
+        [Fact]
+        public void Down_Key_Selecting_From_No_Selection_And_No_Focus_Selects_From_Start()
+        {
+            using var app = UnitTestApplication.Start(TestServices.RealFocus);
+            var target = new ListBox
+            {
+                Template = ListBoxTemplate(),
+                ItemsSource = new[] { "Foo", "Bar", "Baz" },
+                Width = 100,
+                Height = 100,
+            };
+
+            Prepare(target);
+
+            RaiseKeyEvent(target, Key.Down);
+
+            Assert.Equal(0, target.SelectedIndex);
+        }
+
+        [Fact]
+        public void Down_Key_Selecting_From_No_Selection_Selects_From_Focus()
+        {
+            using var app = UnitTestApplication.Start(TestServices.RealFocus);
+            var target = new ListBox
+            {
+                Template = ListBoxTemplate(),
+                ItemsSource = new[] { "Foo", "Bar", "Baz" },
+                Width = 100,
+                Height = 100,
+            };
+
+            Prepare(target);
+
+            target.ContainerFromIndex(1)!.Focus();
+            RaiseKeyEvent(target, Key.Down);
+
+            Assert.Equal(2, target.SelectedIndex);
+        }
+
+        [Fact]
+        public void Ctrl_Down_Key_Moves_Focus_But_Not_Selection()
+        {
+            using var app = UnitTestApplication.Start(TestServices.RealFocus);
+            var target = new ListBox
+            {
+                Template = ListBoxTemplate(),
+                ItemsSource = new[] { "Foo", "Bar", "Baz" },
+                Width = 100,
+                Height = 100,
+                SelectedIndex = 0,
+            };
+
+            Prepare(target);
+
+            target.ContainerFromIndex(0)!.Focus();
+            RaiseKeyEvent(target, Key.Down, KeyModifiers.Control);
+
+            Assert.Equal(0, target.SelectedIndex);
+            Assert.True(target.ContainerFromIndex(1).IsFocused);
+        }
+
+        [Fact]
+        public void Down_Key_Brings_Unrealized_Selection_Into_View()
+        {
+            using var app = UnitTestApplication.Start(TestServices.RealFocus);
+            var items = Enumerable.Range(0, 100).Select(x => $"Item {x}").ToArray();
+            var target = new ListBox
+            {
+                Template = ListBoxTemplate(),
+                ItemsSource = items,
+                ItemTemplate = new FuncDataTemplate<string>((x, _) => new TextBlock { Width = 20, Height = 10 }),
+                Width = 100,
+                Height = 100,
+                SelectedIndex = 0,
+            };
+
+            Prepare(target);
+
+            target.ContainerFromIndex(0)!.Focus();
+            target.Scroll.Offset = new Vector(0, 100);
+            Layout(target);
+
+            var panel = (VirtualizingStackPanel)target.ItemsPanelRoot;
+            Assert.Equal(10, panel.FirstRealizedIndex);
+
+            RaiseKeyEvent(target, Key.Down);
+
+            Assert.Equal(1, target.SelectedIndex);
+            Assert.True(target.ContainerFromIndex(1).IsFocused);
+            Assert.Equal(new Vector(0, 10), target.Scroll.Offset);
+        }
+
         [Fact]
         public void WrapSelection_Should_Wrap()
         {
@@ -776,7 +948,7 @@ namespace Avalonia.Controls.UnitTests
 
                 first.Focus();
 
-                RaisePressedEvent(target, first, MouseButton.Left);
+                RaisePressedEvent(first, MouseButton.Left);
                 Assert.Equal(true, first.IsSelected);
 
                 RaiseKeyEvent(target, Key.Up);

+ 105 - 83
tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs

@@ -17,76 +17,6 @@ namespace Avalonia.Controls.UnitTests
     {
         private MouseTestHelper _helper = new MouseTestHelper();
 
-        [Fact]
-        public void Focusing_Item_With_Shift_And_Arrow_Key_Should_Add_To_Selection()
-        {
-            var target = new ListBox
-            {
-                Template = new FuncControlTemplate(CreateListBoxTemplate),
-                ItemsSource = new[] { "Foo", "Bar", "Baz " },
-                SelectionMode = SelectionMode.Multiple
-            };
-
-            ApplyTemplate(target);
-
-            target.SelectedItem = "Foo";
-
-            target.Presenter.Panel.Children[1].RaiseEvent(new GotFocusEventArgs
-            {
-                NavigationMethod = NavigationMethod.Directional,
-                KeyModifiers = KeyModifiers.Shift
-            });
-
-            Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems);
-        }
-
-        [Fact]
-        public void Focusing_Item_With_Ctrl_And_Arrow_Key_Should_Not_Add_To_Selection()
-        {
-            var target = new ListBox
-            {
-                Template = new FuncControlTemplate(CreateListBoxTemplate),
-                ItemsSource = new[] { "Foo", "Bar", "Baz " },
-                SelectionMode = SelectionMode.Multiple
-            };
-
-            ApplyTemplate(target);
-
-            target.SelectedItem = "Foo";
-
-            target.Presenter.Panel.Children[1].RaiseEvent(new GotFocusEventArgs
-            {
-                NavigationMethod = NavigationMethod.Directional,
-                KeyModifiers = KeyModifiers.Control
-            });
-
-            Assert.Equal(new[] { "Foo" }, target.SelectedItems);
-        }
-
-        [Fact]
-        public void Focusing_Selected_Item_With_Ctrl_And_Arrow_Key_Should_Not_Remove_From_Selection()
-        {
-            var target = new ListBox
-            {
-                Template = new FuncControlTemplate(CreateListBoxTemplate),
-                ItemsSource = new[] { "Foo", "Bar", "Baz " },
-                SelectionMode = SelectionMode.Multiple
-            };
-
-            ApplyTemplate(target);
-
-            target.SelectedItems.Add("Foo");
-            target.SelectedItems.Add("Bar");
-
-            target.Presenter.Panel.Children[0].RaiseEvent(new GotFocusEventArgs
-            {
-                NavigationMethod = NavigationMethod.Directional,
-                KeyModifiers = KeyModifiers.Control
-            });
-
-            Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems);
-        }
-
         [Fact]
         public void Shift_Selecting_From_No_Selection_Selects_From_Start()
         {
@@ -549,6 +479,104 @@ namespace Avalonia.Controls.UnitTests
                 Assert.Equal(1, target.SelectedItems.Count);
             }
         }
+
+        [Fact]
+        public void Shift_Arrow_Key_Selects_Range()
+        {
+            using var app = UnitTestApplication.Start(TestServices.RealFocus);
+            var target = new ListBox
+            {
+                Template = new FuncControlTemplate(CreateListBoxTemplate),
+                ItemsSource = new[] { "Foo", "Bar", "Baz" },
+                SelectionMode = SelectionMode.Multiple,
+                Width = 100,
+                Height = 100,
+                SelectedIndex = 0,
+            };
+
+            var root = new TestRoot(target);
+            root.LayoutManager.ExecuteInitialLayoutPass();
+
+            RaiseKeyEvent(target, Key.Down, KeyModifiers.Shift);
+
+            Assert.Equal(new[] { "Foo", "Bar", }, target.SelectedItems);
+            Assert.Equal(new[] { 0, 1 }, SelectedContainers(target));
+            Assert.True(target.ContainerFromIndex(1).IsFocused);
+
+            RaiseKeyEvent(target, Key.Down, KeyModifiers.Shift);
+
+            Assert.Equal(new[] { "Foo", "Bar", "Baz", }, target.SelectedItems);
+            Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target));
+            Assert.True(target.ContainerFromIndex(2).IsFocused);
+
+            RaiseKeyEvent(target, Key.Up, KeyModifiers.Shift);
+
+            Assert.Equal(new[] { "Foo", "Bar", }, target.SelectedItems);
+            Assert.Equal(new[] { 0, 1 }, SelectedContainers(target));
+            Assert.True(target.ContainerFromIndex(1).IsFocused);
+        }
+
+        [Fact]
+        public void Shift_Down_Key_Selecting_Selects_Range_End_From_Focus()
+        {
+            using var app = UnitTestApplication.Start(TestServices.RealFocus);
+            var target = new ListBox
+            {
+                Template = new FuncControlTemplate(CreateListBoxTemplate),
+                ItemsSource = new[] { "Foo", "Bar", "Baz" },
+                SelectionMode = SelectionMode.Multiple,
+                Width = 100,
+                Height = 100,
+                SelectedIndex = 0,
+            };
+
+            var root = new TestRoot(target);
+            root.LayoutManager.ExecuteInitialLayoutPass();
+
+            target.ContainerFromIndex(1)!.Focus();
+            RaiseKeyEvent(target, Key.Down, KeyModifiers.Shift);
+
+            Assert.Equal(new[] { "Foo", "Bar", "Baz" }, target.SelectedItems);
+            Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target));
+            Assert.True(target.ContainerFromIndex(2).IsFocused);
+        }
+
+        [Fact]
+        public void Shift_Down_Key_Selecting_Selects_Range_End_From_Focus_Moved_With_Ctrl_Key()
+        {
+            using var app = UnitTestApplication.Start(TestServices.RealFocus);
+            var target = new ListBox
+            {
+                Template = new FuncControlTemplate(CreateListBoxTemplate),
+                ItemsSource = new[] { "Foo", "Bar", "Baz", "Qux" },
+                SelectionMode = SelectionMode.Multiple,
+                Width = 100,
+                Height = 100,
+                SelectedIndex = 0,
+            };
+
+            var root = new TestRoot(target);
+            root.LayoutManager.ExecuteInitialLayoutPass();
+
+            RaiseKeyEvent(target, Key.Down, KeyModifiers.Shift);
+
+            Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems);
+            Assert.Equal(new[] { 0, 1 }, SelectedContainers(target));
+            Assert.True(target.ContainerFromIndex(1).IsFocused);
+
+            RaiseKeyEvent(target, Key.Down, KeyModifiers.Control);
+
+            Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems);
+            Assert.Equal(new[] { 0, 1 }, SelectedContainers(target));
+            Assert.True(target.ContainerFromIndex(2).IsFocused);
+
+            RaiseKeyEvent(target, Key.Down, KeyModifiers.Shift);
+
+            Assert.Equal(new[] { "Foo", "Bar", "Baz", "Qux" }, target.SelectedItems);
+            Assert.Equal(new[] { 0, 1, 2, 3 }, SelectedContainers(target));
+            Assert.True(target.ContainerFromIndex(3).IsFocused);
+        }
+
         private Control CreateListBoxTemplate(TemplatedControl parent, INameScope scope)
         {
             return new ScrollViewer
@@ -571,20 +599,14 @@ namespace Avalonia.Controls.UnitTests
             }.RegisterInNameScope(scope);
         }
 
-        private static void ApplyTemplate(ListBox target)
+        private static void RaiseKeyEvent(Control target, Key key, KeyModifiers inputModifiers = 0)
         {
-            // Apply the template to the ListBox itself.
-            target.ApplyTemplate();
-
-            // Then to its inner ScrollViewer.
-            var scrollViewer = (ScrollViewer)target.GetVisualChildren().Single();
-            scrollViewer.ApplyTemplate();
-
-            // Then make the ScrollViewer create its child.
-            ((ContentPresenter)scrollViewer.Presenter).UpdateChild();
-
-            // Now the ItemsPresenter should be reigstered, so apply its template.
-            ((Control)target.Presenter).ApplyTemplate();
+            target.RaiseEvent(new KeyEventArgs
+            {
+                RoutedEvent = InputElement.KeyDownEvent,
+                KeyModifiers = inputModifiers,
+                Key = key
+            });
         }
 
         private static IEnumerable<int> SelectedContainers(SelectingItemsControl target)

+ 0 - 39
tests/Avalonia.Controls.UnitTests/ListBoxTests_Single.cs

@@ -39,45 +39,6 @@ namespace Avalonia.Controls.UnitTests
             Assert.Equal(-1, target.SelectedIndex);
         }
 
-        [Fact]
-        public void Focusing_Item_With_Arrow_Key_Should_Select_It()
-        {
-            var target = new ListBox
-            {
-                Template = new FuncControlTemplate(CreateListBoxTemplate),
-                ItemsSource = new[] { "Foo", "Bar", "Baz " },
-            };
-
-            ApplyTemplate(target);
-
-            target.Presenter.Panel.Children[0].RaiseEvent(new GotFocusEventArgs
-            {
-                NavigationMethod = NavigationMethod.Directional,
-            });
-
-            Assert.Equal(0, target.SelectedIndex);
-        }
-
-        [Fact]
-        public void Focusing_Item_With_Arrow_Key_And_Ctrl_Pressed_Should_Not_Select_It()
-        {
-            var target = new ListBox
-            {
-                Template = new FuncControlTemplate(CreateListBoxTemplate),
-                ItemsSource = new[] { "Foo", "Bar", "Baz " },
-            };
-
-            ApplyTemplate(target);
-
-            target.Presenter.Panel.Children[0].RaiseEvent(new GotFocusEventArgs
-            {
-                NavigationMethod = NavigationMethod.Directional,
-                KeyModifiers = KeyModifiers.Control
-            });
-
-            Assert.Equal(-1, target.SelectedIndex);
-        }
-
         [Fact]
         public void Pressing_Space_On_Focused_Item_With_Ctrl_Pressed_Should_Select_It()
         {