Building a Filtered ComboBox for WPF

Here's my implementation of a WPF ComboBox that takes the traditional Text Search on prefix (via the IsTextSearchEnabled property) to the next level. We don't check only the beginning of the text, but the whole content.

Let's say we want to populate a ComboBox with some titles. At design time, the source code looks like this:

    List<String> names = new List<string>();
    names.Add("WPF rocks");
    names.Add("WCF rocks");
    names.Add("XAML is fun");
    names.Add("WPF rules");
    names.Add("WCF rules");
    names.Add("WinForms not");
 
    FilteredComboBox1.IsEditable = true;
    FilteredComboBox1.IsTextSearchEnabled = false;
    FilteredComboBox1.ItemsSource = names;


At run time, the editable combo box will apply filtering if the text in the editable textbox passes a treshold (e.g. 3 characters):



The FilteredComboBox class looks like this, largely decorated with comments:

//-----------------------------------------------------------------------
// <copyright file="FilteredComboBox.cs" company="DockOfTheBay">
//     http://www.dotbay.be
// </copyright>
// <summary>Defines the FilteredComboBox class.</summary>
//-----------------------------------------------------------------------
 
namespace DockOfTheBay
{
    using System.Collections;
    using System.ComponentModel;
    using System.Windows;
    using System.Windows.Controls;
    using System.Windows.Data;
    using System.Windows.Input;
 
    /// <summary>
    /// Editable combo box which uses the text in its editable textbox to perform a lookup
    /// in its data source.
    /// </summary>
    public class FilteredComboBox : ComboBox
    {
        ////
        // Public Fields
        ////
 
        /// <summary>
        /// The search string treshold length.
        /// </summary>
        /// <remarks>
        /// It's implemented as a Dependency Property, so you can set it in a XAML template 
        /// </remarks>
        public static readonly DependencyProperty MinimumSearchLengthProperty =
            DependencyProperty.Register(
                "MinimumSearchLength",
                typeof(int),
                typeof(FilteredComboBox),
                new UIPropertyMetadata(3));
 
        ////
        // Private Fields
        //// 
 
        /// <summary>
        /// Caches the previous value of the filter.
        /// </summary>
        private string oldFilter = string.Empty;
 
        /// <summary>
        /// Holds the current value of the filter.
        /// </summary>
        private string currentFilter = string.Empty;
 
        ////
        // Constructors
        //// 
 
        /// <summary>
        /// Initializes a new instance of the FilteredComboBox class.
        /// </summary>
        /// <remarks>
        /// You could set 'IsTextSearchEnabled' to 'false' here,
        /// to avoid non-intuitive behavior of the control
        /// </remarks>
        public FilteredComboBox()
        {
        }
 
        ////
        // Properties
        //// 
 
        /// <summary>
        /// Gets or sets the search string treshold length.
        /// </summary>
        /// <value>The minimum length of the search string that triggers filtering.</value>
        [Description("Length of the search string that triggers filtering.")]
        [Category("Filtered ComboBox")]
        [DefaultValue(3)]
        public int MinimumSearchLength
        {
            [System.Diagnostics.DebuggerStepThrough]
            get
            {
                return (int)this.GetValue(MinimumSearchLengthProperty);
            }
 
            [System.Diagnostics.DebuggerStepThrough]
            set
            {
                this.SetValue(MinimumSearchLengthProperty, value);
            }
        }
 
        /// <summary>
        /// Gets a reference to the internal editable textbox.
        /// </summary>
        /// <value>A reference to the internal editable textbox.</value>
        /// <remarks>
        /// We need this to get access to the Selection.
        /// </remarks>
        protected TextBox EditableTextBox
        {
            get
            {
                return this.GetTemplateChild("PART_EditableTextBox") as TextBox;
            }
        }
 
        ////
        // Event Raiser Overrides
        //// 
 
        /// <summary>
        /// Keep the filter if the ItemsSource is explicitly changed.
        /// </summary>
        /// <param name="oldValue">The previous value of the filter.</param>
        /// <param name="newValue">The current value of the filter.</param>
        protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue)
        {
            if (newValue != null)
            {
                ICollectionView view = CollectionViewSource.GetDefaultView(newValue);
                view.Filter += this.FilterPredicate;
            }
 
            if (oldValue != null)
            {
                ICollectionView view = CollectionViewSource.GetDefaultView(oldValue);
                view.Filter -= this.FilterPredicate;
            }
 
            base.OnItemsSourceChanged(oldValue, newValue);
        }
 
