Dynamic filter of WPF combobox based on text input

2020-01-28 09:51发布

I cant seem to find a direct method for implementing filtering of text input into a list of items in a WPF combobox.
By setting IsTextSearchEnabled to true, the comboBox dropdown will jump to whatever the first matching item is. What I need is for the list to be filtered to whatever matches the text string (e.g. If I focus on my combobox and type 'abc', I'd like to see all the items in the ItemsSource collection that start with (or contain preferably) 'abc' as the members of the dropdown list).

I doubt that it makes a difference but my display item is templated to a property of a complex type :

<ComboBox x:Name="DiagnosisComboBox" Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="3" 
          ItemsSource="{Binding Path = ApacheDxList,
                                UpdateSourceTrigger=PropertyChanged,
                                Mode=OneWay}"
          IsTextSearchEnabled="True"
          ItemTemplate="{StaticResource DxDescriptionTemplate}" 
          SelectedValue="{Binding Path = SelectedEncounterDetails.Diagnosis,
                                  Mode=TwoWay,
                                  UpdateSourceTrigger=PropertyChanged}"/>

Thanks.

标签: wpf combobox
6条回答
SAY GOODBYE
2楼-- · 2020-01-28 09:54

This is my take on it. A different approach, one that I have made for myself and one that I am using. It works with IsTextSearchEnabled="true". I've just completed it so there could be some bugs.

    public class TextBoxBaseUserChangeTracker
{
    private bool IsTextInput { get; set; }

    public TextBoxBase TextBox { get; set; }
    private List<Key> PressedKeys = new List<Key>();
    public event EventHandler UserTextChanged;
    private string LastText;

    public TextBoxBaseUserChangeTracker(TextBoxBase textBox)
    {
        TextBox = textBox;
        LastText = TextBox.ToString();

        textBox.PreviewTextInput += (s, e) =>
        {
            IsTextInput = true;
        };

        textBox.TextChanged += (s, e) =>
        {
            var isUserChange = PressedKeys.Count > 0 || IsTextInput || LastText == TextBox.ToString();
            IsTextInput = false;
            LastText = TextBox.ToString();
            if (isUserChange)
                UserTextChanged?.Invoke(this, e);
        };

        textBox.PreviewKeyDown += (s, e) =>
        {
            switch (e.Key)
            {
                case Key.Back:
                case Key.Space:
                case Key.Delete:
                    if (!PressedKeys.Contains(e.Key))
                        PressedKeys.Add(e.Key);
                    break;
            }
        };

        textBox.PreviewKeyUp += (s, e) =>
        {
            if (PressedKeys.Contains(e.Key))
                PressedKeys.Remove(e.Key);
        };

        textBox.LostFocus += (s, e) =>
        {
            PressedKeys.Clear();
            IsTextInput = false;
        };
    }
}

    public static class ExtensionMethods
{
    #region DependencyObject
    public static T FindParent<T>(this DependencyObject child) where T : DependencyObject
    {
        //get parent item
        DependencyObject parentObject = VisualTreeHelper.GetParent(child);

        //we've reached the end of the tree
        if (parentObject == null) return null;

        //check if the parent matches the type we're looking for
        T parent = parentObject as T;
        if (parent != null)
            return parent;
        else
            return parentObject.FindParent<T>();
    }
    #endregion

    #region TextBoxBase
    public static TextBoxBaseUserChangeTracker TrackUserChange(this TextBoxBase textBox)
    {
        return new TextBoxBaseUserChangeTracker(textBox);
    }
    #endregion
}

    public class UserChange<T>
{
    private Action<T> action;

    private bool isUserChange = true;
    public bool IsUserChange
    {
        get
        {
            return isUserChange;
        }
    }

    public UserChange(Action<T> action)
    {
        this.action = action;
    }

    public void Set(T val)
    {
        try
        {
            isUserChange = false;
            action(val);
        }
        finally
        {
            isUserChange = true;
        }
    }
}


public class FilteredComboBox : ComboBox
{
    // private string oldFilter = string.Empty;

    private string CurrentFilter = string.Empty;
    private bool TextBoxFreezed;
    protected TextBox EditableTextBox => GetTemplateChild("PART_EditableTextBox") as TextBox;
    private UserChange<bool> IsDropDownOpenUC;

