Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WinForms

Embedding an ObjectListView in a ComboBox

5.00/5 (6 votes)
8 Nov 2016CPOL7 min read 15.8K   590  
A simple way to place any ObjectListView inside a ComboBox

Introduction

This article is an extension of my previous article Embedding a DataGridView in a ComboBox.

After I wrote the article explaining techniques of how a DataGridView could be used inside a ComboBox control, I found an amazing open source alternative - ObjectListView. It has many advanced built in features including one that is of special value - generic fast search and filtering. This feature made it a perfect candidate for embedding in a ComboBox and the result looks as follows:

Image 1

Background

This article is based on the ideas presented in these articles:

First, a custom ToolStripControlHost is created, then it is used to create custom ComboBox. I do not proceed in creating a custom DataGridViewColumn because it would be a shame to use such a brilliant control as an ObjectListView inside a DataGridView and ObjectListView has a generic mechanism to add any editing control including this one.

Using the Code

To use the provided control, you'd need to create a new UserControl deriving it from the provided InfoListControl class. The control represents a view of the objects that you want to show in the dropdown (some columns showing properties of the objects). The purpose behind the control is to allow a developer to configure an ObjectListView (bind columns to properties of a specific datasource type, set various visual properties, etc.) in a VS designer. Therefore, you are going to need an InfoListControl for each object type that you are going to use. If you create the inherited UserControl using standard Visual Studio methods, you won't need to write a line of code. After creating your inherited control, you will be presented with an ObjectListView designer view. Here, you can configure all you need: set databindings (AspectName for a column), configure column headers, visual appearance, etc. You can find a detailed description here.

After you create your InfoListControl for a particular datasource type, you do all the control setup in a few lines of code:

VB.NET
Dim cntr As AccListComboBox
dim view As InfoListControl = new yourInfoListControlInstance
view.DataSource = GetDataSource(whatever)
' a name of the property that should be used to set SelectedValue
' an empty string if the SelectedValue should contain the object itself
view .ValueMember = ""
' whether to accept a single click to confirm a user selection
view .AcceptSingleClick = True
cntr.AddDataListView(view)
' e.g.. when you want to display integer value 0 as an empty string
cntr.EmptyValueString = "0"

Inside the InfoListControl, I used a flavor of ObjectListView - DataListView, because it supports databinding out of the box. However, in case you need non standard databinding (e.g. DataTable, hierarchical structure), you could easily implement your own control to support it.

IMPORTANT

The current version of the ObjectListView has a bug that causes exceptions when the ObjectListViewComboBox is used as an edit control for ObjectListView itself. Therefore, you need to make the following correction in the ObjectListView source code (property ItemBeingEdited of the class CellEditKeyEngine) and compile it by yourself (i.e., do not use precompiled binaries):

C#
/// <summary>
/// Gets the row of the cell that is currently being edited
/// </summary>
protected OLVListItem ItemBeingEdited {
get {
        OLVListItem olvi = (this.ListView == null ||
        this.ListView.CellEditEventArgs == null) ?
        null : this.ListView.CellEditEventArgs.ListViewItem;

        if (olvi != null && olvi.Index < 0)
        {
            if (olvi.RowObject == null)
                return olvi;
            for (int i = 0; i < this.ListView.Items.Count; i++)
            {
                if (this.ListView.GetItem(i).RowObject == olvi.RowObject)
                {
                    olvi = this.ListView.GetItem(i);
                    break;
                }
            }
        }

        return olvi;
    }
}

Points of Interest

Creating InfoListControl

The essential part of the control is a ObjectListViewToolStrip class that inherits ToolStripControlHost. Basically, a ToolStripControlHost class can handle any control including an ObjectListView by itself however, in this case a developer would need to create an ObjectListView programmatically (not very user friendly). Therefore, I decided to create a dedicated user control that on the one hand holds all the ObjectListView specific logic neatly encapsulated and on the other hand provides full designer support.

Actually in the InfoListControl, I did not use a generic ObjectListView but its descendent - DataListView (because I needed databinding support). The encapsulation of the actual control provides an easy way to rewrite the control for a generic ObjectListView or a TreeListView (another descendant of the ObjectListView).

