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

Embedding a DataGridView in a ComboBox

4.95/5 (18 votes)
8 May 2013CPOL5 min read 71.6K   3.6K  
A simple way to place any DataGridView inside a ComboBox.

Introduction

Multicolumn comboboxes are quite common in WinForms applications. However there are no open source solutions that would fully support data binding and be as customizable as a DataGridView control. The aim of this article is to show how to make one easily with relatively small amount of code.

The most obvious, straightforward and simple way to meet the requirements is to host a DataGridView inside a ComboBox. It seems a non trivial task to do, but actually it is surprisingly easy to do (at least after doing it).

This article and provided source code is more of "proof of concept" type than a finished control. There are many details that are not quite "technical" and aesthetic. On the other hand I use it for one of my open source programs (where DevExpress is impossible) and it works ok for my purposes.

It's also my first article on programming so please forgive me for bad style Smile | <img src=

AccGridComboBox control preview

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 that is in turn used to create an IDataGridViewEditingControl, a custom DataGridViewCell and a custom DataGridViewColumn.

Using the code 

Using the provided custom AccGridComboBox and DataGridViewAccGridComboBoxColumn classes is as simple as using ComboBox and DataGridViewColumn themselves.

All you need is to add an AccGridComboBox or an DataGridViewAccGridComboBoxColumn to a form as you would add a ComboBox or a DataGridViewColumn and assign a corresponding DataGridView instead of datasource:

VB
' for columns    
DataGridViewAccGridComboBoxColumn1.ComboDataGridView = ProgramaticalyCreatedDataGridView
' selection is done by single click, i.e. not double click
DataGridViewAccGridComboBoxColumn1.CloseOnSingleClick = True
' binding is trigered on value change, i.e. not on validating
DataGridViewAccGridComboBoxColumn1.InstantBinding = True

' for comboboxes (second param is CloseOnSingleClick property setter)
AccGridComboBox1.AddDataGridView(ProgramaticalyCreatedDataGridView, True)
AccGridComboBox1.InstantBinding = True

Quick, self-explanatory example on how to programmatically create a DataGridView:

VB
Public Function CreateDataGridViewForPersonInfo(ByVal TargetForm As Form, _
        ByVal ListBindingSource As BindingSource) As DataGridView
 
    ' create the resulting grid and it's columns
    Dim result As New DataGridView
    Dim DataGridViewTextBoxColumn1 As New System.Windows.Forms.DataGridViewTextBoxColumn
    Dim DataGridViewTextBoxColumn2 As New System.Windows.Forms.DataGridViewTextBoxColumn

    ' begin initialization (to minimize events)
    CType(result, System.ComponentModel.ISupportInitialize).BeginInit()

    ' setup grid properties as you need
    result.AllowUserToAddRows = False
    result.AllowUserToDeleteRows = False
    result.AutoGenerateColumns = False
    result.AllowUserToResizeRows = False
    result.ColumnHeadersVisible = False
    result.RowHeadersVisible = False
    result.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.AllCells
    result.ReadOnly = True
    result.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect
    result.Size = New System.Drawing.Size(300, 220)
    result.AutoSize = False
    
    ' add datasource
    result.DataSource = ListBindingSource
    
    ' add columns
    result.Columns.AddRange(New System.Windows.Forms.DataGridViewColumn() _
        {DataGridViewTextBoxColumn1, DataGridViewTextBoxColumn2})
    
    ' setup columns as you need
    DataGridViewTextBoxColumn1.AutoSizeMode = _
         System.Windows.Forms.DataGridViewAutoSizeColumnMode.Fill
    DataGridViewTextBoxColumn1.DataPropertyName = "Name"
    DataGridViewTextBoxColumn1.HeaderText = "Name"
    DataGridViewTextBoxColumn1.Name = ""
    DataGridViewTextBoxColumn1.ReadOnly = True

    DataGridViewTextBoxColumn2.DataPropertyName = "Code"
    DataGridViewTextBoxColumn2.HeaderText = "Code"
    DataGridViewTextBoxColumn2.Name = ""
    DataGridViewTextBoxColumn2.ReadOnly = True
    DataGridViewTextBoxColumn2.AutoSizeMode = DataGridViewAutoSizeColumnMode.NotSet

    ' assign binding context of the form that hosts
    ' the control in order to enable databinding
    result.BindingContext = TargetForm.BindingContext

    ' end initialization
    CType(result, System.ComponentModel.ISupportInitialize).EndInit()
    
    Return result

End Function

Points of Interest

Creating custom ToolStripControlHost

