Last week, I got a call from a rather desperate client who was experiencing a hard time with a couple of comboboxes. She was writing a Windows Forms application to populate a database with user information. First, she created a form and then added all the needed input controls to collect the desired information — mostly name, phone numbers, preferences, and addresses. The user was expected to insert up to two addresses, so the form provided a couple of identical panels, each with a combobox for ZIP codes. As the application became ready to manage its data, she started playing with it and entered some sample data. Oh, bad surprise! She was unable to select two distinct ZIP codes out of distinct and physically different comboboxes.
During our phone conversation, she asked whether there was a way to keep two comboboxes in the same form out of sync. To which I replied, "Why? Do you perhaps know of a way to keep them automatically in sync?" Her response sounded discouraging, "Not me, but ADO.NET or Windows Forms look like they do." What was going on?
<SubheaderZ>Syncing DataGrids
After hanging up, I plunged into experimenting to try to reproduce the problem. I then remembered a rather magical feature of Windows Forms DataGrid controls that make them work automatically in sync. Since I knew that case well, I thought I was on the right track to reproduce and isolate the problem. Suppose that you have a DataSet of, say, employees and two comboboxes in a Windows form. Since the two comboboxes actually play the same role and need to show the same data, there's nothing bad in using the same data source and field names for both. So the following code makes perfect sense:
cboEmp1.DataSource = data.Tables("Employees") cboEmp1.DisplayMember = "lastname" cboEmp1.ValueMember = "employeeid" cboEmp2.DataSource = data.Tables("Employees") cboEmp2.DisplayMember = "lastname" cboEmp2.ValueMember = "employeeid" |
However, as my client discovered, this seemingly innocuous code has the side effect of the two controls being indissolubly tied. Once the form shows up, the two controls have the first item selected. However, as you change the selection in one of them, the other automatically refreshes to show the same item. It happens as if each control silently handles the SelectedIndexChanged for the other. As we'll see in a moment, this is an intentional feature and is not due to a bug, neither in your code, nor in the system's assemblies.
Same Form, Same Data Source
The behavior we're considering arises from two particular circumstances — the comboboxes are on the same form and, more importantly, are connected to the same data source object. In the aforementioned code, both comboboxes are bound to the Employees table in a DataSet object called "data." This is an important fact. If you're not convinced, try making a deep copy of the DataSet so that you obtain two distinct DataSet objects with the same data inside.
' Deep copy of the DataSet object Dim datasetCopy As DataSet = data.Copy() |
If you bind the comboboxes to distinct objects with the same contents, the controls show the same list of items but work independently for selection. Although not particularly efficient, this simple trick is enough to solve my client's problem. But why is this so? To understand why, we need to take a look at the underpinnings of ADO.NET data binding in Windows Forms.
Each form belonging to a Windows Forms application maintains its own binding context; that is, a collection of bindings between data source objects and controls on the form. The binding context is a class named "BindingContext," publicly exposed as the BindingContext property by all objects that inherit from the Control class, including Form.
Overridable Public Property BindingContext As BindingContext Public Class BindingContext Implements ICollection, IEnumerable |
An individual binding is represented by an instance of classes that inherit from BindingManagerBase. Actually, BindingManagerBase is an abstract class. The actual classes you will work with are PropertyManager and CurrencyManager.
The PropertyManager class tracks bindings between a property of a control and a data-bound scalar value — for example, the value of a particular database column. The CurrencyManager class is more sophisticated and maintains bindings between a data source and data-bound list controls such as Comboboxes, ListBoxes, and DataGrids.
The CurrencyManager class provides a uniform interface for bound controls that want to be quickly and automatically informed about changes in the contents and selection of the data source. The class has properties such as Current and Position. Current returns the data object behind the currently selected row in the data-bound list control. For example, if you have a list control (i.e., DataGrid, ListBox) bound to a DataTable, Current returns the DataRow object corresponding to the item. The data type of Current depends on the type of data source. It will be a DataRowView if a DataView is used; a string if the data source is an array of strings.
The Position property indicates the 0-based index that the currently selected item occupies in the control's user interface. The selection can be interactively changed by the user or programmatically by the application.
All data-bound controls in the Windows Forms platform fully support the binding context. This includes Combobox, DataGrid, and ListBox. Whenever a pair of these controls happen to be bound to the same data source object — not the same contents — their internal code keeps them automatically in sync. In summary, the odd behavior that my client signaled is common to various controls but is a precise feature due to built-in code. But what really happens in the folds of a Combobox control?
Spelunking the Depths of Data-Bound Controls
The behavior of comboboxes can be easily replicated in custom controls once you understand what really happens deep inside. First off, the control grabs a reference to form's binding context for its own data source. The following pseudocode illustrates the passage:
Dim cm As CurrencyManager cm = BindingContext(DataSource) If Not(cm Is Nothing) Then AddHandler cm.CurrentChanged, _ AddressOf(CurrentChanged) : End If |
As mentioned earlier, although the BindingContext property is a specific feature of data binding, all subclasses of Control support it. The combobox retrieves the binding manager for its own data source and registers with it to handle any CurrentChanged event that gets fired. Even from this brief description, it's clear that two comboboxes bound to the same data source will retrieve the same CurrencyManager object and register for the same event at the same time. Of course, since two comboboxes are two instances of the same class, they will respond to the event notification in the same way; that is, selecting the new item in their respective user interface.
The preceding code is all that you have to add to a custom control to make it support the same data-binding feature. The internal handler of the CurrentChanged event will then retrieve a reference to the binding manager using the sender argument.
Sub CurrentChanged(sender As Object, e As EventArgs) Dim cm As CurrencyManager cm = CType(sender, CurrencyManager) Dim dataItem As Object = cm.Current : End Sub |
To be precise, the code added so far only makes the control able to detect changes in the selection. A second piece must be added to make the control signal that its own selection has been changed, thus allowing others to refresh. When the selection changes, the combobox sets the Current property on the binding manager and triggers the CurrentChanged event for registered instances of the same class.
Disabling Automatic Sync Mode
There's no way to keep two comboboxes out of sync if they are in the same form and bound to the same data source. The only way to disconnect them is by using different data sources. However, there's a better way of accomplishing this than duplicating a potentially large DataSet object. Let's consider the following code:
cboEmp1.DataSource = data.Tables("Employees") cboEmp1.DisplayMember = "lastname" cboEmp1.ValueMember = "employeeid" cboEmp2.DataSource = data cboEmp2.DisplayMember = "Employees.lastname" cboEmp2.ValueMember = "Employees.employeeid" |
The first combobox is bound to the Employees DataTable in the DataSet. The second combobox is, instead, bound to the whole DataSet. The DisplayMember and ValueMember properties are also set differently. In terms of the displayed data, the two approaches are identical. In this case, though, being bound to different data source objects, the comboboxes work independently. Both the DisplayMember and ValueMember properties contain an expression that evaluates to a field name in the data source. The expression is a relative path in the DataSet. The relationship is DataSet->DataTable->DataColumn. If the data source is a DataTable (as with cboEmp1), then the expression must be the name of a column. If the data source is a DataSet (see cboEmp2), then the expression should also contain the name of the table. This solution is more efficient than using a copy of the DataSet because it requires less memory — no object is actually duplicated.
Using Relations
Another interesting aspect of Windows Forms data binding is the built-in support for relations. A DataRelation object represents a link between two tables in the same DataSet. The link is set on a common field in a sort of in-memory foreign-key relationship. For example, if you have two tables, such as Employees and Orders, you could set up a relation on the employeeid column so that each employee has automatically associated all of the orders she issued. You create a relation as follows:
Dim rel As DataRelation rel = New DataRelation("Emp2Orders", _ data.Tables("Employees").Columns("employeeid"), _ data.Tables("Orders").Columns("employeeid")) data.Relations.Add(rel) |
The constructor of the DataRelation class takes three arguments — there are other richer constructors available, too — the first being the name of the relation. The second and the third arguments are the two columns that will provide the link. You should pass them on as DataColumn objects. When the DataRelation has been successfully created, you add it to the Relations collection of the DataSet object. Only at this point is the relation fully set up.
Let's add a DataGrid control to the Windows form and configure its data binding properties as follows:
ordersGrid.DataSource = data ordersGrid.DataMember = "Employees.Emp2Orders" |
You set the DataSource property with the DataSet and add an expression to the DataMember property. You should note that Combobox and ListBox controls have no DataMember property, and for this reason can't provide the special functionality we'll discuss in a moment.
The DataMember property selects the table, or a subset of a table, to display. If you need to display the whole table, set the property with the table name. However, if you're interested in a free but effective master/detail model, set the DataMember with a string of the form DataTable.DataRelation. For the trick to work, the DataTable in the expression must be the parent table of the specified relation. The parent table of a relation is the table to which the first column belongs.
If a second DataGrid, or a Combobox, are present in the form and bound to the same DataSet and parent table, then any selection you make will automatically update the grid with all and only the records associated with the parent record. Here's an example to help clarify: Suppose you have a Combobox bound to a DataSet with the DisplayMember taken from the Employees table. Add a DataGrid bound to the same DataSet and with the DataMember property set as above. Guess what happens if you select an employee in the combobox? Magically, the DataGrid refreshes to show all the orders issued by that employee! See Figure 1.
Notice that in the sample application of Figure 1, both comboboxes can be used to select an employee — either by last or first name. Also, if disabled, the combobox still refreshes when a synchronized data source changes contents or selection.
Summary
In this article, we delved deep into the intricacies of Windows Forms data binding and revealed the trick behind an apparently odd behavior — identical controls working indissolubly in sync. As for the automatic and codeless master/detail model, you should note that it works as long as you can download and cache the entire data source on the client. This is not always a reasonable assumption, especially if you're dealing with data warehouses or large databases. In this case, you should modify the application's user interface so that it downloads small chunks of data at a time and then work with them in the same way.
Dino Esposito is Wintellect's ADO.NET and XML expert and is a trainer and consultant based in Rome, Italy. He is a contributing editor to MSDN Magazine, writing the "Cutting Edge" column, and is the author of several books for Microsoft Press, including Building Web Solutions with ASP.NET and Applied XML Programming for .NET. Contact him at dinoe@wintellect.com.