InfoListControl contains an instance of a DataListView added by the VS designer and provides a simple public constructor as well as four pretty much self explanatory properties with backing fields:

VB.NET
Private _AcceptSingleClick As Boolean = False
Private _ValueMember As String = ""
Private _FilterString As String = ""


''' <summary>
''' Whether single click is sufficient to choose an item.
''' </summary>
''' <remarks></remarks>
Public Property AcceptSingleClick() As Boolean
    Get
        Return _AcceptSingleClick
    End Get
    Set(ByVal value As Boolean)
        _AcceptSingleClick = value
    End Set
End Property

''' <summary>
''' A value object property that holds the required value (if any).
''' </summary>
''' <remarks></remarks>
Public Property ValueMember() As String
    Get
        Return _ValueMember
    End Get
    Set(ByVal value As String)
        If value Is Nothing Then value = ""
        _ValueMember = value
    End Set
End Property

''' <summary>
''' A <see cref="BindingSource">BindingSource_
</see> that wraps a value object list.
''' </summary>
''' <remarks></remarks>
Public Property DataSource() As Object
    Get
        Return baseDataListView.DataSource
    End Get
    Set(ByVal value As Object)
        baseDataListView.DataSource = value
    End Set
End Property

''' <summary>
''' A string that is used to filter the value object list.
''' </summary>
''' <remarks></remarks>
Public Property FilterString() As String
    Get
        Return _FilterString
    End Get
    Set(ByVal value As String)
        If value Is Nothing Then value = ""
        If value <> _FilterString Then
            _FilterString = value
            baseDataListView.AdditionalFilter = _
                TextMatchFilter.Contains(baseDataListView, _FilterString)
        End If
    End Set
End Property

Public Sub New()
    ' This call is required by the Windows Form Designer.
    InitializeComponent()

    baseDataListView.DefaultRenderer = New HighlightTextRenderer( _
        TextMatchFilter.Contains(baseDataListView, New String() {""}))
    baseDataListView.SelectColumnsMenuStaysOpen = True
End Sub

The only points of interest here are calls to the ObjectListView methods required for setting a filter graphic renderer and the filter (string) itself.

Next, the InfoListControl shall implement event to pass user selection to the parent ToolStripControlHost:

VB.NET
Friend Delegate Sub ValueSelectedEventHandler(ByVal sender As Object, ByVal e As ValueChangedEventArgs)
Friend Event ValueSelected As ValueSelectedEventHandler

Protected Sub OnValueSelected(ByVal e As ValueChangedEventArgs)
    RaiseEvent ValueSelected(Me, e)
End Sub

Protected Sub OnValueSelected(ByVal currentObject As Object, ByVal isCanceled As Boolean)

    If _ValueMember Is Nothing OrElse String.IsNullOrEmpty(_ValueMember) _
        OrElse currentObject Is Nothing Then

        RaiseEvent ValueSelected(Me, New ValueChangedEventArgs(currentObject, isCanceled))

    Else

        If baseDataListView.GetItemCount() < 1 OrElse baseDataListView.GetItem(0). _
            RowObject.GetType().GetProperty(_ValueMember.Trim, BindingFlags.Public _
            OrElse BindingFlags.Instance) Is Nothing Then

            RaiseEvent ValueSelected(Me, New ValueChangedEventArgs(Nothing, isCanceled))

        Else
            RaiseEvent ValueSelected(Me, New ValueChangedEventArgs( _
                GetValueMemberValue(currentObject), isCanceled))
        End If

    End If

End Sub

Public Class ValueChangedEventArgs
    Inherits EventArgs

    Private _SelectedValue As Object = Nothing
    Private _SelectionCanceled As Boolean = False

    Public ReadOnly Property SelectedValue() As Object
        Get
            Return _SelectedValue
        End Get
    End Property

    Public ReadOnly Property SelectionCanceled() As Boolean
        Get
            Return _SelectionCanceled
        End Get
    End Property

    Friend Sub New(ByVal newValue As Object, ByVal isCanceled As Boolean)
        _SelectedValue = newValue
        _SelectionCanceled = isCanceled
    End Sub

