Browse Source

IsEditable combox box (#18094)

* IsEditable combox box with text bindings

* Update after #18405 api review

* ComboBox tests for IsEditable

* remove unneeded error being throw when combox is editable and doesn't have text binding properties

* Changes after code review

* only do naviagtion check if combo box is editable

* Fix editable ComboBox tab navigation

* combobox code review improvements

---------

Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
pull/19418/head
Julian 6 months ago
committed by GitHub
parent
commit
105ba1aa42
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 14
      samples/ControlCatalog/Pages/ComboBoxPage.xaml
  2. 14
      samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs
  3. 166
      src/Avalonia.Controls/ComboBox.cs
  4. 10
      src/Avalonia.Controls/Utils/BindingEvaluator.cs
  5. 56
      src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml
  6. 37
      src/Avalonia.Themes.Simple/Controls/ComboBox.xaml
  7. 80
      tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs

14
samples/ControlCatalog/Pages/ComboBoxPage.xaml

@ -124,7 +124,7 @@
</ComboBox.SelectionBoxItemTemplate>
</ComboBox>
<ComboBox WrapSelection="{Binding WrapSelection}" ItemsSource="{Binding Values}" >
<ComboBox WrapSelection="{Binding WrapSelection}" ItemsSource="{Binding Values}">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
@ -134,6 +134,18 @@
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<StackPanel Spacing="5">
<ComboBox WrapSelection="{Binding WrapSelection}" PlaceholderText="Editable"
ItemsSource="{Binding Values}" DisplayMemberBinding="{Binding Name}"
IsEditable="True" Text="{Binding TextValue}"
TextSearch.TextBinding="{Binding SearchText, DataType=viewModels:IdAndName}"
SelectedItem="{Binding SelectedItem}" />
<TextBlock Text="Editable text is bound to SearchText. Display is bound to Name" />
<TextBlock Text="{Binding TextValue, StringFormat=Text Value: {0}}" />
<TextBlock Text="{Binding SelectedItem.Name, StringFormat=Selected Item: {0}}" />
</StackPanel>
</WrapPanel>
</StackPanel>
</StackPanel>

14
samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs

@ -10,6 +10,8 @@ namespace ControlCatalog.ViewModels
public class ComboBoxPageViewModel : ViewModelBase
{
private bool _wrapSelection;
private string _textValue = string.Empty;
private IdAndName? _selectedItem = null;
public bool WrapSelection
{
@ -17,6 +19,18 @@ namespace ControlCatalog.ViewModels
set => this.RaiseAndSetIfChanged(ref _wrapSelection, value);
}
public string TextValue
{
get => _textValue;
set => this.RaiseAndSetIfChanged(ref _textValue, value);
}
public IdAndName? SelectedItem
{
get => _selectedItem;
set => this.RaiseAndSetIfChanged(ref _selectedItem, value);
}
public ObservableCollection<IdAndName> Values { get; set; } = new ObservableCollection<IdAndName>
{
new IdAndName(){ Id = "Id 1", Name = "Name 1", SearchText = "A" },

166
src/Avalonia.Controls/ComboBox.cs

@ -5,9 +5,9 @@ using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Shapes;
using Avalonia.Controls.Templates;
using Avalonia.Controls.Utils;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Metadata;
@ -20,6 +20,7 @@ namespace Avalonia.Controls
/// A drop-down list control.
/// </summary>
[TemplatePart("PART_Popup", typeof(Popup), IsRequired = true)]
[TemplatePart("PART_EditableTextBox", typeof(TextBox), IsRequired = false)]
[PseudoClasses(pcDropdownOpen, pcPressed)]
public class ComboBox : SelectingItemsControl
{
@ -38,6 +39,12 @@ namespace Avalonia.Controls
public static readonly StyledProperty<bool> IsDropDownOpenProperty =
AvaloniaProperty.Register<ComboBox, bool>(nameof(IsDropDownOpen));
/// <summary>
/// Defines the <see cref="IsEditable"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsEditableProperty =
AvaloniaProperty.Register<ComboBox, bool>(nameof(IsEditable));
/// <summary>
/// Defines the <see cref="MaxDropDownHeight"/> property.
/// </summary>
@ -73,7 +80,13 @@ namespace Avalonia.Controls
/// </summary>
public static readonly StyledProperty<VerticalAlignment> VerticalContentAlignmentProperty =
ContentControl.VerticalContentAlignmentProperty.AddOwner<ComboBox>();
/// <summary>
/// Defines the <see cref="Text"/> property
/// </summary>
public static readonly StyledProperty<string?> TextProperty =
TextBlock.TextProperty.AddOwner<ComboBox>(new(string.Empty, BindingMode.TwoWay));
/// <summary>
/// Defines the <see cref="SelectionBoxItemTemplate"/> property.
/// </summary>
@ -95,6 +108,10 @@ namespace Avalonia.Controls
private object? _selectionBoxItem;
private readonly CompositeDisposable _subscriptionsOnOpen = new CompositeDisposable();
private TextBox? _inputTextBox;
private BindingEvaluator<string?>? _textValueBindingEvaluator = null;
private bool _skipNextTextChanged = false;
/// <summary>
/// Initializes static members of the <see cref="ComboBox"/> class.
/// </summary>
@ -124,6 +141,15 @@ namespace Avalonia.Controls
set => SetValue(IsDropDownOpenProperty, value);
}
/// <summary>
/// Gets or sets a value indicating whether the control is editable
/// </summary>
public bool IsEditable
{
get => GetValue(IsEditableProperty);
set => SetValue(IsEditableProperty, value);
}
/// <summary>
/// Gets or sets the maximum height for the dropdown list.
/// </summary>
@ -188,6 +214,16 @@ namespace Avalonia.Controls
set => SetValue(SelectionBoxItemTemplateProperty, value);
}
/// <summary>
/// Gets or sets the text used when <see cref="IsEditable"/> is true.
/// Does nothing if not <see cref="IsEditable"/>.
/// </summary>
public string? Text
{
get => GetValue(TextProperty);
set => SetValue(TextProperty, value);
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
@ -229,7 +265,7 @@ namespace Avalonia.Controls
SetCurrentValue(IsDropDownOpenProperty, false);
e.Handled = true;
}
else if (!IsDropDownOpen && (e.Key == Key.Enter || e.Key == Key.Space))
else if (!IsDropDownOpen && !IsEditable && (e.Key == Key.Enter || e.Key == Key.Space))
{
SetCurrentValue(IsDropDownOpenProperty, true);
e.Handled = true;
@ -315,6 +351,15 @@ namespace Avalonia.Controls
/// <inheritdoc/>
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
//if the user clicked in the input text we don't want to open the dropdown
if (_inputTextBox != null
&& !e.Handled
&& e.Source is StyledElement styledSource
&& styledSource.TemplatedParent == _inputTextBox)
{
return;
}
if (!e.Handled && e.Source is Visual source)
{
if (_popup?.IsInsidePopup(source) == true)
@ -348,6 +393,8 @@ namespace Avalonia.Controls
_popup = e.NameScope.Get<Popup>("PART_Popup");
_popup.Opened += PopupOpened;
_popup.Closed += PopupClosed;
_inputTextBox = e.NameScope.Find<TextBox>("PART_EditableTextBox");
}
/// <inheritdoc/>
@ -357,6 +404,7 @@ namespace Avalonia.Controls
{
UpdateSelectionBoxItem(change.NewValue);
TryFocusSelectedItem();
UpdateInputTextFromSelection(change.NewValue);
}
else if (change.Property == IsDropDownOpenProperty)
{
@ -366,6 +414,30 @@ namespace Avalonia.Controls
{
CoerceValue(SelectionBoxItemTemplateProperty);
}
else if (change.Property == IsEditableProperty && change.GetNewValue<bool>())
{
UpdateInputTextFromSelection(SelectedItem);
}
else if (change.Property == TextProperty)
{
TextChanged(change.GetNewValue<string>());
}
else if (change.Property == ItemsSourceProperty)
{
//the base handler deselects the current item (and resets Text) so we want to run the base first, then try match by text
string? text = Text;
base.OnPropertyChanged(change);
SetCurrentValue(TextProperty, text);
return;
}
else if (change.Property == DisplayMemberBindingProperty)
{
HandleTextValueBindingValueChanged(null, change);
}
else if (change.Property == TextSearch.TextBindingProperty)
{
HandleTextValueBindingValueChanged(change, null);
}
base.OnPropertyChanged(change);
}
@ -374,6 +446,17 @@ namespace Avalonia.Controls
return new ComboBoxAutomationPeer(this);
}
protected override void OnGotFocus(GotFocusEventArgs e)
{
if (IsEditable && _inputTextBox != null)
{
_inputTextBox.Focus();
_inputTextBox.SelectAll();
}
base.OnGotFocus(e);
}
internal void ItemFocused(ComboBoxItem dropDownItem)
{
if (IsDropDownOpen && dropDownItem.IsFocused && dropDownItem.IsArrangeValid)
@ -386,6 +469,11 @@ namespace Avalonia.Controls
{
_subscriptionsOnOpen.Clear();
if(IsEditable && CanFocus(this))
{
Focus();
}
DropDownClosed?.Invoke(this, EventArgs.Empty);
}
@ -502,6 +590,14 @@ namespace Avalonia.Controls
}
}
private void UpdateInputTextFromSelection(object? item)
{
//if we are modifying the text box which has deselected a value we don't want to update the textbox value
if (_skipNextTextChanged)
return;
SetCurrentValue(TextProperty, GetItemTextValue(item));
}
private void SelectFocusedItem()
{
foreach (var dropdownItem in GetRealizedContainers())
@ -561,5 +657,69 @@ namespace Avalonia.Controls
SelectedItem = null;
SelectedIndex = -1;
}
private void HandleTextValueBindingValueChanged(AvaloniaPropertyChangedEventArgs? textSearchPropChange,
AvaloniaPropertyChangedEventArgs? displayMemberPropChange)
{
IBinding? textValueBinding;
//prioritise using the TextSearch.TextBindingProperty if possible
if (textSearchPropChange == null && TextSearch.GetTextBinding(this) is IBinding textSearchBinding)
textValueBinding = textSearchBinding;
else if (textSearchPropChange != null && textSearchPropChange.NewValue is IBinding eventTextSearchBinding)
textValueBinding = eventTextSearchBinding;
else if (displayMemberPropChange != null && displayMemberPropChange.NewValue is IBinding eventDisplayMemberBinding)
textValueBinding = eventDisplayMemberBinding;
else
textValueBinding = null;
if (_textValueBindingEvaluator == null)
_textValueBindingEvaluator = BindingEvaluator<string?>.TryCreate(textValueBinding);
else if (textValueBinding == null)
_textValueBindingEvaluator = null;
else
_textValueBindingEvaluator.UpdateBinding(textValueBinding);
//if the binding is set we want to set the initial value for the selected item so the text box has the correct value
if (_textValueBindingEvaluator != null)
_textValueBindingEvaluator.Value = GetItemTextValue(SelectedValue);
}
private void TextChanged(string? newValue)
{
if (!IsEditable || _skipNextTextChanged)
return;
int selectedIdx = -1;
object? selectedItem = null;
int i = -1;
foreach (object? item in Items)
{
i++;
string itemText = GetItemTextValue(item);
if (string.Equals(newValue, itemText, StringComparison.CurrentCultureIgnoreCase))
{
selectedIdx = i;
selectedItem = item;
break;
}
}
_skipNextTextChanged = true;
try
{
SelectedIndex = selectedIdx;
SelectedItem = selectedItem;
}
finally
{
_skipNextTextChanged = false;
}
}
private string GetItemTextValue(object? item)
=> TextSearch.GetEffectiveText(item, _textValueBindingEvaluator);
}
}

