Introduction
I had to write a multi-column combobox at work that supported generic data
binding, and I thought it might prove useful for others too.
MultiColumnComboBox
is a
ComboBox
derived class written
entirely in C# and you can bind it to any data source that has multiple columns
(though it doesn't matter if it only has a single column either). It also works
in unbound mode though it doesn't make much sense to use it if you are not using
data binding.
Class usage
Using the class is fairly straightforward. Once you have your data source you
just need to set the DataSource
property of the
MultiColumnComboBox
class. The control does not support the
ComboBoxStyle.Simple
style and in addition it will insist on
DrawMode
being set to
OwnerDrawVariable
. Exceptions are
thrown so that you won't inadvertently attempt to break either of those
limitations which is pretty handy because the Visual Studio property grid will
not then let you change those values.
The class has been tested on Windows XP SP2 as well as on Windows Vista
(Ultimate Edition) both from a Visual Studio design perspective as well as from
a runtime perspective. Here are some examples of populating the control
with various types of data sources. In this first example, we populate it using
a DataTable
.
DataTable dataTable = new DataTable("Employees");
dataTable.Columns.Add("Employee ID", typeof(String));
dataTable.Columns.Add("Name", typeof(String));
dataTable.Columns.Add("Designation", typeof(String));
dataTable.Rows.Add(new String[] { "D1", "Natalia", "Developer" });
dataTable.Rows.Add(new String[] { "D2", "Jonathan", "Developer" });
dataTable.Rows.Add(new String[] { "D3", "Jake", "Developer" });
dataTable.Rows.Add(new String[] { "D4", "Abraham", "Developer" });
dataTable.Rows.Add(new String[] { "T1", "Mary", "Team Lead" });
dataTable.Rows.Add(new String[] { "PM1", "Calvin", "Project Manager" });
dataTable.Rows.Add(new String[] { "T2", "Sarah", "Team Lead" });
dataTable.Rows.Add(new String[] { "D12", "Monica", "Developer" });
dataTable.Rows.Add(new String[] { "D13", "Donna", "Developer" });
multiColumnComboBox1.DataSource = dataTable;
multiColumnComboBox1.DisplayMember = "Employee ID";
multiColumnComboBox1.ValueMember = "Name";
The DisplayMember
property will dictate the value that's visible
in the edit box part of the combobox. And the ValueMember
property
will dictate which of the columns will show up in bold. If you look at the
screenshots, you can clearly see this in action. In the next example, we use an
array of a custom type.
public class Student
{
public Student(String name, int age)
{
this.name = name;
this.age = age;
}
String name;
public String Name
{
get { return name; }
}
int age;
public int Age
{
get { return age; }
}
}
Student[] studentArray = new Student[]
{ new Student("Andrew White", 10), new Student("Thomas Smith", 10),
new Student("Alice Brown", 11), new Student("Lana Jones", 10),
new Student("Jason Smith", 9), new Student("Amamda Williams", 11)
};
multiColumnComboBox2.DataSource = studentArray;
multiColumnComboBox2.DisplayMember = multiColumnComboBox2.ValueMember = "Name";
Notice how we've set both DisplayMember
and ValueMember
to the same column field - this is perfectly okay to do. By the way if you don't
set the ValueMember
it will use the first column by default. You
must set the DisplayMember
though, else you'll see some odd strings
depending on how a specific type's ToString
is implemented. I
decided not to provide a default as it would most likely result in non-ideal
columns getting displayed. I've used a drop-down list style combobox for my 3rd
example and also used a List<>
object - though by now it must be
pretty obvious to anyone reading this that you can basically use any standard data
source.
List<Student> studentList = new List<Student>(studentArray);
The main difference in using a drop-down list will be that you'll see the
multiple columns even when the combobox is not dropped down. Note that those who
want to prevent this behavior can check if DrawItemEventArgs.State
has the ComboBoxEdit
flag (in the OnDrawItem
method)
and change the behavior accordingly. For our purposes this behavior was pretty
good and I personally thought it to be the more intuitive way to do it. And
finally, you can use it without data-binding, though I can't think of any reason
why you'd want to do that.
multiColumnComboBox4.Items.Add("Cat");
multiColumnComboBox4.Items.Add("Tiger");
multiColumnComboBox4.Items.Add("Lion");
multiColumnComboBox4.Items.Add("Cheetah");
multiColumnComboBox4.SelectedIndex = 0;
Implementation details
One of the first things I did was to hide both the DrawMode
and
the DropDownStyle
properties to prevent users from inadvertently
setting unsupported values.
public new DrawMode DrawMode
{
get
{
return base.DrawMode;
}
set
{
if (value != DrawMode.OwnerDrawVariable)
{
throw new NotSupportedException("Needs to be DrawMode.OwnerDrawVariable");
}
base.DrawMode = value;
}
}
public new ComboBoxStyle DropDownStyle
{
get
{
return base.DropDownStyle;
}
set
{
if (value == ComboBoxStyle.Simple)
{
throw new NotSupportedException("ComboBoxStyle.Simple not supported");
}
base.DropDownStyle = value;
}
}
I overrode OnDataSourceChanged
so that the column names could be
initialized.
protected override void OnDataSourceChanged(EventArgs e)
{
base.OnDataSourceChanged(e);
InitializeColumns();
}
private void InitializeColumns()
{
PropertyDescriptorCollection propertyDescriptorCollection =
DataManager.GetItemProperties();
columnWidths = new float[propertyDescriptorCollection.Count];
columnNames = new String[propertyDescriptorCollection.Count];
for (int colIndex = 0; colIndex < propertyDescriptorCollection.Count; colIndex++)
{
String name = propertyDescriptorCollection[colIndex].Name;
columnNames[colIndex] = name;
}
}
I use the DataManager
property which returns the
CurrencyManager
objects that managed the bound
objects for the control. Initially I've also set a widths array to 0 (later the
required widths will be calculated). I also override the
OnValueMemberChanged
method so that I could correctly set the value
member column internally which I use in the drawing code to make the value
column drawn in bold text.
protected override void OnValueMemberChanged(EventArgs e)
{
base.OnValueMemberChanged(e);
InitializeValueMemberColumn();
}
private void InitializeValueMemberColumn()
{
int colIndex = 0;
foreach (String columnName in columnNames)
{
if (String.Compare(columnName, ValueMember, true,
CultureInfo.CurrentUICulture) == 0)
{
valueMemberColumnIndex = colIndex;
break;
}
colIndex++;
}
}
OnMeasureItem
will be called once for every row in the combobox and that's
where I do my width calculations.
protected override void OnMeasureItem(MeasureItemEventArgs e)
{
base.OnMeasureItem(e);
if (DesignMode)
return;
for (int colIndex = 0; colIndex < columnNames.Length; colIndex++)
{
string item = Convert.ToString(
FilterItemOnProperty(Items[e.Index], columnNames[colIndex]));
SizeF sizeF = e.Graphics.MeasureString(item, Font);
columnWidths[colIndex] = Math.Max(columnWidths[colIndex], sizeF.Width);
}
float totWidth = CalculateTotalWidth();
e.ItemWidth = (int)totWidth;
}
The interesting trick here is to use FilterItemOnProperty
to get
the text associated with a specific column. The width calculation is elementary
and I calculate the total width using a CalculateTotalWidth
method
which merely adds all the individual column widths. I also add width for the
vertical scrollbar (in case one shows up). We must also remember to override
OnDropDown
to set the drop down width appropriately (remember this
is different from the width of the combobox itself).
protected override void OnDropDown(EventArgs e)
{
base.OnDropDown(e);
this.DropDownWidth = (int)CalculateTotalWidth();
}
Now we come to the meat of the class -the OnDrawItem
override.
protected override void OnDrawItem(DrawItemEventArgs e)
{
base.OnDrawItem(e);
if (DesignMode)
return;
e.DrawBackground();
Rectangle boundsRect = e.Bounds;
int lastRight = 0;
using (Pen linePen = new Pen(SystemColors.GrayText))
{
using (SolidBrush brush = new SolidBrush(ForeColor))
{
if (columnNames.Length == 0)
{
e.Graphics.DrawString(Convert.ToString(Items[e.Index]),
Font, brush, boundsRect);
}
else
{
for (int colIndex = 0; colIndex < columnNames.Length; colIndex++)
{
string item = Convert.ToString(FilterItemOnProperty(
Items[e.Index], columnNames[colIndex]));
boundsRect.X = lastRight;
boundsRect.Width = (int)columnWidths[colIndex] + columnPadding;
lastRight = boundsRect.Right;
if (colIndex == valueMemberColumnIndex)
{
using (Font boldFont = new Font(Font, FontStyle.Bold))
{
e.Graphics.DrawString(item, boldFont, brush, boundsRect);
}
}
else
{
e.Graphics.DrawString(item, Font, brush, boundsRect);
}
if (colIndex < columnNames.Length - 1)
{
e.Graphics.DrawLine(linePen, boundsRect.Right, boundsRect.Top,
boundsRect.Right, boundsRect.Bottom);
}
}
}
}
}
e.DrawFocusRectangle();
}
Though it's the longest function in the class (and probably exceeds the Marc
Clifton approved limit for maximum number of lines in a method), it's quite
straightforward. For each row, it iterates through all the columns, gets the
column text and draws the text along with vertical lines that will act as column
separators.
Acknowledgements
- Rama Krishna Vavilala - For some awesome suggestions on the
implementation.
History
- July 27th 2007 - Article first published.