Tuesday, October 4, 2011

Implementing complex data binding in custom controls

Mainwindow of ComplexDataBindingSample

Introduction

When I create a new control, it often has to display some data in it. Usually, the data comes from a database or a DataSet. To implement data binding ability from a DataTable is easy (enumerate through the Columns and Rows properties). But what if you want to bind DataSets, DataTableRelations, DataViews, BindingSources, IBindingList-classes, and everything else you can bind to controls written by Microosoft?
If you bind more than one control at the same data source (for example, if you want to create a details-form with some "Next" and "Previous" buttons), you will notice that they will all have the "Current-Ability", meaning, all controls display the data from the same row. If you click "Next" (or click another row in a DataGrid), they display the next row. I want to implement this ability in my custom control too. If you sort the data in a DataGrid, it has to display the data in the new order.
At least, I want to implement the ability to change the data of the current row at any column.

Overview

For all these to be implemented, there is a simple solution: use the CurrencyManager. In this article, I want to explain how it works and what you have to do to implement complex data binding in your own control.

Implementing DataSource and DataMember

Every control written by Microsoft with the support for complex data binding (DataGrid, DataGridView, BindingSource) has two properties: DataSource (object) and DataMember (string). You will need these information to get a CurrencyManager. So you have to implement it to your own control, too:
private object dataSource;

[TypeConverter("System.Windows.Forms.Design.DataSourceConverter, System.Design")]
[Category("Data")]
[DefaultValue(null)]
public object DataSource
{
get
{
return this.dataSource;
}
set
{
if (this.dataSource != value)
{
this.dataSource = value;
tryDataBinding();
}
}
}
The attribute TypeConverter tells the Visual Studio designer to display the dropdownlist with all available data sources in the property grid:
DataSource dropdown in VisualStudio
If you don't need this function, you can leave this attribute away. The method tryDataBinding tries (as the name says) to bind the control to the data source. I will explain the content later.
Implementing the DataMember is similar:
private string dataMember;

[Category("Data")]
[Editor("System.Windows.Forms.Design.DataMemberListEditor,
System.Design"
, "System.Drawing.Design.UITypeEditor,
System.Drawing"
)]
[DefaultValue("")]
public string DataMember
{
get
{
return this.dataMember;
}
set
{
if (this.dataMember != value)
{
this.dataMember = value;
tryDataBinding();
}
}
}
Here, you have to define the attribute Editor to get the Visual Studio designer support.

Using the BindingContext Property

The BindingContext property inherited by Control has a key-part in data binding. With this property, you can get a CurrencyManager, which you can use to implement complex data binding. This property changes every time you add your control to a Form, Panel, or something inherited from Control. If your control is not added to anything, this property is null and you cannot get a CurrencyManager (you cannot display something without showing the control anyway). To get the information when BindingContext is set or changed, you have to overwrite OnBindingContextChanged and call tryDataBinding in it:
protected override void OnBindingContextChanged(EventArgs e)
{
this.tryDataBinding();
base.OnBindingContextChanged(e);
}

How to Get a CurrencyManager

I have implemented the part of getting the CurrencyManager into the method tryDataBinding. It uses the property BindingContext to get the CurrencyManager. BindingContext has an indexer which allows you to give the DataSource object and the DataMember. It will return an object of CurrencyManager. This CurrencyManager depends on the DataSource and the DataMember. Of course, also on the current Control (or Form) on which your control is added. It provides events which fire when the data source data is changed (e.g., by ListChanged) and when the current row is changed (e.g., by PositionChanged). This could be done by navigation-buttons, for example. It also provides an IList object of the data source (List), a method to get the available properties of the data source (GetItemProperties), and some methods to modify the data.
To wire the CurrencyManager with your control (get the information when List or Position is changed), you will need two handlers:
private ListChangedEventHandler listChangedHandler;
private EventHandler positionChangedHandler;
You have to initialize these fields in your constructor:
listChangedHandler = new ListChangedEventHandler(dataManager_ListChanged);
positionChangedHandler = new EventHandler(dataManager_PositionChanged);
I will show the implementation of these methods later.
The next code shows the method tryDataBinding. It gets the CurrencyManager by the BindingContext (see above), unwires the old CurrencyManager (if needed), and wires the new CurrencyManager. At last, it calls calculateColumns and updateAllData.
private CurrencyManager dataManager;

private void tryDataBinding()
{
if (this.DataSource == null ||
base.BindingContext == null)
return;

CurrencyManager cm;
try
{
cm = (CurrencyManager)
base.BindingContext[this.DataSource,
this.DataMember];
}
catch (System.ArgumentException)
{
// If no CurrencyManager was found

return;
}

if (this.dataManager != cm)
{
// Unwire the old CurrencyManager

if (this.dataManager != null)
{
this.dataManager.ListChanged -=
listChangedHandler;
this.dataManager.PositionChanged -=
positionChangedHandler;
}
this.dataManager = cm;
// Wire the new CurrencyManager

if (this.dataManager != null)
{
this.dataManager.ListChanged +=
listChangedHandler;
this.dataManager.PositionChanged +=
positionChangedHandler;
}

// Update metadata and data

calculateColumns();
updateAllData();
}
}