The essential part of the control is a ToolStripDataGridView class that inherits ToolStripControlHost. The ToolStripDataGridView class provides 4 new self-explanatory properties: CloseOnSingleClick, DataGridViewControl, MinDropDownWidth and DropDownHeight. Currently I made MinDropDownWidth and DropDownHeight properties readonly. Their values are set in the constructor by the corresponding DataGridView properties Width and Height in order to limit all the grid area customization code within the grid creation code. Though it's only a matter of preference.

ToolStripDataGridView class subscribes and unsubscribes to child DataGridView events using ToolStripControlHost protected overridable subs OnSubscribeControlEvents and OnUnsubscribeControlEvents:

VB.NET
' 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)
    
    Dim nDataGridView As DataGridView = DirectCast(c, DataGridView)

    ' Add the events:
    ' to highlight the item that is currently under the mouse pointer
    AddHandler nDataGridView.CellMouseEnter, AddressOf OnDataGridViewCellMouseEnter
    ' to accept selection by enter key
    AddHandler nDataGridView.KeyDown, AddressOf OnDataGridViewKeyDown
    ' to accept selection by double clicking
    AddHandler nDataGridView.CellDoubleClick, AddressOf myDataGridView_DoubleClick
    ' to accept selection by single click (if CloseOnSingleClick is set tor TRUE)
    AddHandler nDataGridView.CellClick, AddressOf myDataGridView_Click

End Sub

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

    Dim nDataGridView As DataGridView = DirectCast(c, DataGridView)
 
    ' Remove the events.
    RemoveHandler nDataGridView.CellMouseEnter, AddressOf OnDataGridViewCellMouseEnter
    RemoveHandler nDataGridView.KeyDown, AddressOf OnDataGridViewKeyDown
    RemoveHandler nDataGridView.CellDoubleClick, AddressOf myDataGridView_DoubleClick
    RemoveHandler nDataGridView.CellClick, AddressOf myDataGridView_Click

End Sub

The events are pretty trivial and self-explanatory. The selection of an item is done by calling:

C#
DirectCast(Me.Owner, ToolStripDropDown).Close(ToolStripDropDownCloseReason.ItemClicked)

The OnBoundsChanged and Dispose subs are overridden to resize the child DataGridView when the parent ToolStripDataGridView is resized and to dispose the child DataGridView when the parent ToolStripDataGridView is disposed:

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

Protected Overrides Sub Dispose(ByVal disposing As Boolean)
    MyBase.Dispose(disposing)
    If Not Me.Control Is Nothing AndAlso Not _
       DirectCast(Control, DataGridView).IsDisposed Then Control.Dispose()
End Sub

And that's pretty much all about ToolStripDataGridView class: one constructor, four trivial properties, four trivial event handlers and four simple overrides. Total 109 lines of code including spaces.

Creating custom ComboBox

The next essential part of the control is an AccGridComboBox class itself, which obviously inherits from ComboBox.

AccGridComboBox class has a private variable myDropDown As ToolStripDropDown, which is instantiated in the class constructor and acts as a container for ToolStripDataGridView.

The instance of ToolStripDataGridView itself is set by AddDataGridView sub:

VB
Public Sub AddDataGridView(ByVal nDataGridView As DataGridView, ByVal nCloseOnSingleClick As Boolean)
    If Not myDataGridView Is Nothing Then Throw New Exception( _
        "Error. DataGridView is already assigned to the AccGridComboBox.")
    myDataGridView = New ToolStripDataGridView(nDataGridView, nCloseOnSingleClick)
    myDropDown.Width = Math.Max(Me.Width, myDataGridView.MinDropDownWidth)
    myDropDown.Height = nDataGridView.Height
    myDropDown.Items.Clear()
    myDropDown.Items.Add(Me.myDataGridView)
End Sub

AccGridComboBox 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 area 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

AccGridComboBox method that actually shows the dropdown essentially deals with the dropdown sizing and selecting the appropriate DataGridView row (which hold current SelectedValue).

