Windows Forms Data Binding and Objects

类别:.NET开发 点击:0 评论:0 推荐:

Summary: Rocky Lhotka shows you how to write code that you can add to your business and collection classes to better support the features of Windows Forms data binding. (19 printed pages)

Download the vbobjectbinding.exe sample file.

Microsoft worked hard to make data binding useful when creating both Windows Forms and Web Forms interfaces. For the first time since data binding was introduced to Microsoft Visual Basic® years ago, it is truly practical in a wide range of application scenarios.

One key advancement is that data binding supports not only the DataSet, but also objects, structures and collections of objects, or structures. The basic ability to bind an object or collection to a control on a Windows Form or Web Form requires no extra work on our part. It is an automatic feature of data binding.

Web Forms data binding is read-only. In other words, it pulls data from the data source (DataSet, object, or collection) and uses the data to populate controls that are then rendered to the client device. This is straightforward behavior and requires no extra work on our part as we create either objects or the UI.

Windows Forms data binding is read-write, and therefore more complex. In this case, data is pulled from the data source and displayed in our UI controls, but any changes to the values in the controls are automatically updated back into the data source as well. Much of this behavior is automatic and requires no extra work on our part, but there are a number of features available to us if we choose to write some extra code.

That is the focus of this article-code we can add to our business and collection classes to better support the features of Windows Forms data binding. These features include:

  • Having the object or collection notify the UI that data has changed.
  • Allowing the DataGrid to bind correctly to an empty collection.
  • In-place editing of child objects in a DataGrid.
  • Dynamic addition and removal of child objects in a DataGrid.

For simple objects, we can implement events to notify Windows Forms data binding when our property values have changed. By adding these events, we enable the UI to automatically refresh its display any time the data in our object is changed. We also need to understand how to notify the UI that a validation rule has been broken by newly entered data. Improper implementation of validation can make data binding behave in undesirable ways.

In addition, there are a number of optional features we can support in our collections. Collections are typically bound to list controls such as the DataGrid. By properly implementing strongly-typed collection objects, we enable the DataGrid to intelligently interact with our collection and its child objects. We can also implement the IBindingList interface so our collection can intelligently interact with the DataGrid in various ways.

Finally, there are some optional features we can support in objects that will be contained within a collection. We'll call these child objects. Child objects can implement the IEditableObject interface so the DataGrid can properly interact with the object during in-place editing. The child objects can also implement IDataErrorInfo so the DataGrid can mark the line as being in error if any validation rules are broken as the data of the object is changed.

In the end, by adding a bit of code to our classes and collections, we can take advantage of some very powerful features of Windows Forms data binding.

Simple Windows Forms Data Binding

Simply binding properties of an object to properties of controls on a form is not complex. For instance, consider the following simple Order class:

Public Class Order
  Private mID As String = ""
  Private mCustomer As String = ""

  Public Property ID() As String
    Get
      Return mID
    End Get
    Set(ByVal Value As String)
      mID = Value
    End Set
  End Property

  Public Property Customer() As String
    Get
      Return mCustomer
    End Get
    Set(ByVal Value As String)
      mCustomer = Value
    End Set
  End Property

End Class

The only special code here is in the declaration of the variables:

  Private mID As String = ""
  Private mCustomer As String = ""

Notice that the variables are initialized with empty values as they are declared. This is not typical code because Visual Basic .NET automatically initializes variables when they are declared.

The reason we explicitly initialize them like this is because if we don't, the data binding will fail. It turns out that the automatic initialization of the variables doesn't occur by the time data binding tries to interact with our object, causing a runtime exception to be thrown when data binding attempts to retrieve the value from the uninitialized variables.

However, explicit initialization of the variables occurs before data binding interacts with our object. This means that the variables are properly initialized by the time data binding retrieves their values so we avoid the runtime exception.

If we create a form similar to the one shown in Figure 1, we can simply bind the properties of the object to the controls as the form loads.

Figure 1. Simple Form layout for the Order object

The code to bind an Order object to the form would look like this:

  Private mOrder As Order

  Private Sub OrderEntry_Load(ByVal sender As System.Object, _
             ByVal e As System.EventArgs) Handles MyBase.Load

    mOrder = New Order()

    txtID.DataBindings.Add("Text", mOrder, "ID")
    txtCustomer.DataBindings.Add("Text", mOrder, "Customer")

  End Sub