    public FilteredComboBox()
    {
        IsDropDownOpenUC = new UserChange<bool>(v => IsDropDownOpen = v);
        DropDownOpened += FilteredComboBox_DropDownOpened;

        Loaded += (s, e) =>
        {
            if (EditableTextBox != null)
            {
                EditableTextBox.TrackUserChange().UserTextChanged += FilteredComboBox_UserTextChange;
            }
        };
    }

    public void ClearFilter()
    {
        if (string.IsNullOrEmpty(CurrentFilter)) return;
        CurrentFilter = "";
        CollectionViewSource.GetDefaultView(ItemsSource).Refresh();
    }

    private void FilteredComboBox_DropDownOpened(object sender, EventArgs e)
    {
        //if user opens the drop down show all items
        if (IsDropDownOpenUC.IsUserChange)
            ClearFilter();
    }

    private void FilteredComboBox_UserTextChange(object sender, EventArgs e)
    {
        if (TextBoxFreezed) return;
        var tb = EditableTextBox;
        if (tb.SelectionStart + tb.SelectionLength == tb.Text.Length)
            CurrentFilter = tb.Text.Substring(0, tb.SelectionStart).ToLower();
        else
            CurrentFilter = tb.Text.ToLower();
        RefreshFilter();
    }

    protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue)
    {
        if (newValue != null)
        {
            var view = CollectionViewSource.GetDefaultView(newValue);
            view.Filter += FilterItem;
        }

        if (oldValue != null)
        {
            var view = CollectionViewSource.GetDefaultView(oldValue);
            if (view != null) view.Filter -= FilterItem;
        }

        base.OnItemsSourceChanged(oldValue, newValue);
    }

    private void RefreshFilter()
    {
        if (ItemsSource == null) return;

        var view = CollectionViewSource.GetDefaultView(ItemsSource);
        FreezTextBoxState(() =>
        {
            var isDropDownOpen = IsDropDownOpen;
            //always hide because showing it enables the user to pick with up and down keys, otherwise it's not working because of the glitch in view.Refresh()
            IsDropDownOpenUC.Set(false);
            view.Refresh();

            if (!string.IsNullOrEmpty(CurrentFilter) || isDropDownOpen)
                IsDropDownOpenUC.Set(true);

            if (SelectedItem == null)
            {
                foreach (var itm in ItemsSource)
                {
                    if (itm.ToString() == Text)
                    {
                        SelectedItem = itm;
                        break;
                    }
                }
            }
        });
    }

    private void FreezTextBoxState(Action action)
    {
        TextBoxFreezed = true;
        var tb = EditableTextBox;
        var text = Text;
        var selStart = tb.SelectionStart;
        var selLen = tb.SelectionLength;
        action();
        Text = text;
        tb.SelectionStart = selStart;
        tb.SelectionLength = selLen;
        TextBoxFreezed = false;
    }

    private bool FilterItem(object value)
    {
        if (value == null) return false;
        if (CurrentFilter.Length == 0) return true;

        return value.ToString().ToLower().Contains(CurrentFilter);
    }
}

Xaml:

        <local:FilteredComboBox ItemsSource="{Binding List}" IsEditable="True" IsTextSearchEnabled="true" StaysOpenOnEdit="True" x:Name="cmItems" SelectionChanged="CmItems_SelectionChanged">

    </local:FilteredComboBox>
查看更多
趁早两清
3楼-- · 2020-01-28 09:56

You can try https://www.nuget.org/packages/THEFilteredComboBox/ and give feedback. I plan to get as much feedback as possible and create perfect filtered combobox we all miss in WPF.

查看更多
Fickle 薄情
4楼-- · 2020-01-28 10:05

Based on this answer, I added:

  • The ability to limit user input to the values provided in the InputSource using OnlyValuesInList property.
  • Handling Esc key to clear filter
  • Handling Down arrow key to open the ComboBox.
  • Handling Backspace key does not clear selection, only filter text.
  • Hid auxiliar classes and methods
  • Deleted unnecessary methods
  • Added SelectionEffectivelyChanged event that only fires when the user leaves the control or presses Enter, as in the process of filtering the SelectionChanged eventfrom the standard ComboBox fires several times.
  • Added EffectivelySelectedItem property that only changes when the user leaves the control or presses Enter, as in the process of filtering the SelectedItem item from the standard ComboBox changes several times.