Getting the Available Columns

To get the columns the data source provides, you can use the method GetItemProperties. It returns a PropertyDescriptorCollection which contains a PropertyDescriptor object for every column. At this part, we only need the Name property of the PropertyDescriptor. It returns the unique column-name. With this name, you can find the column again later. If you want to display a header in your control, you can use DisplayName for the text. In my sample, I use a ListView as the base class which provides a column-collection that I can use.
private void calculateColumns()
{
this.Columns.Clear();
if (dataManager == null)
return;

foreach (PropertyDescriptor prop in
dataManager.GetItemProperties())
{
ColumnHeader column = new ColumnHeader();
column.Text = prop.Name;
this.Columns.Add(column);
}
}

Retrieving the Data

For retrieving the data, the CurrencyManager provides a property named List. In most cases, it contains a collection of DataRowView objects, but this does not matter. By using the PropertyDescriptor, you can retrieve the data of the row and the column. The only thing you have to do is to enumerate through the list and call the PropertyDescriptor-method GetValue for every field (identified by row and column). I have split the enumeration through the rows and the enumeration through the columns into two methods (updateAllData and getListViewItem). I will need the method addItem later (in the dataManager_ListChanged method). Because I use a ListView as the base class, I add some ListViewItems here. You can use your own collection of rows.
In the method getListViewItem, I first get the row-object and enumerate through the columns. For every column, I get the PropertyDescriptor (found by the column-name) and call the GetValue method. If you want to implement this method in your own control, you can simply return the ArrayList.
private void updateAllData()
{
this.Items.Clear();
for (int i = 0; i < dataManager.Count; i++ )
{
addItem(i);
}
}

private void addItem(int index)
{
ListViewItem item = getListViewItem(index);
this.Items.Insert(index, item);
}

private ListViewItem getListViewItem(int index)
{
object row = dataManager.List[index];
PropertyDescriptorCollection propColl =
dataManager.GetItemProperties();
ArrayList items = new ArrayList();

// Fill value for each column

foreach(ColumnHeader column in this.Columns)
{
PropertyDescriptor prop = null;
prop = propColl.Find(column.Text, false);
if (prop != null)
{
items.Add(prop.GetValue(row).ToString());
}
}
return new ListViewItem((string[])items.ToArray(typeof(string)));
}

Keeping Your Data Current

At this point, your control displays the data after initializing. But what happens when the data changes after initializing by any other control? When this happens, the CurrencyManager fires ListChanged. You can simply recreate your full data with updateAllData every time the list is changed, but this could be slow when you work with many rows. To solve this, you can use the ListChangedEventArgs. The ListChanged event provides a ListChangedEventArgs with a property (ListChangedType) which tells you what type of changes are made to the list. The ListChangedEventArgs tells you about the following events (descriptions are from the MSDN-library):
  • ItemAdded: An item added to the list. NewIndex contains the index of the item that was added.
  • ItemChanged: An item changed in the list. NewIndex contains the index of the item that was changed.
  • ItemDeleted: An item deleted from the list. NewIndex contains the index of the item that was deleted.
  • ItemMoved: An item moved within the list. OldIndex contains the previous index for the item, whereas NewIndex contains the new index for the item.
  • Reset: Much of the list has changed. Any listening controls should refresh all their data from the list.
In addition to those types, there are three types which tells you that the schema has been changed (PropertyDescriptorAdded, ...Changed and ...Deleted). If these types occur, you have to recalculate your columns.
So you can use this information to show the current data without recreating the full list every time something changes. The information of which item was changed can be obtained by the property NewIndex as you can read in the description. To add functions to change and delete items, I have implemented two methods (updateItem and deleteItem). I have already shown the addItem method above.
private void updateItem(int index)
{
if (index >= 0 &&
index < this.Items.Count)
{
ListViewItem item = getListViewItem(index);
this.Items[index] = item;
}
}

