Click here to Skip to main content
15,868,141 members
Articles / Programming Languages / Visual Basic

Yet Another Multi-Column Combobox

Rate me:
Please Sign up or sign in to vote.
4.92/5 (4 votes)
4 Oct 2016CPOL9 min read 8.8K   340   8  
Combobx component that display multiple columns from any Datasource

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.

Image 1

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.

VB
Public Sub New(ByVal container As System.ComponentModel.IContainer)
   MyClass.New()

   'Required for Windows.Forms Class Composition Designer support
   If (container IsNot Nothing) Then
      container.Add(Me)
   End If
   Me.DrawMode = Windows.Forms.DrawMode.OwnerDrawFixed
End Sub
VB
<System.Diagnostics.DebuggerNonUserCode()> _
Public Sub New()
   MyBase.New()

   'This call is required by the Component Designer.
   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:

Image 2

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:

VB
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.

VB
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:

VB
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.

VB
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.

VB
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

               'generate SelectedValueChanged event
               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
VB
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.

VB
Private Structure Column
   'name of the column to render
   Public Name As String
   'index of the column to render
   Public Index As Integer
   'width of the column
   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:

  1. 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.
  2. 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.

VB
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)

      'Dim dr As DataRow
      'For k As Integer = 0 To NCols - 1
      '   mRenderedCols(k).Index = k
      '   For Each dr In dt.Rows
      '      s = dr.Item(k).ToString
      '      StringLength = CInt(g.MeasureString(s, Me.Font).Width) + 1
      '      If StringLength > mRenderedCols(k).Width Then
      '         mRenderedCols(k).Width = StringLength
      '      End If
      '   Next
      'Next


      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:

VB
Private Sub HandleColumnWidth()
   'calc of the optimal columns width
   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
VB
Private Sub HandleItemsCollection()
   'load the Items collection
   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
VB
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:

VB
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.

VB
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
VB
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:

VB
'Set to True to draw horizontal lines.
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.

VB
Private Sub MultiColumnsCombobox_DrawItem(sender As Object, e As DrawItemEventArgs) Handles Me.DrawItem
   Dim c As Column

   ' Draw the default background
   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
VB
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)

   ' Draw the text 
   e.Graphics.DrawString(CellValue, e.Font, sb, R)

   ' Draw a line to separate the columns 
   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!

License

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


Written By
Engineer
United States United States
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
-- There are no messages in this forum --