Exploring GridView Control Events

 

Michael Weinhardt
www.mikedub.net

January 5, 2003

Summary: Michael Weinhardt continues his examination of the new "Whidbey" Windows Forms GridView control. In particular, he takes a look at a variety of events for you to handle common activities including navigation, editing, validation, and error handling. (18 printed pages)

Download the winforms02172004_Sample.msi sample file.

Fireworks over Sydney

Last night was New Year's Eve, so I'd like to wish all of you a happy, safe, and peaceful new year. I live in Sydney, Australia, which boasted a magnificent fireworks display launched from the Sydney Harbour Bridge on the stroke of midnight. New Year's Eve is one of the biggest events of the calendar year in many cities around the world. It is my guess that the GridView control may also soon be celebrated in many cities around the world, not as a single event but because it offers over 130 events as of the Technology Preview release of "Whidbey." In the last installment, we used OnCellFormatting and OnCellPainting to implement custom cell styling and painting logic. In this installment, we'll continue the journey by exploring events for navigation, editing, validation, and error handling.

The Sample

To demonstrate these events, we will build the Product Editor, shown in shown in Figure 1.

Figure 1. The Product Editor

Product Editor is a simple Windows Forms application that binds a GridView to the Products table of the Northwind database (for information on how to do this, please refer to last month's installment) and uses a variety of events to:

  • Supply the user with positional and contextual location information.
  • Hook into the editing process for value-by-value checks.
  • Relay client-side type and range validation errors.
  • Supplement client-side validation with further custom validation.

When users are presented with a form like Figure 1, they're liable to perform one of the following actions:

  • Casually browse the data to locate a specific cell/value
  • Use sort and find functionality to locate a specific cell/value
  • Scroll to the end of the GridView and add a new row
  • Select a specific row for deletion

Displaying Positional Information

All of these actions involve moving from one cell to another, sometimes across rows. Occasionally, it can be difficult to determine the actual cell position, particularly when large amounts of data are displayed. Applications such as Microsoft Excel and Microsoft Word tend to display this type of information to the user using a control like the status bar. To report this information, we need an event to notify us when a user has navigated, plus information that tells us our current row and column position. The GridView's CellEnter event provides both:

public class Form1 : System.Windows.Forms.Form {
  ...
  this.productsGridView.CellEnter += 
    new System.Windows.Forms.GridViewCellEventHandler(
      this.productsGridView_CellEnter);
  ...
  private void customersGridView_CellEnter(
    object sender, 
    System.Windows.Forms.GridViewCellEventArgs e) {
    // Display row,column position adding 1 for 0-based indices
    int row = e.RowIndex + 1;
    int column = e.ColumnIndex + 1;
    this.pnlPosition.Text = string.Format("Row {0}, Col {1}", row, column);
  }
  ...
}

In this example, we are using the RowIndex and ColumnIndex properties provided by GridViewCellEventArgs. It is also possible to ascertain row and column position by interrogating the GridView's SelectedCells property, like so:

int columnIndex = this.productsGridView.SelectedCells[0].ColumnIndex;
int rowIndex = this.productsGridView.SelectedCells[0].RowIndex;

However, you should use GridViewCellEventArgs rather than SelectedCells for positional information. SelectedCells is empty when the GridView is initially filled with data, which causes the previous code to throw an ArgumentOutOfRangeException, while GridViewCellEventArgs always provides valid row and column indices to the navigation events, even when cells are not selected. And since RowIndex and ColumnIndex map directly into the GridView's object model, we can use them to display a more readable GridView position that shows ProductId and column name:

public class Form1 : System.Windows.Forms.Form {
  ...
  this.productsGridView.CellEnter += 
    new System.Windows.Forms.GridViewCellEventHandler(
      this.productsGridView_CellEnter);
  ...
  private void customersGridView_CellEnter(
    object sender, 
    System.Windows.Forms.GridViewCellEventArgs e) {
    // Display position, adding 1 to compensate for 0-based indices
    int row = e.RowIndex + 1;
    int column = e.ColumnIndex + 1;
    this.pnlPosition.Text = string.Format("Row {0}, Col {1}", row, column);
    // Display human-readable current position
    GridViewRow gridRow = this.productsGridView.Rows[e.RowIndex];
    GridViewColumn gridColumn = 
      this.productsGridView.Columns[e.ColumnIndex];
    string productId = gridRow.Cells["ProductID"].Value.ToString();
    string columnName = gridColumn.Name;
    this.pnlHuman.Text = string.Format("Product {0}: {1}", productId, columnName);
  }
  ...
}

Displaying Contextual Information

Just as positional information might be hard for the user to work out, specific details about the current cell may also be unapparent at first glance, such as whether a cell is read-only or not. In the sample, ProductID is read-only because it is an AutoIncrement column. As such, I've attempted to highlight this by setting its BackColor to LightSteelBlue. Sometimes, however, displaying words like "Read-Only" is the most obvious and, like the grid position, should be displayed on the status bar. To do so, we need to programmatically determine whether the current cell is read-only. Fortunately, RowIndex and ColumnIndex map directly into the GridView's object model and allow us to navigate to the selected column and find this information, like so:

public class Form1 : System.Windows.Forms.Form {
  ...
  this.productsGridView.CellEnter += 
    new System.Windows.Forms.GridViewCellEventHandler(
      this.productsGridView_CellEnter);
  ...
  private void customersGridView_CellEnter(
    object sender, 
    System.Windows.Forms.GridViewCellEventArgs e) {
    // Display position, adding 1 to compensate for 0-based indices
    int row = e.RowIndex + 1;
    int column = e.ColumnIndex + 1;
    this.pnlPosition.Text = string.Format("Row {0}, Col {1}", row, column);
    // Display human-readable current position
    GridViewRow gridRow = this.productsGridView.Rows[e.RowIndex];
    GridViewColumn gridColumn = 
      this.productsGridView.Columns[e.ColumnIndex];
    string productId = gridRow.Cells["ProductID"].Value.ToString();
    string columnName = gridColumn.Name;
    this.pnlHuman.Text = string.Format("Product {0}: {1}", productId, columnName);
    // Display whether current column is read-only
    if( this.productsGridView.Columns[e.ColumnIndex].ReadOnly == true ) {
      this.pnlReadOnly.Text = "Read-Only";
    }
    else {
      this.pnlReadOnly.Text = "Editable";
    } 
  }
  ...
}

Figure 2 shows the results of our tinkering with the CellEnter event.

Figure 2. Showing row/column position and editable cells

Interestingly, both RowEnter and RowLeave accept GridViewCellEventArgs as an argument, rather than the initially more obvious GridViewRowEventArgs. However, this makes sense as entering a row implies entering a cell. Having access to the GridViewCellEventArgs is a great flexibility feature because it helps you avoid tracking cell position with CellEnter and CellLeave if your code only fits into RowEnter and RowLeave.

There are several navigation events that you will probably use on a regular basis, and they are outlined in the following table.

Event Description
RowLeave Fired when:
  • A new cell is selected in a new row.
  • The GridView control loses the focus.
CellLeave Fired whenever the previously selected cell is deselected, which occurs when:
  • A new cell is selected.
  • A new row is selected.
  • The GridView control loses the focus.
CellEnter Always fired when:
  • A new cell is selected.
  • A new row is selected.
  • The GridView control accepts the focus.
RowEnter Fired when a new row is selected, which occurs when:
  • The GridView is loaded with data.
  • A cell in another row is selected.
  • A cell in the GridView control is selected while another control has the focus.
CellClick Fired when a user mouse-click selects a cell.

The various row and cell navigation events can be fired in a variety of ways depending on what type of navigation is taking place. The following table outlines several common scenarios for navigating within a GridView.

Scenario Events Fired
GridView loaded with data
  1. RowEnter
  2. CellEnter
Selecting a new cell in the same row
  1. CellLeave
  2. CellEnter
Selecting a new cell in a different row
  1. CellLeave
  2. RowLeave
  3. RowEnter
  4. CellEnter

What may not be so obvious is how the navigation events are fired when the user navigates to or from the GridView control. The following table highlights these scenarios.

Scenario Events Fired
Selecting another control on the form
  1. CellLeave
  2. RowLeave
  3. Leave
Reselecting the currently selected cells from another control
  1. Enter
  2. RowEnter
  3. CellEnter
Selecting a new cell in the same row as the currently selected cell from another control
  1. Enter
  2. RowEnter
  3. CellEnter
  4. CellLeave
  5. CellEnter
Selecting a new cell in a different row than the currently selected cell from another control
  1. Enter
  2. RowEnter
  3. CellEnter
  4. CellLeave
  5. RowLeave
  6. RowEnter
  7. CellEnter

As you may have noticed, the GridView's Enter and Leave events come into play and are fired as a control receives or loses the focus respectively. Specifically, when a cell in the GridView is selected while another control has the focus, the GridView fires events for the currently selected cell before firing events for the newly selected. Consequently, make sure you deploy code to these events that only processes within the implicit scope of these events. If not, your logic may execute more times or in a different order than you expect, and potentially have unplanned side effects. Fortunately, there are enough events that you can package your logic neatly into appropriate locations.

Editing

Of course, navigation is just window-shopping in an editable grid. When I was young, my parents always used to say "look, but don't touch" whenever we walked into a shop that had even remotely expensive items, as I have had a penchant for breaking anything I could lay my hands on. These days, though, the GridView affords more latitude than my parents did.

Row Editing

For example, if you try and delete a row from the GridView, you can handle the UserDeletingRow event to cancel the action and avoid a potential accident, which is a feature some of those expensive shops wished they had installed, believe me. The following code uses a standard approach to show how to do this:

public class Form1 : System.Windows.Forms.Form {
  ...
  this.productsGridView.UserDeletingRow += 
    new System.Windows.Forms.GridViewRowCancelEventHandler(
      this.productsGridView_UserDeletingRow);
  ...
  private void suppliersGridView_UserDeletingRow(
    object sender, 
    System.Windows.Forms.GridViewRowCancelEventArgs e) {
    // Ask user to confirm row deletion
    string productId = e.Row.Cells["ProductId"].FormattedValue.ToString();
    string productName = e.Row.Cells["ProductName"].FormattedValue.ToString();
    string message = string.Format(
      "Are you sure you want to delete {0} [{1}]?", 
      productName, 
      productId);
    DialogResult  result = MessageBox.Show(
      message, 
      "Delete?", 
      MessageBoxButtons.OKCancel);
    if( result == DialogResult.Cancel ) {
      e.Cancel = true;
      this.pnlMain.Text = string.Format(
        "Cancelled deletion of {0} [{1}].", 
        productName, 
        productId);
    }
  }
  ...
}

UserDeletingRow is passed as a GridViewRowCancelEventArgs argument:

public class GridViewRowCancelEventArgs : System.ComponentModel.CancelEventArgs {
  // Constructor
  public GridViewRowCancelEventArgs ( System.Windows.Forms.GridViewRow gridViewRow );
  // Access the row to be deleted
  public System.Windows.Forms.GridViewRow Row { get; }
}

GridViewRowCancelEventArgs provides the mechanism to cancel the delete process by setting its inherited Cancel property to True, as well as garnering various row data from its Row property. If the user wishes to continue the deletion, an appropriate message can be displayed from the UserDeletedRow event on completion:

public class Form1 : System.Windows.Forms.Form {
  ...
  this.productsGridView.UserDeletedRow += 
    new System.Windows.Forms.GridViewRowEventHandler(
      this.productsGridView_UserDeletedRow);
  ...
  private void suppliersGridView_UserDeletedRow(
    object sender, 
    System.Windows.Forms.GridViewRowEventArgs e) {
    this.pnlMain.Text = string.Format("Row deleted.");     
  }
  ...
}

UserDeletedRow accepts a GridViewRowEventArgs argument that provides access to the row being processed through its Row Property. The other key row-editing event, UserAddedRow, also accepts a GridViewRowEventArgs argument when fired, which is that the moment the user alters the default value of a new row.

Cell Editing

What's good for the rows is good for the gander: the GridView also implements cell-editing events —CellBeginEdit and CellEndEdit. Certain conditions must be met before these events are fired. Specifically, the selected cell must be editable, which can be set either manually or automatically. The default GridView approach is the manual approach, which requires the user to press the F2 key or double-click on each and every cell that needs to be edited. The automatic approach is enabled by setting the GridView's EditCellOnEnter property to True and switches each cell into edit mode on entry without user intervention, unless the cell is read-only. Whichever of the two approaches is used, CellBeginEdit is then fired after the cell has been selected. CellBeginEdit is passed a GridViewCellCancelEventArgs:

public class GridViewCellCancelEventArgs : System.ComponentModel.CancelEventArgs {
  public System.Int32 ColumnIndex { get; }
  public System.Int32 RowIndex { get; }
}

Like we've seen before, ColumnIndex and RowIndex refer to the cell in context. And, like before, you can use a Cancel property to either allow or prevent editing. This can come in handy when you need to make a determination on a case-by-case basis. The Products table records whether a product has been is still available or not, using the Discontinued field. When Discontinued is True, it would not make sense to allow updates on the UnitsOnOrder cell of the same row. And, of course, Discontinued can change from one row to the next. CellBeginEdit accommodate this can change for row to row. BeginCellEdit allows us to employ this business logic and allow or deny editing based on the value in the Discontinued cell:

public class Form1 : System.Windows.Forms.Form {
  ...
  this.productsGridView.CellBeginEdit += 
    new System.Windows.Forms.GridViewCellCancelEventHandler(
      this.productsGridView_CellBeginEdit);
  ...
  private void productsGridView_CellBeginEdit(
    object sender, 
    System.Windows.Forms.GridViewCellCancelEventArgs e) {
      
    // Only allow UnitsOnOrder update if the product is not discontinued
    int columnIndex = this.productsGridView.Columns["UnitsOnOrder"].Index;
    if( e.ColumnIndex == columnIndex ) {
      GridViewRow gridRow = this.productsGridView.Rows[e.RowIndex];     
      bool cantReorder = (bool)gridRow.Cells["Discontinued"].Value;
      if( cantReorder ) {
        MessageBox.Show("You cannot reorder this product ...");
        e.Cancel = true;
        return;
      }
    }
    this.pnlEditing.Text = "EDIT";
  }
  ...
}

In this code, "EDIT" is displayed on the status bar as an added visual aid and is removed from CellBeginEdit's partner in crime, CellEndEdit, which is fired when the user commits the edit by either moving off the cell or canceling the edit by pressing the Escape key. I've used it to remove "EDIT" from the status bar:

public class Form1 : System.Windows.Forms.Form {
  ...
  this.productsGridView.CellEndEdit += 
    new System.Windows.Forms.GridViewCellEventHandler(
      this.productsGridView_CellEndEdit);
  ...
  private void suppliersGridView_CellEndEdit(
    object sender, 
    System.Windows.Forms.GridViewCellEventArgs e) {
    // Signal that user is not editing
    this.pnlEditing.Text = "";
  }
  ...
}

You can also check for edit mode by querying the GridView's IsCurrentCellInEditMode property.

Editing Events Firing Order

CellBeginEdit and CellEndEdit are fired in the following orders if EditCellOnEnter is set to True.

Scenario Events Fired
Navigate from one cell to another in the same row
  1. CellLeave
  2. CellEndEdit
  3. CellEnter
  4. CellBeginEdit
Navigate from one cell to another in a different row
  1. CellLeave
  2. RowLeave
  3. CellEndEdit
  4. RowEnter
  5. CellEnter
  6. CellBeginEdit

If EditCellOnEnter is set to False, the events are fired in the same order, although the user must press F2 or double-click the cell after RowEnter before CellBeginEdit fires.

DataError

We saw how events like UserDeletingRow and CellBeginEdit give us the opportunity to back out potential mistakes through cancellation. Another technique for avoiding potential mistakes is to validate user-entered data, particularly with respect to a value's type and range. For example, users can set the numeric SupplierID with an alphanumeric value and violate type validation. The GridView actually stops this situation from occurring by preventing the edit from being committed and by confining entry to the selected cell until a valid value is entered or the Escape key is pressed, as shown in Figure 3.

Figure 3. Stuck in SupplierId due to a not so obvious validation error

In the current PDC Whidbey release, the GridView does not provide a default error "display" mechanism for you, although in future releases the default action will be to display an error dialog. However, both now and in the future, you can use the DataError event to handle GridView exceptions and display custom error messages if required. DataError is an exception catchall mechanism for exceptions fired under a variety of conditions, including:

  • Cancelling an edit
  • Ending an edit
  • Committing an edit
  • Refreshing an edit by calling RefreshEdit
  • When a cell's value is replicated to the bound datasource
  • Initializing the editing cell's value, either by setting a cell's FormattedValue property or calling a cell's InitializeEditingControl method

You can use DataError to catch any one of these exceptions like so:

public class Form1 : System.Windows.Forms.Form {
  ...
  this.productsGridView.DataError += 
    new System.Windows.Forms.GridViewDataErrorEventHandler(
      this.productsGridView_DataError);
  ...
  private void productsGridView_DataError(
    object sender, 
    System.Windows.Forms.GridViewDataErrorEventArgs e) {
    // Parse error message and display on status bar
    if( e.Exception is TargetInvocationException ) {
      this.pnlMain.Text = e.Exception.InnerException.Message;
    }
    else this.pnlMain.Text = e.Exception.Message;
  }
  ...
}

This code examines the GridViewDataErrorEventArgs' Exception property for the appropriate exception information, and more specifically for TargetInvocationExceptions that the GridView throws for type and range errors. The previous code extracts the exception and displays it on the status bar as shown in Figure 4.

Figure 4. Displaying a validation exception captured by DataError

GridViewDataErrorEventArgs actually has several interesting features, shown here:

public class GridViewDataErrorEventArgs : System.Windows.Forms.GridViewCellCancelEventArgs {
  // Constructor
  public GridViewDataErrorEventArgs (
    System.Exception exception , 
    System.Int32 columnIndex , 
    System.Int32 rowIndex , 
    System.Windows.Forms.GridViewDataErrorContext context )
  // What type of action was happening when the error occurred
  public System.Windows.Forms.GridViewDataErrorContext Context { get; }
  // The raised exception
  public System.Exception Exception { get; }
  // Re-throw the exception after processing
  public System.Boolean ThrowException { get; set; }
}

As GridViewDataErrorEventArgs derives from GridViewCellCancelEventArgs, you can also determine the ColumnIndex and RowIndex of the cell in question and cancel the DataError event altogether. The latter is definitely something you need to do in a variety of scenarios. For example, when a system-generated validation error occurs, it is not possible to click off the GridView onto another control or the Close box for instance. However, we can use both Cancel and Context properties to help us out:

public class Form1 : System.Windows.Forms.Form {
  ...
  private void productsGridView_DataError(
    object sender, 
    System.Windows.Forms.GridViewDataErrorEventArgs e) {
    // Parse error message
    string errorMessage;
    if( e.Exception is TargetInvocationException ) {
      errorMessage = e.Exception.InnerException.Message;
    }
    else {
      errorMessage = e.Exception.Message;
    }
  
    // If error has occurred while the user has clicked onto another form control 
    // or form close button etc warn them and respond accordingly
    if( (e.Context & GridViewDataErrorContext.LeaveControl) ==
         GridViewDataErrorContext.LeaveControl) {
      DialogResult result = MessageBox.Show(
        "Leave cell and lose changes?", 
        "Data Error?", MessageBoxButtons.OKCancel);
      if( result == DialogResult.OK ) {
        e.Cancel = false;
      }
    }
    else {
      // Stay on GridView and display error message
      this.pnlMain.Text = errorMessage;
    }
  } 
  ...
}

The first thing the new code does is check the context under which the error is being thrown, specifically to ascertain whether the user is trying to leave the GridView control or not. GridViewDataErrorContext allows us to determine what activity or activities were underway when an exception was thrown. These are described in the following table.

Value Activity Attempted When Exception Was Thrown
Formatting Attempting to retrieve a cell's formatted value
Display Attempting to paint a cell or calculate a cell's ToolTipText
PreferredSize Calculating a cell's preferred size
RowDeletion Deleting a row
Parsing Committing, ending or canceling an edit
Commit Committing and edit
InitialValueRestoration Initializing a cell's value, or canceling an edit
LeaveControl Attempting to validate GridView data when a GridView control loses focus
CurrentCellChange Validating, updating, committing or retrieving cell content when the current cell changes
Scroll Validating, updating, committing or retrieving cell content when the current cell changes as a result of scrolling
ClipboardContent Attempting to retrieve the a cell's formatted value while creating the clipboard content (Note: this value does not appear in the current PDC Whidbey build)

If the user was attempting to leave the GridView control when the exception was thrown, we don't want to prevent them from leaving the GridView but we should make sure that they leave the control knowing the cell was in error. Unlike previous CancelEventArgs, the default value for Cancel is True, which prevents the user from leaving the control. To avoid this situation, we set Cancel to False if the user tries to leave this control.

Custom Validation

While DataError does capture some errors, it doesn't provide a complete validation solution. For instance, you may not like system-provided error messages such as "Input string was not in a correct format." or you may prefer to avoid the exception thrown when users enter a negative value into the UnitsInStock column, the result of a database check constraint on the UnitsInStock field specifying it must be >= 0. For the latter situation, it is always faster to validate in the client than on the server.

Cell Validation

This can be achieved with by placing the appropriate validation code into the CellValidating event, like so:

public class Form1 : System.Windows.Forms.Form {
  ...
  this.productsGridView.CellValidating += 
    new System.Windows.Forms.GridViewCellFormattedValueCancelEventHandler(
      this.productsGridView_CellValidating);
  ...
  private void productsGridView_CellValidating(
    object sender,
    System.Windows.Forms.GridViewCellFormattedValueCancelEventArgs e) {
    // Only validate if the current cell's value has changed
    if( !productsGridView.IsCurrentCellDirty ) return;
    // Validate
    if( e.ColumnIndex == 
      productsGridView.Columns["UnitsInStock"].Index ) {
      int   value = int.Parse(e.FormattedValue.ToString());
      if( value < 0 ) {
        pnlMain.Text = "UnitsInStock must be >= 0.";
        e.Cancel = true;
      }
    }
  }
  ...
}

First we check whether an edit has actually been made with IsCurrentCellDirty. If it is, we set Cancel to true if UnitsInStock validation fails, which also prevents CellValidated and CellEndEdit from firing too.

Row Validation

Sometimes cell-by-cell validation might be bothersome for the user if there is a lot of it, and if your users are prone to causing trouble like me. In these situations, it might be more user-friendly (and less pesky) if validation is batched up until data for the entire row is entered. If this is your preference, the GridView offers RowValidating and RowValidated events. As it turns out, the Products table has four Check Constraints that require UnitPrice, UnitsInStock, UnitsOnOrder, and ReorderLevel to all be >= 0, and would be better suited to the row validation model. To give the users a head start, I've updated the typed data set by setting the default values for these fields to 0. Then, I used RowValidating to validate each of these values:

public class Form1 : System.Windows.Forms.Form {
  ...
this.productsGridView.RowValidating += 
  new System.Windows.Forms.GridViewCellCancelEventHandler(
    this.productsGridView_RowValidating);
  ...
  private void productsGridView_RowValidating(
    object sender, 
    System.Windows.Forms.GridViewCellCancelEventArgs e) {
    // Validate only if row is dirty
    if( !this.productsGridView.IsCurrentRowDirty ) return;
    StringBuilder sb = new StringBuilder();
    // Get current row
    GridViewRow row = this.productsGridView.Rows[e.RowIndex];
    // Validate UnitPrice
    decimal unitPrice = decimal.Parse(row.Cells["UnitPrice"].Value.ToString());
    if( unitPrice < 0 ) {
      sb.AppendLine("UnitPrice must be >= 0");
    }
    // Validate UnitsInStock
    // Validate UnitsOnOrder
    // Validate ReorderLevel
    // Display message and cancel
    if( sb.ToString().Length > 0 ) {
      string message = string.Format("Could not accept new values:\n\n{0}", sb.ToString());
      MessageBox.Show(message, "Validation");
      e.Cancel = true;
    }
  }
  ...
}

Figure 5 shows the final result.

Figure 5. Custom validation in action

Where Are We?

Well, that wraps up our whirlwind tour of a subset of the more than 130 events the GridView has available. Some of these help you track navigation and display positional and contextual information. Others are handy to have in data entry and validation situations. Not only are there a rich set of events but, in all cases, events are passed relevant and informative event arguments that simplify your life and reduce the amount of hand written code you'll need to write. As with the last installment, the sheer size of the GridView makes it virtually impossible to cover all of its events in a single article and, unfortunately, we've left out events for other interesting features including virtual mode support. In fact, the GridView is so functional that someone could easily make a career out of it, just like the DataGridGirl (http://www.datagridgirl.com) does now for the ASP.NET DataGrid control. GridViewGuy perhaps?

Acknowledgements

Thanks to Mark Rideout for both technical input and for kindly allowing me to use detailed GridView information he posted to the microsoft.beta.whidbey.windowsforms.controls newsgroup.

Michael Weinhardt is currently working full-time on various .NET writing commitments that include co-authoring Windows Forms Programming in C#, 2nd Edition (Addison Wesley) with Chris Sells and writing this column. Michael loves .NET in general, Windows Forms specifically, and watches 80s television shows when he can. Visit www.mikedub.net for further information.