[ Team LiB ] Previous Section Next Section

12.4 Windows Data Binding

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.

Table 12-4. Data binding interfaces

Interface

Description

IList

Allows simple data binding to a collection of objects of the same type. For example, you can data bind to an ArrayList that contains only one type of object because it implements this interface.

IBindingList

Provides additional features for notification. This notification includes when the list itself changes (for example, the number of items in the list increases) and when the list items change (for example, the third item in a list of customers has a change to its FirstName field). This interface is implemented by DataView and DataViewManager.

IEditableObject

Provides support for editing. In other words, when the user modifies the control, the changes are applied to the data object. This is implemented by the DataRowView class.

IDataErrorInfo

Allows a data object to offer error information a control can bind to. This information consists of two strings: the Error property, which returns general error message text (for example, "An error has occurred") and the Item property, which returns a string with a specific error message from the column (for example, "The value in the Cost column can't be negative"). This is implemented by the DataRowView class.

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.

12.4.1 The CurrencyManager and BindingContext

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.

Figure 12-5. The binding context of a form
figs/adonet_1205.gif

The next few sections show how to use Windows Forms data binding with the common set of .NET controls.

12.4.2 List Binding

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());
}

12.4.3 Single-Value Binding

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).

Figure 12-6. Multiple bound controls
figs/adonet_1206.gif

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:

  1. Fill the list control with all possible choices. You can do this using the Add( ) or AddRange( ) method. Do not use data binding.

  2. 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.

12.4.4 Format and Parse

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.

Figure 12-7. Format and parse
figs/adonet_1207.gif

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.

Example 12-4. Formatting and parsing values
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);
    }
}

12.4.5 Controlling Navigation

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.

Example 12-5. Changing record position programmatically
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.

Example 12-6. Handling the PositionChanged event
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);

12.4.6 Master-Detail Forms

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.

Example 12-7. Master-detail data binding
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;
    }

}
Figure 12-8. A master-detail form
figs/adonet_1208.gif

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.

12.4.7 Creating New Binding Contexts

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:

  1. 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.

  2. 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.

  3. 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();
  4. Bind all the controls as you would ordinarily.

    [ Team LiB ] Previous Section Next Section