Introduction
There are many components that inherit from ComboBox and display more than only column of a DataTable. Some of these components are really cool, and others ... not so much! What I would like to add to the existing plethora of this type of control is one that of course will display multiple columns from any object used as Datasource property, but that will also have the capability to accept a generic square array with any number of columns as Datasource. From this array, the control will display the columns that will be specified by using a custom method. To clarify what our final result will be, here is a screenshot of the control in action, showing the content of an array containing a list of my favorite rock songs.
There are three main areas where we will need to focus our attention:
- The Datasource property that will need to work with arrays
- The routines to specify the columns to show
- The painting of the items in the dropdown area of the control
As expected, the control will inherit from combobox and since we will take care of the painting of the items, we start by setting the DrawMode
property to OwnerDrawFixed
in both standard constructors.
Public Sub New(ByVal container As System.ComponentModel.IContainer)
MyClass.New()
If (container IsNot Nothing) Then
container.Add(Me)
End If
Me.DrawMode = Windows.Forms.DrawMode.OwnerDrawFixed
End Sub
<System.Diagnostics.DebuggerNonUserCode()> _
Public Sub New()
MyBase.New()
InitializeComponent()
Me.DrawMode = Windows.Forms.DrawMode.OwnerDrawFixed
End Sub
Datasource property
On its own, the dataSource
property of a combobox only accepts:
- objects that implement
IList
or IListSource
interface - single column arrays
Try any other form of list/collection and you will get this rather clear error message:
I will not talk much about the IListSource
interface, except to say that it only has one property and one method. This method (GetList()
) "Returns an IList that can be bound to a data source from an object that does not implement an IList itself." In other words, an object implementing the IListSource
must have a method that return an object that implements IList.
I am not going to dissect the IList
interface, but as far as we are concerned, for the task at hand the single most important thing to know about this interface is that in order to access an element of IList
, the interface provides a property Item(Int32)
which returns one object of the list.
Now, it is important to note that while this object can be of any type and no assumption can be made about it (for all we know, the object could be an image), our aim is to display a set of "fields" belonging to this object. Therefore, we are implicitly assuming that the object returned by the Item property has a structure, and that the elements of this structure can be accessed by this type of syntax:
Element = IList.Item(Int32)(Index)
This observation will be useful when we will need to get value of these "fields" and display them. Unfortunately, this also mean that we will need to rely on late binding to access this structure and that we will not be able to use Option Strict On.
To proceed, the idea is to create a shadowed version of the DataSource
property that will handle both arrays and objects implementing IList
or IListSource
interfaces. But before we get to that point, there are few intermediate steps that we must take.
First off, we need to make sure that if the object used as Datasource
is not an array, it must implement either IList
or IListSource
.
I created a simple Boolean function that uses the System.Reflection
library to check if the object assigned to the DataSource
property implements IList
or IListSource
. This Function is called when the value for the DataSource
property is set. The type of interface implemented by the object is saved in a module level variable and used where necessary. Also, to make the code more readable, this module level variable will be linked to a enumerative type.
Private Enum enDatasourceType
[IList] = 1
[IListSource] = 2
[Array] = 3
End Enum
Dim mDatasourceType As enDatasourceType
Private Function ImplementsIList(obj As Object) As Boolean
Dim T As Type
T = obj.GetType
If T.GetInterfaces.Contains(GetType(IList)) Then
mDatasourceType = enDatasourceType.IList
Return True
ElseIf T.GetInterfaces.Contains(GetType(System.ComponentModel.IListSource)) Then
mDatasourceType = enDatasourceType.IListSource
Return True
End If
Return False
End Function
If the datasource is not an array and does not implement IListSource
or IList
, we will throw an exception. At this point, the first draft of the DataSource
property would look like this:
Public Shadows Property DataSource As Object
...
Set(ByVal value As Object)
If TypeOf value Is Array Then
...
Else
If ImplementsIList(value) Then
MyBase.DataSource = value
Me.Text = ""
Else
Throw New System.ArgumentException("Additional information: Complex DataBinding accepts as a data source either an IList or an IListSource.")
End If
End If
End Set
End Property
Now we need to code the part of the property that deals with the arrays. To this end, there are few things that we need to consider.
- For obvious reasons, the array cannot be directly assigned to the
MyBase.DataSource
property. We will need to save it in a internal array and to set MyBase.DataSource
to nothing. The internal array will be also used in the Get
section of the property. - The array data will be stored in a datatable object. While this is not strictly necessary, it will be very helpful when displaying the items in the dropdown of the combobox.
The last tasks is performed by a procedure called ConvertToDataTable
, whose code is very straightforward and easy to understand.
As said, when the datasource is an array, such array will be kept in a module level variable called mDSArr
and the data in it will be loaded in the Datatable module variable mArrayDT
. The variable mDSArr
will be toggled to Nothing when the Datasource is set to a IList
type of object.
Dim mDSArr As Array = Nothing
Dim mArrayDT As DataTable
Public Shadows Property DataSource As Object
Get
If IsNothing(mDSArr) Then
Return MyBase.DataSource
Else
Return mDSArr
End If
End Get
Set(ByVal value As Object)
If TypeOf value Is Array Then
mDSArr = CType(value, Array)
mArrayDT = ConvertToDataTable(mDSArr)
MyBase.DataSource = Nothing
mDatasourceType = enDatasourceType.Array
HandleItemsCollection()
Else
If ImplementsIList(value) Then
mDSArr = Nothing
MyBase.DataSource = value
Me.Text = ""
Else
Throw New System.ArgumentException("Additional information: Complex DataBinding accepts as a data source either an IList or an IListSource.")
End If
End If
HandleColumnWidth()
HandleDropDownWidth()
End Set
End Property
Don't worry about the call to the HandleColumnWidth()
, HandleColumnItems()
and HandleDropDownWidth()
private methods. We will discuss this later.
Another property that will need some tweaking is SelectedValue
. When the datasource is an array, we will need to return the value of the field referenced by the ValueMember
property. When SelectedValue
is set (I know that probably that does not happen very often, but we must take this possibility in account anyway), we must loop through the values of the field referenced by ValueMember
and look for the index of the value set for the property (see the GetValueIndex
function below). If this index is found, we set the SelectedIndex
property of the control to it.
Public Shadows Property SelectedValue As Object
Get
Try
If mDatasourceType = enDatasourceType.Array Then
If IsNumeric(MyBase.ValueMember) Then
Return mArrayDT.Rows(Me.SelectedIndex).Item(CInt(MyBase.ValueMember))
Else
Throw New Exception("ValueMember property must be a number when used with Array as Datasource")
End If
Else
Return MyBase.SelectedValue
End If
Catch ex As Exception
End Try
Return Nothing
End Get
Set(value As Object)
If mDatasourceType = enDatasourceType.Array Then
If IsNumeric(MyBase.ValueMember) Then
Dim idx As Integer = GetValueIndex(CStr(value))
If idx > -1 Then
Me.SelectedIndex = idx
MyBase.SelectedValue = value
End If
Else
Throw New Exception("ValueMember property must be a number when used with Array as Datasource")
End If
Else
MyBase.SelectedValue = value
End If
End Set
End Property
Private Function GetValueIndex(Value As String) As Integer
Dim Col As Integer = CInt(MyBase.ValueMember)
Dim Idx As Integer = 0
If Col > mDSArr.GetUpperBound(1) Then
Throw New Exception("ValueMember property must be a number smaller than the number of columns in the array.")
End If
For Each r As DataRow In mArrayDT.Rows
Try
If r.Item(Col).ToString = Value Then
Return Idx
End If
Catch ex As Exception
End Try
Idx += 1
Next
Return -1
End Function
The columns
The control must allow us to specify which column we want to visualize and also the width of any of these columns. This is done through the use of the overloaded RenderColumn
method: one version allows to specify the index of a column (essential when we use arrays), and one version allows to specify the name of a column. To store the information coming from RenderColumn
it will use an array of an internal structure called Column
.
Private Structure Column
Public Name As String
Public Index As Integer
Public Width As Integer
End Structure
Private mRenderedCols() As Column = {}
Public Sub RenderColumn(Name As String, Width As Integer)
Dim Index As Integer = mRenderedCols.Length
ReDim Preserve mRenderedCols(Index)
mRenderedCols(Index).Name = Name
mRenderedCols(Index).Width = Width
End Sub
Public Sub RenderColumn(ArrayColIndex As Integer, Width As Integer)
Dim size As Integer = mRenderedCols.Length
ReDim Preserve mRenderedCols(Size)
mRenderedCols(size).Index = ArrayColIndex
mRenderedCols(Size).Width = Width
End Sub
There is another functionality that I think the control should have. If all the columns of an array or a datatable need to be displayed it should be possible to do so without having to go through the tedious task to call RenderColumn
for all of them. To accomplish that, the program calculates the optimal columns width of each column and the width of the Dropdown. Optimal column width is intended to be the width of the longest string in each column. To calculate such number, there are essentially to ways:
- Loop through every column and every row of the datatable used as datasource and for each element calculate its graphic length using the
MeasureString
function. The longest element in a column will be used as optimal column length. This approach is valid even when we use an array as Datasource
because the data is loaded in the mArrayDT
datatable. - Loop through every column and every row of the datatable used as datasource, and for each element calculate the number of characters. The length of the element with the maximum number of characters in a column will be used as optimal column length.
While the second way can produce results slightly less precise that the first way (for example, depending on the font type, the length of the string"ii" can be shorter of the string "W"), it is certainly much faster because the function MeasureString
is used only against the one string with the most characters. In the code below I used the second approach and commented out code for the first one.
Private Sub CalcOptimalColumnsWidth(ByVal dt As DataTable)
Dim s As String = ""
Dim g As Graphics = Me.CreateGraphics
Dim StringLength As Integer
Dim MaxLength As Integer
Dim NCols As Integer
Dim Index As Integer = -1
Dim NRows As Integer
If Not IsNothing(dt) Then
NCols = dt.Columns.Count
NRows = dt.Rows.Count - 1
ReDim mRenderedCols(NCols - 1)
For k As Integer = 0 To NCols - 1
mRenderedCols(k).Index = k
MaxLength = 0
For j As Integer = 0 To NRows
If Not IsNothing(dt.Rows(j).Item(k)) Then
StringLength = Len(dt.Rows(j).Item(k).ToString)
If StringLength > MaxLength Then
s = dt.Rows(j).Item(k).ToString
MaxLength = StringLength
End If
End If
Next
mRenderedCols(k).Width = CInt(g.MeasureString(s, Me.Font).Width) + 1
Next
End If
End Sub
One more thing we must do is to load the Items collection of the combobox. We will load the collection using the data coming from either column number zero of the array or in the column whose index is specified in the DisplayMemeber
property, if the property is not Nothing.
The reason for doing this is that when the Items collection is empty and the datasource property is set to nothing, the DrawItem
event is not fired. The task is performed by the LoadItemsCollection
method. The code is very easy and I will not show it here.
Last but not least, we also need to calculate the width of the dropdown area of the control to make sure that all the information will be visible. The task is performed by the CalcDropDownWidth
method. The code of it, is very easy and I will not show it here.
Using the Pieces
At this point we pack the above pieces in three procedures:
Private Sub HandleColumnWidth()
If mRenderedCols.Length = 0 Then
If mDatasourceType = enDatasourceType.Array Then
If Not IsNothing(mDSArr) Then
CalcOptimalColumnsWidth(mArrayDT)
End If
ElseIf TypeOf MyBase.DataSource Is DataTable Then
CalcOptimalColumnsWidth(CType(MyBase.DataSource, DataTable))
End If
End If
End Sub
Private Sub HandleItemsCollection()
If mDatasourceType = enDatasourceType.Array Then
If MyBase.DisplayMember = "" Then
LoadItemsCollection(0)
Else
If Not IsNumeric(MyBase.DisplayMember) Then
LoadItemsCollection(0)
Else
LoadItemsCollection(CInt(MyBase.DisplayMember))
End If
End If
End If
End Sub
Private Sub HandleDropDownWidth()
Dim L As Integer
L = CalcDropDownWidth()
If L > 0 Then
Me.DropDownWidth = L
End If
End Sub
Now, when do we call this procedures? Well, HandleItemCollection
is used only when the Datasource
is of array type and the parameter we use to call it depends on the DisplayMemebr
property. Therefore we place a call to this procedure inside the Datasource
property (as we already noted before) and inside OnDisplayMemeberChanged
:
Protected Overrides Sub OnDisplayMemberChanged(e As EventArgs)
MyBase.OnDisplayMemberChanged(e)
HandleItemsCollection()
End Sub
The calculation of the optimal width of the columns needs to be done only when the length of array mRenderedCols
is zero, that is when either method RenderColumn
is never used. So we place a call to HandleDropDownWidth
in the Datasource
property, as we already seen before. But we also need to change the code of the RenderColumn
methods so that when one of them is called for the first time, the elements of the mRenderedCols
array that might have been set up in Datasource
are eliminated.
Private mRendered As Boolean = False
Public Sub RenderColumn(Name As String, Width As Integer)
If Not mRendered Then
ReDim mRenderedCols(-1)
mRendered = True
End If
Dim Index As Integer = mRenderedCols.Length
ReDim Preserve mRenderedCols(Index)
mRenderedCols(Index).Name = Name
mRenderedCols(Index).Width = Width
HandleDropDownWidth()
End Sub
Public Sub RenderColumn(ArrayColIndex As Integer, Width As Integer)
If Not mRendered Then
ReDim mRenderedCols(-1)
mRendered = True
End If
Dim Index As Integer = mRenderedCols.Length
ReDim Preserve mRenderedCols(Index)
mRenderedCols(Index).Index = ArrayColIndex
mRenderedCols(Index).Width = Width
HandleDropDownWidth()
End Sub
We could have avoided this added complexity in the code of RenderColumn
by using a different array to calculate the optimal width when the length of mRenderedCols
is zero.
Painting the Combobox Items
At this point, the most of the work is done.
We add few property to customize the look of the control and paint the items:
Public Property DrawHorizontalLine As Boolean = True
Public Property HorizontalLineColor As Color = Color.Bisque
Public Property HorizontalLineWidth As Integer = 1
Public Property VerticalLineWidth As Integer = 1
Public Property VerticalLineColor As Color = Color.Blue
We then handle the DrawItem
event. The code in this event deserves few remarks.
Depending on the value of the mDatasourceType
variable, the ListItem object will host either one row of the array or one "record" of the Datasource
. Once the ListItem
object is set, we loo through the mRenderdCols
array and using the Name (or Index) property of its Column objects we get the value of the correspondent ListItem
"field" and we paint it.
Private Sub MultiColumnsCombobox_DrawItem(sender As Object, e As DrawItemEventArgs) Handles Me.DrawItem
Dim c As Column
e.DrawBackground()
Dim k As Integer = 0
Dim iLeft As Integer = 0
Dim r As Rectangle
Dim i As Integer = 0
Dim ListItem As Object = Nothing
Try
If mDatasourceType = enDatasourceType.Array Then
ListItem = mArrayDT.Rows(e.Index)
ElseIf mDatasourceType = enDatasourceType.IList Then
ListItem = CType(MyBase.DataSource, IList).Item(e.Index)
ElseIf mDatasourceType = enDatasourceType.IListSource Then
ListItem = CType(MyBase.DataSource,
System.ComponentModel.IListSource).GetList.Item(e.Index)
End If
Dim ph As Pen = New Pen(HorizontalLineColor, HorizontalLineWidth)
Dim IsLastColumn As Boolean = False
If mRenderedCols.Length > 0 Then
For k = 0 To mRenderedCols.Count - 1
c = mRenderedCols(k)
If k = mRenderedCols.Count - 1 Then
IsLastColumn = True
End If
r = New Rectangle(iLeft, e.Bounds.Y, c.Width, e.Bounds.Height)
If c.Name <> "" Then
DrawColumn(e, r, Not IsLastColumn, ListItem(c.Name).ToString())
Else
DrawColumn(e, r, Not IsLastColumn, ListItem(c.Index).ToString())
End If
iLeft = iLeft + c.Width
Next
If DrawHorizontalLine Then
e.Graphics.DrawLine(ph, 0, r.Bottom - 1, Me.DropDownWidth, r.Bottom - 1)
End If
End If
Catch ex As Exception
End Try
End Sub
Private Sub DrawColumn(e As DrawItemEventArgs, R As Rectangle, DrawSeparatingLine As Boolean,
CellValue As String)
Dim sb As SolidBrush = New SolidBrush(e.ForeColor)
Dim p As Pen = New Pen(VerticalLineColor, VerticalLineWidth)
e.Graphics.DrawString(CellValue, e.Font, sb, R)
If DrawSeparatingLine Then
e.Graphics.DrawLine(p, R.Right, 0, R.Right, R.Bottom)
End If
End Sub
And this is all folks.
Happy Coding!
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.