public class FilterableComboBox : ComboBox
{
    /// <summary>
    /// If true, on lost focus or enter key pressed, checks the text in the combobox. If the text is not present
    /// in the list, it leaves it blank.
    /// </summary>
    public bool OnlyValuesInList {
        get => (bool)GetValue(OnlyValuesInListProperty);
        set => SetValue(OnlyValuesInListProperty, value);
    }
    public static readonly DependencyProperty OnlyValuesInListProperty =
        DependencyProperty.Register(nameof(OnlyValuesInList), typeof(bool), typeof(FilterableComboBox));

    /// <summary>
    /// Selected item, changes only on lost focus or enter key pressed
    /// </summary>
    public object EffectivelySelectedItem {
        get => (bool)GetValue(EffectivelySelectedItemProperty);
        set => SetValue(EffectivelySelectedItemProperty, value);
    }
    public static readonly DependencyProperty EffectivelySelectedItemProperty =
        DependencyProperty.Register(nameof(EffectivelySelectedItem), typeof(object), typeof(FilterableComboBox));

    private string CurrentFilter = string.Empty;
    private bool TextBoxFreezed;
    protected TextBox EditableTextBox => GetTemplateChild("PART_EditableTextBox") as TextBox;
    private UserChange<bool> IsDropDownOpenUC;

    /// <summary>
    /// Triggers on lost focus or enter key pressed, if the selected item changed since the last time focus was lost or enter was pressed.
    /// </summary>
    public event Action<FilterableComboBox, object> SelectionEffectivelyChanged;

    public FilterableComboBox()
    {
        IsDropDownOpenUC = new UserChange<bool>(v => IsDropDownOpen = v);
        DropDownOpened += FilteredComboBox_DropDownOpened;

        IsEditable = true;
        IsTextSearchEnabled = true;
        StaysOpenOnEdit = true;
        IsReadOnly = false;

        Loaded += (s, e) => {
            if (EditableTextBox != null)
                new TextBoxBaseUserChangeTracker(EditableTextBox).UserTextChanged += FilteredComboBox_UserTextChange;
        };

        SelectionChanged += (_, __) => shouldTriggerSelectedItemChanged = true;

        SelectionEffectivelyChanged += (_, o) => EffectivelySelectedItem = o;
    }

    protected override void OnPreviewKeyDown(KeyEventArgs e)
    {
        base.OnPreviewKeyDown(e);
        if (e.Key == Key.Down && !IsDropDownOpen) {
            IsDropDownOpen = true;
            e.Handled = true;
        }
        else if (e.Key == Key.Escape) {
            ClearFilter();
            Text = "";
            IsDropDownOpen = true;
        }
        else if (e.Key == Key.Enter || e.Key == Key.Tab) {
            CheckSelectedItem();
            TriggerSelectedItemChanged();
        }
    }

    protected override void OnPreviewLostKeyboardFocus(KeyboardFocusChangedEventArgs e)
    {
        base.OnPreviewLostKeyboardFocus(e);
        CheckSelectedItem();
        if ((e.OldFocus == this || e.OldFocus == EditableTextBox) && e.NewFocus != this && e.NewFocus != EditableTextBox)
            TriggerSelectedItemChanged();
    }

    private void CheckSelectedItem()
    {
        if (OnlyValuesInList)
            Text = SelectedItem?.ToString() ?? "";
    }

    private bool shouldTriggerSelectedItemChanged = false;
    private void TriggerSelectedItemChanged()
    {
        if (shouldTriggerSelectedItemChanged) {
            SelectionEffectivelyChanged?.Invoke(this, SelectedItem);
            shouldTriggerSelectedItemChanged = false;
        }
    }

    public void ClearFilter()
    {
        if (string.IsNullOrEmpty(CurrentFilter)) return;
        CurrentFilter = "";
        CollectionViewSource.GetDefaultView(ItemsSource).Refresh();
    }

