Building a Better ComboBox

 

George Politis
Intertech Software

January 2005

Applies to:
   Windows Forms 1.x
   Windows Forms 2.0
   Visual Studio

Summary: George Politis outlines several techniques to improve the default functionality of the Windows Forms ComboBox, which will provide a better user experience for your own applications. (9 printed pages)

Download the BetterComboBoxSource.msi file.

This article assumes you are familiar with creating and manipulating projects and class files within Visual Studio. For simplicity, the code in this article uses the ComboBox Items collection for data. With small modifications this code can be altered to provide the same improved functionality for ComboBoxes that use data binding.

Introduction

For a majority of applications, the Windows Forms ComboBox contains most of the functionality a developer might need. There are a few scenarios, however, where you may want to enhance its default behavior. For instance, while the DropDownWidth property allows for setting the width of the drop-down portion of the ComboBox, there is no way to specify that the width automatically match the widest item within the ComboBox. Another problem with increasing the DropDownWidth property happens when you place the ComboBox near the right edge of the screen. If the drop-down portion of the ComboBox happens to be wider than the actual control's width, which it typically will be, it will display partially off-screen. This can end up hiding some of the text items as well as the scrollbar, resulting in a negative user experience. We will resolve both of these issues by building our own better ComboBox control.

First Things First

The first thing we need to do is create our own ComboBox control so we can enhance its behavior. Open Visual Studio and create a new Windows Control Library project called BetterComboBox, and rename the UserControl1.cs file within the Solution Explorer to BetterComboBox.cs. Now we need to change the instances within the class of the previous file name, UserControl1, to our new name, BetterComboBox. Once the file is open for editing, the quickest way to do this is to select Edit, then click Find and Replace, and then click Replace, and replace all instances of UserControl1 with BetterComboBox.

Since we want all the functionality of the Windows Forms ComboBox, the next change we need to make is to inherit from System.Windows.Forms.ComboBox rather than System.Windows.Forms.UserControl. Within the BetterComboBox.cs file, change the class we inherit from to System.Windows.Forms.ComboBox, as shown below.

public class BetterComboBox : System.Windows.Forms.ComboBox

Now we have everything we need to add our own enhancements to create a better ComboBox.

Intelligent DropDownWidth

The items within a ComboBox are typically data-driven and not predetermined. It is this dynamic nature that prevents a developer from being able to set the DropDownWidth to an appropriate width that accommodates the largest contained item at design time. If some of the data you have bound to a ComboBox becomes longer than was expected at design time, your users could encounter the-hard-to use ComboBox in Figure 1.

Figure 1. Hidden Item Text

Figure 2. Intelligent DropDownWidth

Obviously, the improper DropDownWidth for the ComboBox in Figure 1 makes for a bad user experience. So how do we fix it? Since we cannot determine what the DropDownWidth property should be set to while designing a form, we need to do it at run time. We will need to iterate over the items within the Items collection and determine the maximum width using the System.Drawing.Graphics.MeasureString method. To accomplish this, add the following UpdateDropDownWidth method to the BetterComboBox class. This method measures each display entry based on the font of the ComboBox and sets the DropDownWidth property if the calculated width is larger than the original DropDownWidth.

private void UpdateDropDownWidth()
{
   //Create a GDI+ drawing surface to measure string widths
   System.Drawing.Graphics ds = this.CreateGraphics();

   //Float to hold largest single item width
   float maxWidth = 0;

   //Iterate over each item, measuring the maximum width
   //of the DisplayMember strings
   foreach(object item in this.Items)
   {
      maxWidth = Math.Max(maxWidth, ds.MeasureString(item.ToString(), this.Font).Width);
   }

   //Add a buffer for some white space
   //around the text
   maxWidth +=30;

   //round maxWidth and cast to an int
   int newWidth = (int)Decimal.Round((decimal)maxWidth,0);

   //If the width is bigger than the screen, ensure
   //we stay within the bounds of the screen
   if (newWidth > Screen.GetWorkingArea(this).Width)
   {
      newWidth = Screen.GetWorkingArea(this).Width;
   }

   //Only change the default width if it's smaller
   //than the newly calculated width
   if (newWidth > initialDropDownWidth)
   {
      this.DropDownWidth = newWidth;
   }

   //Clean up the drawing surface
   ds.Dispose();
}

At the end of UpdateDropDownWidth we check to ensure that the newly calculated width is larger than the default width that our ComboBox was set to at design time. Add the initial DropDownWidth member variable and set it to the value of the design time DropDownWidth in the constructor.

//Store the default width to perform check in UpdateDropDownWidth.
private int initialDropDownWidth = 0;

public BetterComboBox()
{
   // This call is required by the Windows.Forms Form Designer.
   InitializeComponent();

   initialDropDownWidth = this.DropDownWidth;

this.HandleCreated += new EventHandler(BetterComboBox_HandleCreated);
}

private void BetterComboBox_HandleCreated(object sender, EventArgs e)
{
   UpdateDropDownWidth();
}

Notice that at the end of the constructor we hook up the HandleCreated event. We need to call UpdateDropDownWidth, and if we call it from the constructor the Items collection will not yet be populated. Hooking the HandleCreated event allows us to call UpdateDropDownWidth once the Items collection is populated and will set our BetterComboBox DropDownWidth correctly based on the data it contains.

With our ComboBox DropDownWidth now set appropriately, our users can make a more informed decision about the selections they make as shown in Figure 2.

