Reputation: 35400
Has anyone succeeded with WPF's ComboBox auto-complete and filter functionalities? I have spent several hours now and haven't been able to nailed it. This is WPF + MVVM Light. Here is my setup.
VM layer
A ViewModel that provides the following properties:
FilterText
(string
): Text that user has typed in TextBox area for filtering. Fires change notification on FilteredItems
.Items
(List<string>
): This is the main data source containing all options.FilteredItems
: Filtered list of Items
using FilterText
.SelectedOption
(string
): Currently selected option.View layer
A ComboBox where user can choose from the drop-down options only. However, user should be allowed to type text in the textbox area and the dropdown should filter out items that do not begin with typed text. First matching item should automatically be appended to the textbox (auto-complete that is). Here are my bindings:
ItemsSource
: binds to FilteredItems
, One-wayText
binds to FilterText
, Two-waySelectedItem
binds to SelectedOption
, Two-wayIsTextSearchEnabled
is set to true for enabling auto-complete.
Problem with this setup is that as soon as user types the first letter, auto-complete is triggered and tries to locate the first matching entry and if found, sets SelectedItem
to that entry, which in set the Text
property of the ComboBox
to that item, which in turn triggers filter operation and the dropdown is left with only one entry that fully matches Text
, which is not what it should be like.
For example, if user types "C", auto-complete will try to locate first entry starting with "C". Let's say that the first matching entry is "Customer". Auto-complete will select that entry, which will set SelectedItem
to "Customer" and therefore Text
will also become "Customer. This will invoke FilterText
because of binding, which will update FilteredItems
, which will now return only one entry, instead of returning all entries starting with "C".
What am I missing here?
Upvotes: 0
Views: 2439
Reputation: 28968
I think your approach is too complicated.
You can implement a simple Attached Behavior to achieve a filtered suggestion list while autocomplete is enabled.
This example doesn't require any additional properties except the common source collection for the ComboBox.ItemsSource
. The filtering is done by using the ICollectionView.Filter
property. This will modify only the view of the internal source collection of the ItemsControl
, but not the underlying binding source collection itself. Setting IsTextSearchEnabled
to True
is not required to enable autocomplete.
The basic idea is to trigger the filtering rather on TextBox.TextChanged
than on ComboBox.SelectedItemChanged
(or ComboBox.SelectedItem
in general).
ComboBox.cs
class ComboBox : DependencyObject
{
#region IsFilterOnAutoCompleteEnabled attached property
public static readonly DependencyProperty IsFilterOnAutocompleteEnabledProperty =
DependencyProperty.RegisterAttached(
"IsFilterOnAutocompleteEnabled",
typeof(bool),
typeof(ComboBox),
new PropertyMetadata(default(bool), ComboBox.OnIsFilterOnAutocompleteEnabledChanged));
public static void SetIsFilterOnAutocompleteEnabled(DependencyObject attachingElement, bool value) =>
attachingElement.SetValue(ComboBox.IsFilterOnAutocompleteEnabledProperty, value);
public static bool GetIsFilterOnAutocompleteEnabled(DependencyObject attachingElement) =>
(bool)attachingElement.GetValue(ComboBox.IsFilterOnAutocompleteEnabledProperty);
#endregion
// Use hash tables for faster lookup
private static Dictionary<TextBox, System.Windows.Controls.ComboBox> TextBoxComboBoxMap { get; }
private static Dictionary<TextBox, int> TextBoxSelectionStartMap { get; }
private static Dictionary<System.Windows.Controls.ComboBox, TextBox> ComboBoxTextBoxMap { get; }
private static bool IsNavigationKeyPressed { get; set; }
static ComboBox()
{
ComboBox.TextBoxComboBoxMap = new Dictionary<TextBox, System.Windows.Controls.ComboBox>();
ComboBox.TextBoxSelectionStartMap = new Dictionary<TextBox, int>();
ComboBox.ComboBoxTextBoxMap = new Dictionary<System.Windows.Controls.ComboBox, TextBox>();
}
private static void OnIsFilterOnAutocompleteEnabledChanged(
DependencyObject attachingElement,
DependencyPropertyChangedEventArgs e)
{
if (!(attachingElement is System.Windows.Controls.ComboBox comboBox
&& comboBox.IsEditable))
{
return;
}
if (!(bool)e.NewValue)
{
ComboBox.DisableAutocompleteFilter(comboBox);
return;
}
if (!comboBox.IsLoaded)
{
comboBox.Loaded += ComboBox.EnableAutocompleteFilterOnComboBoxLoaded;
return;
}
ComboBox.EnableAutocompleteFilter(comboBox);
}
private static async void FilterOnTextInput(object sender, TextChangedEventArgs e)
{
await Application.Current.Dispatcher.InvokeAsync(
() =>
{
if (ComboBox.IsNavigationKeyPressed)
{
return;
}
var textBox = sender as TextBox;
int textBoxSelectionStart = textBox.SelectionStart;
ComboBox.TextBoxSelectionStartMap[textBox] = textBoxSelectionStart;
string changedTextOnAutocomplete = textBox.Text.Substring(0, textBoxSelectionStart);
if (ComboBox.TextBoxComboBoxMap.TryGetValue(
textBox,
out System.Windows.Controls.ComboBox comboBox))
{
comboBox.Items.Filter = item => item.ToString().StartsWith(
changedTextOnAutocomplete,
StringComparison.OrdinalIgnoreCase);
}
},
DispatcherPriority.Background);
}
private static async void HandleKeyDownWhileFiltering(object sender, KeyEventArgs e)
{
var comboBox = sender as System.Windows.Controls.ComboBox;
if (!ComboBox.ComboBoxTextBoxMap.TryGetValue(comboBox, out TextBox textBox))
{
return;
}
switch (e.Key)
{
case Key.Down
when comboBox.Items.CurrentPosition < comboBox.Items.Count - 1
&& comboBox.Items.MoveCurrentToNext():
case Key.Up
when comboBox.Items.CurrentPosition > 0
&& comboBox.Items.MoveCurrentToPrevious():
{
// Prevent the filter from re-apply as this would override the
// current selection start index
ComboBox.IsNavigationKeyPressed = true;
// Ensure the Dispatcher en-queued delegate
// (and the invocation of the SelectCurrentItem() method)
// executes AFTER the FilterOnTextInput() event handler.
// This is because key input events have a higher priority
// than text change events by default. The goal is to make the filtering
// triggered by the TextBox.TextChanged event ignore the changes
// introduced by this KeyDown event.
// DispatcherPriority.ContextIdle will force to "override" this behavior.
await Application.Current.Dispatcher.InvokeAsync(
() =>
{
ComboBox.SelectCurrentItem(textBox, comboBox);
ComboBox.IsNavigationKeyPressed = false;
},
DispatcherPriority.ContextIdle);
break;
}
}
}
private static void SelectCurrentItem(TextBox textBox, System.Windows.Controls.ComboBox comboBox)
{
comboBox.SelectedItem = comboBox.Items.CurrentItem;
if (ComboBox.TextBoxSelectionStartMap.TryGetValue(textBox, out int selectionStart))
{
textBox.SelectionStart = selectionStart;
}
}
private static void EnableAutocompleteFilterOnComboBoxLoaded(object sender, RoutedEventArgs e)
{
var comboBox = sender as System.Windows.Controls.ComboBox;
ComboBox.EnableAutocompleteFilter(comboBox);
}
private static void EnableAutocompleteFilter(System.Windows.Controls.ComboBox comboBox)
{
if (comboBox.TryFindVisualChildElement(out TextBox editTextBox))
{
ComboBox.TextBoxComboBoxMap.Add(editTextBox, comboBox);
ComboBox.ComboBoxTextBoxMap.Add(comboBox, editTextBox);
editTextBox.TextChanged += ComboBox.FilterOnTextInput;
// Need to receive handled KeyDown event
comboBox.AddHandler(UIElement.PreviewKeyDownEvent, new KeyEventHandler(HandleKeyDownWhileFiltering), true);
}
}
private static void DisableAutocompleteFilter(System.Windows.Controls.ComboBox comboBox)
{
if (comboBox.TryFindVisualChildElement(out TextBox editTextBox))
{
ComboBox.TextBoxComboBoxMap.Remove(editTextBox);
editTextBox.TextChanged -= ComboBox.FilterOnTextInput;
}
}
}
Extensions.cs
public static class Extensions
{
/// <summary>
/// Traverses the visual tree towards the leafs until an element with a matching element type is found.
/// </summary>
/// <typeparam name="TChild">The type the visual child must match.</typeparam>
/// <param name="parent"></param>
/// <param name="resultElement"></param>
/// <returns></returns>
public static bool TryFindVisualChildElement<TChild>(this DependencyObject parent, out TChild resultElement)
where TChild : DependencyObject
{
resultElement = null;
if (parent is Popup popup)
{
parent = popup.Child;
if (parent == null)
{
return false;
}
}
for (var childIndex = 0; childIndex < VisualTreeHelper.GetChildrenCount(parent); childIndex++)
{
DependencyObject childElement = VisualTreeHelper.GetChild(parent, childIndex);
if (childElement is TChild child)
{
resultElement = child;
return true;
}
if (childElement.TryFindVisualChildElement(out resultElement))
{
return true;
}
}
return false;
}
}
Usage Example
<ComboBox ItemsSource="{Binding Items}"
IsEditable="True"
ComboBox.IsFilterOnAutocompleteEnabled="True" />
Upvotes: 4