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
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:
DataGridViewAccGridComboBoxColumn1.ComboDataGridView = ProgramaticalyCreatedDataGridView
DataGridViewAccGridComboBoxColumn1.CloseOnSingleClick = True
DataGridViewAccGridComboBoxColumn1.InstantBinding = True
AccGridComboBox1.AddDataGridView(ProgramaticalyCreatedDataGridView, True)
AccGridComboBox1.InstantBinding = True
Quick, self-explanatory example on how to programmatically create a DataGridView:
Public Function CreateDataGridViewForPersonInfo(ByVal TargetForm As Form, _
ByVal ListBindingSource As BindingSource) As DataGridView
Dim result As New DataGridView
Dim DataGridViewTextBoxColumn1 As New System.Windows.Forms.DataGridViewTextBoxColumn
Dim DataGridViewTextBoxColumn2 As New System.Windows.Forms.DataGridViewTextBoxColumn
CType(result, System.ComponentModel.ISupportInitialize).BeginInit()
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
result.DataSource = ListBindingSource
result.Columns.AddRange(New System.Windows.Forms.DataGridViewColumn() _
{DataGridViewTextBoxColumn1, DataGridViewTextBoxColumn2})
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
result.BindingContext = TargetForm.BindingContext
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
:
Protected Overrides Sub OnSubscribeControlEvents(ByVal c As Control)
MyBase.OnSubscribeControlEvents(c)
Dim nDataGridView As DataGridView = DirectCast(c, DataGridView)
AddHandler nDataGridView.CellMouseEnter, AddressOf OnDataGridViewCellMouseEnter
AddHandler nDataGridView.KeyDown, AddressOf OnDataGridViewKeyDown
AddHandler nDataGridView.CellDoubleClick, AddressOf myDataGridView_DoubleClick
AddHandler nDataGridView.CellClick, AddressOf myDataGridView_Click
End Sub
Protected Overrides Sub OnUnsubscribeControlEvents(ByVal c As Control)
MyBase.OnUnsubscribeControlEvents(c)
Dim nDataGridView As DataGridView = DirectCast(c, DataGridView)
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:
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:
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.
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)
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
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
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
).
Private Sub ShowDropDown()
If Not Me.myDataGridView Is Nothing Then
If Not myDropDown.Items.Contains(Me.myDataGridView) Then
myDropDown.Items.Clear()
myDropDown.Items.Add(Me.myDataGridView)
End If
myDropDown.Width = Math.Max(Me.Width, Me.myDataGridView.MinDropDownWidth)
myDataGridView.Size = myDropDown.Size
myDataGridView.DataGridViewControl.Size = myDropDown.Size
myDataGridView.DataGridViewControl.AutoResizeColumns()
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 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
Dim SelectionFound As Boolean = False
For Each r As DataGridViewRow In myDataGridView.DataGridViewControl.Rows
Try
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
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
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
.
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 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 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:
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.
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.
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
cEditBox.AddToolStripDataGridView(DirectCast(MyBase.OwningColumn, _
DataGridViewAccGridComboBoxColumn).GetToolStripDataGridView)
cEditBox.ValueMember = DirectCast(MyBase.OwningColumn, _
DataGridViewAccGridComboBoxColumn).ValueMember
cEditBox.InstantBinding = DirectCast(MyBase.OwningColumn, _
DataGridViewAccGridComboBoxColumn).InstantBinding
End If
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