Reputation: 1616
I'm implementing a filterable Combobox
but have been running into trouble. My implementation below works initially for typing to filter results but once you select an item from the dropdown it becomes difficult to backspace characters or delete all for the Textbox
filter. It appears like the Combobox
auto-selects the originally selected value and resets to that on each button press. Below is the minimum code to reproduce the issue which will demonstrate the issue clearer. Simply select a value from the dropdown and then backspace or delete text to try to select a new value.
With the following test data;
public IEnumerable<Fruit> Fruits { get; set; }
public MainWindow()
{
List<Fruit> temp = new List<Fruit>
{
new Fruit(Name = "apple"),
new Fruit(Name = "banana"),
new Fruit(Name = "grape"),
new Fruit(Name = "lemon"),
new Fruit(Name = "strawberry")
};
Fruits = temp;
DataContext = this;
InitializeComponent();
}
}
public class Fruit
{
public string Name { get; set; }
public Fruit(string name)
{
Name = name;
}
}
Updated FilteredComboBox code;
public class FilteredComboBox : ComboBox
{
public static readonly DependencyProperty MinimumSearchLengthProperty = DependencyProperty.Register(
"MinimumSearchLength",
typeof(int),
typeof(FilteredComboBox),
new UIPropertyMetadata(3));
public int MinimumSearchLength
{
get => (int)this.GetValue(FilteredComboBox.MinimumSearchLengthProperty);
set => SetValue(FilteredComboBox.MinimumSearchLengthProperty, value);
}
public TextBox PART_EditableTextBox { get; set; }
static FilteredComboBox()
{
// Register a delegate to handle property changes of the Text dependency property
ComboBox.TextProperty.OverrideMetadata(
typeof(FilteredComboBox),
new FrameworkPropertyMetadata(default, FilteredComboBox.FilterItemsOnTextChanged));
}
public FilteredComboBox()
{
this.IsTextSearchEnabled = false;
}
private static void FilterItemsOnTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var this_ = d as FilteredComboBox;
this_.IsDropDownOpen = true;
this_.Items.Filter =
item => e.NewValue.ToString().Length < this_.MinimumSearchLength
|| ((Fruit)item).Name.Contains(e.NewValue.ToString());
}
/// <inheritdoc />
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
this.PART_EditableTextBox = GetTemplateChild("PART_EditableTextBox") as TextBox;
this.PART_EditableTextBox.TextChanged += RemoveTextSelectionOnTextBoxTextChanged;
}
/// <inheritdoc />
protected override void OnKeyUp(KeyEventArgs e)
{
base.OnKeyUp(e);
switch (e.Key)
{
// Clear the filter and reset the text field
case Key.Tab:
case Key.Enter:
this.Items.Filter = null;
this.PART_EditableTextBox.Text = string.Empty;
break;
case Key.Down:
this.IsDropDownOpen = true;
break;
}
}
private void RemoveTextSelectionOnTextBoxTextChanged(object sender, TextChangedEventArgs e)
{
if (this.PART_EditableTextBox.SelectionLength > 0)
{
this.PART_EditableTextBox.CaretIndex = e.Changes.FirstOrDefault()?.Offset + 1
?? this.PART_EditableTextBox.Text.Length;
}
}
}
And am implementing it in a test solution like so;
<local:FilteredComboBox
MinimumSearchLength="2"
IsEditable="True"
ItemsSource="{Binding Fruits}"
DisplayMemberPath = "Name">
</local:FilteredComboBox>
Upvotes: 2
Views: 724
Reputation: 29028
You can't edit the text of the edit TextBox
as long there is a SelectedItem
value assigned. The ComboBox
internally tracks the changes of the edit text box to synchronize it with a matching item, when the control ComboBox.IsEditable
is set to true
. Changes that occurred during the ComboBox.Text
change process are reverted/aborted. Deleting the current SelectedItem
reference may help, but in most cases this isn't desirable.
It seems reasonable to execute the filtering before or after the internal lookup process. For this purpose I recommend to register a PropertyChangedCallback
with the ComboBox.TextProperty
. The edit text box is synchronized with the ComboBox.Text
property, where the ComboBox.Text
changes first to trigger the internal ComboBox
selected item matching process.
I think there is some optimization potential:
FrameworkElement.GetTemplateChild
in the get method of the EditableTextBox
property (calculated property). This results in a template lookup for every property read access, although the returned element will never change. You should call FrameworkElement.GetTemplateChild
once in the FrameworkElement.OnApplyTemplate
override and store the instance in a private property.ItemsControl.ItemsSource
, but on the ItemsControl.Items
property instead. ItemsControl.Items
returns a ItemCollection
, which already implements a ICollectionView
. Use the ItemsControl.Items
property to sort, group or filter the items (without affecting the original source collection).The fixed and refactored FilteredComboBox
control could look as followed:
public class FilteredComboBox : ComboBox
{
#region Dependency properties
public static readonly DependencyProperty MinimumSearchLengthProperty =
DependencyProperty.Register(
"MinimumSearchLength",
typeof(int),
typeof(FilteredComboBox),
new UIPropertyMetadata(3));
[Description("Length of the search string that triggers filtering.")]
[Category("Filtered ComboBox")]
[DefaultValue(1)]
public int MinimumSearchLength
{
[DebuggerStepThrough]
get => (int)GetValue(FilteredComboBox.MinimumSearchLengthProperty);
[DebuggerStepThrough]
set => SetValue(FilteredComboBox.MinimumSearchLengthProperty, value);
}
private static object CoerceValue(DependencyObject d, object baseValue)
{
var this_ = d as FilteredComboBox;
this_.CaretIndexBeforeDropDownOpen = this_.PART_EditableTextBox.CaretIndex;
return baseValue;
}
#endregion
static FilteredComboBox()
{
ComboBox.TextProperty.OverrideMetadata(
typeof(FilteredComboBox),
new FrameworkPropertyMetadata(default, FilteredComboBox.OnTextChanged));
ComboBox.IsDropDownOpenProperty.OverrideMetadata(
typeof(FilteredComboBox),
new FrameworkPropertyMetadata(false, null, FilteredComboBox.CoerceValue));
}
public FilteredComboBox()
{
this.IsTextSearchEnabled = false;
this.DropDownOpened += RemoveTextSelectionOnDropDownOpened;
}
private static void OnTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var this_ = d as FilteredComboBox;
this_.IsDropDownOpen = true;
if (FilteredComboBox.IsNavigating)
{
FilteredComboBox.IsNavigating = false;
return;
}
this_.Items.Filter = item => this_.PART_EditableTextBox.Text.Length < this_.MinimumSearchLength
|| ((Fruit) item).Name.Contains(this_.PART_EditableTextBox.Text);
if (string.IsNullOrEmpty(this_.PART_EditableTextBox.Text))
{
this_.ForceTextSiteSync();
}
}
private void ForceTextSiteSync()
{
Application.Current.Dispatcher.InvokeAsync(
() =>
{
object currentItem = this.SelectedItem;
this.SelectedItem = null;
this.SelectedItem = currentItem;
this.Items.Filter = null;
},
DispatcherPriority.ContextIdle);
}
private void RemoveTextSelectionOnDropDownOpened(object sender, EventArgs e)
{
if (this.IsSelectionBoxHighlighted)
{
this.PART_EditableTextBox.CaretIndex = this.CaretIndexBeforeDropDownOpen;
}
}
/// <inheritdoc />
protected override void OnSelectionChanged(SelectionChangedEventArgs e)
{
base.OnSelectionChanged(e);
FilteredComboBox.IsNavigating = true;
}
/// <inheritdoc />
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
this.PART_EditableTextBox = GetTemplateChild("PART_EditableTextBox") as TextBox;
}
/// <inheritdoc />
protected override void OnPreviewKeyUp(KeyEventArgs e)
{
base.OnPreviewKeyUp(e);
this.IsDropDownOpen = true;
switch (e.Key)
{
case Key.Back when string.IsNullOrEmpty(this.PART_EditableTextBox.Text):
case Key.Delete when string.IsNullOrEmpty(this.PART_EditableTextBox.Text):
ForceTextSiteSync();
break;
case Key.Tab:
case Key.Enter:
this.Items.Filter = null;
ForceTextSiteSync();
break;
}
}
private TextBox PART_EditableTextBox { get; set; }
private int CaretIndexBeforeDropDownOpen { get; set; }
private static bool IsNavigating { get; set; }
}
Upvotes: 1