The secret lies in the fact that every Windows Forms control has a DataBindings collection. This collection contains a list of bindings between the properties of the control and the properties of our data source (or data sources). An interesting side effect of this scheme is that we can bind properties from a data source to several different control properties. Also, we can bind different control properties to properties from multiple data sources.

Just by using this simple data binding code we can create some pretty complex user interfaces. For example, in the sample code for this article you'll find that we bind the Enabled property of the Save button to an IsValid property on the business object. This way the button is only available to the user when the object is ready to be saved.

Remember that this data binding is bi-directional. Not only is the data from the object displayed on the form, but any changes the user makes to the data is automatically updated back into our object. This occurs when the user tabs off each field. For instance, if the user changes the value in the txtID control, the new data is updated into the object as the user tabs out of the control. The data is updated into our object through the property Set routine. This is nice because it means our existing Property code is automatically invoked; we don't need to do anything extra to support bi-directional data binding.

Notification of Changed Properties

Now that we've seen how simple data binding an object and the controls is, let's discuss how we can enhance our object to support automatic notification of changed properties. The issue here is that if some other code in our application changes the data in an object, there is no way for the controls in the UI to know about that change. The result is that the UI and object get out of sync and the user won't see the correct data in their display.

What we need is a way for the object to notify the UI any time a property value changes. This is supported through events that we can optionally declare and raise from our object. When we bind a control to a property on our object, data binding automatically starts listening for a property changed event named propertyChanged, where property is the name of the property of the object .

For instance, our Order class defines an ID property. When the ID property is bound to a control, data binding starts listening for an IDChanged event. If this event is raised by our object, data binding automatically refreshes all controls bound to our object.

We can enhance our Order class by declaring these events:

Public Class Order
  Public Event IDChanged As EventHandler
  Public Event CustomerChanged As EventHandler

Notice that the events are declared to be of type EventHandler. This is required for data binding to understand the event. If we don't declare the events this way, we'll get a runtime exception when data binding attempts to interact with our object.

The EventHandler is the standard event model used through Windows Forms. It defines the event with two parameters-sender (the object raising the event) and e (an EventArgs object).

With these events declared, we need to make sure to raise these events any time the corresponding property value changes. One obvious place to do this is in the Set routines. For instance, in the ID property we would do this:

  Public Property ID() As String
    Get
      Return mID
    End Get
    Set(ByVal Value As String)
      mID = Value
      RaiseEvent IDChanged(Me, New EventArgs())
    End Set
  End Property

What can be trickier is to remember that any time we change mID anywhere in our class, we also need to raise this event. Most classes include code that modifies internal variables in addition to property Set routines. We must raise the appropriate event any time a value is changed that will impact a property.

For a better example, let's assume that our Order object will have a collection of LineItem objects. We'll implement the collection a bit later, but right now let's look at the event and variable declarations of the basic LineItem class:

Public Class LineItem
  Public Event ProductChanged As EventHandler
  Public Event QuantityChanged As EventHandler
  Public Event PriceChanged As EventHandler
  Public Event AmountChanged As EventHandler

  Private mProduct As String
  Private mQuantity As Integer
  Private mPrice As Double

Notice that we have four events, one for each of our properties, but we only have three variables. The Amount property will be calculated by multiplying Quantity and Price:

  Public ReadOnly Property Amount() As Double
    Get
      Return mQuantity * mPrice
    End Get
  End Property

It is a read-only property. However, it can change. In fact, it will change any time that the value of either Price or Quantity changes, and thus we can raise an event to indicate that it has changed. For instance, when the Price changes:

  Public Property Price() As Double
    Get
      Return mPrice
    End Get
    Set(ByVal Value As Double)
      mPrice = Value
      RaiseEvent PriceChanged(Me, New EventArgs())
      RaiseEvent AmountChanged(Me, New EventArgs())
    End Set
  End Property

Not only do we raise the PriceChanged event because the Price property changed, but we also raise the AmountChanged event because we've indirectly caused the Amount property to change as well. This illustrates how we must be vigilant in our coding to ensure that these events are raised when appropriate.

