Skip to content

IsEditable combox box #18094

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions samples/ControlCatalog/Pages/ComboBoxPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should use full property names to make this clearer. Not everyone will go straight to XAML when running the catalogue.

<TextBlock Text="{Binding TextValue, StringFormat=Text Value: {0}}" />
<TextBlock Text="{Binding SelectedItem.Name, StringFormat=Selected Item: {0}}" />
</StackPanel>
</WrapPanel>
</StackPanel>
</StackPanel>
Expand Down
14 changes: 14 additions & 0 deletions samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ public bool WrapSelection
set => this.RaiseAndSetIfChanged(ref _wrapSelection, value);
}

private string _textValue = string.Empty;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In C#, variables generally go near the top of the class and are grouped together.

public string TextValue
{
get => _textValue;
set => this.RaiseAndSetIfChanged(ref _textValue, value);
}

private IdAndName? _selectedItem = null;
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" },
Expand Down
158 changes: 155 additions & 3 deletions src/Avalonia.Controls/ComboBox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
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;
Expand Down Expand Up @@ -38,6 +38,14 @@ public class ComboBox : SelectingItemsControl
public static readonly StyledProperty<bool> IsDropDownOpenProperty =
AvaloniaProperty.Register<ComboBox, bool>(nameof(IsDropDownOpen));

/// <summary>
/// Defines the <see cref="IsEditable"/> property.
/// </summary>
public static readonly DirectProperty<ComboBox, bool> IsEditableProperty =
AvaloniaProperty.RegisterDirect<ComboBox, bool>(nameof(IsEditable),
o => o.IsEditable,
(o, v) => o.IsEditable = v);

/// <summary>
/// Defines the <see cref="MaxDropDownHeight"/> property.
/// </summary>
Expand Down Expand Up @@ -73,7 +81,13 @@ public class ComboBox : SelectingItemsControl
/// </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>
Expand All @@ -95,6 +109,10 @@ public class ComboBox : SelectingItemsControl
private object? _selectionBoxItem;
private readonly CompositeDisposable _subscriptionsOnOpen = new CompositeDisposable();

private bool _isEditable;
private TextBox? _inputText;
private BindingEvaluator<string?>? _textValueBindingEvaluator = null;

/// <summary>
/// Initializes static members of the <see cref="ComboBox"/> class.
/// </summary>
Expand All @@ -103,6 +121,12 @@ static ComboBox()
ItemsPanelProperty.OverrideDefaultValue<ComboBox>(DefaultPanel);
FocusableProperty.OverrideDefaultValue<ComboBox>(true);
IsTextSearchEnabledProperty.OverrideDefaultValue<ComboBox>(true);
TextProperty.Changed.AddClassHandler<ComboBox>((x, e) => x.TextChanged(e));
DisplayMemberBindingProperty.Changed.AddClassHandler<ComboBox>((x, e) => x.DisplayMemberBindingChanged(e));
TextSearch.TextBindingProperty.Changed.AddClassHandler<ComboBox>((x, e) => x.ItemTextBindingChanged(e));
//when the items change we need to simulate a text change to validate the text being an item or not and selecting it
ItemsSourceProperty.Changed.AddClassHandler<ComboBox>((x, e) => x.TextChanged(
new AvaloniaPropertyChangedEventArgs<string?>(e.Sender, TextProperty, x.Text, x.Text, e.Priority)));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be an issue if something is listening for text change events? It might not. I have not tested it. Personally though, I would expect a text change event to only ever be fired when the text actually changes.

}

/// <summary>
Expand All @@ -124,6 +148,15 @@ public bool IsDropDownOpen
set => SetValue(IsDropDownOpenProperty, value);
}

/// <summary>
/// Gets or sets a value indicating whether the control is editable
/// </summary>
public bool IsEditable
{
get => _isEditable;
set => SetAndRaise(IsEditableProperty, ref _isEditable, value);
}

/// <summary>
/// Gets or sets the maximum height for the dropdown list.
/// </summary>
Expand Down Expand Up @@ -188,6 +221,21 @@ public IDataTemplate? SelectionBoxItemTemplate
set => SetValue(SelectionBoxItemTemplateProperty, value);
}

/// <summary>
/// Gets or sets the text used when <see cref="IsEditable"/> is true.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should document what this does if IsEditable is not true.

/// </summary>
public string? Text
{
get => GetValue(TextProperty);
set => SetValue(TextProperty, value);
}

