diff --git a/samples/ControlCatalog/Pages/ComboBoxPage.xaml b/samples/ControlCatalog/Pages/ComboBoxPage.xaml index decbd763a1..64e80a8e11 100644 --- a/samples/ControlCatalog/Pages/ComboBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ComboBoxPage.xaml @@ -1,77 +1,95 @@ - - - A drop-down list. + + + A drop-down list. - - - - - - Inline Items - Inline Item 2 - Inline Item 3 - Inline Item 4 - + + + + + - - - - - Hello - World - - - - - - - - - - - + + Inline Items + Inline Item 2 + Inline Item 3 + Inline Item 4 + - - - - - Control Items - - - - - - - - - + + + + + Hello + World + + + + + + + + + + + - - - - - - - - - - Inline Items - Inline Item 2 - Inline Item 3 - Inline Item 4 - - - - - + + + + + Control Items + + + + + + + + + - + + + + + + + + + + Inline Items + Inline Item 2 + Inline Item 3 + Inline Item 4 + + + + + + + WrapSelection + + + diff --git a/samples/ControlCatalog/Pages/ComboBoxPage.xaml.cs b/samples/ControlCatalog/Pages/ComboBoxPage.xaml.cs index d50b051d9f..d304bf227d 100644 --- a/samples/ControlCatalog/Pages/ComboBoxPage.xaml.cs +++ b/samples/ControlCatalog/Pages/ComboBoxPage.xaml.cs @@ -2,6 +2,7 @@ using System.Linq; using Avalonia.Controls; using Avalonia.Markup.Xaml; using Avalonia.Media; +using ControlCatalog.ViewModels; namespace ControlCatalog.Pages { @@ -10,6 +11,7 @@ namespace ControlCatalog.Pages public ComboBoxPage() { this.InitializeComponent(); + DataContext = new ComboBoxPageViewModel(); } private void InitializeComponent() diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index 41658329df..433592345a 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -21,6 +21,7 @@ Toggle AlwaysSelected AutoScrollToSelectedItem + WrapSelection @@ -30,6 +31,7 @@ + SelectionMode="{Binding SelectionMode^}" + WrapSelection="{Binding WrapSelection}"/> diff --git a/samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs b/samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs new file mode 100644 index 0000000000..bbe970afd6 --- /dev/null +++ b/samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using Avalonia.Controls; +using Avalonia.Controls.Selection; +using MiniMvvm; + +namespace ControlCatalog.ViewModels +{ + public class ComboBoxPageViewModel : ViewModelBase + { + private bool _wrapSelection; + + public bool WrapSelection + { + get => _wrapSelection; + set => this.RaiseAndSetIfChanged(ref _wrapSelection, value); + } + } +} diff --git a/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs b/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs index 7f2d6e9572..59489ebcc0 100644 --- a/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs +++ b/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs @@ -14,6 +14,7 @@ namespace ControlCatalog.ViewModels private bool _toggle; private bool _alwaysSelected; private bool _autoScrollToSelectedItem = true; + private bool _wrapSelection; private int _counter; private IObservable _selectionMode; @@ -85,6 +86,12 @@ namespace ControlCatalog.ViewModels set => this.RaiseAndSetIfChanged(ref _autoScrollToSelectedItem, value); } + public bool WrapSelection + { + get => _wrapSelection; + set => this.RaiseAndSetIfChanged(ref _wrapSelection, value); + } + public MiniCommand AddItemCommand { get; } public MiniCommand RemoveItemCommand { get; } public MiniCommand SelectRandomItemCommand { get; } diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index e9eca97e13..72b09b7a3c 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/src/Avalonia.Controls/ComboBox.cs @@ -10,9 +10,7 @@ using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; -using Avalonia.LogicalTree; using Avalonia.Media; -using Avalonia.Threading; using Avalonia.VisualTree; namespace Avalonia.Controls @@ -91,7 +89,7 @@ namespace Avalonia.Controls { ItemsPanelProperty.OverrideDefaultValue(DefaultPanel); FocusableProperty.OverrideDefaultValue(true); - SelectedItemProperty.Changed.AddClassHandler((x,e) => x.SelectedItemChanged(e)); + SelectedItemProperty.Changed.AddClassHandler((x, e) => x.SelectedItemChanged(e)); KeyDownEvent.AddClassHandler((x, e) => x.OnKeyDown(e), Interactivity.RoutingStrategies.Tunnel); IsTextSearchEnabledProperty.OverrideDefaultValue(true); } @@ -221,8 +219,9 @@ namespace Avalonia.Controls e.Handled = true; } } + // This part of code is needed just to acquire initial focus, subsequent focus navigation will be done by ItemsControl. else if (IsDropDownOpen && SelectedIndex < 0 && ItemCount > 0 && - (e.Key == Key.Up || e.Key == Key.Down)) + (e.Key == Key.Up || e.Key == Key.Down) && IsFocused == true) { var firstChild = Presenter?.Panel?.Children.FirstOrDefault(c => CanFocus(c)); if (firstChild != null) @@ -430,7 +429,18 @@ namespace Avalonia.Controls int next = SelectedIndex + 1; if (next >= ItemCount) - next = 0; + { + if (WrapSelection == true) + { + next = 0; + } + else + { + return; + } + } + + SelectedIndex = next; } @@ -440,7 +450,16 @@ namespace Avalonia.Controls int prev = SelectedIndex - 1; if (prev < 0) - prev = ItemCount - 1; + { + if (WrapSelection == true) + { + prev = ItemCount - 1; + } + else + { + return; + } + } SelectedIndex = prev; } diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 10e12a1ae0..ed8f9efb2e 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -143,6 +143,8 @@ namespace Avalonia.Controls protected set; } + private protected bool WrapFocus { get; set; } + event EventHandler? IChildIndexProvider.ChildIndexChanged { add => _childIndexChanged += value; @@ -315,7 +317,7 @@ namespace Avalonia.Controls { if (current.VisualParent == container && current is IInputElement inputElement) { - var next = GetNextControl(container, direction.Value, inputElement, false); + var next = GetNextControl(container, direction.Value, inputElement, WrapFocus); if (next != null) { diff --git a/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs b/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs index a34e5d6438..361febf305 100644 --- a/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs +++ b/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs @@ -233,11 +233,6 @@ namespace Avalonia.Controls.Presenters var itemIndex = generator.IndexFromContainer(from); var vertical = VirtualizingPanel.ScrollDirection == Orientation.Vertical; - if (itemIndex == -1) - { - return null; - } - var newItemIndex = -1; switch (direction) @@ -250,6 +245,16 @@ namespace Avalonia.Controls.Presenters newItemIndex = ItemCount - 1; break; + default: + if (itemIndex == -1) + { + return null; + } + break; + } + + switch (direction) + { case NavigationDirection.Up: if (vertical) { diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index 840a5ac1dc..b4cfd9404c 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -114,6 +114,12 @@ namespace Avalonia.Controls.Primitives "SelectionChanged", RoutingStrategies.Bubble); + /// + /// Defines the property. + /// + public static readonly StyledProperty WrapSelectionProperty = + AvaloniaProperty.Register(nameof(WrapSelection), defaultValue: false); + private static readonly IList Empty = Array.Empty(); private string _textSearchTerm = string.Empty; private DispatcherTimer? _textSearchTimer; @@ -321,6 +327,16 @@ namespace Avalonia.Controls.Primitives set { SetValue(IsTextSearchEnabledProperty, value); } } + /// + /// Gets or sets a value which indicates whether to wrap around when the first + /// or last item is reached. + /// + public bool WrapSelection + { + get { return GetValue(WrapSelectionProperty); } + set { SetValue(WrapSelectionProperty, value); } + } + /// /// Gets or sets the selection mode. /// @@ -580,6 +596,10 @@ namespace Avalonia.Controls.Primitives var newValue = change.NewValue.GetValueOrDefault(); _selection.SingleSelect = !newValue.HasAllFlags(SelectionMode.Multiple); } + else if (change.Property == WrapSelectionProperty) + { + WrapFocus = WrapSelection; + } } /// diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index 34b774e23f..f27568694d 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -36,7 +36,7 @@ namespace Avalonia.Controls { get { - var bounds = Orientation == Orientation.Horizontal ? + var bounds = Orientation == Orientation.Horizontal ? _availableSpace.Width : _availableSpace.Height; return Math.Max(0, _takenSpace - bounds); } @@ -129,14 +129,11 @@ namespace Avalonia.Controls protected override IInputElement? GetControlInDirection(NavigationDirection direction, IControl? from) { - if (from == null) - return null; - var logicalScrollable = Parent as ILogicalScrollable; if (logicalScrollable?.IsLogicalScrollEnabled == true) { - return logicalScrollable.GetControlInDirection(direction, from); + return logicalScrollable.GetControlInDirection(direction, from!); } else { @@ -145,7 +142,7 @@ namespace Avalonia.Controls } internal override void ArrangeChild( - IControl child, + IControl child, Rect rect, Size panelSize, Orientation orientation) diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs index 7c57e22933..aa63e18691 100644 --- a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs @@ -613,5 +613,54 @@ namespace Avalonia.Controls.UnitTests Assert.True(DataValidationErrors.GetHasErrors(target)); Assert.True(DataValidationErrors.GetErrors(target).SequenceEqual(new[] { exception })); } + + private void RaiseKeyEvent(ListBox listBox, Key key, KeyModifiers inputModifiers = 0) + { + listBox.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + KeyModifiers = inputModifiers, + Key = key + }); + } + + [Fact] + public void WrapSelection_Should_Wrap() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var items = Enumerable.Range(0, 10).Select(x => $"Item {x}").ToArray(); + var target = new ListBox + { + Template = ListBoxTemplate(), + Items = items, + ItemTemplate = new FuncDataTemplate((x, _) => new TextBlock { Height = 10 }), + WrapSelection = true + }; + + Prepare(target); + + var lbItems = target.GetLogicalChildren().OfType().ToArray(); + + var first = lbItems.First(); + var last = lbItems.Last(); + + first.Focus(); + + RaisePressedEvent(target, first, MouseButton.Left); + Assert.Equal(true, first.IsSelected); + + RaiseKeyEvent(target, Key.Up); + Assert.Equal(true, last.IsSelected); + + RaiseKeyEvent(target, Key.Down); + Assert.Equal(true, first.IsSelected); + + target.WrapSelection = false; + RaiseKeyEvent(target, Key.Up); + + Assert.Equal(true, first.IsSelected); + } + } } }