Click here to Skip to main content
15,891,951 members
Articles / Programming Languages / C#
Tip/Trick

ComboBox with Suggest Ability based on Substring-Search

Rate me:
Please Sign up or sign in to vote.
4.88/5 (36 votes)
5 Aug 2013CPOL2 min read 242.8K   7.9K   32   71
A custom ComboBox that supports live filtering of items by Substring-Search or any other Lamda-Expression

Introduction 

The WinForms ComboBox-Control offers a functionality called AutoComplete if

DropDownStyle 
is set to DropDown. There are different options available, but the focus of this article is on AutoCompleteMode = Suggest with AutoCompleteSource = ListItems. This setting provides a ListBox of suggested items when you type some text in the ComboBox.

The problem is: you cannot define the way the suggested items are filtered/determined. It's always a 'StartsWith'-search.

That's why i decided to write my own class (SuggestComboBox) based on ComboBox which looks more or less the same, but is in fact self-made. The screenshot below shows the difference: on the left hand side the input 'j' matches one more item because it's a Contains-search. 

     

The code and how it works   

At first you need a new class that inherits from ComboBox and a ListBox + BindingList as DataSource that will contain the suggested items. The filtering is done in the OnTextChanged - method via LINQ, the Lamda-Expressions for filtering and ordering can be set by the Properties FilterRule and SuggestListOrderRule (default values are a Contains-filter and ascending alphabetic order).  

To support the expected behavior for keyboard actions (UP, DOWN, ENTER, ESCAPE) you primarily need to override two methods: 

  1. OnPreviewKeyDown, that is called first when you hit a key. Here the actions to be done are listed in a switch-statement for the KeyCode.
  2. ProcessCmdKey, that is always called after the first one. Here only the base functionality is interrupted. 

The remaining methods are mainly to manage the visibility and location/size of the ListBox and need no further explanation. Just read the code! Wink | <img src= (examples are included in the zip-file)  

Update:

To support any DataSource-Type (not just string), i added the PropertySelector: you can pass the selection rule for the databound items. The default setting still assumes that a list of strings is the datasource.

Example:

C#
// assume you bind a list of persons to the ComboBox with 'Name' as DisplayMember:
suggestComboBox.DataSource = new List<person>();
suggestComboBox.DisplayMember = "Name";

// then you have to set the PropertySelector like this:
suggestComboBox.PropertySelector = collection => collection.Cast<person>().Select(p => p.Name);

// the class Person looks something like this:
class Person
{
    public string Name { get; set; }
    public DateTime DateOfBirth { get; set; }
    public int Height { get; set; }
}</person></person>

I also fixed some UI-bugs regarding the location and size of the suggest box (2 Changed-EventHandler added). 

C#
public class SuggestComboBox : ComboBox
{
    #region fields and properties
 
    private readonly ListBox _suggLb = new ListBox { Visible = false, TabStop = false };
    private readonly BindingList<string> _suggBindingList = new BindingList<string>();
    private Expression<Func<ObjectCollection, IEnumerable<string>>> _propertySelector;
    private Func<ObjectCollection, IEnumerable<string>> _propertySelectorCompiled;
    private Expression<Func<string, string, bool>> _filterRule;
    private Func<string, bool> _filterRuleCompiled;
    private Expression<Func<string, string>> _suggestListOrderRule;
    private Func<string, string> _suggestListOrderRuleCompiled;
 
    public int SuggestBoxHeight
    {
        get { return _suggLb.Height; }
        set { if (value > 0) _suggLb.Height = value; }
    }
    /// <summary>
    /// If the item-type of the ComboBox is not string,
    /// you can set here which property should be used
    /// </summary>
    public Expression<Func<ObjectCollection, IEnumerable<string>>> PropertySelector
    {
    	get { return _propertySelector; }
	set
	{
	    if (value == null) return;
	    _propertySelector = value;
	    _propertySelectorCompiled = value.Compile();
	}
    }
 
    ///<summary>
    /// Lambda-Expression to determine the suggested items
    /// (as Expression here because simple lamda (func) is not serializable)
    /// <para>default: case-insensitive contains search</para>
    /// <para>1st string: list item</para>
    /// <para>2nd string: typed text</para>
    ///</summary>
    public Expression<Func<string, string, bool>> FilterRule
    {
        get { return _filterRule; }
        set
        {
            if (value == null) return;
            _filterRule = value;
            _filterRuleCompiled = item => value.Compile()(item, Text);
        }
    }
 
