在自定义控件中实现复杂数据绑定






4.83/5 (48投票s)
一篇关于在自定义控件上实现复杂数据绑定(DataSource 和 DataMember)的文章。
引言
当我创建一个新控件时,它通常需要在其中显示一些数据。通常,数据来自数据库或 DataSet
。实现 DataTable
的数据绑定功能很容易(遍历 Columns
和 Rows
属性)。但是,如果您想绑定 DataSet
、DataTableRelation
、DataView
、BindingSource
、IBindingList
类以及所有您可以绑定到 Microsoft 编写的控件的其他内容呢?
如果您将多个控件绑定到同一个数据源(例如,如果您想创建一个带有“下一步”和“上一步”按钮的详细信息表单),您会注意到它们都将具有“当前能力”,这意味着所有控件都显示来自同一行的数据。如果您单击“下一步”(或在 DataGrid
中单击另一行),它们将显示下一行。我也想在我的自定义控件中实现此功能。如果您在 DataGrid
中对数据进行排序,它必须按新顺序显示数据。
至少,我想实现在任何列中更改当前行数据的能力。
概述
要实现所有这些,有一个简单的解决方案:使用 CurrencyManager
。在本文中,我想解释它是如何工作的以及您需要做些什么才能在自己的控件中实现复杂数据绑定。
实现 DataSource 和 DataMember
每个支持复杂数据绑定(DataGrid
、DataGridView
、BindingSource
)的 Microsoft 编写的控件都有两个属性:DataSource
(object
) 和 DataMember
(string
)。您需要这些信息才能获得 CurrencyManager
。因此,您也必须将其实现到自己的控件中。
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();
}
}
}
TypeConverter
属性告诉 Visual Studio 设计器在属性网格中显示所有可用数据源的下拉列表
如果您不需要此功能,可以省略此属性。tryDataBinding
方法(顾名思义)尝试将控件绑定到数据源。我稍后将解释其内容。
实现 DataMember
类似
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();
}
}
}
在这里,您必须定义 Editor
属性才能获得 Visual Studio 设计器支持。
使用 BindingContext 属性
由 Control
继承的 BindingContext
属性在数据绑定中起着关键作用。通过此属性,您可以获得一个 CurrencyManager
,您可以使用它来实现复杂数据绑定。每次您将控件添加到 Form
、Panel
或从 Control
继承的任何内容时,此属性都会发生变化。如果您的控件未添加到任何内容中,则此属性为 null
,您无法获得 CurrencyManager
(无论如何,如果您不显示控件,就无法显示任何内容)。要获取 BindingContext
设置或更改的信息,您必须覆盖 OnBindingContextChanged
并在其中调用 tryDataBinding
protected override void OnBindingContextChanged(EventArgs e)
{
this.tryDataBinding();
base.OnBindingContextChanged(e);
}
如何获取 CurrencyManager
我已将获取 CurrencyManager
的部分实现到 tryDataBinding
方法中。它使用 BindingContext
属性来获取 CurrencyManager
。BindingContext
有一个索引器,允许您提供 DataSource
对象和 DataMember
。它将返回一个 CurrencyManager
对象。此 CurrencyManager
取决于 DataSource
和 DataMember
。当然,也取决于您的控件所添加的当前 Control
(或 Form
)。它提供在数据源数据更改时(例如,通过 ListChanged
)和当前行更改时(例如,通过 PositionChanged
)触发的事件。例如,这可以通过导航按钮完成。它还提供数据源的 IList
对象 (List
)、获取数据源可用属性的方法 (GetItemProperties
) 以及一些修改数据的方法。
要将 CurrencyManager
与您的控件连接起来(在 List
或 Position
更改时获取信息),您将需要两个处理程序
private ListChangedEventHandler listChangedHandler;
private EventHandler positionChangedHandler;
您必须在构造函数中初始化这些字段
listChangedHandler = new ListChangedEventHandler(dataManager_ListChanged);
positionChangedHandler = new EventHandler(dataManager_PositionChanged);
我稍后将展示这些方法的实现。
以下代码显示了 tryDataBinding
方法。它通过 BindingContext
获取 CurrencyManager
(见上文),断开旧 CurrencyManager
的连接(如果需要),并连接新的 CurrencyManager
。最后,它调用 calculateColumns
和 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();
}
}
获取可用列
要获取数据源提供的列,您可以使用 GetItemProperties
方法。它返回一个 PropertyDescriptorCollection
,其中包含每个列的 PropertyDescriptor
对象。在此部分,我们只需要 PropertyDescriptor
的 Name
属性。它返回唯一的列名。通过此名称,您可以稍后再次找到该列。如果您想在控件中显示标题,可以使用 DisplayName
作为文本。在我的示例中,我使用 ListView
作为基类,它提供了一个我可以使用的列集合。
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);
}
}
检索数据
为了检索数据,CurrencyManager
提供了一个名为 List
的属性。在大多数情况下,它包含 DataRowView
对象的集合,但这并不重要。通过使用 PropertyDescriptor
,您可以检索行和列的数据。您唯一需要做的就是遍历列表并为每个字段(由行和列标识)调用 PropertyDescriptor
方法 GetValue
。我将行遍历和列遍历分为两个方法(updateAllData
和 getListViewItem
)。我稍后(在 dataManager_ListChanged
方法中)将需要 addItem
方法。因为我使用 ListView
作为基类,所以我在这里添加了一些 ListViewItem
。您可以使用自己的行集合。
在 getListViewItem
方法中,我首先获取行对象并遍历列。对于每个列,我获取 PropertyDescriptor
(通过列名找到)并调用 GetValue
方法。如果您想在自己的控件中实现此方法,您可以简单地返回 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)));
}
保持数据最新
此时,您的控件在初始化后显示数据。但是,如果数据在初始化后被任何其他控件更改,会发生什么?当发生这种情况时,CurrencyManager
会触发 ListChanged
。您可以在列表更改时简单地使用 updateAllData
重新创建完整数据,但这在处理大量行时可能会很慢。为了解决这个问题,您可以使用 ListChangedEventArgs
。ListChanged
事件提供一个 ListChangedEventArgs
,其中包含一个属性 (ListChangedType
),它告诉您对列表进行了何种类型的更改。ListChangedEventArgs
告诉您以下事件(描述来自 MSDN 库)
ItemAdded
:一个项目已添加到列表中。NewIndex
包含已添加项目的索引。ItemChanged
:列表中有一个项目已更改。NewIndex
包含已更改项目的索引。ItemDeleted
:列表中有一个项目已删除。NewIndex
包含已删除项目的索引。ItemMoved
:项目在列表中移动。OldIndex
包含项目的先前索引,而NewIndex
包含项目的新索引。Reset
:列表的大部分已更改。任何侦听控件都应从列表中刷新其所有数据。
除了这些类型,还有三种类型告诉您架构已更改(PropertyDescriptorAdded
、...Changed
和 ...Deleted
)。如果出现这些类型,您必须重新计算您的列。
因此,您可以使用此信息显示当前数据,而无需在每次更改时都重新创建完整列表。通过 NewIndex
属性可以获取哪个项目已更改的信息,如您在描述中所示。为了添加更改和删除项目的功能,我实现了两个方法(updateItem
和 deleteItem
)。我已经在上面展示了 addItem
方法。
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);
}
在 updateItem
中,我只检索新数据并在给定索引处显示它。在 deleteItem
中,我只是删除该项目。仅此而已。
dataManager_ListChanged
的实现如下
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();
}
}
因为我有点懒惰,所以没有明确实现“ItemMoved
”。无论如何,这种情况很少出现。其余的只调用上面显示的方法。
此时,您的控件始终显示当前数据。这包括项目的顺序(按任何内容排序)。在这种情况下,ListChangedType
为“Reset
”。
如何获取和设置当前项目
当您使用详细信息表单时,您正在编辑特定行。关于哪一行是当前行的信息由 CurrencyManager
提供。Position
包含编号索引(从零开始),Current
包含行对象。在此实现中,我只使用了 Position
属性。
如果数据源更改其位置,您将获得 PositionChanged
事件。
private void dataManager_PositionChanged(object sender, EventArgs e)
{
if (this.Items.Count > dataManager.Position)
{
this.Items[dataManager.Position].Selected = true;
this.EnsureVisible(dataManager.Position);
}
}
此方法只选择项目的当前索引,并在需要时滚动到它。如果您想在用户单击控件中的某个项目时设置位置,则必须首先实现 SelectedIndex
和 SelectedIndexChanged
。在我的示例中,这是由 ListView
提供的,所以我不需要自己实现它。在 SelectedIndexChanged
处获取一个事件处理程序(或覆盖 OnSelectedIndexChanged
),并用以下代码填充它
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.
}
}
请注意,无法将位置设置为 -1,因为数据绑定不允许“未选中任何内容”。此外,如果您尝试将位置设置到列表范围之外,它将不会执行。您不会得到异常。CurrencyManager
只会将小于零的位置设置为零,将大于 count - 1 的位置设置为 count - 1。
此时,您的控件显示当前数据和位置。如果您只想显示这些点,那么您就完成了!如果您还想更改数据,请继续阅读。
将数据从您的控件更改到数据源
如果您想将控件中对数据的更改写入数据源,您可以再次使用 PropertyDescriptor
。我已覆盖 ListView
的 OnAfterLabelEdit
方法。PropertyDescriptor
提供了一个名为 SetValue
的方法,它需要两个参数:保存数据的行对象(列已由 PropertyDescriptor
表示)和新值。您需要做的就是通过使用唯一的名称(您可以使用集合的 Find
方法搜索它)获取正确的 PropertyDescriptor
。在此示例中,我只能使用第一列,因为 ListView
只允许在第一列中编辑。
使用 SetValue
后,CurrencyManager
处于“编辑”状态。这意味着您的更改在您更改行或调用 CurrencyManager
的 EndCurrentEdit
之前不会保存。在 DataGrid
中,当您处于此状态时,您会在左侧看到一支铅笔。现在您可以使用 dataManager.EndCurrentEdit()
保存您的更改,或者您可以使用 dataManager.CancelCurrentEdit()
拒绝您的更改。
当您为该列插入了无效数据时,EndCurrentEdit
可能会抛出异常。例如,如果您插入过长的字符串或字符串而不是数字。您应该显示异常消息,以便您的用户下次可以做得更好。
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;
}
}
}
就是这样!现在您的控件像 DataGrid
或 DataGridView
一样进行数据绑定。您不需要为每个可能的数据源编写显式代码,因为 CurrencyManager
为您完成了所有这些工作。
摘要
您会发现实现复杂数据绑定并不困难。多亏了 CurrencyManager
,才有可能显示所有数据,始终显示当前数据,显示和更改当前行,以及编辑行中的数据。
如果您想在自己的控件中使用此代码,您将需要 DataSource
、DataMember
、类似 Items
集合的东西、类似 Columns
集合的东西以及 SelectedIndex
属性。
历史
- 首次发布。