Browse Source

ItemsControl: WrapSelection (#6286)

Add support for selection wrapping for ListBox, ItemsControl, ComboBox.
Co-authored-by: Steven Kirk <grokys@users.noreply.github.com>
Co-authored-by: Takoooooo <tako0qq@gmail.com>
Co-authored-by: Steven Kirk <grokys@gmail.com>
pull/7527/head
Dominik Matijaca 4 years ago
committed by GitHub
parent
commit
b040ac5414
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 160
      samples/ControlCatalog/Pages/ComboBoxPage.xaml
  2. 2
      samples/ControlCatalog/Pages/ComboBoxPage.xaml.cs
  3. 4
      samples/ControlCatalog/Pages/ListBoxPage.xaml
  4. 21
      samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs
  5. 7
      samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs
  6. 31
      src/Avalonia.Controls/ComboBox.cs
  7. 4
      src/Avalonia.Controls/ItemsControl.cs
  8. 15
      src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs
  9. 20
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  10. 9
      src/Avalonia.Controls/VirtualizingStackPanel.cs
  11. 49
      tests/Avalonia.Controls.UnitTests/ListBoxTests.cs

160
samples/ControlCatalog/Pages/ComboBoxPage.xaml

@ -1,77 +1,95 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ControlCatalog.Pages.ComboBoxPage"
xmlns:sys="using:System"
xmlns:col="using:System.Collections">
<StackPanel Orientation="Vertical" Spacing="4">
<TextBlock Classes="h2">A drop-down list.</TextBlock>
<UserControl
x:Class="ControlCatalog.Pages.ComboBoxPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:col="using:System.Collections"
xmlns:sys="using:System">
<StackPanel Orientation="Vertical" Spacing="4">
<TextBlock Classes="h2">A drop-down list.</TextBlock>
<WrapPanel HorizontalAlignment="Center" Margin="0 16 0 0"
MaxWidth="660">
<WrapPanel.Styles>
<Style Selector="ComboBox">
<Setter Property="Width" Value="250" />
<Setter Property="Margin" Value="10" />
</Style>
</WrapPanel.Styles>
<ComboBox PlaceholderText="Pick an Item">
<ComboBoxItem>Inline Items</ComboBoxItem>
<ComboBoxItem>Inline Item 2</ComboBoxItem>
<ComboBoxItem>Inline Item 3</ComboBoxItem>
<ComboBoxItem>Inline Item 4</ComboBoxItem>
</ComboBox>
<StackPanel
Margin="0,16,0,0"
HorizontalAlignment="Center"
Orientation="Horizontal"
Spacing="8">
<WrapPanel
MaxWidth="660"
Margin="0,16,0,0"
HorizontalAlignment="Center">
<WrapPanel.Styles>
<Style Selector="ComboBox">
<Setter Property="Width" Value="250" />
<Setter Property="Margin" Value="10" />
</Style>
</WrapPanel.Styles>
<ComboBox>
<ComboBox.Items>
<col:ArrayList>
<x:Null />
<sys:String>Hello</sys:String>
<sys:String>World</sys:String>
</col:ArrayList>
</ComboBox.Items>
<ComboBox.ItemTemplate>
<DataTemplate>
<Panel>
<TextBlock Text="{Binding}" />
<TextBlock Text="Null object" IsVisible="{Binding Converter={x:Static ObjectConverters.IsNull}}" />
</Panel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox PlaceholderText="Pick an Item" WrapSelection="{Binding WrapSelection}">
<ComboBoxItem>Inline Items</ComboBoxItem>
<ComboBoxItem>Inline Item 2</ComboBoxItem>
<ComboBoxItem>Inline Item 3</ComboBoxItem>
<ComboBoxItem>Inline Item 4</ComboBoxItem>
</ComboBox>
<ComboBox SelectedIndex="0">
<ComboBoxItem>
<Panel>
<Rectangle Fill="{DynamicResource SystemAccentColor}"/>
<TextBlock Margin="8">Control Items</TextBlock>
</Panel>
</ComboBoxItem>
<ComboBoxItem>
<Ellipse Width="50" Height="50" Fill="Yellow"/>
</ComboBoxItem>
<ComboBoxItem>
<TextBox Text="TextBox"/>
</ComboBoxItem>
</ComboBox>
<ComboBox WrapSelection="{Binding WrapSelection}">
<ComboBox.Items>
<col:ArrayList>
<x:Null />
<sys:String>Hello</sys:String>
<sys:String>World</sys:String>
</col:ArrayList>
</ComboBox.Items>
<ComboBox.ItemTemplate>
<DataTemplate>
<Panel>
<TextBlock Text="{Binding}" />
<TextBlock IsVisible="{Binding Converter={x:Static ObjectConverters.IsNull}}" Text="Null object" />
</Panel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox x:Name="fontComboBox" SelectedIndex="0">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" FontFamily="{Binding}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox PlaceholderText="Pick an Item">
<ComboBoxItem>Inline Items</ComboBoxItem>
<ComboBoxItem>Inline Item 2</ComboBoxItem>
<ComboBoxItem>Inline Item 3</ComboBoxItem>
<ComboBoxItem>Inline Item 4</ComboBoxItem>
<DataValidationErrors.Error>
<sys:Exception />
</DataValidationErrors.Error>
</ComboBox>
</WrapPanel>
<ComboBox SelectedIndex="0" WrapSelection="{Binding WrapSelection}">
<ComboBoxItem>
<Panel>
<Rectangle Fill="{DynamicResource SystemAccentColor}" />
<TextBlock Margin="8">Control Items</TextBlock>
</Panel>
</ComboBoxItem>
<ComboBoxItem>
<Ellipse
Width="50"
Height="50"
Fill="Yellow" />
</ComboBoxItem>
<ComboBoxItem>
<TextBox Text="TextBox" />
</ComboBoxItem>
</ComboBox>
</StackPanel>
<ComboBox
x:Name="fontComboBox"
SelectedIndex="0"
WrapSelection="{Binding WrapSelection}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock FontFamily="{Binding}" Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox PlaceholderText="Pick an Item" WrapSelection="{Binding WrapSelection}">
<ComboBoxItem>Inline Items</ComboBoxItem>
<ComboBoxItem>Inline Item 2</ComboBoxItem>
<ComboBoxItem>Inline Item 3</ComboBoxItem>
<ComboBoxItem>Inline Item 4</ComboBoxItem>
<DataValidationErrors.Error>
<sys:Exception />
</DataValidationErrors.Error>
</ComboBox>
</WrapPanel>
<CheckBox IsChecked="{Binding WrapSelection}">WrapSelection</CheckBox>
</StackPanel>
</StackPanel>
</UserControl>

2
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()

4
samples/ControlCatalog/Pages/ListBoxPage.xaml

@ -21,6 +21,7 @@
<CheckBox IsChecked="{Binding Toggle}">Toggle</CheckBox>
<CheckBox IsChecked="{Binding AlwaysSelected}">AlwaysSelected</CheckBox>
<CheckBox IsChecked="{Binding AutoScrollToSelectedItem}">AutoScrollToSelectedItem</CheckBox>
<CheckBox IsChecked="{Binding WrapSelection}">WrapSelection</CheckBox>
</StackPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="4">
<Button Command="{Binding AddItemCommand}">Add</Button>
@ -30,6 +31,7 @@
<ListBox Items="{Binding Items}"
Selection="{Binding Selection}"
AutoScrollToSelectedItem="{Binding AutoScrollToSelectedItem}"
SelectionMode="{Binding SelectionMode^}"/>
SelectionMode="{Binding SelectionMode^}"
WrapSelection="{Binding WrapSelection}"/>
</DockPanel>
</UserControl>

21
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);
}
}
}

