Introduction
This project provides a 101 tour of the Windows Forms DataGrid
Control, with emphasis on easy-to-use (and understand) customizations.
You don't have database connectivity? No problem, this project's for you.
We build a simple, memory-resident database using classes provided by ADO.NET, no external database required. Next, we employ a DataGrid
object to display the contents of a table within our database. Finally, we move along to customizing the columns in the DataGrid
. And yes, if you want a custom combobox column, then look no further. It�s robust, uncomplicated and it works!
This article was not developed in a vacuum. I would like to credit the excellence of authors Kristy K. Saunders and Dino Esposito. I'm going to elaborate on their work, tempered by personal experience, to present an article for the novice user.
The Database
We are going to model telephone number in just two database tables; one describes a set of countries, the other contains the phone numbers themselves:
For the purpose of this exercise, let�s assume any telephone number is a combination of a Country-Code, Area-Code, Office-Code, Phone Number and Extension. With the exception of Country-Code, each of these elements is stored under a corresponding column in the Phone
table. The columns are PhAreaCode
, PhOfficeCode
, PhPhoneNo
and PhExtension
. Each telephone number is represented by a row in the Phone
table.
The Phone
table also contains a PhCountryId
column that we can use to look-up a matching entry in the Country
table. From this table, we can extract the name of the country, and the country-code. The relationship is modeled as an arrow pointing from the PhCountryId
column (in Phone
) to the CyId
column (in Country
). For this to work, two conditions must be met:
- The
CyId
column cannot contain empty or duplicate entries
- Each entry in the
PhCountryId
column must contains a value that is also found in the CyId
column
Beyond these rules, we do want to allow for an empty value in the PhCountryId
Column. This can happen when, for instance, the phone is part of an International Satellite Phone System. Clearly it makes no sense to assign a 'Country-Code' to this type of phone! In database parlance this is referred to as a null index (or DBnull
in ADO.NET). We can accommodate this value in the Country
Table by inserting a row with a corresponding DBnull
value in the CyId
Column. If you look into the code that generates the memory-resident database, you will see this is the very first row I add to the Country
Table.
Now, ADO.NET is based on the concept of �Connectionless Database�. The name is really a misnomer because we obviously must be connected when we read, write or update the underlying database. However, in-between times, we are working out of database subsets that reside purely in application memory space. Contrast this with classic ADO (Visual Studio 6) which puts heavy emphasis on a continuous connection to the underlying database!
As a part of this approach, ADO.NET provides classes that correspond to database tables, columns and rows. ADO.NET (within the System.Data
namespace) also provides a rich set of database-related classes to manage Filters, Constraints, Relationships etc.). However, to keep our example manageable, we�ll focus primarily on just the DataSet
, DataTable
, DataColumn
and DataRow
classes.
If we have a live database (SQL Server, Oracle, Access), we can use ADO.NET-aware components to auto-generate these objects directly from the database structure and content. However, it is instructive to perform this task as a manual exercise.
Please note: The DataGrid
has a visual representation that allows us to navigate between related tables. However we are trying, instead, to portray a merger of the Phone and Country columns into a spreadsheet-like view. For this reason, I chose not to make use of the available ADO.NET DataRelation
class or the DataSet.Relations
collection that DataGrid
can hook into. However, you can uncomment the following lines of code (in PhoneDataSet.cs) if you would like to explore inter-table navigation:
DataColumn parentCol;
DataColumn childCol;
DataColumn parentCol = _dsInfo.Tables["Phone"].Columns["PhCountryId"];
DataColumn childCol = _dsInfo.Tables["Country"].Columns["CyId"];
_dsInfo.Relations.Add("ByCountry", parentCol, childCol, false);
First, we generate structure for each DataTable
using DataColumn
objects. Only then can we populate our DataTable
objects with actual data stored in DataRow
objects. Finally, we move both DataTable
objects (Phone and Country) into a container defined by the DataSet
class. There is no obligation to perform this step; we are not making extensive use of DataSet
. However, since most database applications employ this class, I thought it was representative to include DataSet
in my example.
Database construction is handled in the PhoneDataSet.cs class file. It�s quite straight forward and heavily commented. I�ve created a few helper routines to assemble columns, tables, rows and primary keys (which incidentally don�t get used in this project). The only property exposed by this class is the DataSet
object (_dsInfo
) that contains our DataTable
s. Except for the helper functions, this is mostly throw-away code, but if you�ve never tried manual construction of database, the PhoneDataSet
class you might enjoy a quick look.
The DataColumn
class has two properties which are of interest to us:
aColumn.ColumnName;
aColumn.Caption;
Now to quote from Microsoft � Help pages, "You can use the Caption property to display a descriptive or friendly name for a DataColumn.". Okay, what I�m hoping is that ColumnName
corresponds to the title of a column in a database table. Likewise, I expect Caption
will be grabbed for the displayed DataGrid
column header. So in my database, I�ve set a friendly name for the Caption
property and a hostile database descriptor for the ColumnName
. As we shall shortly see, my optimism is once again to be dashed against the rocks.
Display the Phone Table using a DataGrid Control
Connecting our memory-resident Phone table to the DataGrid
is simplicity itself; we use two properties of the DataGrid
called DataSource
and DataMember
. However, we do get choices on how we use these properties:
First, we can connect directly to the Phone DataTable
like this:
grdPhone.DataSource = _pdsPhone._dsInfo.Tables["Phone"];
Alternately, we can connect to the DataSet
container, then identify the contained DataTable
by name:
this.grdPhone.DataSource = _pdsPhone._dsInfo;
this.grdPhone.DataMember = "Phone";
Both approaches work equally well, which hints at the true versatility of the DataGrid
control.
However, our DataGrid
looks quite sad and is, frankly, less than I was hoping for. At the very least, I thought the DataGrid
would pick-up and display the Caption
property from the DataColumn
objects. Instead, what I see is the ColumnName
property, which is not what I wanted. In addition, I really don�t have much use for the index representation of a country (PhCountryId
). I want to see the actual name of the country. So it�s time to beautify our DataGrid
.
The DataGrid gets a Makeover
The DataGrid
control has a property called TableStyles
. TableStyles
is a collection of DataGridTableStyle
objects, indexed by a something called the MappingName
. This name is used because DataGrid
can bind to many collection types; hence calling it "TableName" might be considered quite inappropriate in some circumstances. However, in our case, this will be the name we gave to a DataTable
object.
After binding the DataSet
and DataTable
to the DataGrid
, I set a breakpoint to explore the TableStyles
property to see how it was constructed. Unfortunately, the DataGrid
is running off an internal, default collection of DataGridTableStyles
that we are not intended to access (it's protected
!). Fortunately, there is a published trick to exposing this collection:
DataGridTableStyle GridTableStyle = new DataGridTableStyle();
GridTableStyle.MappingName = "Phone";
grdPhone.TableStyles.Add(GridTableStyle);
Amazingly, internal code within the DataGrid
has kindly populated my DataGridTableStyle
object with all the information about DataGrid
columns that I could reasonably wish for. Here is what I get:
TablesStyles []
(indexed by Phone
) yields a DataGridTableStyle
.
- The
DataGridTableStyle
provides a property called GridColumnStyles
which is a collection.
GridColumnStyles []
(indexed by the ColumnName
string property) yields a DataGridColumnStyle
object.
A diagram makes these relationships a little clearer. Note, these objects can navigate up the hierarchy, as indicated by the arrows:
Finally, we have a set of objects (class DataGridColumnStyle
) that describe the appearance and performance of each column that appears in our DataGrid
!
Once I have navigated down to this object, I can change just about anything I want. For instance:
- Cell text alignment
- Header text
- Null text (the value displayed when there is no corresponding entry in the
DataTable
linked to the DataGrid
)
- Column width
We can also delete or add columns, re-order them or even add custom columns. But let�s not get ahead of ourselves here!
My project contains a single button which is labeled "Press me". The first time you press the button, it re-labels the columns using the Caption
property of the DataColumn
objects that we used to create the database itself. This task is performed by the method:
CopyCaptionToHeader(grdPhone);
To demonstrate how simple it is to change column properties, I have also expanded the width of the PhCountryId
column to 90 pixels. Finally, I've chosen to set the first column (PhIndex
or IdxPhone
) as read-only and centre-aligned.
We are making progress but I want to see, and select, the country name for each telephone entry that appears in the DataGrid
. Unfortunately, all I have at the moment, is an index value (for instance the value "501" represents "America"). So what to do?
Creating a Custom Column (our friend the DataGridComboBoxColumn)
I�ve talked about the DataGridColumnStyle
object which governs the appearance and performance of a single column in the DataGrid
. However, in reality, DataGridColumnStyle
is an abstract class. Unfortunately (and I would love to know why), we are only offered two concrete subclasses which we can actually use. These are:
DataGridBoolColumn
(which implements a Boolean
object displayed in a DataGrid
column); and
DataGridTextBoxColumn
(which hosts a TextBox
object in a DataGrid
column)
Oops! Neither of these is going to help me much! What I really want is a ComboBox
which magically appears whenever I click within a cell under the "Country" column header. Okay, I�ve read several articles which offered a custom ComboBox
column and right here I�m offering you my interpretation of this useful class. Due to a failure in my imaginative-naming subroutines, I�ve called my Class MyComboColumn
and you can view the code in MyComboColumn.cs. If you want to understand how I arrived at this Class, then read on. However if you simply want to use the class "as is", then skip to the section entitled "Using the DataGridComboBoxColumn".
Now, the Visual Studio .NET Help files invite us to sub-class DataGridColumnStyle
to create our own custom columns. I�m told which methods I need to override, but when do they get called and why? What are my responsibilities as the coder of a new, robust sub-class? I spent several hours experimenting, then decided to take the path of least resistance. I simply sub-classed the DataGridTextBoxColumn
as others have done before me!
What I discovered during my experimenting was the following:
- The
DataGridTextBoxColumn
is hosting a single TextBox
control.
- The
DataGrid
acts as a control container. The TextBox
is contained within the DataGrid.Controls
collection. This is important because it affects positioning, key handling, navigation and visibility.
- The
TextBox
control only appears when the column is selected for editing (you click in one of the cells belonging to the column).
- When the
TextBox
is not visible, a Paint()
method is called which simply draws the appropriate text string into the cell boundaries.
- In the best traditions of parametric polymorphism, there are three signatures for the
Paint()
method but it appears only one of these is used consistently.
- When a cell becomes active, the
Edit()
method is called to make the TextBox
visible. We can override this method and substitute our own ComboBox
Control.
My first action was to simply override the Paint()
methods and put a breakpoint in each before calling the base.Paint()
method. In this way, I was able to determine which signature was in-use. I could equally well have drawn a picture instead of a string! How cool, we are half way to a DataGridPictureColumn
!
Next I override the Edit()
methods. Again there are multiple signatures but only one appears to be in-use within the DataGridTextBoxColumn
. So I can now construct an override method to create and display a ComboBox
within the boundaries supplied on the Edit()
parameter list. I must also remember to add the ComboBox
to the DataGrid.Controls
property. I cannot overstress the importance of this step!
I attach a (Leave
) event handler, so whenever the ComboBox
loses focus, we execute code to make the ComboBox
invisible. And voila! A DataGridComboBoxColumn
control. Well almost. We have a few more tasks to take care of:
- We need to populate the
ComboBox
control with all the countries from the "Country
" DataTable
. The ComboBox
must be indexed by the country index (which is called CyId
in our "Country
" DataTable
).
- We use the country identifier (
PhCountryId
) in the "Phone
" DataTable
to find the corresponding country name in the Country
DataTable
. This is the string which gets drawn when not editing the cell.
- When we start to
Edit()
the cell, we need to set the ComboBox
to display the country which currently appears in the cell. Thereafter, the user can select a different country if they so desire.
- When the
ComboBox
is dismissed, we need to write-back the country index to the underlying "Phone
" DataTable
.
We do have one additional problem, and it is quite significant. I would like to thank my (almost) tame testers, Dave (I can break anything) and Baldev (I can terrorise any coder) for pointing out this issue to me. The ComboBox
Control may be implemented using either a DropDownList
or a DropDown
style. The two styles result in quite different behaviors:
DropDown Style
With this style the navigation keys (up-arrow or down-arrow) select the previous or next row from the ComboBox
. We can also edit the selected entry (which in most cases is undesirable).
DropDownList Style
With this style the navigation keys (up-arrow or down-arrow) select the previous or next row in the DataGrid
. Editing of the selected entry is not enabled, however if you press a key, such as the letter 'A' the next entry in the ComboBox
that starts with the same letter is selected. This can be very useful! However, the drawbacks derive from behaviors inherited from the super-class (DataGridComboBoxColumn
). When we navigate using the up-arrow or down-arrow keys, we do NOT get a 'Leave
' event generated on the ComboBox
Control.
Style Solutions
The Constructor for MyComboColumn
supports selection of either a DropDownList
or DropDown
style; both have virtue in specific circumstances, although I suspect the DropDownList
style is the preferred choice.
The solution for the DropDownList
style is almost as bizarre as the problem itself. After much thought (and some experiments) I discovered that setting the 'ReadOnly
' Property of the super-class (DataGridComboBoxColumn
) restores the missing 'Leave
' events. Ouch!
To block editing on the DropDown
style I have added an event handler for the 'KeyPress' ComboBox
event. The handler does not impact the navigation keys, nor does it impact the 'delete' key. However editing with ascii characters is now blocked. The value of retaining the 'delete' key is that it can be used to select the DBnull
object.
To see how the two styles are accommodate in code, look at the Constructor for MyComboColumn
. The rest of the code is oblivious to the style we choose.
And that, ultimately, is about all it takes to create a custom ComboBox
column. I�ve stripped the code to a minimum so don�t be outraged by the absence of parameter validation and error recovery code (try
-catch
). I felt the subject matter was complex enough without including extraneous code that might cause confusion. But the code does work without throwing exceptions, provided it�s used as intended. Which brings me to the next topic:
Using the DataGridComboBoxColumn
Here is all the code you need to prepare the MyComboColumn
object:
MyComboColumn aCboCol = new MyComboColumn(
_pdsPhone._dsInfo.Tables["Country"], "CyName", "CyId", true);
aCboCol.Width = 129;
aCboCol.MappingName = "PhCountryId";
aCboCol.HeaderText = "Country";
The constructor takes four parameters:
- The
DataSource
which is used to populate the ComboBox
. In this case, we are using the "Country
" DataTable
.
- The name of the column in the
DataSource
we want displayed in the ComboBox
. In this case, the CyName
column in the "Country
" DataTable
will do the trick.
- The object in the
DataSource
used to bind the ComboBox
to a corresponding object in the "Phone
" DataTable
. In this case, we are using the CyId
column.
- A
boolean
which selects the DropDownList
style of ComboBox
when set true
, and the DropDown
style when set false
.
To allow either the DropDownList
or DropDown
style to be chosen I have provide a CheckBox
on the Form
. When checked, the DropDownList
is employed.
We must also bind the MyComboColumn
itself to the appropriate column in the DataTable
that currently underlies the DataGrid
itself. In this case, we are binding the PhCountryId
column in the "Country
" DataTable
. The Width
property is set purely for aesthetic value.
I�ve tried to illustrate the bindings in the following diagram. I hope it helps:
Bindings (a) and (b) and (c) are responsible for populating the ComboBox
control and are established in the constructor for MyComboColumn
. Binding (a) links to the ValueMember
property of the ComboBox
while binding (c) links to the DisplayMember
property.
Binding (d) connects MyComboColumn
to the PhCountryId
column in the Phone
table and is established through the MappingName
property of MyComboColumn
.
Binding (e) is provided by code in MyComboColumn
and synchronizes the CyId
and PhCountryId
columns. On the Edit()
method, this binding is used to select the initial entry shown in the ComboBox
control when it receives focus. On the corresponding "lose-focus" event, the ValueMember
property from the current row of the ComboBox
is written back to the corresponding row and column in the Phone
table.
Before we insert MyComboColumn
, we must first remove the existing DataGridTextBoxColumn
that binds to PhCountryId
in the "Phone
" DataTable
. We simply cannot bind a new DataGridColumnStyle
to the same DataColumn
in the same DataTable
:
grdPhone.TableStyles["Phone"].GridColumnStyles.RemoveAt(1);
this.InsertColumnAt(grdPhone.TableStyles["Phone"], aCboCol, 1);
The RemoveAt()
methods works on a zero-based item array. Consequently, we are actually removing the second DataGridColumnStyle
from the collection, and not the first. Now you would think that a collection which implements a RemoveAt()
method would have a corresponding InsertAt()
method. Well you�d be wrong. Instead I�ve had to kludge my own method to perform this onerous task. InsertColumnAt()
makes a copy of the current DataGridColumnStyle
collection. Then it clears the existing collection and repopulates it by sequentially adding objects across from the copy. At the appropriate point in this re-construction sequence, the new DataGridColumnStyle
is added. Simple, inelegant, but it works.
To see the results of adding a new ComboBox
column, press the button (now labeled) "Press again".
Points of Interest
If you find problems related to my implementation, please let me know. I will fix errors in the code (if I can). Changes will be incorporated if they have merit in the context of an article written for novices. Now on to a few points that might interest you:
When you�ve finished adding DataRow
objects to your DataTable
objects, remember to AcceptChanges()
on the DataSet
or DataTable
objects! Otherwise you may find they suddenly disappear en-masse if you reject recent updates using RejectChanges()
.
In both the Edit()
and Paint()
methods that I have overridden, we must deal with the possibility of encountering a null value (System.DBNull
) for the "Country
" index. This is a normal occurrence when we are adding a new row to the underlying "Phone
" DataTable
. In both cases, I default to using the first entry from the "Country
" DataTable
.
You might be wondering why I delay binding to the parent DataGrid
object until the first time the Edit()
method is called. This is because the DataGrid
object is not available until MyComboColumn
is added into the DataGrid.TableStyles
collection. As the Edit()
method only gets called after this necessary step has occurred, I can safely bind at this point.
Another issue which arose was an eye-opener! I discovered the ComboBox
does not get populated until the ComboBox.Visible
property is set for the first time. Consequently, the code to make the ComboBox
visible, in Edit()
, is called BEFORE we select an item in the ComboBox
. To avoid multiple Paint
events, I use the BeginUpdate()
and EndUpdate()
methods.
It is important to note that a ComboBox
is taller than a TextBox
which uses the same font. Consequently, you should set the PreferredRowHeight
to a suitable value. I do this by creating a temporary ComboBox
populated with the same font used for the current DataGridTableStyle
.
Quite a few implementations I�ve seen appear to intercept the Scroll
event for the DataGrid
. But if you bind the ComboBox
to the DataGrid
's Control's collection, I don�t see this as a necessary step. The DataGrid
scrolls quite nicely even when the ComboBox
is visible.
Another issue I encountered relates to the color of individual columns. The DataGrid
control provides support for alternate-row coloring, however I wanted to color the columns. So I've added code which allows me to override (if I choose) the default colors for the MyComboColumn
control, both background and foreground. To use the code, uncomment the following two lines in Form1.cs:
aCboCol.backgroundColour = System.Drawing.Color.Aquamarine;
aCboCol.foregroundColour = System.Drawing.Color.RoyalBlue;
You can question the South-Western color scheme, but what you should get is this:
We've arrived. You can RemoveAt()
or Remove()
the IdxPhone
column, but this was as much as I set out to achieve. Incidentally, some people think the way to remove a column is to set the width of the column to zero (0). However the column still exists and the "Tab" key will require a second press to skip over the invisible column.
I hope you can now see how to build your own columns in a DataGrid
. A column class to display graphics (such as items from a Parts Bin) should now be within your grasp.
Tip of the Day: If you are buying books on C# and you're a novice, consider also buying a book or two written for Visual Basic. Because Visual Basic is often regarded as "The People's Program Language", authors are expected to write super-friendly material. So often times I can get easy-to-read guidance from Visual Basic books. C# and Visual Basic are truly convergent languages!