That said, it turns out that the AmountChanged event may not be strictly necessary. When we bind controls on a form to properties of an object, data binding listens for propertyChanged events for each property to which our controls are bound. If any one of them is raised, all the controls bound to that object are refreshed.

In other words, if our form has controls bound to both the Price and Amount properties, raising the PriceChanged event will cause data binding to refresh not only the control bound to the Price property, but also the one bound to the Amount property.

The downside to taking advantage of this fact is that the UI becomes tightly bound to the object implementation. If we later decide to only bind to Amount, our UI won't work correctly because no AmountChanged event will be raised. Because of this, it is best to declare and raise a propertyChanged event for each property on our object.

The rest of the code for the LineItem class is contained in the sample code download for this article.

Binding to a Strongly-Typed Collection

As we've noted, our Order object contains a collection of LineItem objects. In our UI, we can bind a DataGrid to this collection, allowing the user to easily add, remove, or edit the LineItem objects. The resulting UI is shown in Figure 2.

Figure 2. Form with DataGrid bound to collection of objects

We can bind a DataGrid control to an array or collection with one line of code:

Dim arLineItems As New ArrayList()

dgLineItems.DataSource = arLineItems

While this works in that the data from the array is displayed in the grid, it doesn't provide all the features we'd expect from binding to a DataGrid control. This is because the basic array and collection classes don't provide enough information for the DataGrid to work the way it does when it is bound to a DataTable.

We can create our own strongly-typed collection object, which can include some extra code to better support the features of the DataGrid. In particular, we'll end up being able to support the following:

  • Binding to an empty collection
  • Notifying the DataGrid of changes to the collection
  • Dynamic addition and removal of child objects
  • In-place editing of child objects

Let's tackle these issues one at a time. The first one, binding a DataGrid to an empty collection, is relatively easy to solve. The problem here is that the DataGrid needs to be able to figure out the columns that are available from the data source. If we bind the DataGrid to a simple array, ArrayList, or collection object, how does it figure this out?

The answer is that it looks at the first item in the collection and then uses reflection to get a list of the Public properties and fields of that item (be it a structure, object, or simple type such as Integer).

If the collection is empty, then this technique won't work and the DataGrid is unable to automatically generate a list of columns. The result is that the control is displayed to the user with no content whatsoever, as shown in Figure 3.

Figure 3. DataGrid bound to empty collection

To fix this, all we need to do is create a custom, strongly-typed collection that has a default, strongly-typed Item property. The DataGrid can use the type of the Item property to get a list of the specific Public properties and fields of that type. Because of this, it no longer needs to look at the first item in the collection to get the list of columns, so we can bind to an empty collection and have it work nicely.

Figure 4. DataGrid bound to empty, strongly-typed collection.

When you compare Figure 3 to Figure 4 it is obvious that the strongly-typed collection is preferable because the DataGrid displays the list of columns, even though no items are present in either case.

Implementing a strongly-typed collection is straightforward. The following steps outline the basic process:

  1. Add a new class to the project
  2. Inherit from System.Collections.CollectionBase
  3. Implement a default, strongly-typed Item property
  4. Implement strongly-typed Add and Remove methods

For instance, in the code download you'll find a LineItems class that implements a strongly-typed collection of LineItem objects. The key to allowing the DataGrid to bind to an empty collection is the Item property:

  Default Public ReadOnly Property Item(ByVal index As Integer) _
       As LineItem
    Get
      Return CType(list(index), LineItem)
    End Get
  End Property

Note that the property is strongly-typed in that it returns objects of type LineItem. It is also important that it is marked as Default and is a Property, not a Function. Data binding requires this specific declaration in order to work.

In the class from the code download you'll also see that there are strongly-typed implementations of Add and Remove methods, which are part of any strongly-typed collection, but aren't required for data binding to work.

With very little extra coding, we've solved the first issue of allowing the DataGrid to bind to an empty collection. Let's move on and tackle the next couple issues in our list.

Implementing IBindingList

