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.
DataGridView
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).
ComboBox
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
This article is based on the ideas presented in these articles:
ToolStripControlHost
DataGridViewColumn
IDataGridViewEditingControl
DataGridViewCell
Using the provided custom AccGridComboBox and DataGridViewAccGridComboBoxColumn classes is as simple as using ComboBox and DataGridViewColumn themselves.
AccGridComboBox
DataGridViewAccGridComboBoxColumn
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:
' 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:
DataGridView:
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
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
CloseOnSingleClick
DataGridViewControl
MinDropDownWidth
DropDownHeight
Width
Height
ToolStripDataGridView class subscribes and unsubscribes to child DataGridView events using ToolStripControlHost protected overridable subs OnSubscribeControlEvents and OnUnsubscribeControlEvents:
OnSubscribeControlEvents
OnUnsubscribeControlEvents
' 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:
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:
OnBoundsChanged
Dispose
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.
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:
myDropDown As ToolStripDropDown
AddDataGridView
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.
WndProc
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).
SelectedValue
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.
ValueMember
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.
InstantBinding
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:
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.
ToolStripDropDown
DisposeToolStripDataGridView
AccGridComboBoxEditingControl
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.
SelectedValueChanged
GetEditingControlFormattedValue
Text
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.
AccGridComboBoxDataGridViewCell
ValueType
Object
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:
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