        /// <summary>
        /// Confirm or cancel the selection when Tab, Enter, or Escape are hit. 
        /// Open the DropDown when the Down Arrow is hit.
        /// </summary>
        /// <param name="e">Key Event Args.</param>
        /// <remarks>
        /// The 'KeyDown' event is not raised for Arrows, Tab and Enter keys.
        /// It is swallowed by the DropDown if it's open.
        /// So use the Preview instead.
        /// </remarks>
        protected override void OnPreviewKeyDown(KeyEventArgs e)
        {
            if (e.Key == Key.Tab || e.Key == Key.Enter)
            {
                // Explicit Selection -> Close ItemsPanel
                this.IsDropDownOpen = false;
            }
            else if (e.Key == Key.Escape)
            {
                // Escape -> Close DropDown and redisplay Filter
                this.IsDropDownOpen = false;
                this.SelectedIndex = -1;
                this.Text = this.currentFilter;
            }
            else
            {
                if (e.Key == Key.Down)
                {
                    // Arrow Down -> Open DropDown
                    this.IsDropDownOpen = true;
                }
 
                base.OnPreviewKeyDown(e);
            }
 
            // Cache text
            this.oldFilter = this.Text;
        }
 
        /// <summary>
        /// Modify and apply the filter.
        /// </summary>
        /// <param name="e">Key Event Args.</param>
        /// <remarks>
        /// Alternatively, you could react on 'OnTextChanged', but navigating through 
        /// the DropDown will also change the text.
        /// </remarks>
        protected override void OnKeyUp(KeyEventArgs e)
        {
            if (e.Key == Key.Up || e.Key == Key.Down)
            {
                // Navigation keys are ignored
            }
            else if (e.Key == Key.Tab || e.Key == Key.Enter)
            {
                // Explicit Select -> Clear Filter
                this.ClearFilter();
            }
            else
            {
                // The text was changed
                if (this.Text != this.oldFilter)
                {
                    // Clear the filter if the text is empty,
                    // apply the filter if the text is long enough
                    if (this.Text.Length == 0 || this.Text.Length >= this.MinimumSearchLength)
                    {
                        this.RefreshFilter();
                        this.IsDropDownOpen = true;
 
                        // Unselect
                        this.EditableTextBox.SelectionStart = int.MaxValue;
                    }
                }
 
                base.OnKeyUp(e);
 
                // Update Filter Value
                this.currentFilter = this.Text;
            }
        }
 
        /// <summary>
        /// Make sure the text corresponds to the selection when leaving the control.
        /// </summary>
        /// <param name="e">A KeyBoardFocusChangedEventArgs.</param>
        protected override void OnPreviewLostKeyboardFocus(KeyboardFocusChangedEventArgs e)
        {
            this.ClearFilter();
            int temp = this.SelectedIndex;
            this.SelectedIndex = -1;
            this.Text = string.Empty;
            this.SelectedIndex = temp;
            base.OnPreviewLostKeyboardFocus(e);
        }
 
        ////
        // Helpers
        ////
 
        /// <summary>
        /// Re-apply the Filter.
        /// </summary>
        private void RefreshFilter()
        {
            if (this.ItemsSource != null)
            {
                ICollectionView view = CollectionViewSource.GetDefaultView(this.ItemsSource);
                view.Refresh();
            }
        }
 
        /// <summary>
        /// Clear the Filter.
        /// </summary>
        private void ClearFilter()
        {
            this.currentFilter = string.Empty;
            this.RefreshFilter();
        }
 
        /// <summary>
        /// The Filter predicate that will be applied to each row in the ItemsSource.
        /// </summary>
        /// <param name="value">A row in the ItemsSource.</param>
        /// <returns>Whether or not the item will appear in the DropDown.</returns>
        private bool FilterPredicate(object value)
        {
            // No filter, no text
            if (value == null)
            {
                return false;
            }
 
            // No text, no filter
            if (this.Text.Length == 0)
            {
                return true;
            }
 
            // Case insensitive search
            return value.ToString().ToLower().Contains(this.Text.ToLower());
        }
    }
}


As far as I know, the control is not allergic to templating nor databinding, even with large (2000+ items) datasources. For large datasources it makes sense to display the DropDown via a virtualization panel, like in the following XAML:

<Window x:Class="FilteredComboBoxSample.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:DockOfTheBay"
    Title="Filtered ComboBox" Height="120" Width="300">
    <Window.Resources>
        <!-- For large content, better go for a Virtualizing StackPanel -->
        <ItemsPanelTemplate x:Key="ItemsTemplate">
            <VirtualizingStackPanel/>
        </ItemsPanelTemplate>
    </Window.Resources>
    <StackPanel Orientation="Horizontal" 
                VerticalAlignment="Top"
                Margin="10 10">
        <TextBlock Text="Select: " 
                   Padding="4 3"/>
        <local:FilteredComboBox 
            x:Name="FilteredComboBox1" 
            ItemsPanel="{DynamicResource ItemsTemplate}" 
            Padding="4 3" 
            MinWidth="200"/>
    </StackPanel>
</Window>


Here's how the control looks like in a real life application, filtering a list of over 2000 possible tumor locations on a human body:

11 comments:

  1. Hi there.

    Gr8 post. One problem I am having is binding with a data source. Will that work? I get this error

    "Specified method is not supported"

    here OnItemsSourceChanged

    Kind regards
    Christo

    ReplyDelete
    Replies
    1. did you get any help on itemsource binding?

      Delete
  2. This is exactly what I've been looking for. Thanks a lot Diederik! //Stefan

    ReplyDelete
  3. The search & filtering fails when the DisplayMemberPath & SelectedValuePath us being set dynamically.

    Any workaround for this?

    ReplyDelete
  4. I set those properties dynamically and the control works for me.

    However, I do have an issue with the SelectionChanged event being fired when the control gains focus.

    ReplyDelete
  5. useful post !!

    I have removed Drop Down button (toggle button) from the combobox template. To make it look like a TextBox with pop-up containing Items.

    If I type '10' then all matching data is populated and dropdown is opened with the data, e.g. data is: 10, 100, 1000. I do not want to change the text in editable textbox, which is the default behaviour of the combobox.

    Any help would be highly appreciated ?



    Now, if I press the 'DoWN' arrow then I want to traverse thru the list, 10,100,1000.

    ReplyDelete
  6. useful post !!

    I have removed Drop Down button (toggle button) from the combobox template. To make it look like a TextBox with pop-up containing Items.

    If I type '10' then all matching data is populated and dropdown is opened with the data, e.g. data is: 10, 100, 1000.

    Now, if I press the 'DoWN' arrow then I want to traverse thru the list, 10,100,1000.
    I do not want to change the text in editable textbox, i.e. It should be always '10', which is the default behaviour of the combobox.

    Any help would be highly appreciated ?

    ReplyDelete
  7. Can anyone tell me how to actually get this implemented in my project?

    ReplyDelete
  8. I do a little modification :

    public class FiltroComboEventArgs : EventArgs
    {
    public object Item { get; set; }
    public string Testo { get; set; }
    public bool Seleziona { get; set; }
    }

    public delegate void FiltroComboHandler(object sender, FiltroComboEventArgs e);

    ///
    /// Editable combo box which uses the text in its editable textbox to perform a lookup
    /// in its data source.
    ///
    public class AutoFilteredComboBox : ComboBox
    {
    ////
    // Public Fields
    ////

    public event FiltroComboHandler FiltroCombo;

    ....
    }

    private bool FilterPredicate(object value)
    {
    // No filter, no text
    if (value == null)
    {
    return false;
    }

    // No text, no filter
    if (this.Text.Length == 0)
    {
    return true;
    }

    if (FiltroCombo != null)
    {
    FiltroComboEventArgs e = new FiltroComboEventArgs();
    e.Item = value;
    e.Seleziona = false;
    e.Testo = this.Text;
    FiltroCombo(this, e);
    return e.Seleziona;
    }
    else
    {
    // Case insensitive search
    return value.ToString().ToLower().Contains(this.Text.ToLower());
    }

    }

    in this case you can create a event for filtering with any custom criteria.
    best regards
    Flavio

    ReplyDelete
  9. Very cool, thanks for posting!!!
    Femke

    ReplyDelete
  10. Nice post, but it does not work if you have a collection of non string values. Then you can have following code:

    Change FilterPredicate method to:

    private bool FilterPredicate(object value)
    {
    // No filter, no text
    if (value == null)
    {
    return false;
    }

    if (!(value is string))
    {
    var textPath = (string)GetValue(TextSearch.TextPathProperty);
    if (textPath != null)
    {
    value = value.GetType().GetProperty(textPath).GetValue(value);
    }
    }

    // No text, no filter
    if (this.Text.Length == 0)
    {
    return true;
    }

    // Case insensitive search
    return value.ToString().ToLower().Contains(this.Text.ToLower());
    }

    ReplyDelete