VB
Private Sub ShowDropDown()
        
    ' if a DataGridView is assigned
    If Not Me.myDataGridView Is Nothing Then
        
        ' just in case, though such situation is not supposed to happen
        If Not myDropDown.Items.Contains(Me.myDataGridView) Then
            myDropDown.Items.Clear()
            myDropDown.Items.Add(Me.myDataGridView)
        End If

        ' do sizing
        myDropDown.Width = Math.Max(Me.Width, Me.myDataGridView.MinDropDownWidth)
        myDataGridView.Size = myDropDown.Size
        myDataGridView.DataGridViewControl.Size = myDropDown.Size
        myDataGridView.DataGridViewControl.AutoResizeColumns()

        ' select DataGridViewRow that holds the currently selected value
        If _SelectedValue Is Nothing OrElse IsDBNull(_SelectedValue) Then
            myDataGridView.DataGridViewControl.CurrentCell = Nothing

        ElseIf Not Me.ValueMember Is Nothing AndAlso _
                   Not String.IsNullOrEmpty(Me.ValueMember.Trim) Then

            ' If ValueMember is set, look for the value by reflection
            
            If myDataGridView.DataGridViewControl.Rows.Count < 1 OrElse _
                myDataGridView.DataGridViewControl.Rows(0).DataBoundItem Is Nothing OrElse _
                myDataGridView.DataGridViewControl.Rows(0).DataBoundItem.GetType. _
                GetProperty(Me.ValueMember.Trim, _
                  BindingFlags.Public OrElse BindingFlags.Instance) Is Nothing Then

                myDataGridView.DataGridViewControl.CurrentCell = Nothing

            Else

                Dim CurrentValue As Object
                For Each r As DataGridViewRow In myDataGridView.DataGridViewControl.Rows
                    If Not r.DataBoundItem Is Nothing Then
                        CurrentValue = GetValueMemberValue(r.DataBoundItem)
                        If _SelectedValue = CurrentValue Then
                            myDataGridView.DataGridViewControl.CurrentCell = _
                                myDataGridView.DataGridViewControl.Item(0, r.Index)
                            Exit For
                        End If
                    End If
                Next

            End If

        Else
 
            ' If ValueMember is NOT set, look for the value by value or 
            
            Dim SelectionFound As Boolean = False
            For Each r As DataGridViewRow In myDataGridView.DataGridViewControl.Rows
                Try
                    ' try by value because it's faster and lookup
                    ' objects usualy implement equal operators
                    If _SelectedValue = r.DataBoundItem Then
                        myDataGridView.DataGridViewControl.CurrentCell = _
                            myDataGridView.DataGridViewControl.Item(0, r.Index)
                        SelectionFound = True
                        Exit For
                    End If
                Catch ex As Exception
                    Try
                        If _SelectedValue Is r.DataBoundItem Then
                            myDataGridView.DataGridViewControl.CurrentCell = _
                                myDataGridView.DataGridViewControl.Item(0, r.Index)
                            SelectionFound = True
                            Exit For
                        End If
                    Catch e As Exception
                    End Try
                End Try
            Next
            If Not SelectionFound Then _
                       myDataGridView.DataGridViewControl.CurrentCell = Nothing

        End If

        myDropDown.Show(Me, CalculatePoz) 

    End If

End Sub
    
' Helper method, tries geting ValueMember property value by reflection
Private Function GetValueMemberValue(ByVal DataboundItem As Object) As Object
    Dim newValue As Object = Nothing
    Try
        newValue = DataboundItem.GetType.GetProperty(Me.ValueMember.Trim, BindingFlags.Public _
            OrElse BindingFlags.Instance).GetValue(DataboundItem, Nothing)
    Catch ex As Exception
    End Try
    Return newValue
End Function

' Helper method, takes care of dropdown fitting the window
Private Function CalculatePoz() As Point

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

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

    Return point

End Function

AccGridComboBox 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 SetValue(ByVal value As Object, ByVal IsValueMemberValue As Boolean)

    If value Is Nothing Then
        Me.Text = ""
        _SelectedValue = Nothing

    Else

        If Me.ValueMember Is Nothing OrElse String.IsNullOrEmpty(Me.ValueMember.Trim) _
            OrElse IsValueMemberValue Then

            Me.Text = value.ToString
            _SelectedValue = value

        Else

            Dim newValue As Object = GetValueMemberValue(value)

            ' If getting the ValueMember property value fails, try setting the object itself
            If newValue Is Nothing Then
                Me.Text = value.ToString
                _SelectedValue = value
            Else
                Me.Text = newValue.ToString
                _SelectedValue = newValue
            End If

        End If

    End If

End Sub

Private Sub ToolStripDropDown_Closed(ByVal sender As Object, _
            ByVal e As ToolStripDropDownClosedEventArgs)
    If e.CloseReason = ToolStripDropDownCloseReason.ItemClicked Then
        If Not MyBase.Focused Then MyBase.Focus()
        If myDataGridView.DataGridViewControl.CurrentRow Is Nothing Then
            SetValue(Nothing, False)
        Else
            SetValue(myDataGridView.DataGridViewControl.CurrentRow.DataBoundItem, False)
        End If
        MyBase.OnSelectedValueChanged(New EventArgs)
        ' If InstantBinding property is set to TRUE, force binding.
        If _InstantBinding Then
            For Each b As Binding In MyBase.DataBindings
                b.WriteValue()
            Next
        End If
    End If