    ///<summary>
    /// Lambda-Expression to order the suggested items
    /// (as Expression here because simple lamda (func) is not serializable)
    /// <para>default: alphabetic ordering</para>
    ///</summary>
    public Expression<Func<string, string>> SuggestListOrderRule
    {
        get { return _suggestListOrderRule; }
        set
        {
            if (value == null) return;
            _suggestListOrderRule = value;
            _suggestListOrderRuleCompiled = value.Compile();
        }
    }
 
    #endregion
 
    /// <summary>
    /// ctor
    /// </summary>
    public SuggestComboBox()
    {
        // set the standard rules:
        _filterRuleCompiled = s => s.ToLower().Contains(Text.Trim().ToLower());
        _suggestListOrderRuleCompiled = s => s;
        _propertySelectorCompiled = collection => collection.Cast<string>();
 
        _suggLb.DataSource = _suggBindingList;
        _suggLb.Click += SuggLbOnClick;
 
        ParentChanged += OnParentChanged;
    }
 
    /// <summary>
    /// the magic happens here ;-)
    /// </summary>
    /// <param name="e"></param>
    protected override void OnTextChanged(EventArgs e)
    {
        base.OnTextChanged(e);
 
        if (!Focused) return;
 
        _suggBindingList.Clear();
        _suggBindingList.RaiseListChangedEvents = false;
        _propertySelectorCompiled(Items)
             .Where(_filterRuleCompiled)
             .OrderBy(_suggestListOrderRuleCompiled)
             .ToList()
             .ForEach(_suggBindingList.Add);
        _suggBindingList.RaiseListChangedEvents = true;
        _suggBindingList.ResetBindings();
        
        _suggLb.Visible = _suggBindingList.Any(); 
        
        if (_suggBindingList.Count == 1 &&  
                    _suggBindingList.Single().Length == Text.Trim().Length)
        {
            Text = _suggBindingList.Single();
            Select(0, Text.Length);
            _suggLb.Visible = false;
        }
    }
 
    /// <summary>
    /// suggest-ListBox is added to parent control
    /// (in ctor parent isn't already assigned)
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void OnParentChanged(object sender, EventArgs e)
    {
        Parent.Controls.Add(_suggLb);
        Parent.Controls.SetChildIndex(_suggLb, 0);
        _suggLb.Top = Top + Height - 3;
        _suggLb.Left = Left + 3;
        _suggLb.Width = Width - 20;
        _suggLb.Font = new Font("Segoe UI", 9);
    }
 
    protected override void OnLostFocus(EventArgs e)
    {
        // _suggLb can only getting focused by clicking (because TabStop is off)
        // --> click-eventhandler 'SuggLbOnClick' is called
        if (!_suggLb.Focused)
            HideSuggBox();
        base.OnLostFocus(e);
    }
    protected override void OnLocationChanged(EventArgs e)
    {
	base.OnLocationChanged(e);
	_suggLb.Top = Top + Height - 3;
	_suggLb.Left = Left + 3;
    }
    protected override void OnSizeChanged(EventArgs e)
    {
	base.OnSizeChanged(e);
	_suggLb.Width = Width - 20;
    }
 
    private void SuggLbOnClick(object sender, EventArgs eventArgs)
    {
        Text = _suggLb.Text;
        Focus();
    }
 
    private void HideSuggBox()
    {
        _suggLb.Visible = false;
    }
 
    protected override void OnDropDown(EventArgs e)
    {
        HideSuggBox();
        base.OnDropDown(e);
    }
 
    #region keystroke events
 
    /// <summary>
    /// if the suggest-ListBox is visible some keystrokes
    /// should behave in a custom way
    /// </summary>
    /// <param name="e"></param>
    protected override void OnPreviewKeyDown(PreviewKeyDownEventArgs e)
    {
        if (!_suggLb.Visible)
        {
            base.OnPreviewKeyDown(e);
            return;
        }
 
        switch (e.KeyCode)
        {
            case Keys.Down:
                if (_suggLb.SelectedIndex < _suggBindingList.Count - 1)
                    _suggLb.SelectedIndex++;
                return;
            case Keys.Up:
                if (_suggLb.SelectedIndex > 0)
                    _suggLb.SelectedIndex--;
                return;
            case Keys.Enter:
                Text = _suggLb.Text;
	        Select(0, Text.Length);
	        _suggLb.Visible = false;
                return;
            case Keys.Escape:
                HideSuggBox();
                return;
        }
 
        base.OnPreviewKeyDown(e);
    }
 
