Click here to Skip to main content
15,861,168 members
Articles / Programming Languages / Visual Basic
Article

OwnerDrawn ComboBox with Icons, Divider, and a generic data store

Rate me:
Please Sign up or sign in to vote.
4.65/5 (24 votes)
5 Apr 2006CPOL12 min read 90.1K   2.6K   73   11
Customize the appearance and behavior of a ComboBox without using Windows messaging.

Sample Image - IconComboBox1.jpg

IconComboBox Table of Contents

Introduction

Many, if not all, of the Windows ComboBoxes contain interesting features such as icons, margins, and dividers. What doesn't make sense is, why doesn't the ComboBox control included with .NET contain these features? For some applications, it significantly improves the user's experience to have visual indication of a feature, conceptual division, or grouping. One, it provides quick understanding of the data, and two, it just downright looks cooler than a plain old text-only combo box.

Background

First, I do want to point to the IconComboBox control at vbAccelerator. I point this out because it uses a different approach than I do, and also because it has additional advanced features that readers might find useful. For my purposes, I could not use a control developed by someone else, for legal reasons - it must be a control I can include in my project without copyright restrictions (by the way, I grant the same right to readers here; I built this control, it's yours to use).

For my own control, I originally did not start including icons in the combo box - my initial desire was simply to be able to produce divider lines between groups in the combo box. However, when I realized how easy it was to add that feature, I included it, which required very few changes.

Prior attempts to customize the ComboBox to include dividers and icons have relied on using the Windows API to send Windows messages to the controls to control their appearance, as in another CodeProject article from 2005, whose code looks familiar to an article from 2003.

However, I wanted to stay away from that approach, for three reasons:

  • It is platform dependent, relying on a specific version of Windows to support a particular messaging schema.
  • That approach, while useful for its time, has a way of obfuscating what the code does - without knowing the inner workings of what Windows is doing when you send those messages, to the reader you might as well be telling Windows to make biscuits.
  • I thought .NET was supposed to eliminate the confusion of using the Win32 API?

Thus, I decided that my approach needed to make as little use of the Windows API as possible - which, in fact, it will do if you don't want to use icons in your ComboBox. It was as easy as overriding the OnDrawItem event, which was pretty surprising when I discovered the technique.

Now, I wanted to implement a ComboBox whose interface is as close as possible to the normal ComboBox. I believe my approach has done that - the only change is the item you add to the combo box, a custom item used to facilitate the custom data store.

Finally, I would like to give credit where credit is due. Some of the logic I used in the control was inspired by the Explorer ComboBox and ListView in VB.NET by Calum McLellan. While I did not end up using any of his code, except for controlling the font size and display style, it was his methodology that taught me how to implement the custom drawing.

Next, I would like to give credit for the IconExtractor class. I do not know who invented it first, because it is used in two places, so I will simply provide both credits: Associated Icons Image Control By Sergio Pereira, whose project was posted first, and FilesListBox By Eli Gazit, whose approach to custom drawing the ListBox I also used in another control I am using to create a custom file listing display (I am using a ListView instead of a ListBox though, and the logic is significantly different). I added one other function to the IconExtractor for my own uses, which extracts icons not normally found using ExtractIcon, which will be discussed below as the documentation on the function did not actually tell how to use it.

Using the library

If you want to skip the detailed explanation of how the control works, simply open your Toolbox, right-click and select "Choose Items", navigate to the IconComboBox/bin/ directory, and select IconComboBox.dll. Now, you can use the control just like a regular ComboBox from the Toolbox.

You may additionally need to add IconComboBox.dll as a reference to your project. The source code contains full XML documentation, so you should have full IntelliSense support for what all the objects, methods, and functions do.

The Code

IconComboBox consists of three objects:

  • IconComboBox - The actual IconComboBox control itself, which derives from System.Windows.Forms.ComboBox
  • IconComboItem - A class representing an item in the dropdown list of the ComboBox.
  • IconComboItemCollection - A Generic collection which stores the list of IconComboItems and provides an interface identical to:
    1. the regular ComboBox.Items interface, and
    2. the Generic.List(Of T) interface.

IconComboBox

First, we will discuss the main control class, IconComboBox. This is the main class that inherits from the System.Windows.Forms.ComboBox control.

First, in order to control how the ComboBox dropdown items are drawn, the control's DrawMode must be set to Windows.Forms.DrawMode.OwnerDrawFixed. What does this mean? The OwnerDrawFixed DrawMode means that the control's items will be drawn by the current class, and that each item will be the same size.

Second, the DropDownStyle on the base ComboBox must be set to DropDownList - we don't want the associated TextBox that accompanies some ComboBoxes.

Finally, we don't want these properties changed - so we catch those events and force the styles to remain the way we want, in case a user somehow finds a way to set the styles to unacceptable values.

Listing 1 demonstrates controlling the visual styles on the control.

VB
Public Sub New()
    MyBase.New()

    InitializeComponent()

    ' Instantiate a new IconComboItemCollection
    m_IconComboItemList = New IconComboItemCollection

    ' Add an event handler for the events thrown
    ' by the IconComboItemCollection
    AddHandler m_IconComboItemList.CollectionChanged, _
               AddressOf m_IconComboItemList_CollectionItemsChanged

    'Set to OwnerDraw
    Me.DrawMode = Windows.Forms.DrawMode.OwnerDrawFixed

    'Must be set to DropDownList
    Me.DropDownStyle = ComboBoxStyle.DropDownList
End Sub

'ComboBox must have its style set to DropDownList
Private Sub ExpCombo_DropDownStyleChanged(ByVal sender _
            As Object, ByVal e As System.EventArgs)
    Me.DropDownStyle = ComboBoxStyle.DropDownList
End Sub

'Make sure font doesn't get set to more than 12 points
Protected Overrides Sub OnFontChanged(ByVal e As System.EventArgs)
    If Me.Font.SizeInPoints > 12 Then
        Me.Font = New Font(Me.Font.FontFamily, 12, Me.Font.Style)
    End If
End Sub

The IconComboBox exposes all the normal ComboBox properties, as well as two additional properties: SelectedItem and ToolTipText. SelectedItem is pretty straightforward - it returns the IconComboItem represented by the currently selected item in the ComboBox.

I would like to talk for a moment though about ToolTipText. Strangely, the standard System.Windows.Forms.ComboBox does not expose a ToolTip for you to use. It was easy enough to add one, and in the source code, you will see ToolTip1 added as a component to the IconComboBox control. Then, IconComboBox exposes the ToolTipText property, so that you may set this text string. The ToolTip's default value is the Data string for the current IconComboItem - so remember that if you want to set the ToolTip text yourself, set this property each time the SelectedIndex changes.

Next, we want to handle the SelectedIndexChanged event. Why do we want to handle this event? Normally, consumer code handles these kinds of events. In our case, since we are using a custom data store, we need to make sure that the ComboBox displays what we want it to display. We also want to provide a ToolTip to the user so that they know more about the selected item, in case the ComboBox is not wide enough to display everything (which is usually the case).

Listing 2 demonstrates handling the SelectedIndexChanged event.

VB
Protected Overrides Sub OnSelectedIndexChanged(ByVal e As System.EventArgs)
        If Me.SelectedIndex < 0 Then
            Exit Sub
        End If

        If Me.SelectedIndex < m_IconComboItemList.Count Then
            If m_IconComboItemList(Me.SelectedIndex).IsDivider Then
                If Me.SelectedIndex > 0 Then
                    Me.SelectedIndex -= 1
                    m_CurrentItem = m_IconComboItemList(Me.SelectedIndex)
                ElseIf Me.SelectedIndex < m_IconComboItemList.Count - 1 Then
                    Me.SelectedIndex += 1
                    m_CurrentItem = m_IconComboItemList(Me.SelectedIndex)
                End If
            Else
                m_CurrentItem = m_IconComboItemList(Me.SelectedIndex)
                MyBase.OnSelectedIndexChanged(e)
            End If

            ' Set the default ToolTip string.
            ToolTip1.SetToolTip(Me, m_CurrentItem.Data)
        End If
    End Sub

You'll notice, I implement a couple of checks that seem to be unnecessary - that Me.SelectedIndex is >= 0 and also that Me.SelectedIndex is within the array bounds of the IconComboItemCollection. If the user selected an item from the dropdown, why is that necessary?

Because the selected index can be changed programmatically, as the Demo project demonstrates. We need to ensure that any indexing operations only occur on actual IconComboItems.

Finally, notice that I check to see if the selected item is a divider. If it is, I don't want dividers selected - they don't contain any useful data. If we're at the beginning of the list, select the next item. Otherwise, select the previous item. Finally, set the ToolTip text to the Data string for the selected IconComboItem. That text can also be set using the ToolTipText property, discussed above.

Note that consumer code can never create a divider item - only the IconComboBox can do that, using a .NET 2.0 feature called "Split Properties". Basically, the IsDivider property on the IconComboItem has a "Friend" modifier on the Set operation - meaning that only code in the same assembly can Set the IsDivider property.

So how do we actually add a divider then? There is an AddDivider method exposed by IconComboBox just for that purpose, shown in Listing 3.

Listing 3 - the AddDivider method, which demonstrates that only internal code can set the IsDivider property.

VB
Public Function AddDivider() As Integer
    Dim tempItem As New IconComboItem
    tempItem.DisplayText = ""
    tempItem.IsDivider = True

    Return Me.Items.Add(tempItem)
End Function

Why do I bother to set the DisplayText property to the empty string? This is because the standard ComboBox cannot accept Null values for the display text - which makes sense.

We also want to handle changes to the IconComboItemCollection - remember the event handler defined in the IconComboBox constructor? Here's the code that handles those events. It is very simple and straightforward, and is the backend interface to the ComboBox we're inheriting from. Listing 4 updates the underlying ComboBox with the changes that have been made to our custom collection.

VB
Private Sub m_IconComboItemList_CollectionItemsChanged(ByVal sender _
        As Object, ByVal e As IconComboItemCollectionChangedEventArgs)
    If e.ChangeType = IconComboItemCollectionChangeType.ItemAdded Then
        MyBase.Items.Add(e.ChangedItem.DisplayText)
    ElseIf e.ChangeType = IconComboItemCollectionChangeType.ItemInserted Then
        MyBase.Items.Insert(e.ChangedIndex, e.ChangedItem.DisplayText)
    ElseIf e.ChangeType = IconComboItemCollectionChangeType.ItemRemoved Then
        MyBase.Items.Remove(e.ChangedItem.DisplayText)
    ElseIf e.ChangeType = IconComboItemCollectionChangeType.CollectionCleared Then
        MyBase.Items.Clear()
    End If
End Sub

Finally, we need to discuss the real meat of this object - everything so far has just been decoration around the ComboBox. You remember that the basic method we are using here to draw the icons and divider lines is overriding the OnDrawItem method, instead of using the Windows Messaging API. Well, that part is fairly straightforward:

VB
Protected Overrides Sub OnDrawItem(ByVal e As _
          System.Windows.Forms.DrawItemEventArgs)
    e.DrawBackground()
    e.DrawFocusRectangle()

    If e.Index > -1 AndAlso e.Index < Me.Items.Count _
         AndAlso e.Index < m_IconComboItemList.Count Then
        Dim currentItem As IconComboItem = m_IconComboItemList(e.Index)
    ...

    End If

    MyBase.OnDrawItem(e)
End Sub

First, you'll notice that independent of any of my custom drawing, I am drawing the item background, focus rectangle, and calling the OnDrawItem method of my base class. This is so that all the standard behaviors you expect are implemented. Second, we're checking the index of the item being drawn - why? Because when the control has no items, e.Index is equal to -1. Now, why is OnDrawItem being called if there are no items to draw? What this is actually drawing is the underlying empty ListBox that is used in the base ComboBox. We're also checking that this item is inside our IconComboItemCollection, because again, nothing stops someone from calling this function programatically. We want to make sure we're going to draw an existing IconComboItem. There is also a check to make sure currentItem is not Null, not shown here.

If this IconComboItem is a divider, we want to draw a divider. Listing 5 draws a divider. bounds is the e.Bounds value passed into the OnDrawItem event.

VB
If currentItem.IsDivider Then
    Dim xStart As Single = bounds.X + 10
    Dim yStart As Single = Convert.ToSingle(bounds.Y + bounds.Height / 2 + 1)
    Dim xEnd As Single = bounds.Width - 10
    Dim yEnd As Single = yStart
    e.Graphics.DrawLine(Pens.Black, xStart, yStart, xEnd, yEnd)

    yStart = Convert.ToSingle(bounds.Y + bounds.Height / 2 - 1)
    yEnd = yStart
    e.Graphics.DrawLine(Pens.Black, xStart, yStart, xEnd, yEnd)
Else

If this item is an actual IconComboItem, we want to draw its associated icon and then its DisplayText. Listing 6 draws an IconComboItem.

VB
Dim imageSize As Size
Dim fileNameOnly As String = currentItem.DisplayText 'Items(e.Index).ToString()
Dim fullFileName As String = currentItem.Data
Dim fileIcon As Icon = currentItem.ItemImage
Dim imageRectangle As New Rectangle(bounds.Left + 1, _
                   bounds.Top + 1, ItemHeight, ItemHeight)

If fileIcon IsNot Nothing Then
    e.Graphics.DrawIcon(fileIcon, imageRectangle)
End If
imageSize = imageRectangle.Size

Dim fileNameRec As New Rectangle(bounds.Left + _
    imageSize.Width + 3, bounds.Top, _
    bounds.Width - imageSize.Width - 3, bounds.Height)
Dim format As New StringFormat()
format.LineAlignment = StringAlignment.Center
e.Graphics.DrawString(fileNameOnly, e.Font, _
           New SolidBrush(e.ForeColor), fileNameRec, format)

Do you see how easy it is to draw the icon in the specified location, then move over a little and draw the text? There is no need to use the Windows Messaging API - this is a much more straightforward, understandable, and readable approach. Now, what is that ItemHeight value? This is a property of the underlying ComboBox; it specifies how high the ComboBox says an item should be. This value is determined by the underlying ListBox when OnMeasureItem is called - an event I don't seem to have access to. Otherwise, I would modify the height of the divider items to only be several pixels high, instead of being the height of an entire row.

IconComboItem

This is the custom object that holds our combo box item data. It is a very simple class that exposes four properties:

  • ItemImage() As Icon - the icon associated with this item
  • Data() As String - The full data string to save for this item
  • DisplayText() As String - the string to display for this item
  • IsDivider() As Boolean - returns whether or not this is a divider item; can only be set by IconComboBox.

There is an associated constructor which accepts these values, except for IsDivider, which is Set explicitly by the IconComboBox.

There is one point of interest for the IconComboItem - the fact that it implements the IEquatable(Of T) interface. Generic lists will use the Equals method exposed by the IEquatable interface to compare items. I implement this interface so that the IconComboItemCollection is able to use the Contains, IndexOf,Remove, and Sort methods of the underlying Generic.List.

IconComboItemCollection

Finally, there is the IconComboItemCollection. Since the preceding discussion has addressed most of this collection's functionality, I will only demonstrate how the collection notifies the IconComboBox that it has been changed:

Listing 7 - Raising CollectionChanged events from the IconComboItemCollection.
VB
Public Function Add(ByVal argItem As IconComboItem) As Integer
    m_List.Add(argItem)
    RaiseEvent CollectionChanged(Me, New _
      IconComboItemCollectionChangedEventArgs(m_List.Count _
      - 1, IconComboBox.IconComboItemCollectionChangeType.ItemAdded, _
      argItem))
    Return m_List.Count - 1
End Function

m_List is the private variable used to implement the Generic.List(Of IconComboItem). IconComboItemCollectionChangedEventArgs is a tiny class used to deliver event arguments to event handlers; if it is not obvious here what data the event handler delivers, take a look at the source code.

Points of Interest

I have made a list of the following items I think could be done to improve this control. If anyone has any ideas on how they can be implemented, please let me know.

  • How to truly generalize the custom data store so that the Data item can be any Type, instead of just a string. In my application, I needed to save the full string but only display part of it; I tried implementing the collection using Generics, but I ran into the chicken and the egg problem - how do I know what System.Type to use for the IconComboItemCollection prior to the object being instantiated?
  • I would like to make it so that the divider lines:
    1. do not take up a full item's row height, and
    2. are not highlightable as the mouse passes over the list.

    I attempted to override the OnMeasureItem event, but that function never got called; I assume it is only getting called by the underlying ListBox control, which I do not appear to be able to access.

  • It might be nice to implement a drawing interface so that the consumer can specify a Draw method for the dividers - that way, they can draw any divider they want, instead of the hardcoded double lines I have now.
  • I have also thought about implmenting the icons as an ImageList and providing each IconComboItem an ImageIndex property, instead of straight assignment of the icon to the Item. This would bring the interface even closer to the ListView interface for using item icons. (Which suggests another method of implementing a ComboBox - rewrite the control entirely to use a ListView as its underlying dropdown, rather than a ListBox.)
  • It would be nice to provide Designer support for the control. This is kinda hard to do, and while I know the basics, creating a solid Designer framework for the control is not something I can do right now.
That's about it. Suggestions, comments, questions, bug fixes are welcome!

History

  • Thursday, April 06, 2006 - Initial submission.

License

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


Written By
Software Developer Microsoft
United States United States
I did some stuff.. I live in Seattle.. now I work for Live Search at Microsoft Smile | :)

Comments and Discussions

 
GeneralMy vote of 5 Pin
karenpayne16-Dec-10 4:17
karenpayne16-Dec-10 4:17 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.