Best Practices Using Windows Forms ComboBox Control



Introduction

ComboBox is a common Windows Forms control, used to present user a set of acceptable values. User then selects one of the values at will or, depending on DropDownStyle property value, adds new value to select.

In particular, when DropDownList value is used for DropDownStyle, user can only select one of the items already added to ComboBox. User cannot alter the list, but must choose one value from a fixed list.

This article explains how to populate DropDownList-styled ComboBox control so that program remains easy to understand and maintain, still helping avoid bugs as much as possible.

Different Ways to Populate DropDownList ComboBox

Basically, a ComboBox is populated in one of two ways:

  1. By setting DataSource property to appropriate sequential structure (array, list, etc.) which becomes the list of items shown by the control, or
  2. By inserting items manually into the control's Items collection.
In either case, there are good and bad programming practices and we will shortly identify them.

Generally unwelcomed methods of populating ComboBox are:
  1. Replacing objects with their string counterparts in such a way that SelectedIndex is the only information identifying which object has been selected.
  2. Adding objects and hoping that their string representations will be properly determined by each item's ToString() method.

The suggested method is to use DataSource and DisplayMember properties to populate ComboBox with desired objects, whenever applicable. Although attractive, this idea might sometimes be trouble, when no suitable DisplayMember can be determined. Two ways of dealing with the problem will be presented further.

Why Not Rely on ToString() Method?

ComboBox exposes collection of objects through its Items property. This may cause temptation to insert any kind of objects into it and let ComboBox show strings returned by ToString() method of each object.

This is not a desirable method because the result of ToString() is not well documented and structured in general case to be included in user interface. It is also susceptible to future changes with or without notice.

Even if you are the one to write the class at hand, you should not write ToString() method with ComboBox in mind. That method is intended to give user friendly information about the contents of an object, rather than specific string suitable for ComboBox - these two intentions may be quite different in practice.

Typical custom implementation of ToString() method returns too much information for a ComboBox. On the other end of the spectrum, default implementation returns almost no usable information, i.e. it returns just a class name which is not really applicable to ComboBox.


Solution #1 - Using Tag Property

When objects do not have a property that can be referred to by DisplayMember, then it is not simple to populate the ComboBox. In any case, strings that represent objects must be calculated using any appropriate algorithm. So we end up with having a collection of objects and a collection (typically array) of strings. The two collections are interrelated in the sense that string representation and reference to a particular item both occupy the same position in their respective collections.

In this situation, ComboBox can be populated by array of strings (Items collection), and original collection, which contains actual objects, can be stored in ComboBox's Tag property. Selected item is now calculated as object contained in Tag property which occupies SelectedIndex position.

Here is an example. Suppose that you wish to list all subdirectories of one directory, recursively, in a ComboBox. It is easy to list subdirectories using System.IO.Directory.GetDirectories() method. However, if you just populate ComboBox with directories listed in that way, you will notice that some of the directory paths are too long to fit into a ComboBox.

Now you may decide to compact paths that are too long using common technique of replacing middle section of the path with ellipsis. However, Directory and DirectoryInfo classes do not expose properties that contain compacted paths. This is because there is no information of how long a path can be - it depends on the rectangle in which it will be rendered and on the font used. To make the long story short, below is the function which compacts path, given size bounds and font used to render text.

(Function that follows would be much simpler without couple of bugs shipped with .NET Framework. Some of those bugs are explained in full detail, including explanation of workarounds, in article "Consequences of .NET String Immutability and Three Related .NET Framework Bugs". Affected places are just noted in the source code with only basic explanations.)

string CompactPath(string path, Size targetSize, Font font)
{

    string compactedPath = null;
    bool finished = false;
    Size curSize = new Size(targetSize.Width, targetSize.Height);

    while (!finished)   // Function is possibly executed in
    {                   // more than one iteration to cope
                        // with a bug in MeasureText function, as described below
 
        compactedPath = string.Copy(path);  // Workaround for .NET bug -
                                            // MeasureText would modify both
                                            // compactedPath and path
                                           
// if simple compactedPath=path is done.

        System.Drawing.Size effectiveSize =
            System.Windows.Forms.TextRenderer.MeasureText(compactedPath, font, curSize,
                                                            TextFormatFlags.ModifyString |
                                                            TextFormatFlags.PathEllipsis);
 
        if (effectiveSize.Width <= targetSize.Width)
        {
            finished = true;    // Modified string fits into target size
                                // Function will exit after this
        }
        else
            curSize.Width -= (effectiveSize.Width - targetSize.Width + 1) / 2;
            // Workaround for a bug - MeasureText uses Windows API PathCompactPathEx
            // function to compact path. This function uses GDI, so text measured in GDI+
            // may be larger and breach the target bounds. If this happens,
            // we will heuristically reduce target size by half of the error (rounded up)
            // and then try again
 
    }
 
    int pos = compactedPath.IndexOf('\0');              // Workaround for a bug in MeasureText,
    if (pos >= 0)                                       // which leaves C-string terminator in
        compactedPath = compactedPath.Substring(0, pos);// compactedPath character buffer and
                                                        
// leaves incorrect length of compactedPath

    return compactedPath;

}

This function will be used then to populate ComboBox (named combo) with compacted directory paths, still maintaining array of System.IO.DirectoryInfo objects in the Tag property. Here is the code:

string progFilesPath = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu);
string[] names = System.IO.Directory.GetDirectories(progFilesPath, "*.*", System.IO.SearchOption.AllDirectories);

System.IO.DirectoryInfo[] dirs = new System.IO.DirectoryInfo[names.Length];

System.Drawing.Size comboSize = combo.ClientRectangle.Size;
comboSize.Width -= SystemInformation.VerticalScrollBarWidth;

for (int i = 0; i < names.Length; i++)
{

    dirs[i] = new System.IO.DirectoryInfo(names[i]);
    names[i] = CompactPath(names[i], comboSize, combo.Font);

 }

combo.Tag = dirs;

if (names.Length > 0)
{
    combo.Items.AddRange(names);
    combo.SelectedIndex = 0;
}

First we are obtaining the array of full names of all directories belonging to common Start menu (recursively). These full names are then used to create DirectoryInfo objects which are stored in ComboBox's Tag property. After DirectoryInfo object is created, path is compacted and added to Items collection.

The result is a ComboBox which shows compacted paths to the user and, once user selects one path, DirectoryInfo can be recovered by taking object occupying position SelectedIndex from the array stored in the Tag property. Here is a utility read-only property which returns selected directory:

public System.IO.DirectoryInfo TagSelectedDirectory
{
    get
    {

        System.IO.DirectoryInfo dir = null;
        System.IO.DirectoryInfo[] dirs = combo.Tag as System.IO.DirectoryInfo[];

        if (dirs != null)
            dir = dirs[combo.SelectedIndex];
 
        return dir;
 
    }
}

It is always good practice to have a property like this, which saves you from typecasting and validation in many places in code.

When code listed above is applied, output may look as shown on the following picture. You can see that all paths are compacted if necessary, so that they perfectly fit the ComboBox drawing area. Note that all code required to perform this task is only about 60 lines in length, including workarounds for three bugs related to MeasureText function.

combo.gif

Solution #2 - Using DataSource and DisplayMember Properties

This is method of choice whenever applicable, and that is in all cases where objects used to populate ComboBox expose a property with public getter which provides user-friendly representation of the object.

Short example will be used to demonstrate this simplest of all techniques. We will load all installed fonts (FontFamily objects) and then use their Name property to populate drop list. Here is the code:

System.Drawing.Text.InstalledFontCollection fonts = new System.Drawing.Text.InstalledFontCollection();
combo.DataSource = fonts.Families;
combo.DisplayMember = "Name";

There is no shorter code than this. For that reason, always use DataSource and DisplayMember when desirable property is available.

Even strong-typed getter of the selected object is a single liner:

public System.IO.DirectoryInfo DataSourceSelectedDirectory
{
    get
    {
        return combo.SelectedItem as System.IO.DirectoryInfo;
    }
}

Solution #3 - Using DataSource Without Appropriate DisplayMember Value

This case is similar to one in which Tag property was used. Although it is always possible to populate ComboBox with plain string, that way requires additional code typing and typecasting. One way to cope with the problem of lacking a useful property is to use general class which has such a property.

For example, KeyValuePair generic class can be used in such way that keys are strings shown in the ComboBox and values are DirectoryInfo objects.

We will demonstrate this idea on the same example as in Solution #1, i.e. on directory paths. Here is the code which uses KeyValuePair class to populate DataSource and DisplayMember properties of the ComboBox:

private void populateCustomDataSource_Click(object sender, EventArgs e)
{

    List<KeyValuePair<string, System.IO.DirectoryInfo>> dirs = new List<KeyValuePair<string, System.IO.DirectoryInfo>>();
    string progFilesPath = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu);
    string[] names = System.IO.Directory.GetDirectories(progFilesPath, "*.*", System.IO.SearchOption.AllDirectories);

    System.Drawing.Size comboSize = combo.ClientRectangle.Size;
    comboSize.Width -= SystemInformation.VerticalScrollBarWidth;

    for (int i = 0; i < names.Length; i++)
    {
        System.IO.DirectoryInfo dir = new System.IO.DirectoryInfo(names[i]);
        names[i] = CompactPath(names[i], comboSize, combo.Font);
        dirs.Add(new KeyValuePair<string, System.IO.DirectoryInfo>(names[i], dir));

    }

    combo.DataSource = dirs;
    combo.DisplayMember = "Key";

}

This code is not much shorter than the one that uses Tag property. But ComboBox populated in this way is simpler to use, which becomes obvious when strong-typed getter is looked at:

public System.IO.DirectoryInfo CustomDataSourceSelectedDirectory
{
    get
    {
        return ((KeyValuePair<string, System.IO.DirectoryInfo>)combo.SelectedItem).Value;
    }
}

Yet another single liner demonstrates that DataSource property is the best way to populate ComboBox with objects of any class.

Conclusion

This article has demonstrated that simplest, hence most desirable, method of populating ComboBox with objects is to use DataSource and DisplayMember properties.

When DisplayMember cannot be used directly, because there is no public property which could serve the purpose, one can decide either to store original objects in Tag property or to use custom class which wraps original class and exposes property suitable for DisplayMember use.

Another two methods are never suggested to be used. First highly undesirable method is to populate ComboBox with objects (either using Items or DataSource properties) without providing custom or built-in conversion to string, relying just on the ToString() method. Second undesirable solution is to populate ComboBox with strings and then use SelectedIndex to access external structures in order to decode meaning of the selected item.