protected override void OnInitialized()
{
EnsureTextValueBinderOrThrow();
base.OnInitialized();
}

protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
Expand Down Expand Up @@ -229,7 +277,7 @@ protected override void OnKeyDown(KeyEventArgs e)
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;
Expand Down Expand Up @@ -315,6 +363,15 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
/// <inheritdoc/>
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
//if the user clicked in the input text we don't want to open the dropdown
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does WPF/winforms do this? I seem to vaguely recall that it actually opens the drop down and filters the list as you type. But it has been a long time, so I could be misremembering.

if (_inputText != null
&& !e.Handled
&& e.Source is StyledElement styledSource
&& styledSource.TemplatedParent == _inputText)
{
return;
}

if (!e.Handled && e.Source is Visual source)
{
if (_popup?.IsInsidePopup(source) == true)
Expand Down Expand Up @@ -348,6 +405,8 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
_popup = e.NameScope.Get<Popup>("PART_Popup");
_popup.Opened += PopupOpened;
_popup.Closed += PopupClosed;

_inputText = e.NameScope.Get<TextBox>("PART_InputText");
}

/// <inheritdoc/>
Expand All @@ -357,6 +416,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
{
UpdateSelectionBoxItem(change.NewValue);
TryFocusSelectedItem();
UpdateInputTextFromSelection(change.NewValue);
}
else if (change.Property == IsDropDownOpenProperty)
{
Expand All @@ -366,6 +426,10 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
{
CoerceValue(SelectionBoxItemTemplateProperty);
}
else if (change.Property == IsEditableProperty && change.GetNewValue<bool>())
{
UpdateInputTextFromSelection(SelectedItem);
}
base.OnPropertyChanged(change);
}

Expand All @@ -386,6 +450,11 @@ private void PopupClosed(object? sender, EventArgs e)
{
_subscriptionsOnOpen.Clear();

if(IsEditable && CanFocus(this))
{
Focus();
}

DropDownClosed?.Invoke(this, EventArgs.Empty);
}

Expand Down Expand Up @@ -502,6 +571,14 @@ private void UpdateFlowDirection()
}
}

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())
Expand Down Expand Up @@ -561,5 +638,80 @@ public void Clear()
SelectedItem = null;
SelectedIndex = -1;
}

private void ItemTextBindingChanged(AvaloniaPropertyChangedEventArgs e)
=> HandleTextValueBindingValueChanged(e, null);

private void DisplayMemberBindingChanged(AvaloniaPropertyChangedEventArgs e)
=> HandleTextValueBindingValueChanged(null, e);

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;

_textValueBindingEvaluator = BindingEvaluator<string?>.TryCreate(textValueBinding);

if (IsInitialized)
EnsureTextValueBinderOrThrow();

//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 EnsureTextValueBinderOrThrow()
{
if (IsEditable && _textValueBindingEvaluator == null)
throw new InvalidOperationException($"When {nameof(ComboBox)}.{nameof(IsEditable)} is true you must either set {nameof(ComboBox)}.{nameof(DisplayMemberBinding)} or set the text value binding using attached property {nameof(TextSearch)}.{nameof(TextSearch.TextBindingProperty)}");
}

private bool _skipNextTextChanged = false;
private void TextChanged(AvaloniaPropertyChangedEventArgs e)
{
if (Items == null || !IsEditable || _skipNextTextChanged)
return;

string newVal = e.GetNewValue<string>();
int selectedIdx = -1;
object? selectedItem = null;
int i = -1;
foreach (object? item in Items)
{
i++;
string itemText = GetItemTextValue(item);
if (string.Equals(newVal, itemText, StringComparison.CurrentCultureIgnoreCase))
{
selectedIdx = i;
selectedItem = item;
break;
}
}

_skipNextTextChanged = true;
SelectedIndex = selectedIdx;
SelectedItem = selectedItem;
_skipNextTextChanged = false;
}

private string GetItemTextValue(object? item)
{
if (_textValueBindingEvaluator == null)
return string.Empty;

return TextSearch.GetEffectiveText(item, _textValueBindingEvaluator);
}
}
}
10 changes: 10 additions & 0 deletions src/Avalonia.Controls/Utils/BindingEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,6 +58,7 @@ public void Dispose()
DataContext = null;
}

[return: NotNullIfNotNull(nameof(binding))]
public static BindingEvaluator<T>? TryCreate(IBinding? binding)
{
if (binding is null)
Expand Down
Loading
Loading