Data binding has a formal scheme by which a collection can indicate that the collection has been changed. This is done by having the collection implement the IBindingList interface, which includes a ListChanged event. Any time the collection changes (due to the addition, removal, or change of an item) it should raise this event to tell data binding that the underlying data has changed.

IBindingList supports more than just change notification. The following table lists the optional features we can support by implementing the interface:

Optional feature Description
Change notification Notifies the UI of any changes (additions, removals, or edits) to the collection
Automatic addition of items Allows the DataGrid to insert new items into the collection as the user moves to the end of the grid
Automatic removal of items Allows the DataGrid to remove items from the collection when the user presses the Delete key in the grid
In-place editing of items Allows the DataGrid to perform in-place editing of items in the collection (this also requires that the item implement IEditableObject, which we'll discuss later)
Searching Enables a Find method to search the collection for a specific item
Sorting Enables a Sort method, which sorts the collection on specific columns

Note that it is up to us to implement each of these functions. The IBindingList interface merely defines the properties, methods, and events. We must write the actual code. Fortunately each of these features is optional. The IBindingList interface defines a set of Boolean properties we use to specify which features our particular collection supports:

  • SupportsChangeNotification
  • AllowNew
  • AllowEdit
  • AllowRemove
  • SupportsSearching
  • SupportsSorting

We only return True for those features we choose to implement. In the code download, you'll find the LineItems collection that implements change notification, add, edit, and remove. It does not support searching or sorting, so those two properties return False.

Basic implementation of the interface requires that we use the Implements keyword:

Public Class LineItems
  Inherits CollectionBase

  Implements IBindingList

By adding this statement, we indicate that we're implementing the interface, so we must provide implementations for all the methods defined by the interface. This includes methods that we might not actually want to implement, such as SortDirection (since we're not implementing sorting). The thing is, we don't need to write any code inside the method; we just need to code the shell of the method:

  Public ReadOnly Property SortDirection() _
      As System.ComponentModel.ListSortDirection _
      Implements System.ComponentModel.IBindingList.SortDirection
    Get

    End Get
  End Property

For the methods that we are implementing, we'll write real code.

Change Notification

For instance, we're supporting change notification, so we notify data binding anytime the collection is changed. This means that any time an item is added, removed, or changed, we need to write some code.

The first step is to indicate that we support change notification:

  Public ReadOnly Property SupportsChangeNotification() _
      As Boolean Implements _
        System.ComponentModel.IBindingList.SupportsChangeNotification
    Get
      Return True
    End Get
  End Property

We also must declare the ListChanged event as defined by the interface:

  Public Event ListChanged(ByVal sender As Object, _
    ByVal e As System.ComponentModel.ListChangedEventArgs) _
    Implements System.ComponentModel.IBindingList.ListChanged

Then all we need to do is raise this event any time the collection changes. This isn't as hard as it might sound because the CollectionBase class defines a set of methods we can override so we know exactly when the collection has been changed. The ones we need are:

  • OnClearComplete
  • OnInsertComplete
  • OnRemoveComplete
  • OnSetComplete

We can simply raise the ListChanged event inside each of these methods. For instance, when an item is inserted, we raise the event:

  Protected Overrides Sub OnInsertComplete(ByVal index As Integer, _
      ByVal value As Object)

    RaiseEvent ListChanged( _
      Me, New ListChangedEventArgs(ListChangedType.ItemAdded, index))

  End Sub

Our collection object will now notify the UI of any changes, so any data bound controls (such as the DataGrid) can accurately reflect the contents of our collection at all times.

Editing and Removing Items

The IBindingList interface defines the AllowEdit and AllowRemove properties so we can control whether we want to allow the DataGrid to allow in-place editing and dynamic removal of items in the collection. There are no other IBindingList methods to support these features. They are simply toggles we can use to control what is allowed.

However, if we return True for AllowEdit, then our child objects themselves must support in-place editing by implementing the IEditableObject interface, which we'll discuss later in this article. No exceptions will be thrown if the child objects don't implement the interface, but to get proper behavior as expected by the user they must implement it.

Adding Items

We can also allow the DataGrid to dynamically add new items to the collection. This is a nice feature as it allows the user to simply navigate to the bottom of the grid and they automatically get a new row into which they can add data. This feature is dependant on our support for in-place editing. If we allow adding of items, we must also enable editing of items, so AllowEdit must return True, and our child objects must implement the IEditableObject interface.