There is another issue we need to deal with when setting the DropDownWidth. Since the drop-down is most likely going to be wider than the ComboBox, we need to be sure that the drop-down is displayed fully onscreen when our new ComboBox is positioned near the right edge of the screen.

Keeping the Drop-Down Onscreen

An unintended consequence of setting the DropDownWidth is that the drop-down portion of the ComboBox can now extend off the right edge of the screen, as shown in Figure 3.

Figure 3. Drop-down Displayed Off-screen

Figure 4. Functioning BetterComboBox

We need to ensure than the drop-down is always drawn entirely onscreen so our users can see the selections they are making. How are we going to do that? Thanks to the glorious WndProc method, we are going to move the drop-down portion of the ComboBox when it attempts to display off-screen.

Start off by overriding the WndProc method in our BetterComboBox class:

protected override void WndProc(ref Message m)
{
   base.WndProc (ref m);
}

Now, we need to handle the appropriate message to know when the drop-down portion of the ComboBox is going to be painted. The WM_CTLCOLORLISTBOX message is sent before the system draws the list box, so we need to react to that message. First, we need to get the constant value for WM_CTLCOLORLISTBOX, and for that I like to visit http://www.pinvoke.net/. A search for the message of interest yields the following information, which should be added to our BetterComboBox class:

private const UInt32 WM_CTLCOLORLISTBOX = 0x0134;

Returning to our WndProc method, we can now add handling for the WM_CTLCOLORLISTBOX message.

protected override void WndProc(ref Message m)
{
   if (m.Msg == WM_CTLCOLORLISTBOX)
   {
      //TODO: Position Drop-down
   }

   base.WndProc (ref m);
}

All that is left to do now is to move the drop-down portion of the ComboBox when it would be painted off-screen. Ultimately, the SetWindowPos function is what we need to use for that; however, there is some more work that needs to be done first. For instance, we want to take control of positioning the drop-down only when it would be painted off-screen. So we need to calculate where on the screen the left-most edge of the ComboBox is and determine if our newly calculated DropDownWidth would push the drop-down off the right edge:

protected override void WndProc(ref Message m)
{
   if (m.Msg == WM_CTLCOLORLISTBOX)
   {
      // Make sure we are inbounds of the screen
      int left = this.PointToScreen(new Point(0, 0)).X;
                        
      //Only do this if the dropdown is going off right edge of screen
      if (this.DropDownWidth > Screen.PrimaryScreen.WorkingArea.Width - left)
      {
         //TODO: Position Drop-down
      }
   }

   base.WndProc (ref m);
}

Now all we need is a few more calculations and we are ready to call SetWindowPos. Let's prepare for the SetWindowPos by either searching msdn.microsoft.com or going back to http://www.pinvoke.net/ and getting the signature and the SWP_NOSIZE constant that we need to use to make the call and then add them to our BetterComboBox class along with the necessary using statement for the DllImport:

using System.Runtime.InteropServices;

[DllImport("user32.dll")]
 static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int 
X, int Y, int cx, int cy, uint uFlags);

private const int SWP_NOSIZE = 0x1;

Getting back to our WndProc method, we can now add our final calculations and the positioning code:

protected override void WndProc(ref Message m)
{
   if (m.Msg == WM_CTLCOLORLISTBOX)
   {
      // Make sure we are inbounds of the screen
      int left = this.PointToScreen(new Point(0, 0)).X;
                      
      //Only do this if the dropdown is going off right edge of screen
      if (this.DropDownWidth > Screen.PrimaryScreen.WorkingArea.Width - left)
      {
         // Get the current combo position and size
         Rectangle comboRect = this.RectangleToScreen(this.ClientRectangle);

         int dropHeight = 0;
         int topOfDropDown = 0;
         int leftOfDropDown = 0;

         //Calculate dropped list height
         for (int i=0;(i<this.Items.Count && i<this.MaxDropDownItems);i++)
         {
            dropHeight += this.ItemHeight;
         }

         //Set top position of the dropped list if 
         //it goes off the bottom of the screen
         if (dropHeight > Screen.PrimaryScreen.WorkingArea.Height -
            this.PointToScreen(new Point(0, 0)).Y)
         {
            topOfDropDown = comboRect.Top - dropHeight - 2;
         }
         else
         {
            topOfDropDown = comboRect.Bottom;
         }
         
         //Calculate shifted left position
         leftOfDropDown = comboRect.Left - (this.DropDownWidth -
            (Screen.PrimaryScreen.WorkingArea.Width - left));

         // Postioning/sizing the drop-down
         //SetWindowPos(HWND hWnd,
         //      HWND hWndInsertAfter,
         //      int X,
         //      int Y,
         //      int cx,
         //      int cy,
         //      UINT uFlags);
         //when using the SWP_NOSIZE flag, cx and cy params are ignored
         SetWindowPos(m.LParam,
            IntPtr.Zero,
            leftOfDropDown,
            topOfDropDown,
            0,
            0,
            SWP_NOSIZE);
      }
   }

   base.WndProc (ref m);
}

That's all there is to it. As shown in Figure 4, our BetterComboBox drop-down will now shift to the left when it would have displayed off-screen, and it will even move the drop-down above the ComboBox when it would have displayed off the bottom edge of the screen. Just build the BetterComboBox project, add the control to your form, add some data to the Items collection, and you will give your users the ability to see all of their data!

 

About the Author

George Politis works as a consultant with Intertech Software and has been providing consulting services using Microsoft technologies for 10 years. He can be reached at gpolitis@intertechsoftware.com.