7
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> _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; }

31
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<ComboBox>(DefaultPanel);
FocusableProperty.OverrideDefaultValue<ComboBox>(true);
SelectedItemProperty.Changed.AddClassHandler<ComboBox>((x,e) => x.SelectedItemChanged(e));
SelectedItemProperty.Changed.AddClassHandler<ComboBox>((x, e) => x.SelectedItemChanged(e));
KeyDownEvent.AddClassHandler<ComboBox>((x, e) => x.OnKeyDown(e), Interactivity.RoutingStrategies.Tunnel);
IsTextSearchEnabledProperty.OverrideDefaultValue<ComboBox>(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;
}

4
src/Avalonia.Controls/ItemsControl.cs

@ -143,6 +143,8 @@ namespace Avalonia.Controls
protected set;
}
private protected bool WrapFocus { get; set; }
event EventHandler<ChildIndexChangedEventArgs>? 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)
{

15
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)
{

20
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@ -114,6 +114,12 @@ namespace Avalonia.Controls.Primitives
"SelectionChanged",
RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="WrapSelection"/> property.
/// </summary>
public static readonly StyledProperty<bool> WrapSelectionProperty =
AvaloniaProperty.Register<ItemsControl, bool>(nameof(WrapSelection), defaultValue: false);
private static readonly IList Empty = Array.Empty<object>();
private string _textSearchTerm = string.Empty;
private DispatcherTimer? _textSearchTimer;
@ -321,6 +327,16 @@ namespace Avalonia.Controls.Primitives
set { SetValue(IsTextSearchEnabledProperty, value); }
}
/// <summary>
/// Gets or sets a value which indicates whether to wrap around when the first
/// or last item is reached.
/// </summary>
public bool WrapSelection
{
get { return GetValue(WrapSelectionProperty); }
set { SetValue(WrapSelectionProperty, value); }
}
/// <summary>
/// Gets or sets the selection mode.
/// </summary>
@ -580,6 +596,10 @@ namespace Avalonia.Controls.Primitives
var newValue = change.NewValue.GetValueOrDefault<SelectionMode>();
_selection.SingleSelect = !newValue.HasAllFlags(SelectionMode.Multiple);
}
else if (change.Property == WrapSelectionProperty)
{
WrapFocus = WrapSelection;
}
}
/// <summary>

9
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)

49
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<string>((x, _) => new TextBlock { Height = 10 }),
WrapSelection = true
};
Prepare(target);
var lbItems = target.GetLogicalChildren().OfType<ListBoxItem>().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);
}
}
}
}

Loading…
Cancel
Save