Tuesday, October 4, 2011
Implementing complex data binding in custom controls
Introduction
When I create a new control, it often has to display some data in it. Usually, the data comes from a database or aDataSet
. To implement data binding ability from a DataTable
is easy (enumerate through the Columns
and Rows
properties). But what if you want to bind DataSet
s, DataTableRelation
s, DataView
s, BindingSource
s, 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 theCurrencyManager
. 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: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
TheBindingContext
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 theCurrencyManager
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
. Collapse | Copy Code
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 methodGetItemProperties
. 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, theCurrencyManager
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 ListViewItem
s 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, theCurrencyManager
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, whereasNewIndex
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.
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 theCurrencyManager
. 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 thePropertyDescriptor
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 theCurrencyManager
, 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 aColumns
collection, and a SelectedIndex