End Sub

As you can see from the code above AccGridComboBox 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 value change.

That is all the code required by the combo control itself, but in order to make it ready for use as IDataGridViewEditingControl you need to implement a few more methods:

VB
Protected Overridable ReadOnly Property DisposeToolStripDataGridView() As Boolean
    Get
        Return True
    End Get
End Property

Friend Sub AddToolStripDataGridView(ByVal nToolStripDataGridView As ToolStripDataGridView)
    If nToolStripDataGridView Is Nothing OrElse (Not myDataGridView Is Nothing _
        AndAlso myDataGridView Is nToolStripDataGridView) Then Exit Sub
    myDataGridView = nToolStripDataGridView
    myDropDown.Width = Math.Max(Me.Width, myDataGridView.MinDropDownWidth)
    myDropDown.Height = myDataGridView.DropDownHeight
    myDropDown.Items.Clear()
    myDropDown.Items.Add(Me.myDataGridView)
End Sub

Protected Overrides Sub Dispose(ByVal disposing As Boolean)
    If disposing Then
        If components IsNot Nothing Then components.Dispose()
        If DisposeToolStripDataGridView Then
            If Not myDropDown Is Nothing AndAlso Not _
                      myDropDown.IsDisposed Then myDropDown.Dispose()
            If Not myDataGridView Is Nothing AndAlso _
                Not myDataGridView.DataGridViewControl Is Nothing AndAlso _
                Not myDataGridView.DataGridViewControl.IsDisposed Then _
                myDataGridView.DataGridViewControl.Dispose()
            If Not myDataGridView Is Nothing AndAlso Not myDataGridView.IsDisposed Then _
                myDataGridView.Dispose()
        ElseIf Not DisposeToolStripDataGridView AndAlso Not myDropDown Is Nothing _
             AndAlso Not myDropDown.IsDisposed Then
            If Not myDataGridView Is Nothing Then myDropDown.Items.Remove(myDataGridView)
            myDropDown.Dispose()
        End If
    End If
    MyBase.Dispose(disposing)
End Sub

If you have a standalone AccGridComboBox it is reasonable to dispose hosted ToolStripDropDown, ToolStripDataGridView, and DataGridView instances together with the combo itself as DataGridView instance cannot be reused across different forms. On the other hand, if you have an AccGridComboBox instance as a part of DataGridView column, you need to keep DataGridView instance for the column lifetime, not the combo lifetime (combo instances get disposed during column lifetime). To implement both behaviors protected overridable DisposeToolStripDataGridView property is used. This property indicates if the Dispose method should also dispose of ToolStripDataGridView and DataGridView instances. It always returns true unless overrided. And it is overriden in AccGridComboBoxEditingControl class that is in turn used by custom DataGridViewCell.

Creating custom IDataGridViewEditingControl, DataGridViewCell, and DataGridViewColumn

The process of creating a custom DataGridViewColumn is presented in details in the MSDN article How to: Host Controls in Windows Forms DataGridView Cells. So I will only discuss the code parts that are specific to AccGridComboBox implementation.

In the implementation of AccGridComboBoxEditingControl class there are only a few specific methods to compare with the implementation described in the mentioned MSDN article. This class needs to override DisposeToolStripDataGridView property as discussed previously in order to prevent disposing of DataGridView. This class also needs to handle SelectedValueChanged event and notify about the change DataGridView infrastructure. And finaly value to/from text conversions are handled by the base class AccGridComboBox thus the implementation of GetEditingControlFormattedValue contains merely a reference to Text property.

VB
Protected Overrides ReadOnly Property DisposeToolStripDataGridView() As Boolean
    Get
        Return False
    End Get
End Property

Private Sub SelectedValueChangedHandler(ByVal sender As Object, _
            ByVal e As EventArgs) Handles Me.SelectedValueChanged
    If Not _hasValueChanged Then
        _hasValueChanged = True
        _dataGridView.NotifyCurrentCellDirty(True)
    End If
End Sub

Public Function GetEditingControlFormattedValue(ByVal context As DataGridViewDataErrorContexts) _
       As Object Implements _
       System.Windows.Forms.IDataGridViewEditingControl.GetEditingControlFormattedValue
    Return Me.Text