    private void FilteredComboBox_DropDownOpened(object sender, EventArgs e)
    {
        if (IsDropDownOpenUC.IsUserChange)
            ClearFilter();
    }

    private void FilteredComboBox_UserTextChange(object sender, EventArgs e)
    {
        if (TextBoxFreezed) return;
        var tb = EditableTextBox;
        if (tb.SelectionStart + tb.SelectionLength == tb.Text.Length)
            CurrentFilter = tb.Text.Substring(0, tb.SelectionStart).ToLower();
        else
            CurrentFilter = tb.Text.ToLower();
        RefreshFilter();
    }

    protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue)
    {
        if (newValue != null) {
            var view = CollectionViewSource.GetDefaultView(newValue);
            view.Filter += FilterItem;
        }

        if (oldValue != null) {
            var view = CollectionViewSource.GetDefaultView(oldValue);
            if (view != null) view.Filter -= FilterItem;
        }

        base.OnItemsSourceChanged(oldValue, newValue);
    }

    private void RefreshFilter()
    {
        if (ItemsSource == null) return;

        var view = CollectionViewSource.GetDefaultView(ItemsSource);
        FreezTextBoxState(() => {
            var isDropDownOpen = IsDropDownOpen;
            //always hide because showing it enables the user to pick with up and down keys, otherwise it's not working because of the glitch in view.Refresh()
            IsDropDownOpenUC.Set(false);
            view.Refresh();

            if (!string.IsNullOrEmpty(CurrentFilter) || isDropDownOpen)
                IsDropDownOpenUC.Set(true);

            if (SelectedItem == null) {
                foreach (var itm in ItemsSource)
                    if (itm.ToString() == Text) {
                        SelectedItem = itm;
                        break;
                    }
            }
        });
    }

    private void FreezTextBoxState(Action action)
    {
        TextBoxFreezed = true;
        var tb = EditableTextBox;
        var text = Text;
        var selStart = tb.SelectionStart;
        var selLen = tb.SelectionLength;
        action();
        Text = text;
        tb.SelectionStart = selStart;
        tb.SelectionLength = selLen;
        TextBoxFreezed = false;
    }

    private bool FilterItem(object value)
    {
        if (value == null) return false;
        if (CurrentFilter.Length == 0) return true;

        return value.ToString().ToLower().Contains(CurrentFilter);
    }

    private class TextBoxBaseUserChangeTracker
    {
        private bool IsTextInput { get; set; }

        public TextBoxBase TextBoxBase { get; set; }
        private List<Key> PressedKeys = new List<Key>();
        public event EventHandler UserTextChanged;
        private string LastText;

        public TextBoxBaseUserChangeTracker(TextBoxBase textBoxBase)
        {
            TextBoxBase = textBoxBase;
            LastText = TextBoxBase.ToString();

            textBoxBase.PreviewTextInput += (s, e) => {
                IsTextInput = true;
            };

            textBoxBase.TextChanged += (s, e) => {
                var isUserChange = PressedKeys.Count > 0 || IsTextInput || LastText == TextBoxBase.ToString();
                IsTextInput = false;
                LastText = TextBoxBase.ToString();
                if (isUserChange)
                    UserTextChanged?.Invoke(this, e);
            };

            textBoxBase.PreviewKeyDown += (s, e) => {
                switch (e.Key) {
                    case Key.Back:
                    case Key.Space:
                        if (!PressedKeys.Contains(e.Key))
                            PressedKeys.Add(e.Key);
                        break;
                }
                if (e.Key == Key.Back) {
                    var textBox = textBoxBase as TextBox;
                    if (textBox.SelectionStart > 0 && textBox.SelectionLength > 0 && (textBox.SelectionStart + textBox.SelectionLength) == textBox.Text.Length) {
                        textBox.SelectionStart--;
                        textBox.SelectionLength++;
                        e.Handled = true;
                        UserTextChanged?.Invoke(this, e);
                    }
                }
            };

            textBoxBase.PreviewKeyUp += (s, e) => {
                if (PressedKeys.Contains(e.Key))
                    PressedKeys.Remove(e.Key);
            };

            textBoxBase.LostFocus += (s, e) => {
                PressedKeys.Clear();
                IsTextInput = false;
            };
        }
    }

    private class UserChange<T>
    {
        private Action<T> action;

        public bool IsUserChange { get; private set; } = true;

        public UserChange(Action<T> action)
        {
            this.action = action;
        }

        public void Set(T val)
        {
            try {
                IsUserChange = false;
                action(val);
            }
            finally {
                IsUserChange = true;
            }
        }
    }
}
查看更多
够拽才男人
5楼-- · 2020-01-28 10:06