    private static readonly Keys[] KeysToHandle  = new[] 
                { Keys.Down, Keys.Up, Keys.Enter, Keys.Escape };
    protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
    {
        // the keysstrokes of our interest should not be processed be base class:
        if (_suggLb.Visible && KeysToHandle.Contains(keyData))
            return true;
        return base.ProcessCmdKey(ref msg, keyData);
    }
 
    #endregion
} 

Remark 

One thing is really annoying i think: every time you hit ENTER or ESCAPE a 'bing'-sound is generated by the framework. I read this is a bug of .NET 4. Anybody has an idea how to fix it properly?

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Germany Germany
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
SuggestionBing Sound Pin
bjoernkr27-Mar-14 21:42
bjoernkr27-Mar-14 21:42 
GeneralRe: Bing Sound Pin
Physlcu$3-May-14 0:34
Physlcu$3-May-14 0:34 
SuggestionAvoid hardcoded font for listbox Pin
paintballkev8123-Feb-14 10:45
paintballkev8123-Feb-14 10:45 
GeneralRe: Avoid hardcoded font for listbox Pin
Physlcu$8-Feb-14 7:08
Physlcu$8-Feb-14 7:08 
QuestionSuggest list appears behind other controls Pin
Sirous Weisy3-Feb-14 6:26
Sirous Weisy3-Feb-14 6:26 
AnswerRe: Suggest list appears behind other controls Pin
Physlcu$8-Feb-14 7:05
Physlcu$8-Feb-14 7:05 
GeneralRe: Suggest list appears behind other controls Pin
Anon1mSharp9-Mar-15 5:28
Anon1mSharp9-Mar-15 5:28 
GeneralVB.net Version Pin
paintballkev8123-Feb-14 4:41
paintballkev8123-Feb-14 4:41 
Here is the control converted to VB

VB
Imports System.Collections.Generic
Imports System.ComponentModel
Imports System.Drawing
Imports System.Linq
Imports System.Linq.Expressions
Imports System.Windows.Forms

Namespace AutoCompleteComboBox
    Public Class SuggestComboBox
        Inherits ComboBox
#Region "fields and properties"
         Private ReadOnly _suggLb As New ListBox() With {.Visible = False, .TabStop = False}
        Private ReadOnly _suggBindingList As New BindingList(Of String)()
        Private _propertySelector As Expression(Of Func(Of ObjectCollection, IEnumerable(Of String)))
        Private _propertySelectorCompiled As Func(Of ObjectCollection, IEnumerable(Of String))
        Private _filterRule As Expression(Of Func(Of String, String, Boolean))
        Private _filterRuleCompiled As Func(Of String, Boolean)
        Private _suggestListOrderRule As Expression(Of Func(Of String, String))
        Private _suggestListOrderRuleCompiled As Func(Of String, String)

        Public Property SuggestBoxHeight() As Integer
            Get
                Return _suggLb.Height
            End Get
            Set(value As Integer)
                If value > 0 Then
                    _suggLb.Height = value
                End If
            End Set
        End Property

        ''' <summary>
        ''' If the item-type of the ComboBox is not string,
        ''' you can set here which property should be used
        ''' </summary>
        Public Property PropertySelector() As Expression(Of Func(Of ObjectCollection, IEnumerable(Of String)))
            Get
                Return _propertySelector
            End Get
            Set(value As Expression(Of Func(Of ObjectCollection, IEnumerable(Of String))))
                If value Is Nothing Then
                    Return
                End If
                _propertySelector = value
                _propertySelectorCompiled = value.Compile()
            End Set
        End Property

        '''<summary>
        ''' Lambda-Expression to determine the suggested items
        ''' (as Expression here because simple lamda (func) is not serializable)
        ''' <param>default: case-insensitive contains search</param>
        ''' <param>1st string: list item</param>
        ''' <param>2nd string: typed text</param>
        '''</summary>
        Public Property FilterRule() As Expression(Of Func(Of String, String, Boolean))
            Get
                Return _filterRule
            End Get
            Set(value As Expression(Of Func(Of String, String, Boolean)))
                If value Is Nothing Then
                    Return
                End If
                _filterRule = value
                _filterRuleCompiled = Function(item) value.Compile()(item, Text)
            End Set
        End Property

        '''<summary>
        ''' Lambda-Expression to order the suggested items
        ''' (as Expression here because simple lamda (func) is not serializable)
        ''' <param>default: alphabetic ordering</param>
        '''</summary>
        Public Property SuggestListOrderRule() As Expression(Of Func(Of String, String))
            Get
                Return _suggestListOrderRule
            End Get
            Set(value As Expression(Of Func(Of String, String)))
                If value Is Nothing Then
                    Return
                End If
                _suggestListOrderRule = value
                _suggestListOrderRuleCompiled = value.Compile()
            End Set
        End Property