private void deleteItem(int index)
{
if (index >= 0 &&
index < this.Items.Count)
this.Items.RemoveAt(index);
}
In updateItem, I only retrieve the new data and show it at the given index. In deleteItem, I simply delete the item. Nothing less and nothing more.
The implementation of dataManager_ListChanged follows:
private void dataManager_ListChanged(object sender, ListChangedEventArgs e)
{
if (e.ListChangedType == ListChangedType.Reset ||
e.ListChangedType == ListChangedType.ItemMoved)
{
// Update all data

updateAllData();
}
else if (e.ListChangedType == ListChangedType.ItemAdded)
{
// Add new Item

addItem(e.NewIndex);
}
else if (e.ListChangedType == ListChangedType.ItemChanged)
{
// Change Item

updateItem(e.NewIndex);
}
else if (e.ListChangedType == ListChangedType.ItemDeleted)
{
// Delete Item

deleteItem(e.NewIndex);
}
else
{
// Update metadata and all data

calculateColumns();
updateAllData();
}
}
Because I was a little bit lazy, I didn't implement "ItemMoved" explicitly. Anyhow, this case appears rarely. The rest only calls the methods shown above.
At this time, your control always shows the current data. This includes the order of the items (sorted by anything). In this case, the ListChangedType is "Reset".

How to Get and Set the Current Item

When you use a details-form, you are editing a specific row. The information on which row is current is provided by the CurrencyManager. Position contains the numbered index (zero based), and Current contains the row-object. In this implementation, I only used the property Position.
If the data source changes its position, you get the PositionChanged event.
private void dataManager_PositionChanged(object sender, EventArgs e)
{
if (this.Items.Count > dataManager.Position)
{
this.Items[dataManager.Position].Selected = true;
this.EnsureVisible(dataManager.Position);
}
}
This method only selects the current index of the items, and scrolls to it, if needed. If you want to set the position when your user clicks at one item in your control, you first have to implement SelectedIndex and SelectedIndexChanged. In my sample, this is provided by the ListView, so I don't need to implement it myself. Get an event handler at SelectedIndexChanged (or overwrite OnSelectedIndexChanged), and fill it with the following code:
private void ListViewDataBinding_SelectedIndexChanged(object sender, EventArgs e)
{
try
{
if (this.SelectedIndices.Count > 0 &&
dataManager.Position != this.SelectedIndices[0])
dataManager.Position = this.SelectedIndices[0];
}
catch
{
// Could appear, if you change the position

// while someone edits a row with invalid data.

}
}
Note that it is impossible to set the position to -1 because data binding does not allow "nothing selected". Also, if you try to set the position out of the list-range, it won't do it. You will not get an exception. The CurrencyManager only sets positions smaller than zero to zero, and positions larger than count - 1 to count - 1.
At this point, your control shows the current data and position. If you only want to show these points, you are done! If you also want to change data, continue reading.

Changing the Data From Your Control to the DataSource

If you want to write changes to the data in your control to the data source, you can use the PropertyDescriptor again. I have overwritten the OnAfterLabelEdit method from ListView. The PropertyDescriptor provides a method named SetValue which needs two parameters: the row-object which holds the data (the column is already represented by the PropertyDescriptor), and the new value. All you have to do is to get the right PropertyDescriptor by using the unique name (you can search it with the Find method of the collection). In this sample, I only can use the first column because a ListView only allows editing in the fist column.
After using SetValue, the CurrencyManager is in the "Edit" state. This means that your changes are not saved until you change the row, or call EndCurrentEdit from the CurrencyManager. In a DataGrid, you can see a pencil at the left side when you are in this state. Now you can save your changes with dataManager.EndCurrentEdit(), or you can reject your changes by dataManager.CancelCurrentEdit().
EndCurrentEdit could throw exceptions when you have inserted invalid data for this column. For example, if you insert too long strings or strings instead of numbers. You should show the exception message so that your user can do it better next time.
protected override void OnAfterLabelEdit(LabelEditEventArgs e)
{
base.OnAfterLabelEdit(e);
if (e.Label == null)
{
// If you press ESC while editing.

e.CancelEdit = true;
return;
}

if (dataManager.List.Count > e.Item)
{
object row = dataManager.List[e.Item];
// In a ListView you are only able to edit the first Column.

PropertyDescriptor col =
dataManager.GetItemProperties().Find(this.Columns[0].Text, false);
try
{
if (row != null &&
col != null)
col.SetValue(row, e.Label);
dataManager.EndCurrentEdit();
}
catch(Exception ex)
{
// If you try to enter strings in number-columns,

// too long strings or something

// else wich is not allowed by the DataSource.

MessageBox.Show("Edit failed:\r\n" + ex.Message,
"Edit failed", MessageBoxButtons.OK,
MessageBoxIcon.Error);
dataManager.CancelCurrentEdit();
e.CancelEdit = true;
}
}
}
That's all! Now your control is data bound like a DataGrid or a DataGridView. You don't need to write explicit code for every possible data source because the CurrencyManager does all this work for you.

Summary

You saw that it is not difficult to implement complex data binding. Thanks to the CurrencyManager, it is possible to show every data, to always show the current data, to show and change the current row, and edit data in a row.
If you want to use this code in your own control, you will need DataSource, DataMember, something like an Items collection, something like a
Columns collection, and a SelectedIndex