End Class

The point of interest here is the OnValueSelected method overload that raises the event and passes the selected object to the event args. The purpose of the method is to filter the user selection input and (in case the ValueMember property is set) to return not the object by itself but the value of the required property. A value of the required property is fetched using a simple helper method GetValueMemberValue that uses a simple reflection in a Try...Catch block to avoid runtime exceptions.

And finally the InfoListControl needs to handle a user input (either by mouse or by keyboard):

VB.NET
Private Sub baseDataListView_CellClick(ByVal sender As Object, _
    ByVal e As CellClickEventArgs) Handles baseDataListView.CellClick

    If Not e.Model Is Nothing AndAlso (e.ClickCount = 2 OrElse _
        (e.ClickCount = 1 AndAlso _AcceptSingleClick)) Then

        OnValueSelected(e.Model, False)

    End If

End Sub

Private Sub baseDataListView_KeyDown(ByVal sender As Object, _
    ByVal e As System.Windows.Forms.KeyEventArgs) Handles baseDataListView.KeyDown

    If e.KeyData = Keys.Enter AndAlso Not baseDataListView.SelectedItem Is Nothing _
        AndAlso Not baseDataListView.SelectedItem.RowObject Is Nothing Then

        OnValueSelected(baseDataListView.SelectedItem.RowObject, False)
        e.Handled = True

    ElseIf e.KeyData = Keys.Back Then

        If _FilterString <> "" Then
            _FilterString = _FilterString.Substring(0, _FilterString.Length - 1)
            baseDataListView.AdditionalFilter = _
                TextMatchFilter.Contains(baseDataListView, _FilterString)
        End If
        e.Handled = True

    ElseIf e.KeyData = Keys.Delete Then

        If _FilterString <> "" Then
            _FilterString = ""
            baseDataListView.AdditionalFilter = _
                TextMatchFilter.Contains(baseDataListView, _FilterString)
        End If
        e.Handled = True

    ElseIf e.KeyData = Keys.Escape Then

        OnValueSelected(Nothing, True)
        e.Handled = True

    End If

End Sub

Private Sub baseDataListView_KeyPress(ByVal sender As Object, _
    ByVal e As System.Windows.Forms.KeyPressEventArgs) Handles baseDataListView.KeyPress

    If Not Char.IsControl(e.KeyChar) AndAlso (Char.IsLetterOrDigit(e.KeyChar) _
        OrElse Char.IsPunctuation(e.KeyChar)) Then

        _FilterString = _FilterString & e.KeyChar
        baseDataListView.AdditionalFilter = _
            TextMatchFilter.Contains(baseDataListView, _FilterString)

    End If

End Sub

The code is self explanatory. The standard event handles evaluates user input and either raises ValueSelected event to indicate user selection or modify filter string:

  • Letters, digits or punctuation mark keys are added to the filter string
  • BackSpace key removes last char in the filter string
  • Delete key clears the filter string
  • Enter key raises ValueSelected event with the current selected object
  • Escape key raises ValueSelected event with a null object and cancellation flag set to true
  • Single mouse click raises ValueSelected event with the clicked object if the AcceptSingleClick is set to TRUE
  • Double mouse click raises ValueSelected event with the clicked object

From the external point of view (a point of view of the parent ToolStripControlHost control), the InfoListControl is a Control that exposes 4 custom properties and 1 custom event. The contained DataListView control is also accessible from an external object however normally it will only be configured using a VS designer.

Creating ObjectListViewToolsStrip

The essential part of the control is a ObjectListViewToolStrip class that inherits ToolStripControlHost. It is an internal class (only used internally by the ObjectListViewComboBox) and handles the InfoListControl:

  • provides a proxy property to the FilterString property of the encapsulated InfoListControl
  • provides a proxy access method GetDataSource to the DataSource property of the encapsulated InfoListControl
  • handles ValueSelected event of the encapsulated InfoListControl and encapsulates the current selected value (SelectedValue) and the cancellation flag (SelectionCanceled)
  • provides standard properties for the drop down size (MinDropDownWidth and DropDownHeight)