#End Region

        ''' <summary>
        ''' ctor
        ''' </summary>
        Public Sub New()
            ' set the standard rules:
            _filterRuleCompiled = Function(s) s.ToLower().Contains(Text.Trim().ToLower())
            '_filterRuleCompiled = Function(s) Text.ToLower.Split(" "c).All(Function(x) s.ToLower().Contains(x)) 'Searched multiple words in mixed order
            _suggestListOrderRuleCompiled = Function(s) s
            _propertySelectorCompiled = Function(collection) collection.Cast(Of String)()

            _suggLb.DataSource = _suggBindingList
            AddHandler _suggLb.Click, AddressOf SuggLbOnClick

            AddHandler ParentChanged, AddressOf OnParentChanged
        End Sub

        ''' <summary>
        ''' the magic happens here ;-)
        ''' </summary>
        ''' <param name="e"></param>
        Protected Overrides Sub OnTextChanged(e As EventArgs)
            MyBase.OnTextChanged(e)

            If Not Focused Then
                Return
            End If

            _suggBindingList.Clear()
            _suggBindingList.RaiseListChangedEvents = False
            _propertySelectorCompiled(Items).Where(_filterRuleCompiled).OrderBy(_suggestListOrderRuleCompiled).ToList().ForEach(AddressOf _suggBindingList.Add)
            _suggBindingList.RaiseListChangedEvents = True
            _suggBindingList.ResetBindings()

            _suggLb.Visible = _suggBindingList.Any()

            If _suggBindingList.Count = 1 AndAlso _suggBindingList.[Single]().Length = Text.Trim().Length Then
                Text = _suggBindingList.[Single]()
                [Select](0, Text.Length)
                _suggLb.Visible = False
            End If
        End Sub

#Region "size and position of suggest box"
         ''' <summary>
        ''' suggest-ListBox is added to parent control
        ''' (in ctor parent isn't already assigned)
        ''' </summary>
        ''' <param name="sender"></param>
        ''' <param name="e"></param>
        Private Overloads Sub OnParentChanged(sender As Object, e As EventArgs)
            Dim p = TryCast(Me, Control)
            Dim t = Top
            Dim l = Left

            While p.Parent IsNot Nothing
                p = p.Parent
                t += p.Top
                l += p.Left
            End While

            P.Controls.Add(_suggLb)
            P.Controls.SetChildIndex(_suggLb, 0)
            _suggLb.Top = t + Height - 3
            _suggLb.Left = l + 3
            _suggLb.Width = Width - 20
            _suggLb.Font = TryCast(Me, Control).Font
        End Sub

        Protected Overrides Sub OnLocationChanged(e As EventArgs)
            MyBase.OnLocationChanged(e)
            _suggLb.Top = Top + Height - 3
            _suggLb.Left = Left + 3
        End Sub

        Protected Overrides Sub OnSizeChanged(e As EventArgs)
            MyBase.OnSizeChanged(e)
            _suggLb.Width = Width - 20
        End Sub

#End Region

#Region "visibility of suggest box"
         Protected Overrides Sub OnLostFocus(e As EventArgs)
            ' _suggLb can only getting focused by clicking (because TabStop is off)
            ' --> click-eventhandler 'SuggLbOnClick' is called
            If Not _suggLb.Focused Then
                HideSuggBox()
            End If
            MyBase.OnLostFocus(e)
        End Sub

        Private Sub SuggLbOnClick(sender As Object, eventArgs As EventArgs)
            Text = _suggLb.Text
            Focus()
        End Sub

        Private Sub HideSuggBox()
            _suggLb.Visible = False
        End Sub

        Protected Overrides Sub OnDropDown(e As EventArgs)
            HideSuggBox()
            MyBase.OnDropDown(e)
        End Sub

#End Region

#Region "keystroke events"
         ''' <summary>
        ''' if the suggest-ListBox is visible some keystrokes
        ''' should behave in a custom way
        ''' </summary>
        ''' <param name="e"></param>
        Protected Overrides Sub OnPreviewKeyDown(e As PreviewKeyDownEventArgs)
            If Not _suggLb.Visible Then
                MyBase.OnPreviewKeyDown(e)
                Return
            End If

            Select Case e.KeyCode
                Case Keys.Down
                    If _suggLb.SelectedIndex < _suggBindingList.Count - 1 Then
                        _suggLb.SelectedIndex += 1
                    End If
                    Return
                Case Keys.Up
                    If _suggLb.SelectedIndex > 0 Then
                        _suggLb.SelectedIndex -= 1
                    End If
                    Return
                Case Keys.Enter
                    Text = _suggLb.Text
                    [Select](0, Text.Length)
                    _suggLb.Visible = False
                    Return
                Case Keys.Escape
                    HideSuggBox()
                    Return
            End Select

            MyBase.OnPreviewKeyDown(e)
        End Sub

        Public Sub InvokeParentChanged()
            OnParentChanged(Nothing, Nothing)
        End Sub

        ' Private Shared ReadOnly KeysToHandle As Keys()
        Private Shared ReadOnly KeysToHandle As Keys() = New Keys(3) {Keys.Down, Keys.Up, Keys.Enter, Keys.Escape}
        Protected Overrides Function ProcessCmdKey(ByRef msg As Message, keyData As Keys) As Boolean
            ' the keysstrokes of our interest should not be processed be base class:
            If _suggLb.Visible AndAlso KeysToHandle.Contains(keyData) Then
                Return True
            End If
            Return MyBase.ProcessCmdKey(msg, keyData)
        End Function

#End Region
    End Class
End Namespace


modified 3-Feb-14 16:47pm.

GeneralRe: VB.net Version Pin
Roneson Senday26-Feb-14 11:35
Roneson Senday26-Feb-14 11:35 
GeneralRe: VB.net Version Pin
Roneson Senday26-Feb-14 13:56
Roneson Senday26-Feb-14 13:56 
GeneralRe: VB.net Version Pin
Kenny-A25-Mar-15 4:02
Kenny-A25-Mar-15 4:02 
GeneralRe: VB.net Version Pin
Member 1149195612-Feb-16 4:35
Member 1149195612-Feb-16 4:35 
QuestionSupport for Multiple Words in mixed order? Pin
paintballkev81231-Jan-14 11:07
paintballkev81231-Jan-14 11:07 
AnswerRe: Support for Multiple Words in mixed order? Pin
Physlcu$1-Feb-14 2:26
Physlcu$1-Feb-14 2:26 
GeneralRe: Support for Multiple Words in mixed order? Pin
paintballkev8123-Feb-14 4:39
paintballkev8123-Feb-14 4:39 
QuestionIf you put this control in a panel the list is hidden, how can i solve this? Pin
sisecom17-Jan-14 8:29
sisecom17-Jan-14 8:29 
AnswerRe: If you put this control in a panel the list is hidden, how can i solve this? Pin
Physlcu$18-Jan-14 0:36
Physlcu$18-Jan-14 0:36 
QuestionProblem with dataTable Pin
EnDee32130-Dec-13 8:39
EnDee32130-Dec-13 8:39 
AnswerRe: Problem with dataTable Pin
Physlcu$31-Dec-13 1:28
Physlcu$31-Dec-13 1:28 
GeneralRe: Problem with dataTable Pin
EnDee3211-Jan-14 7:03
EnDee3211-Jan-14 7:03 
GeneralRe: Problem with dataTable Pin
Physlcu$2-Jan-14 5:36
Physlcu$2-Jan-14 5:36 
GeneralRe: Problem with dataTable Pin
Member 107351008-Apr-14 19:32
Member 107351008-Apr-14 19:32 
GeneralRe: Problem with dataTable Pin
Physlcu$9-Apr-14 9:58
Physlcu$9-Apr-14 9:58 
AnswerRe: Problem with dataTable Pin
Roneson Senday25-Feb-14 13:23
Roneson Senday25-Feb-14 13:23 
AnswerRe: Problem with dataTable Pin
Roneson Senday26-Feb-14 4:01
Roneson Senday26-Feb-14 4:01 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.