diff --git a/samples/ControlCatalog/Pages/ComboBoxPage.xaml b/samples/ControlCatalog/Pages/ComboBoxPage.xaml index bed7d2fe48..449c10f000 100644 --- a/samples/ControlCatalog/Pages/ComboBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ComboBoxPage.xaml @@ -124,7 +124,7 @@ - + @@ -134,6 +134,18 @@ + + + + + + + + diff --git a/samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs b/samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs index e98d9f61f1..7d415e5d6d 100644 --- a/samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs +++ b/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 Values { get; set; } = new ObservableCollection { new IdAndName(){ Id = "Id 1", Name = "Name 1", SearchText = "A" }, diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index e34f9d2613..4440f05261 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/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. /// [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 IsDropDownOpenProperty = AvaloniaProperty.Register(nameof(IsDropDownOpen)); + /// + /// Defines the property. + /// + public static readonly StyledProperty IsEditableProperty = + AvaloniaProperty.Register(nameof(IsEditable)); + /// /// Defines the property. /// @@ -73,7 +80,13 @@ namespace Avalonia.Controls /// public static readonly StyledProperty VerticalContentAlignmentProperty = ContentControl.VerticalContentAlignmentProperty.AddOwner(); - + + /// + /// Defines the property + /// + public static readonly StyledProperty TextProperty = + TextBlock.TextProperty.AddOwner(new(string.Empty, BindingMode.TwoWay)); + /// /// Defines the property. /// @@ -95,6 +108,10 @@ namespace Avalonia.Controls private object? _selectionBoxItem; private readonly CompositeDisposable _subscriptionsOnOpen = new CompositeDisposable(); + private TextBox? _inputTextBox; + private BindingEvaluator? _textValueBindingEvaluator = null; + private bool _skipNextTextChanged = false; + /// /// Initializes static members of the class. /// @@ -124,6 +141,15 @@ namespace Avalonia.Controls set => SetValue(IsDropDownOpenProperty, value); } + /// + /// Gets or sets a value indicating whether the control is editable + /// + public bool IsEditable + { + get => GetValue(IsEditableProperty); + set => SetValue(IsEditableProperty, value); + } + /// /// Gets or sets the maximum height for the dropdown list. /// @@ -188,6 +214,16 @@ namespace Avalonia.Controls set => SetValue(SelectionBoxItemTemplateProperty, value); } + /// + /// Gets or sets the text used when is true. + /// Does nothing if not . + /// + 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 /// 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("PART_Popup"); _popup.Opened += PopupOpened; _popup.Closed += PopupClosed; + + _inputTextBox = e.NameScope.Find("PART_EditableTextBox"); } /// @@ -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()) + { + UpdateInputTextFromSelection(SelectedItem); + } + else if (change.Property == TextProperty) + { + TextChanged(change.GetNewValue()); + } + 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.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); } } diff --git a/src/Avalonia.Controls/Utils/BindingEvaluator.cs b/src/Avalonia.Controls/Utils/BindingEvaluator.cs index 00d9a71513..4f1c4f500c 100644 --- a/src/Avalonia.Controls/Utils/BindingEvaluator.cs +++ b/src/Avalonia.Controls/Utils/BindingEvaluator.cs @@ -19,6 +19,15 @@ internal sealed class BindingEvaluator : StyledElement, IDisposable public static readonly StyledProperty ValueProperty = AvaloniaProperty.Register, T>("Value"); + /// + /// Gets or sets the data item value. + /// + 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 : StyledElement, IDisposable DataContext = null; } + [return: NotNullIfNotNull(nameof(binding))] public static BindingEvaluator? TryCreate(IBinding? binding) { if (binding is null) diff --git a/src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml b/src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml index 116529d095..255cf29133 100644 --- a/src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml @@ -16,6 +16,7 @@ Item 1 Item 2 + @@ -25,6 +26,25 @@ + + + Item A + Item b + Item c + + + + + + + + + + + Item A + Item b + Item c + @@ -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}"> + + + + + + + + VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" + IsVisible="{TemplateBinding IsEditable, Converter={x:Static BoolConverters.Not}}"> + + + Transparent + Transparent + 0 + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/ComboBox.xaml b/src/Avalonia.Themes.Simple/Controls/ComboBox.xaml index cbea6bf79f..54e75db0a2 100644 --- a/src/Avalonia.Themes.Simple/Controls/ComboBox.xaml +++ b/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}"> + + + + + + + + ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}" + IsVisible="{TemplateBinding IsEditable, Converter={x:Static BoolConverters.Not}}"> + + + Transparent + Transparent + 0 + + + + + + diff --git a/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs index 96ff5ff786..8c7710971b 100644 --- a/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs @@ -209,6 +209,10 @@ namespace Avalonia.Controls.UnitTests ItemsPanel = new FuncTemplate(() => 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"); + } } }