Kelly's answer is great. However, there is a small bug that if you select an item in the list (highlighting the input text) then press BackSpace, the input text will revert to the selected item and the SelectedItem property of the ComboBox is still the item you selected previously.

Below is the code to fix the bug and add the ability to automatically select the item when the input text matches it.

using System.Collections;
using System.Diagnostics;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;

namespace MyControls
{
    public class FilteredComboBox : ComboBox
    {
        private string oldFilter = string.Empty;

        private string currentFilter = string.Empty;

        protected TextBox EditableTextBox => GetTemplateChild("PART_EditableTextBox") as TextBox;


        protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue)
        {
            if (newValue != null)
            {
                var view = CollectionViewSource.GetDefaultView(newValue);
                view.Filter += FilterItem;
            }

            if (oldValue != null)
            {
                var view = CollectionViewSource.GetDefaultView(oldValue);
                if (view != null) view.Filter -= FilterItem;
            }

            base.OnItemsSourceChanged(oldValue, newValue);
        }

        protected override void OnPreviewKeyDown(KeyEventArgs e)
        {
            switch (e.Key)
            {
                case Key.Tab:
                case Key.Enter:
                    IsDropDownOpen = false;
                    break;
                case Key.Escape:
                    IsDropDownOpen = false;
                    SelectedIndex = -1;
                    Text = currentFilter;
                    break;
                default:
                    if (e.Key == Key.Down) IsDropDownOpen = true;

                    base.OnPreviewKeyDown(e);
                    break;
            }

            // Cache text
            oldFilter = Text;
        }

        protected override void OnKeyUp(KeyEventArgs e)
        {
            switch (e.Key)
            {
                case Key.Up:
                case Key.Down:
                    break;
                case Key.Tab:
                case Key.Enter:

                    ClearFilter();
                    break;
                default:                                        
                    if (Text != oldFilter)
                    {
                        var temp = Text;
                        RefreshFilter(); //RefreshFilter will change Text property
                        Text = temp;

                        if (SelectedIndex != -1 && Text != Items[SelectedIndex].ToString())
                        {
                            SelectedIndex = -1; //Clear selection. This line will also clear Text property
                            Text = temp;
                        }


                        IsDropDownOpen = true;

                        EditableTextBox.SelectionStart = int.MaxValue;
                    }

                    //automatically select the item when the input text matches it
                    for (int i = 0; i < Items.Count; i++)
                    {
                        if (Text == Items[i].ToString())
                            SelectedIndex = i;
                    }

                    base.OnKeyUp(e);                    
                    currentFilter = Text;                    
                    break;
            }
        }

        protected override void OnPreviewLostKeyboardFocus(KeyboardFocusChangedEventArgs e)
        {
            ClearFilter();
            var temp = SelectedIndex;
            SelectedIndex = -1;
            Text = string.Empty;
            SelectedIndex = temp;
            base.OnPreviewLostKeyboardFocus(e);
        }

        private void RefreshFilter()
        {
            if (ItemsSource == null) return;

            var view = CollectionViewSource.GetDefaultView(ItemsSource);
            view.Refresh();
        }

        private void ClearFilter()
        {
            currentFilter = string.Empty;
            RefreshFilter();
        }

        private bool FilterItem(object value)
        {
            if (value == null) return false;
            if (Text.Length == 0) return true;

            return value.ToString().ToLower().Contains(Text.ToLower());
        }
    }
}
查看更多
家丑人穷心不美
6楼-- · 2020-01-28 10:07

I just did this a few days ago using a modified version of the code from this site: Credit where credit is due

My full code listed below:

using System.Collections;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;

    namespace MyControls
    {
        public class FilteredComboBox : ComboBox
        {
            private string oldFilter = string.Empty;

            private string currentFilter = string.Empty;

            protected TextBox EditableTextBox => GetTemplateChild("PART_EditableTextBox") as TextBox;


            protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue)
            {
                if (newValue != null)
                {
                    var view = CollectionViewSource.GetDefaultView(newValue);
                    view.Filter += FilterItem;
                }

                if (oldValue != null)
                {
                    var view = CollectionViewSource.GetDefaultView(oldValue);
                    if (view != null) view.Filter -= FilterItem;
                }

                base.OnItemsSourceChanged(oldValue, newValue);
            }

            protected override void OnPreviewKeyDown(KeyEventArgs e)
            {
                switch (e.Key)
                {
                    case Key.Tab:
                    case Key.Enter:
                        IsDropDownOpen = false;
                        break;
                    case Key.Escape:
                        IsDropDownOpen = false;
                        SelectedIndex = -1;
                        Text = currentFilter;
                        break;
                    default:
                        if (e.Key == Key.Down) IsDropDownOpen = true;

                        base.OnPreviewKeyDown(e);
                        break;
                }

                // Cache text
                oldFilter = Text;
            }

            protected override void OnKeyUp(KeyEventArgs e)
            {
                switch (e.Key)
                {
                    case Key.Up:
                    case Key.Down:
                        break;
                    case Key.Tab:
                    case Key.Enter:

                        ClearFilter();
                        break;
                    default:
                        if (Text != oldFilter)
                        {
                            RefreshFilter();
                            IsDropDownOpen = true;

                            EditableTextBox.SelectionStart = int.MaxValue;
                        }

                        base.OnKeyUp(e);
                        currentFilter = Text;
                        break;
                }
            }

            protected override void OnPreviewLostKeyboardFocus(KeyboardFocusChangedEventArgs e)
            {
                ClearFilter();
                var temp = SelectedIndex;
                SelectedIndex = -1;
                Text = string.Empty;
                SelectedIndex = temp;
                base.OnPreviewLostKeyboardFocus(e);
            }

            private void RefreshFilter()
            {
                if (ItemsSource == null) return;

                var view = CollectionViewSource.GetDefaultView(ItemsSource);
                view.Refresh();
            }

            private void ClearFilter()
            {
                currentFilter = string.Empty;
                RefreshFilter();
            }

            private bool FilterItem(object value)
            {
                if (value == null) return false;
                if (Text.Length == 0) return true;

                return value.ToString().ToLower().Contains(Text.ToLower());
            }
        }
    }

And the WPF should be something like so:

<MyControls:FilteredComboBox ItemsSource="{Binding MyItemsSource}"
    SelectedItem="{Binding MySelectedItem}"
    DisplayMemberPath="Name" 
    IsEditable="True" 
    IsTextSearchEnabled="False" 
    StaysOpenOnEdit="True">

    <MyControls:FilteredComboBox.ItemsPanel>
        <ItemsPanelTemplate>
            <VirtualizingStackPanel VirtualizationMode="Recycling" />
        </ItemsPanelTemplate>
    </MyControls:FilteredComboBox.ItemsPanel>
</MyControls:FilteredComboBox>

A few things to note here. You will notice the FilterItem implementation does a ToString() on the object. This means the property of your object you want to display should be returned in your object.ToString() implementation. (or be a string already) In other words something like so:

public class Customer
{
    public string Name { get; set; }
    public string Address { get; set; }
    public string PhoneNumber { get; set; }

    public override string ToString()
    {
        return Name;
    }
}

If this does not work for your needs I suppose you could get the value of DisplayMemberPath and use reflection to get the property to use it, but that would be slower so I wouldn't recommend doing that unless necessary.

Also this implementation does NOT stop the user from typing whatever they like in the TextBox portion of the ComboBox. If they type something stupid there the SelectedItem will revert to NULL, so be prepared to handle that in your code.

Also if you have many items I would highly recommend using the VirtualizingStackPanel like my example above as it makes quite a difference in loading time

查看更多
家丑人穷心不美
7楼-- · 2020-01-28 10:20

It sounds like what you are really looking for is something similar to an auto-complete textbox, which provides completion suggestions in a popup similar to a combobox popup.

You might find this CodeProject article useful:

A Reusable WPF Autocomplete TextBox

查看更多
登录 后发表回答