When I first met the ASP.NET DataGrid control, it was love at first sight.
Together we built several applications, taught dozens of classes, published countless articles and tips; we even wrote a book. I can say with no fear of lying that we have an intimate knowledge of each other. When I need the Web DataGrid to perform some rather odd task, I only have to cast a glance (or two, if the task is quite complicated.) With this in mind, I enthusiastically accepted a proposal from one of my clients: Build a Windows Forms application with grid functionalities. Although at the time I had no serious experience with the Windows Forms DataGrid control, I took the gig because - I thought - a Windows DataGrid works more or less the same as an ASP.NET DataGrid. Next, hardly containing some genuine enthusiasm, I optimistically conjectured it could have been even easier - smart controls are handiest compared to HTML markup.
In the end it was a kind of nightmare. The Windows Forms DataGrid control is as much terrific to use as terrible to code. It is great as long as your needs match one of its built-in features. It can be hate at first sight; not love!
In this article, after a quick introduction to the overall capabilities of the control, I'll discuss how to achieve a basic customization made of custom column styles and improved user interaction. Next I'll take the plunge into the architecture of Windows Forms controls and discuss features and trends of the desktop .NET platform as well as possible strategies for us, poor developers, to defend.
The Windows Forms DataGrid at a Glance
The System.Windows.Forms.DataGrid control is an Excel-like component and designed to display data contained in .NET collections and list objects, including ADO.NET DataTable and DataView objects and arrays. The control provides scrolling capabilities, both vertically and horizontally, column resize, in-place editing, sorting, and even navigation should your data source contain hierarchical information.
If you need to display records out of a data source then the DataGrid is ideal. You run the query, gather the data into a DataTable, bind the object to the control, and you're done.
Dim adapter As New SqlDataAdapter(m_selectCommand, _
m_connectionString)
Dim data As New DataSet()
adapter.Fill(data, "Employees")
grid.DataSource = data
grid.DataMember = "Employees"
Any changes you enter in the bound data source is automatically persisted when you move out of the textbox, or when you call the EndEdit method. The underlying DataTable is guaranteed to be always up-to-date and, more importantly, you get all this for free.
But there's another really cool feature that you can obtain in a rather codeless way. Place two DataGrid controls on the same form and bind both to the same DataSet object. As long as the DataSet contains tables connected by a DataRelation object, you get automatic drill-down. You select a row on the grid that display the parent table and automatically the grid that bound to the child table refreshes to show related rows. Kind of magic!
However, don't forget that in this brief section I deliberately concentrated on all the best features of the controls. The worst is yet to come. Precisely, it begins when you?easily fired for these demonstrations?decide that you want to build the perfect grid control. Where do you start?
Defining Table and Column Styles
Notice that the DataGrid control shows all the columns in the data source and defaults to the column name for the header text. Not too bad?you think?in ASP.NET too this is the default. There should be a Boolean property to disable this feature. And there should be some sort of collection to add your own columns. The key object is a collection with the name you don't expect?the TableStyles collection.
Data displayed in the Windows Forms DataGrid is grouped in tables and columns. A DataGrid can display more than one table if the tables are children of the same DataSet and hierarchically related. Each table can have its own style thereby justifying the TableStyles collection. The collection is created by the DataGrid constructor and is left empty by default. An empty TableStyles collection causes the grid to display as many columns as there are fields in the DataTable. This behavior is nearly identical to the AutoGenerateColumns property of the ASP.NET DataGrid.
To restrict the number of displayed columns, you add ColumnStyle objects to the TableStyles collection.
Dim gridStyle As DataGridTableStyle
gridStyle = New DataGridTableStyle()
gridStyle.MappingName = "Employees"
gridStyle.BackColor = Color.Beige
:
grid.TableStyles.Add(gridStyle)
I thought a Windows DataGrid works more or less the same as an ASP.NET DataGrid. In the end, it was a kind of nightmare. The Windows Forms DataGrid control is as much terrific to use as terrible to code.
TableStyles is a property declared of type GridTableStylesCollection. It takes items of type DataGridTableStyle. Multiple TableStyle objects are allowed?ideally one per each table bound to the grid. The mapping between the DataGridTableStyle object and one of the bound tables is set in the MappingName property. Whenever the specified table is selected for view in the grid, the linked styles are applied.
A TableStyle object features several visual properties including colors, line styles, and preferred width. More important, a TableStyle object contains a collection of ColumnStyle objects?the GridColumnStyles property.
You populate the GridColumnStyles collection with objects of type DataGridColumnStyle. This class represents the base abstract class for DataGrid columns. If you want to create a custom type of column, this class is your starting point. In truth, you will probably never use the DataGridColumnStyle class but one of its derived classes?DataGridTextBoxColumn and DataGridBoolColumn.
Dim col1 As DataGridTextBoxColumn
col1 = New DataGridTextBoxColumn()
gridStyle.GridColumnStyles.Add(col1)
col1.TextBox.Enabled = False
col1.Format = "[#0]"
col1.Alignment = HorizontalAlignment.Right
col1.HeaderText = "ID"
col1.MappingName = "employeeid"
Dim col2 As DataGridTextBoxColumn
col2 = New DataGridTextBoxColumn()
gridStyle.GridColumnStyles.Add(col2)
col2.HeaderText = "Last Name"
col2.MappingName = "lastname"
The code snippet above shows you how to create a couple of columns to render the employee ID and the last name. In the former case, the column is right-aligned, read-only, and with a special formatting string. The MappingName property establishes the link between the column in the grid and a column in the DataTable mapped to the parent table style's object. The property HeaderText denotes the text that will be displayed as the title of the column.
If you need to display records out of a data source then the DataGrid is ideal. You run the query, gather the data into a DataTable, bind the object to the control, and you're done.
You cannot set many visual properties on a column object. You can set the width of the column, its mapping, and its alignment, but not font names, font styles, or colors. The MappingName property must exactly match the name of a table column. If your query returns first and last name as two distinct columns and you want to see them displayed as a single field, try the following: Add an in-memory new column to the table and make it an expression-based column. No further data is ever transmitted from the database to the application and the value for each row is determined by dynamically evaluating the expression. The following code snippet shows you how to concatenate first and last name into a single field called DisplayName.
Dim cols As DataColumnCollection
cols.Add("DisplayName", GetType(String), _
"lastname + ', ' + firstname")
I create and add the new column as soon as the DataTable is generated and before the column configuration takes place.
The DataGridTextBoxColumn supports in-place editing and exposes the TextBox property in case you want to use the underlying TextBox interface to interact. The content displayed in a column can be formatted to some extent. Use numeric placeholders to identify the cell value and then add formatting information as needed. For example, the expression [#0] indicates the i.th element bracketed by square brackets (see Figure 2). The property of interest is Format.
User Interface Refinements
Even with just the number of columns you want, the DataGrid is far from being perfect. You need to assign proper widths to the various columns so that all the data is displayed in its entirety. Columns do have a width property but unless you take ad hoc countermeasures, that is not enough to ensure that the full client area is covered. Figure 1 shows what I mean.
Figure 1 shows two of the things I really hate in the Windows Forms DataGrid. Although effective, the default user interface of the grid looks like a well done half work. First, the average programmer doesn't want different colors between the control's background (dark gray) and the grid's background (white). Second, the width of all columns should be set in such a way that the data spans over the whole horizontal axis. More important, the width of all columns should be automatically adjusted as the grid is resized. Figure 2 shows a much more elegant and professional-looking grid.
The BackgroundColor property defines the color of the client area of the grid window?the dark gray space in Figure 1. For a better visual effect you might want to synchronize it with the column's background colors.
Dim col3 As DataGridTextBoxColumn
col3 = New DataGridTextBoxColumn()
gridStyle.GridColumnStyles.Add(col3)
col3.TextBox.Enabled = False
col3.HeaderText = "First Name"
col3.MappingName = "firstname"
col3.Width = CalculateExactWidth(grid, _
gridStyle.GridColumnStyles)
In the code snippet I give all columns but the last a fixed width that users can modify interactively using their mouse. The last column is given a dynamically computed width. To ensure that it is always set to the exact number of pixels left, I sum up the width of all preceding columns with the value of the RowHeaderWidth DataGrid property. The final result is subtracted from the DataGrid's overall width. The RowHeaderWidth property determines the width of the leftmost gray selector column of the grid. A bit trickier is ensuring that the columns always span over the available horizontal space even when the form is resized. The SizeChanged event is key to achieve this. The following code snippet shows the source of the size handler.
Sub grid_SizeChanged(ByVal sender As Object, _
ByVal e As EventArgs) _
Dim grid As DataGrid
grid = CType(sender, DataGrid)
If grid.TableStyles.Count <= 0 Then
Return
End If
Dim cst As GridColumnStylesCollection
cst = grid.TableStyles(0).GridColumnStyles
Dim lastCol As Integer
lastCol = cst.Count-1
Dim cw As Integer
cw = CalculateExactWidth(grid, cst)
cst(lastCol).Width = cw
End Sub
If you look at the programming interface of the DataGrid control, you notice that only two types of columns are defined?DataGridTextBoxColumn and DataGridBoolColumn. The reason for this will be clear in a moment. For now it is sufficient to say that, in practice, either you use a textbox style rendering or a checkbox if the column is marked as Boolean. Should you need to display other types of data in the cells (such as pictures, drop-down lists, embedded forms, mixed text, or images) you must resort to writing your own column type. (More on this shortly.)
Accessing the Selected Row
If you use a DataGrid control, chances are good that you need to implement some sort of drill-down mechanism on top of a parent/child table relationship. To illustrate this, suppose that you want users to click on the parent grid to select an employee name and see all the orders he or she managed in a timeframe. When it comes to this there's good and bad news.
The bad news is that the DataGrid provides no ready-to-use way to get the data object behind the currently selected row. The good news (great, indeed) is that in most cases you don't need to take care of that because the DataGrid has the ability to automatically set up master/details relationships.
Suppose that you want to update the caption of a child control to reflect the last name of the currently selected employee. To do this you must cache a reference to the form's so-called binding context. The binding context for a Windows Forms form is a collection called BindingContext that tracks all links between controls and bound data containers. You must obtain the binding manager for all controls bound to the specified data source. The returned object is of type BindingManagerBase.
Sub SetBindingContext()
m_bindingContext = Me.BindingContext( _
m_data, "Employees")
' *** more code here
End Sub
You declare a member of type BindingManagerBase with the WithEvents qualifier and define a handler for its CurrentChanged event.
Sub m_bindingContext_CurrentChanged( _
ByVal sender As Object, _
ByVal e As EventArgs) _
Handles m_bindingContext.CurrentChanged
Dim row As DataRow
Row = CType(m_bindingContext.Current(), _
DataRowView).Row
:
End Sub
Cast the object returned by the binding context's Current property to DataRowView and you finally get the reference to the currently selected row.
Columns do have a width property but unless you take ad hoc countermeasures that is not enough to ensure that the full client area is covered.
Scared by this code? Consider that if you don't need to directly manipulate (i.e., to refresh helper controls) the contents of the selected data row, you can rely on the auto-refresh capability of the DataGrid control. You define a relation between tables in the DataSet and let the parent and child grids know about that. Magically, the two grids synchronize in a rather codeless way.
For example, add two tables to the DataSet?Employees and Orders?and define the following relation on the common employeeid column. The Orders table contains orders issued by a given employee.
Dim t1 As DataTable
t1 = m_data.Tables("Employees")
Dim t2 As DataTable
t2 = m_data.Tables("Orders")
Dim dc1, dc2 As DataColumn
dc1 = t1.Columns("employeeid")
dc2 = t2.Columns("employeeid")
Dim relEmpToOrders As DataRelation
relEmpToOrders = New DataRelation("Emp2Ord", _
dc1, dc2)
m_data.Relations.Add(relEmpToOrders)
' Add an extra column
t1.Columns.Add("OrdersPerEmp", _
GetType(Integer), _
"count(child(Emp2Ord).employeeid)")
The code above defines a DataRelation object between the two tables in the same DataSet. The relation makes it easy to retrieve all the orders for a given employee. It is so easy that you could also add an expression-based column to the Employees table to cache how many orders each employee issued. The expression counts the distinct employeeid values according to the defined relationship.
Believe it or not, this code produces the output in Figure 3. The two grids are synchronized without the need to write specific code.
Let's look at what's needed to connect the grids to the related data.
grid.DataSource = m_data
grid.DataMember = "Employees"
gridOrders.DataSource = m_data
gridOrders.DataMember = "Employees.Emp2Ord"
Both grids are bound to the same DataSet which contains both Employees and Orders tables. The parent grid is bound to the parent table?Employees. The child grid is bound to the dynamically changing table resulting from the Emp2Ord relation applied to the Employees table. As a result, when a new row is selected on the parent grid, the child automatically detects the movement, retrieves the new subset of rows, and refreshes itself.
All this works for free but doesn't give any chance to get into the game; you can't even grab the key field of the selected row. To access the row yourself, you must take the binding context route.
What strikes me most about this feature is that the two grids are not related in any way. The child grid doesn't know which control acts as the parent; the parent ignores if and where a child grid exists. Explaining the underpinnings of this mechanism (no black magic, just smart code) is beyond this article. I wrote an article for MSDN Magazine about this that you can access at the following URL:
http://msdn.microsoft.com/msdnmag/issues/02/02/cutting/default.aspx
I'll only say that the binding context plays a key role. In practice, if the grid control detects that its DataMember property has the form of Table.Relation, then it connects to the form's binding context asking for the manager of its DataSource and the table name in the DataMember. Next, it registers to handle the CurrentChanged event on the binding manager. This way the grid is notified whenever a control bound to that source changes its current element. At this point, the grid realizes it's been configured to be the child of a relation. It extracts the Relation part from the DataMember property and using plain ADO.NET code retrieves the subset of rows to display. Guessed the final step already? The content of the grid is cleared and refreshed!
Present and Future of Windows Forms
If you want to customize the look-and-feel of the Windows Forms DataGrid control beyond the threshold discussed here, consider that the complexity of the code required grows exponentially. To define a new column type, for example, you just have to add concrete code to a bunch of abstract methods on the DataGridColumnStyle class; you also have to figure out how to do that. No samples are provided but a clear message surfaces from the MSDN documentation. The underlying mechanism of Windows Forms is still the Win32 owner-draw model?a feature that I hope to see changed in .NET. The CreateWindowEx API is still key to Windows Forms and Microsoft didn't put much effort into creating an abstraction layer to let us use and embed .NET controls in the UI of controls.
A DataGrid can display more than one table if the tables are children of the same DataSet and hierarchically related.
Windows Forms give you great features but leaves a lot of unfilled gaps here and there. Professional looking and feature-rich grids often require you to do without the DataGrid and be designed from scratch. So much for the present, but what's the future?
The future of Windows Forms is something called Longhorn and it is expected to ship sometime around 2004. Longhorn will be the first version of Windows that features completely managed code. By then the Win32 API will be gone forever. Microsoft is working on a new user interface API (codenamed Avalon) to be used in the Windows shell as well as in the next generation of desktop applications. Putting more effort in the Windows Forms of today is a lost investment. That's why they come up with some great features and some gaps as well. Not too great; not too bad. Stay tuned.