10
src/Avalonia.Controls/Utils/BindingEvaluator.cs

@ -19,6 +19,15 @@ internal sealed class BindingEvaluator<T> : StyledElement, IDisposable
public static readonly StyledProperty<T> ValueProperty =
AvaloniaProperty.Register<BindingEvaluator<T>, T>("Value");
/// <summary>
/// Gets or sets the data item value.
/// </summary>
public T Value
{
get => GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
public T Evaluate(object? dataContext)
{
// Only update the DataContext if necessary
@ -49,6 +58,7 @@ internal sealed class BindingEvaluator<T> : StyledElement, IDisposable
DataContext = null;
}
[return: NotNullIfNotNull(nameof(binding))]
public static BindingEvaluator<T>? TryCreate(IBinding? binding)
{
if (binding is null)

56
src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml

@ -16,6 +16,7 @@
<ComboBoxItem>Item 1</ComboBoxItem>
<ComboBoxItem>Item 2</ComboBoxItem>
</ComboBox>
<ComboBox PlaceholderText="Error">
<DataValidationErrors.Error>
<sys:Exception>
@ -25,6 +26,25 @@
</sys:Exception>
</DataValidationErrors.Error>
</ComboBox>
<ComboBox SelectedIndex="1" IsEditable="True">
<ComboBoxItem>Item A</ComboBoxItem>
<ComboBoxItem>Item b</ComboBoxItem>
<ComboBoxItem>Item c</ComboBoxItem>
</ComboBox>
<ComboBox SelectedIndex="0">
<ComboBox.SelectionBoxItemTemplate>
<DataTemplate>
<Border Padding="20" BorderBrush="Red" BorderThickness="1">
<TextBlock Text="{ReflectionBinding}"/>
</Border>
</DataTemplate>
</ComboBox.SelectionBoxItemTemplate>
<ComboBoxItem>Item A</ComboBoxItem>
<ComboBoxItem>Item b</ComboBoxItem>
<ComboBoxItem>Item c</ComboBoxItem>
</ComboBox>
</StackPanel>
</Border>
</Design.PreviewWith>
@ -80,17 +100,42 @@
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Margin="{TemplateBinding Padding}"
Text="{TemplateBinding PlaceholderText}"
Foreground="{TemplateBinding PlaceholderForeground}"
IsVisible="{TemplateBinding SelectionBoxItem, Converter={x:Static ObjectConverters.IsNull}}" />
Foreground="{TemplateBinding PlaceholderForeground}">
<TextBlock.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="SelectionBoxItem" RelativeSource="{RelativeSource TemplatedParent}" Converter="{x:Static ObjectConverters.IsNull}" />
<Binding Path="!IsEditable" RelativeSource="{RelativeSource TemplatedParent}" />
</MultiBinding>
</TextBlock.IsVisible>
</TextBlock>
<ContentControl x:Name="ContentPresenter"
Content="{TemplateBinding SelectionBoxItem}"
Grid.Column="0"
Margin="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}">
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
IsVisible="{TemplateBinding IsEditable, Converter={x:Static BoolConverters.Not}}">
</ContentControl>
<TextBox Name="PART_EditableTextBox"
Grid.Column="0"
Padding="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Foreground="{TemplateBinding Foreground}"
Background="Transparent"
Text="{TemplateBinding Text, Mode=TwoWay}"
Watermark="{TemplateBinding PlaceholderText}"
BorderThickness="0"
IsVisible="{TemplateBinding IsEditable}">
<TextBox.Resources>
<SolidColorBrush x:Key="TextControlBackgroundFocused">Transparent</SolidColorBrush>
<SolidColorBrush x:Key="TextControlBackgroundPointerOver">Transparent</SolidColorBrush>
<Thickness x:Key="TextControlBorderThemeThicknessFocused">0</Thickness>
</TextBox.Resources>
</TextBox>
<Border x:Name="DropDownOverlay"
Grid.Column="1"
Background="Transparent"
@ -203,6 +248,11 @@
<Setter Property="Foreground" Value="{DynamicResource ComboBoxDropDownGlyphForegroundDisabled}" />
</Style>
</Style>
<Style Selector="^[IsEditable=true]">
<Setter Property="IsTabStop" Value="False" />
<Setter Property="KeyboardNavigation.TabNavigation" Value="Local" />
</Style>
</ControlTheme>
</ResourceDictionary>

37
src/Avalonia.Themes.Simple/Controls/ComboBox.xaml

@ -27,15 +27,37 @@
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Foreground="{TemplateBinding PlaceholderForeground}"
IsVisible="{TemplateBinding SelectionBoxItem,
Converter={x:Static ObjectConverters.IsNull}}"
Text="{TemplateBinding PlaceholderText}" />
Text="{TemplateBinding PlaceholderText}">
<TextBlock.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="SelectionBoxItem" RelativeSource="{RelativeSource TemplatedParent}" Converter="{x:Static ObjectConverters.IsNull}" />
<Binding Path="!IsEditable" RelativeSource="{RelativeSource TemplatedParent}" />
</MultiBinding>
</TextBlock.IsVisible>
</TextBlock>
<ContentControl Margin="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}">
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
IsVisible="{TemplateBinding IsEditable, Converter={x:Static BoolConverters.Not}}">
</ContentControl>
<TextBox Name="PART_EditableTextBox"
Grid.Column="0"
Padding="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Foreground="{TemplateBinding Foreground}"
Background="Transparent"
Text="{TemplateBinding Text, Mode=TwoWay}"
BorderThickness="0"
IsVisible="{TemplateBinding IsEditable}">
<TextBox.Resources>
<SolidColorBrush x:Key="TextControlBackgroundFocused">Transparent</SolidColorBrush>
<SolidColorBrush x:Key="TextControlBackgroundPointerOver">Transparent</SolidColorBrush>
<Thickness x:Key="TextControlBorderThemeThicknessFocused">0</Thickness>
</TextBox.Resources>
</TextBox>
<ToggleButton Name="toggle"
Grid.Column="1"
Background="Transparent"
@ -75,11 +97,18 @@
</Border>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Border#border">
<Setter Property="BorderBrush" Value="{DynamicResource ThemeBorderHighBrush}" />
</Style>
<Style Selector="^:disabled /template/ Border#border">
<Setter Property="Opacity" Value="{DynamicResource ThemeDisabledOpacity}" />
</Style>
<Style Selector="^[IsEditable=true]">
<Setter Property="IsTabStop" Value="False" />
<Setter Property="KeyboardNavigation.TabNavigation" Value="Local" />
</Style>
</ControlTheme>
</ResourceDictionary>

80
tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs

@ -209,6 +209,10 @@ namespace Avalonia.Controls.UnitTests
ItemsPanel = new FuncTemplate<Panel>(() => new VirtualizingStackPanel()),
}.RegisterInNameScope(scope)
}.RegisterInNameScope(scope)
}.RegisterInNameScope(scope),
new TextBox
{
Name = "PART_InputText"
}.RegisterInNameScope(scope)
}
};
@ -635,5 +639,81 @@ namespace Avalonia.Controls.UnitTests
}
private sealed record Item(string Value, string Display);
[Fact]
public void When_Editable_Input_Text_Matches_An_Item_It_Is_Selected()
{
var target = new ComboBox
{
DisplayMemberBinding = new Binding(),
IsEditable = true,
ItemsSource = new[] { "foo", "bar" }
};
target.SelectedItem = null;
Assert.Null(target.SelectedItem);
target.Text = "foo";
Assert.NotNull(target.SelectedItem);
Assert.Equal(target.SelectedItem, "foo");
}
[Fact]
public void When_Editable_TextSearch_TextBinding_Is_Prioritised_Over_DisplayMember()
{
var items = new[]
{
new Item("Value 1", "Display 1"),
new Item("Value 2", "Display 2")
};
var target = new ComboBox
{
DisplayMemberBinding = new Binding("Display"),
IsEditable = true,
ItemsSource = items
};
TextSearch.SetTextBinding(target, new Binding("Value"));
target.SelectedItem = null;
Assert.Null(target.SelectedItem);
target.Text = "Value 1";
Assert.NotNull(target.SelectedItem);
Assert.Equal(target.SelectedItem, items[0]);
}
[Fact]
public void When_Items_Source_Changes_It_Selects_An_Item_By_Text()
{
var items = new[]
{
new Item("Value 1", "Display 1"),
new Item("Value 2", "Display 2")
};
var items2 = new[]
{
new Item("Value 1", "Display 3"),
new Item("Value 2", "Display 4")
};
var target = new ComboBox
{
DisplayMemberBinding = new Binding("Display"),
IsEditable = true,
ItemsSource = items
};
TextSearch.SetTextBinding(target, new Binding("Value"));
target.SelectedItem = null;
Assert.Null(target.SelectedItem);
target.Text = "Value 1";
Assert.NotNull(target.SelectedItem);
Assert.Equal(target.SelectedItem, items[0]);
target.ItemsSource = items2;
Assert.NotNull(target.SelectedItem);
Assert.Equal(target.SelectedItem, items2[0]);
Assert.Equal(target.Text, "Value 1");
}
}
}

Loading…
Cancel
Save