IBindingList defines the AllowNew property, which must return True if we support this feature. It also defines an AddNew method that we must implement. This method must include code to create a new child object, add it to the collection, and return it as a result of the method.

For instance, we can create a new LineItem object as follows:

  Public Function AddNew() As Object Implements _
        System.ComponentModel.IBindingList.AddNew

    Dim item As New LineItem()
    list.Add(item)
    Return item

  End Function

The most common difficulty with this action is that we must be able to create child objects without any user input. The DataGrid itself requests the addition of the child object, so we must be able to programmatically create a new child object on request at any time as shown in the code.

Some object designs require that constructor data be provided as child objects are created, so they are preloaded with information. That type of model won't work in this case because there's no way to get child-specific information from the user before creating the child object.

Once we've implemented AllowNew and AddNew, the DataGrid will allow the user to navigate to the bottom of the grid, and new child objects will be automatically inserted into the collection (and thus the grid) for the user to edit.

Implementing IEditableObject in Child Classes

As we've discussed, in-place editing requires not only the implementation of the IBindingList interface in our custom collection, but also the implementation of the IEditableObject interface in the child classes.

The IEditableObject interface appears deceptively simple. It merely defines three methods.

Method Definition
BeginEdit Called by data binding to indicate the start of an edit process, and that the object should take a snapshot of its current state.
CancelEdit Called by data binding to indicate that the edit process is over, and that the object should reset its state to original values.
EndEdit Called by data binding to indicate that the edit process is over, and that the object should keep any changed values.

Copying Data

Implementing this interface requires that we have the ability to take a snapshot of the current state of our object. This typically means somehow making a copy of the values of all the instance variables in our object. There are a variety of ways to make this copy. Unfortunately, that discussion is outside the scope of this article. In this article, we'll simply copy the variable values to another set of variables:

      mOldProduct = Product
      mOldPrice = Price
      mOldQuantity = Quantity

To restore the values, we simply copy the values the other direction.

Knowing If We Are New

It turns out that to properly implement IEditableObject we need to know whether this particular child object has been newly added to the collection. The reason is that we need to remove a new child object from the collection if the user presses the Esc key to cancel the edit. On the other hand, if the user is editing a preexisting child object and they press Esc, we don't want to remove the object from the collection. We just want to restore its state to the previous values.

To solve this issue, we need a variable to track whether the object is new or not. We'll declare a variable named mIsNew and we'll set the value to True when it is first declared:

  Private mIsNew As Boolean = True

It will get set to False after the first edit process is complete. This is when CancelEdit or EndEdit are called.

BeginEdit

What makes this tricky is that BeginEdit can (and will) be called numerous times during the edit process. The on-line help for the .NET Framework SDK makes it clear that we should only honor the first call, and ignore all others. To do this, we'll declare and use a Boolean variable, mEditing, as follows:

  Public Sub BeginEdit() Implements _
        System.ComponentModel.IEditableObject.BeginEdit

    If Not mEditing Then
      mEditing = True
      mOldProduct = Product
      mOldPrice = Price
      mOldQuantity = Quantity
    End If

  End Sub

This variable will be set to False in both the CancelEdit and EndEdit methods, so any future edit process will work properly.

EndEdit

The EndEdit method is the easiest to implement because this method is called when the edit process is over and we want to keep any changes to the data. All we need to do is set mEditing to False to indicate that the edit process is over:

  Public Sub EndEdit() Implements _
        System.ComponentModel.IEditableObject.EndEdit

    mEditing = False
    mIsNew = False

  End Sub

Note that we also set mIsNew to False in this method. At this point we know that the user has accepted the child object, so it is no longer new (at least from the perspective of data binding and the DataGrid).

CancelEdit

The CancelEdit method is perhaps the most complex of the three. It not only needs to restore the values of the object to the values we stored when BeginEdit was called, but we also need to see if the child object is new. If the child object is new, we need to ensure that it is removed from the collection.

To tell the collection object that we want to be removed, we'll declare an event:

  Friend Event RemoveMe(ByVal LineItem As LineItem)