Specific ObjectListViewToolStrip methods are the constructor and the methods that wire the events of the encapsulated InfoListControl:

VB.NET
Public Sub New(ByVal listView As InfoListControl)
    MyBase.New(listView)
    Me.AutoSize = False
    Me._MinDropDownWidth = listView.Width
    Me._DropDownHeight = listView.Height
End Sub

Private Sub OnDataListViewValueSelected(ByVal sender As Object, _
    ByVal e As InfoListControl.ValueChangedEventArgs)
    _SelectedValue = e.SelectedValue
    _SelectionCanceled = e.SelectionCanceled
    DirectCast(Me.Owner, ToolStripDropDown).Close(ToolStripDropDownCloseReason.ItemClicked)
End Sub

' Subscribe and unsubscribe the control events you wish to expose.
Protected Overrides Sub OnSubscribeControlEvents(ByVal c As Control)
    ' Call the base so the base events are connected.
    MyBase.OnSubscribeControlEvents(c)

    ' Cast the control to a InfoListControl control.
    Dim nDataListView As InfoListControl = DirectCast(c, InfoListControl)

    ' Add the event.
    AddHandler nDataListView.ValueSelected, AddressOf OnDataListViewValueSelected

End Sub

Protected Overrides Sub OnUnsubscribeControlEvents(ByVal c As Control)
    ' Call the base method so the basic events are unsubscribed.
    MyBase.OnUnsubscribeControlEvents(c)

    ' Cast the control to a InfoListControl control.
    Dim nDataListView As InfoListControl = DirectCast(c, InfoListControl)

    ' Remove the event.
    RemoveHandler nDataListView.ValueSelected, AddressOf OnDataListViewValueSelected

End Sub

Protected Overrides Sub OnBoundsChanged()
    MyBase.OnBoundsChanged()
    If Not Control Is Nothing Then
        DirectCast(Control, InfoListControl).Size = Me.Size
    End If
End Sub

The constructor sets the encapsulated InfoListControl and turns off autosizing leaving the size control to the overridden OnBoundsChanged method. For unknown reason, built in autosizing methods fail when used inside a ToolStripControlHost.

Methods OnSubscribeControlEvents and OnUnsubscribeControlEvents wires the ValueSelected event to the OnDataListViewValueSelected handler. Which in turn persists the ValueSelected event arguments within SelectedValue and SelectionCanceled properties and closes the selection dropdown.

Creating ObjectListViewComboBox

The control itself inherits ComboBox. It exposes the following public properties and methods:

  • HasAttachedInfoList - Indicates whether an InfoListControl is already assigned to the control
  • InstantBinding - Whether to update datasource instantly when the user selects a value (as opposed on validation)
  • SelectedValue - A currently selected value object or a value of the value object InfoListControl.ValueMember property (if set)
  • EmptyValueString - A SelectedValue string expression (ToString) that should be displayed as an empty string (e.g. if you set EmptyValueString="0" then an integer value 0 will be displayed as an empty string)
  • FilterString - A currently applied filter string (a proxy property)
  • InfoListControlDataSource - A datasource of the nested InfoListControl (a proxy property)
  • AddDataListView - Adds a new InfoListControl to the control, i.e. initializes the control. InfoListControl cannot be replaced afterwards.

ObjectListViewComboBox class has a private variable myDropDown As ToolStripDropDown, which acts as a container for ObjectListViewToolStrip. The instance of ObjectListViewToolStrip itself is created by the AddDataListView method:

VB.NET
 Public Sub AddDataListView(ByVal dataView As InfoListControl)

    If Not myListView Is Nothing Then Throw New InvalidOperationException( _
        "Error. DataListView is already assigned to the ObjectListViewComboBox.")

    myListView = New ObjectListViewToolStrip(dataView)

    If myDropDown Is Nothing OrElse myDropDown.IsDisposed Then

        myDropDown = New ToolStripDropDown()
        myDropDown.AutoSize = False
        myDropDown.GripStyle = ToolStripGripStyle.Visible
        AddHandler myDropDown.Closed, AddressOf ToolStripDropDown_Closed

    Else

        myDropDown.Items.Clear()

    End If

    myDropDown.Items.Add(myListView)
    myDropDown.Width = Math.Max(Me.Width, myListView.MinDropDownWidth)
    myDropDown.Height = myListView.Height

