So far, the examples have concentrated
on a single control designed specifically for data binding: the
Windows DataGrid
. But the Windows Forms platform
also supports data binding with just about any control (as
demonstrated a little later in this section) and automatically
synchronizes multiple data-bound controls. This ability goes far
beyond just ADO.NET and the DataSet
. In fact, the
ability to bind a data object to a Windows control depends on the
small set of interfaces shown in Table 12-4.
Some collection classes, such as the Array
and
ArrayList
, support data binding because they
implement the IList
interface. This is the minimum
requirement for simple read-only data binding. The ADO.NET data
objects implement three additional interfaces, giving them the
ability to support notification, editable binding, and error
information.
These
interfaces
don’t tell the whole data binding story, however.
Windows Forms can also synchronize multiple controls. This allows you
to (for example) choose a record using a list control and see the
related field information automatically appear in other data-bound
text or label controls on the same form. This ability
isn’t directly derived from ADO.NET; in fact, unlike
the ADO Recordset, classes such as the DataSet
and
DataView
don’t store any
positional information that would allow them to
“point” to a single row. Instead,
this ability comes from the Windows Forms architecture and is
provided by two classes: CurrencyManager
and
BindingContext
.
When you bind a data object to a control, it is automatically
assigned a CurrencyManager
object. The
CurrencyManager
keeps track of the position in the
data source. If you are binding to more than one data object, each
has a separate CurrencyManager
. If several
controls are bound to the same data source, they share the same
CurrencyManager
.
Every form has a
BindingContext
object. The
BindingContext
object keeps track of all the
CurrencyManager
objects on the form. It is
possible to create and use more than one
BindingContext
object (as discussed a little
later) but, by default, every form is given a single
BindingContext
. Figure 12-5
diagrams this relationship.
The next few sections show how to use Windows Forms data binding with the common set of .NET controls.
All
controls that derive from
ListControl
(including ListBox
and ComboBox
) support read-only data binding to a
DataTable
object. Indicate the desired
DataTable
by setting the
DataSource
property, much as you would with the
DataGrid
control. However, list controls can track
only two pieces of information, and they can display only a single
field. Specify the field to display by setting the
DisplayMember
property to the field name.
For example, the following code binds a DataTable
to a ComboBox
and shows the CustomerID field. (You
can add this code to the end of Example 12-1 to test
it.)
cboCustomerID.DataSource = ds.Tables["Customers"].DefaultView; cboCustomerID.DisplayMember = "CustomerID";
You can also use the
ValueMember
property to store additional
information (technically, an instance of any .NET type) with each
list item:
cboCustomer.DataSource = ds.Tables["Customers"].DefaultView; cboCustomer.DisplayMember = "ContactName"; cboCustomer.ValueMember = "CustomerID";
You can then retrieve the value of the currently selected item using
the SelectedValue
property. For example,
here’s the event handler for a button that displays
the CustomerID of the currently selected record:
private void button1_Click(object sender, System.EventArgs e) { // Display the CustomerID of the currently selected record. MessageBox.Show(cboCustomer.SelectedValue.ToString()); }
Keep in mind that this is only a convenience. When you bind a
DataTable
, the object is retained with all its
information, regardless of what item you choose to show in the
control. By accessing the binding context directly, the following
code snippet accomplishes the same task, relying on the display
member. This approach is useful if you need to retrieve several
columns of undisplayed information.
private void button1_Click(object sender, System.EventArgs e) { // Retrieve the binding context for the form. BindingContext binding = this.BindingContext; // Look up the currency manager for the appropriate data source. BindingManagerBase currency = binding[cboCustomer.DataSource]; // Using the currency manager, retrieve the currently selected // DatRowView. DataRowView drView = (DataRowView)currency.Current; // Display the CustomerID of the currently selected record. MessageBox.Show(drView["CustomerID"].ToString()); }
Most controls don’t
provide a DataSource
property. For example, common
.NET controls such as the TextBox
,
Label
, and Button
don’t provide any special data-binding member.
However, they can display a single value of bound information. This
functionality is inherited from the base Control
class.
The Control
class provides a
DataBindings
collection that allows you to link
any control property to a field in the data source. Usually,
you’ll add a data binding that binds information to
a display property like Text
. However, much more
exotic designs are possible—such as binding a color name to the
Control.ForeColor
property.
To connect a TextBox
to the
ContactName
field of a
DataTable
, use the following code:
txtContact.DataBindings.Add("Text", ds.Tables["Customers"].DefaultView, "ContactName");
The first parameter is the name of the control property. .NET uses
reflection to find the matching property at runtime (although it
can’t catch mistakes at design time). The second
parameter is the data source. The third parameter is the property or
field in the data source that will be bound—in this case, the
ContactName
field. The Add( )
method is a shorthand that allows you to
create and add a Binding
object in one step.
Here’s the equivalent code that creates the
Binding
object manually:
Binding propertyFieldBinding; = new Binding("Text", ds.Tables["Customers"].DefaultView, "ContactName"); txtContact.DataBindings.Add(propertyFieldBinding);
You can use a similar approach to link the
CustomerID
value to the
TextBox.Tag
property. The Tag
property isn’t used by .NET but is available for
information storage you might want later. This way, you can determine
the CustomerID
for the current customer, just as
you did with the list control.
txtContact.DataBindings.Add("Tag", ds.Tables["Customers"].DefaultView, "CustomerID");
Unlike the list binding, single-value binding provides no way to move
from record to record. However, if you’ve followed
the previous examples, you will now have a form with multiple
synchronized controls. When you choose a record in a list control or
DataGrid
control, the corresponding information is
shown in any linked single-value controls such as the
Label
or TextBox
(see Figure 12-6).
Single-value binding is also useful with list
controls. When you bind a list control by setting the
DataSource
property, you create a read-only
navigational control. When a value is selected from the list, the
CurrencyManager
moves to the appropriate record,
and all other controls are updated appropriately. When you use
single-value binding with a list, you create an editable value that
allows you to modify the bound field for the current record.
To use a list control in this fashion, follow these two steps:
Fill the list control with all possible choices. You can do this
using the Add( )
or AddRange( )
method. Do not use data binding.
Bind the Text
or SelectedValue
property to the appropriate field in the data source using
single-value binding.
If you use this technique with a ListBox
or
ComboBox
that uses the
DropDownList
style, you must ensure there is an
item added for every possible value. Otherwise, an exception is
thrown when the user navigates to a record that has a value not
included in the list. You don’t need to follow this
restriction when using a ComboBox
that has the
DropDown
or Simple
style.
Now, the list control shows the bound field automatically when you navigate to a record. However, the user can also select a new value to modify the field.
One of the traditional limitations with data binding was that it provided relatively few opportunities to format the data. Unfortunately, the raw data drawn directly from a database may contain numeric codes or short forms that need to be replaced with more descriptive equivalents or numbers that need to be formatted to a specific scale or currency format. If your data is editable, you’ll also need to take user-supplied data and convert it to data that can be inserted into the database.
To accomplish these tasks, you need to handle the
Format
and Parse
events for the
Binding
object. Use the Format
event handler to modify values from the database before they appear
in a data bound control. Use the Parse
event
handler to take a user-supplied value and modify it before it is
entered in the data object. Figure 12-7 diagrams the
process.
For example, the Products
table in the
Northwind database includes a
UnitPrice
column. By default, this displays a
number in ordinary decimal format as shown here:
21.3 12 14.33
A more consistent representation looks like this:
$21.30 $12.00 $14.33
The following code shows how you might write the data binding code to
support this conversion. This code binds the UnitPrice field to a
TextBox
and registers to handle the
Format
and Parse
events:
// Create the binding. Binding dataBinding = new Binding("Text", dsStore.Tables["Products"].DefaultView, "UnitPrice"); // Connect the methods for formatting and parsing data. dataBinding.Format += new ConvertEventHandler(DecimalToCurrencyString); dataBinding.Parse += new ConvertEventHandler(CurrencyStringToDecimal); // Add the binding. txtUnitCost.DataBindings.Add(dataBinding);
The Format
and Parse
event
handlers access the value to convert from the
ConvertEventArgs.Value
property. They replace this
value with the converted value. It’s also good
practice for the Format
and
Parse
event handlers to verify the expected data
type using the
ConvertEventArgs.DesiredType
property. For example, in a
TextBox
, every value is converted to a string.
However, the reverse conversion expects a decimal. If the desired
type doesn’t meet expectations, the event handlers
leave the value untouched. See Example 12-4.
private void DecimalToCurrencyString(object sender, ConvertEventArgs e) { if (e.DesiredType == typeof(string)) { // Use the ToString method to format the value as currency ("c"). e.Value += ((decimal)e.Value).ToString("c"); } } private void CurrencyStringToDecimal(object sender, ConvertEventArgs e) { if (e.DesiredType == typeof(decimal)) { // Convert the string back to decimal using the static Parse() // method. e.Value = Decimal.Parse(e.Value.ToString(), System.Globalization.NumberStyles.Currency, null); } }
So far, we’ve
considered only one way to control navigation: using a navigational
control such as a ListBox
or
DataGrid
. However, you can also control navigation
programmatically by directly interacting with the
CurrencyManager
.
Example 12-5 shows the event handlers for Next and
Previous buttons. When one of these buttons is clicked, a new record
is selected, and all bound controls are updated automatically. In
this case, the CurrencyManager
is retrieved every
time it is needed. It might be a better approach to store a reference
to it in a private form-level variable.
private void cmdPrev_Click(object sender, System.EventArgs e) { // Retrieve the binding context for the form. BindingContext binding = this.BindingContext; // Look up the currency manager for the appropriate data source. BindingManagerBase currency = binding[dataGrid1.DataSource]; // Move to the previous record. currency.Position--; } private void cmdNext_Click(object sender, System.EventArgs e) { // Retrieve the binding context for the form. BindingContext binding = this.BindingContext; // Look up the currency manager for the appropriate data source. BindingManagerBase currency = binding[dataGrid1.DataSource]; // Move to the next record. currency.Position++; }
In this example, the code doesn’t bother to check
whether it’s reached the limits of the data source.
For example, if the user clicks the previous button while positioned
on the first record, it tries to set the Position
property to the invalid value -1. Fortunately, the
CurrencyManager
simply ignores invalid
instructions, and the Position
remains unchanged.
Example 12-6 shows the event handler you’ll need.
private void Binding_PositionChanged(object sender, System.EventArgs e) { // Retrieve the binding context for the form. BindingContext binding = this.BindingContext; // Look up the currency manager for the appropriate data source. BindingManagerBase currency = binding[dataGrid1.DataSource]; if (currency.Position == currency.Count - 1) { cmdNext.Enabled = false; } else { cmdNext.Enabled = true; } if (currency.Position == 0) { cmdPrev.Enabled = false; } else { cmdPrev.Enabled = true; } }
And here is some of the data binding code, which now also hooks up the required event handler:
// Bind a DataGrid. dataGrid1.DataSource = ds.Tables["Customers"].DefaultView; // Hook up the PositionChanged event handler. BindingContext binding = this.BindingContext[dataGrid1.DataSource]; currencyPositionChanged += new EventHandler(Binding_PositionChanged);
The
PostionChanged
event also makes it easy to create
master-detail forms (see Figure 12-8). A
master-detail binds two data objects and
uses two CurrencyManager
objects. When the parent
record changes, the child data must also be modified. This
modification can be accomplished by configuring the
DataView.RowFilter
property.
public class MasterDetail : System.Windows.Forms.Form { private System.Windows.Forms.DataGrid gridSuppliers; private System.Windows.Forms.DataGrid gridProducts; // (Designer code omitted.) DataSet ds = new DataSet("Northwind"); private void MasterDetail_Load(object sender, System.EventArgs e) { string connectionString = "Data Source=localhost;" + "Initial Catalog=Northwind;Integrated Security=SSPI"; string SQL = "SELECT * FROM Products"; // Create ADO.NET objects. SqlConnection con = new SqlConnection(connectionString); SqlCommand com = new SqlCommand(SQL, con); SqlDataAdapter adapter = new SqlDataAdapter(com); // Execute the command. try { adapter.Fill(ds, "Products"); com.CommandText = "SELECT * FROM Suppliers"; adapter.Fill(ds, "Suppliers"); } finally { con.Close(); } // Display the results. gridSuppliers.DataSource = ds.Tables["Suppliers"].DefaultView; gridProducts.DataSource = ds.Tables["Products"].DefaultView; // Handle the PositionChanged event for the Suppliers table. BindingManagerBase currency; currency = this.BindingContext[gridSuppliers.DataSource]; currency.PositionChanged += new EventHandler(Binding_PositionChanged); } private void Binding_PositionChanged(object sender, System.EventArgs e) { string filter; DataRowView selectedRow; // Find the current category row. selectedRow = (DataRowView)this.BindingContext[ gridSuppliers.DataSource].Current; // Create a filter expression using its SupplierID. filter = "SupplierID='" + selectedRow["SupplierID"].ToString() + "'"; // Modify the view onto the product table. ds.Tables["Products"].DefaultView.RowFilter = filter; } }
Example 12-7 uses two
DataGrid
controls: one that displays Suppliers
(the parent
table) and one that displays the Products
offered
by the currently selected supplier. The DataGrid
controls are bound as before. The difference is that the
PositionChanged
event handler dynamically builds a
filter string based on the currently selected supplier and uses it to
filter the product list.
Another equivalent option is to use the CreateChildView( )
method discussed earlier in this chapter
to generate a new DataView
object based on a
DataRelation
each time the position changes.
Every form provides a default
BindingContext
object. As you’ve seen, you can access this object
to determine the currently selected item or change the current
position. However, what happens if you want to create a form that has
more than one BindingContext
? For example, imagine
you show two differently filtered views of the same data. In this
case, when the user selects an item in one view, you
don’t want the same item
selected in the second view, even if it is available. Fortunately,
you can create new binding contexts with these three steps:
Create one container control for each binding context you want.
(Forms and container controls are the only controls that can host a
CurrencyManager
.) A common choice is the
GroupBox
.
Organize all the data-bound controls into the container control
according to the desired binding context. For example, if you have
two DataGrid
controls that should not be
synchronized, you can place each DataGrid
in a
separate GroupBox
.
Create a new binding context for each container control. You do this
by assigning a new BindingContext
object to the
BindingContext
property of the container control,
as shown here:
// Create two new binding contexts. grpNormalView.BindingContext = new BindingContext(); grpSortedView.BindingContext = new BindingContext();