The event takes a parameter of type LineItem, so we can pass a reference to the child object itself. This way we can make sure that the collection code will know which item to remove.

The CancelEdit code then looks like this:

  Public Sub CancelEdit() Implements _
       System.ComponentModel.IEditableObject.CancelEdit

    mEditing = False
    Product = mOldProduct
    Price = mOldPrice
    Quantity = mOldQuantity
    If mIsNew Then
      mIsNew = False
      RaiseEvent RemoveMe(Me)
    End If

  End Sub

First, we indicate that the edit process is complete by setting mEditing to False. Next, we restore the state of the object to the values we stored in BeginEdit. Each property value is restored to the previous state. Finally, we check the mIsNew variable to see if this was a newly added child object. If so, we raise the RemoveMe event to tell the collection that it should be removed.

Handling the RemoveMe Event

Since our child objects may now raise events to the collection, we need to enhance the code in the custom collection class to handle this event. The first step is to add a method to the LineItems collection class to handle the event. This code will be run when the event is raised:

  Private Sub RemoveChild(ByVal Child As LineItem)

    list.Remove(Child)

  End Sub

All it does is remove the specified child object from the collection. Notice that there's no Handles clause on this method. How does it get the event? The answer lies in the AddHandler function. This function allows us to dynamically connect an event to an event handler at runtime. It is particularly useful when creating collection classes, as it allows us to hook child object events as each child object is added to the collection.

In the collection class, we already have an OnInsertComplete method where we raise the ListChanged event. We can add the AddHandler call to this method, so it is sure to be invoked for any newly added child object:

  Protected Overrides Sub OnInsertComplete(ByVal index As Integer, _
         ByVal value As Object)

    AddHandler CType(value, LineItem).RemoveMe, AddressOf RemoveChild

    RaiseEvent ListChanged( _
       Me, New ListChangedEventArgs(ListChangedType.ItemAdded, index))

  End Sub

Now, when a new child object is added to the collection, we establish a link between the RemoveMe event of that object and our RemoveChild event handler. When the user presses Esc on a newly added line item, that child object will automatically be removed from the collection through this mechanism.

Implementing IDataErrorInfo in Child Classes

There's one last optional feature we can add to our child class-the ability to tell the DataGrid when the line item is valid or invalid. This is done by implementing the IDataErrorInfo interface in our child LineItem class.

The IDataErrorInfo interface defines two methods.

Method Description
Error Returns text describing what is wrong with this object (an empty string indicates no problem).
Item Returns text describing what is wrong with a specific property or field on the object (an empty string indicates no problem).

The key method is the Error method. The Item method allows us to provide more detailed information if we choose, but the DataGrid keys off the text returned by Error to determine if the line item is valid or not. In the code download, you'll see that I only implemented code in the Error method:

  Private ReadOnly Property [Error]() As String Implements _
       System.ComponentModel.IDataErrorInfo.Error
    Get
      If Len(Product) = 0 Then Return "Product name required"
      If Quantity <= 0 Then Return "Quantity must be greater than zero"
      Return ""
    End Get
  End Property

This method checks a couple business rules for the line item. We're saying that a line item must have a product name and a quantity that is greater than zero. If either of these conditions is not met, we return text that indicates the nature of the problem. On the other hand, if the object is valid, we return an empty string.

The result is that the DataGrid graphically indicates which line items are valid, as shown in Figure 5 below.

Figure 5. DataGrid indicating the validity of line items

Notice that the Error property is marked as Private. If we make it Public, then the error text will be displayed as a column in the DataGrid so the user can clearly see what is wrong with the line item.

Conclusion

Data binding has finally come of age. The implementation of data binding in both Web Forms and Windows Forms is practical and useful in many cases. One of the biggest benefits is that we can now data bind to objects and collections, not just to the DataSet and related ADO.NET objects.

As we've seen in this article, with a relatively small amount of extra code in our business and collection classes, we can allow Windows Forms data binding to interact with our objects in very rich and powerful ways. No longer are we stuck using pure data technologies in RAD development. Now we can be object-oriented and RAD at the same time!

本文地址:http://com.8s8s.com/it/it45572.htm