End Function

In the implementation of AccGridComboBoxDataGridViewCell class there are only a few specific methods to compare with the implementation described in the mentioned MSDN article. As this cell is going to handle different object types ValueType property returns the most general type - Object. The other two methods are self explanatory and responsible for initializing AccGridComboBox editing control, getting and setting cell value.

VB
Public Overrides ReadOnly Property ValueType() As Type
    Get
        Return GetType(Object)
    End Get
End Property
 
Public Overrides Sub InitializeEditingControl(ByVal nRowIndex As Integer, _
    ByVal nInitialFormattedValue As Object, ByVal nDataGridViewCellStyle As DataGridViewCellStyle)

    MyBase.InitializeEditingControl(nRowIndex, nInitialFormattedValue, nDataGridViewCellStyle)

    Dim cEditBox As AccGridComboBox = TryCast(Me.DataGridView.EditingControl, AccGridComboBox)

    If cEditBox IsNot Nothing Then

        If Not MyBase.OwningColumn Is Nothing AndAlso Not DirectCast(MyBase.OwningColumn, _
            DataGridViewAccGridComboBoxColumn).ComboDataGridView Is Nothing Then

            ' Add the common column ToolStripDataGridView and set common properties
            cEditBox.AddToolStripDataGridView(DirectCast(MyBase.OwningColumn, _
                DataGridViewAccGridComboBoxColumn).GetToolStripDataGridView)
            cEditBox.ValueMember = DirectCast(MyBase.OwningColumn, _
                     DataGridViewAccGridComboBoxColumn).ValueMember
            cEditBox.InstantBinding = DirectCast(MyBase.OwningColumn, _
                     DataGridViewAccGridComboBoxColumn).InstantBinding

            End If
 
        ' try to set current value
        Try
            cEditBox.SelectedValue = Value
        Catch ex As Exception
            cEditBox.SelectedValue = Nothing
        End Try

    End If

End Sub

Protected Overrides Function SetValue(ByVal rowIndex As Integer, ByVal value As Object) As Boolean
    If Not Me.DataGridView Is Nothing AndAlso Not Me.DataGridView.EditingControl Is Nothing _
        AndAlso TypeOf Me.DataGridView.EditingControl Is AccGridComboBox Then
        Return MyBase.SetValue(rowIndex, DirectCast(Me.DataGridView.EditingControl, _
            AccGridComboBox).SelectedValue)
    Else
        Return MyBase.SetValue(rowIndex, value)
    End If
End Function

Finally DataGridViewAccGridComboBoxColumn class only implements properties that mirror AccGridComboBox properties and takes care of disposing the associated grid:

VB
Private myDataGridView As ToolStripDataGridView = Nothing
Public Property ComboDataGridView() As DataGridView
    Get
        If Not myDataGridView Is Nothing Then Return myDataGridView.DataGridViewControl
        Return Nothing
    End Get
    Set(ByVal value As DataGridView)
        If Not value Is Nothing Then
            myDataGridView = New ToolStripDataGridView(value, _CloseOnSingleClick)
        Else
            myDataGridView = Nothing
        End If
    End Set
End Property

Private _ValueMember As String = ""
Public Property ValueMember() As String
    Get
        Return _ValueMember
    End Get
    Set(ByVal value As String)
        _ValueMember = value
    End Set
End Property

Private _CloseOnSingleClick As Boolean = True
Public Property CloseOnSingleClick() As Boolean
    Get
        Return _CloseOnSingleClick
    End Get
    Set(ByVal value As Boolean)
        _CloseOnSingleClick = value
        If Not myDataGridView Is Nothing Then _
           myDataGridView.CloseOnSingleClick = value
    End Set
End Property

Private _InstantBinding As Boolean = True
Public Property InstantBinding() As Boolean
    Get
        Return _InstantBinding
    End Get
    Set(ByVal value As Boolean)
        _InstantBinding = value
    End Set
End Property

Protected Overrides Sub Dispose(ByVal disposing As Boolean)
    If disposing Then
        If Not myDataGridView Is Nothing _
            AndAlso Not myDataGridView.DataGridViewControl Is Nothing _
            AndAlso Not myDataGridView.DataGridViewControl.IsDisposed Then _
            myDataGridView.DataGridViewControl.Dispose()
        If Not myDataGridView Is Nothing AndAlso Not myDataGridView.IsDisposed Then _
            myDataGridView.Dispose()
    End If
    MyBase.Dispose(disposing)
End Sub

License

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