End Sub

ObjectListViewComboBox handles showing the dropdown by overriding WndProc and intercepting messages. The current implementation of this method is copied from CodeProject article Flexible ComboBox and EditingControl and should be changed in case manual entry support is needed because it captures clicks in all the areas of combobox thus preventing text entry.

VB
Private Const WM_LBUTTONDOWN As UInt32 = &H201
Private Const WM_LBUTTONDBLCLK As UInt32 = &H203
Private Const WM_KEYF4 As UInt32 = &H134

Protected Overrides Sub WndProc(ByRef m As Message)

    '#Region "WM_KEYF4"
    If m.Msg = WM_KEYF4 Then
        Me.Focus()
        Me.myDropDown.Refresh()
        If Not Me.myDropDown.Visible Then

            ShowDropDown()

        Else
            myDropDown.Close()

        End If
        Return
    End If
    '#End Region

    '#Region "WM_LBUTTONDBLCLK"
    If m.Msg = WM_LBUTTONDBLCLK OrElse m.Msg = WM_LBUTTONDOWN Then
        If Not Me.myDropDown.Visible Then

            ShowDropDown()

        Else
            myDropDown.Close()

        End If
        Return
    End If
    '#End Region

    MyBase.WndProc(m)

End Sub

ObjectListViewComboBox method that actually shows the dropdown essentially deals with the dropdown sizing, positioning and selecting the appropriate DataListView row (which holds the current SelectedValue):

VB
Private Sub ShowDropDown()
    If Not myDropDown Is Nothing AndAlso Not Me.myListView Is Nothing Then

        If Not myDropDown.Items.Contains(Me.myListView) Then
            myDropDown.Items.Clear()
            myDropDown.Items.Add(Me.myListView)
        End If

        myDropDown.Width = Math.Max(Me.Width, Me.myListView.MinDropDownWidth)
        myListView.Size = myDropDown.Size

        myListView.SetSelectedValue(_SelectedValue)

        myDropDown.Show(Me, CalculatePoz)

        SendKeys.Send("{down}")

    End If

End Sub

Private Function CalculatePoz() As Point

    Dim point As New Point(0, Me.Height)

    If (Me.PointToScreen(New Point(0, 0)).Y + Me.Height + Me.myListView.Height) _
        > Screen.PrimaryScreen.WorkingArea.Height Then
        point.Y = -Me.myListView.Height - 7
    End If

    Return point

End Function

ObjectListViewComboBox handles setting the current value by overloading SelectedValue property (to bypass native ComboBox logic) and providing custom setter method that enables setting value object by ValueMember.

VB
Private Sub ToolStripDropDown_Closed(ByVal sender As Object, _
    ByVal e As ToolStripDropDownClosedEventArgs)

    If e.CloseReason = ToolStripDropDownCloseReason.ItemClicked _
        AndAlso Not myListView Is Nothing AndAlso Not myListView.SelectionCanceled Then

        If Not MyBase.Focused Then MyBase.Focus()

        SetValue(myListView.SelectedValue)

        If _InstantBinding Then
            For Each b As Binding In MyBase.DataBindings
                b.WriteValue()
            Next
        End If

    End If

End Sub

Private Sub SetValue(ByVal value As Object)

    If value Is Nothing Then
        Me.Text = ""
    ElseIf Not _EmptyValueString Is Nothing AndAlso _
        Not String.IsNullOrEmpty(_EmptyValueString.Trim) AndAlso _
        value.ToString.ToLower.Trim = _EmptyValueString.Trim.ToLower Then
        Me.Text = ""
    Else
        Me.Text = value.ToString
    End If

    _SelectedValue = value

    MyBase.OnSelectedValueChanged(New EventArgs)

End Sub

As you can see from the code above, ObjectListViewComboBox also has a custom property InstantBinding implemented. It is not necessary by itself, but in some cases, it is good to have bindings updated not on validated but on actual value change.

That is pretty much all, enjoy the ObjectListView!

License

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