65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (48投票s)

2006年8月29日

CPOL

9分钟阅读

viewsIcon

214436

downloadIcon

5108

一篇关于在自定义控件上实现复杂数据绑定(DataSource 和 DataMember)的文章。

Mainwindow of ComplexDataBindingSample

引言

当我创建一个新控件时,它通常需要在其中显示一些数据。通常,数据来自数据库或 DataSet。实现 DataTable 的数据绑定功能很容易(遍历 ColumnsRows 属性)。但是,如果您想绑定 DataSetDataTableRelationDataViewBindingSourceIBindingList 类以及所有您可以绑定到 Microsoft 编写的控件的其他内容呢?

如果您将多个控件绑定到同一个数据源(例如,如果您想创建一个带有“下一步”和“上一步”按钮的详细信息表单),您会注意到它们都将具有“当前能力”,这意味着所有控件都显示来自同一行的数据。如果您单击“下一步”(或在 DataGrid 中单击另一行),它们将显示下一行。我也想在我的自定义控件中实现此功能。如果您在 DataGrid 中对数据进行排序,它必须按新顺序显示数据。

至少,我想实现在任何列中更改当前行数据的能力。

概述

要实现所有这些,有一个简单的解决方案:使用 CurrencyManager。在本文中,我想解释它是如何工作的以及您需要做些什么才能在自己的控件中实现复杂数据绑定。

实现 DataSource 和 DataMember

每个支持复杂数据绑定(DataGridDataGridViewBindingSource)的 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 设计器在属性网格中显示所有可用数据源的下拉列表

DataSource dropdown in VisualStudio

如果您不需要此功能,可以省略此属性。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,您可以使用它来实现复杂数据绑定。每次您将控件添加到 FormPanel 或从 Control 继承的任何内容时,此属性都会发生变化。如果您的控件未添加到任何内容中,则此属性为 null,您无法获得 CurrencyManager(无论如何,如果您不显示控件,就无法显示任何内容)。要获取 BindingContext 设置或更改的信息,您必须覆盖 OnBindingContextChanged 并在其中调用 tryDataBinding

protected override void OnBindingContextChanged(EventArgs e)
{
    this.tryDataBinding();
    base.OnBindingContextChanged(e);
}

如何获取 CurrencyManager

我已将获取 CurrencyManager 的部分实现到 tryDataBinding 方法中。它使用 BindingContext 属性来获取 CurrencyManagerBindingContext 有一个索引器,允许您提供 DataSource 对象和 DataMember。它将返回一个 CurrencyManager 对象。此 CurrencyManager 取决于 DataSourceDataMember。当然,也取决于您的控件所添加的当前 Control(或 Form)。它提供在数据源数据更改时(例如,通过 ListChanged)和当前行更改时(例如,通过 PositionChanged)触发的事件。例如,这可以通过导航按钮完成。它还提供数据源的 IList 对象 (List)、获取数据源可用属性的方法 (GetItemProperties) 以及一些修改数据的方法。

要将 CurrencyManager 与您的控件连接起来(在 ListPosition 更改时获取信息),您将需要两个处理程序

private ListChangedEventHandler listChangedHandler;
private EventHandler positionChangedHandler;

您必须在构造函数中初始化这些字段

listChangedHandler = new ListChangedEventHandler(dataManager_ListChanged);
positionChangedHandler = new EventHandler(dataManager_PositionChanged);

我稍后将展示这些方法的实现。

以下代码显示了 tryDataBinding 方法。它通过 BindingContext 获取 CurrencyManager(见上文),断开旧 CurrencyManager 的连接(如果需要),并连接新的 CurrencyManager。最后,它调用 calculateColumnsupdateAllData

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 对象。在此部分,我们只需要 PropertyDescriptorName 属性。它返回唯一的列名。通过此名称,您可以稍后再次找到该列。如果您想在控件中显示标题,可以使用 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。我将行遍历和列遍历分为两个方法(updateAllDatagetListViewItem)。我稍后(在 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 重新创建完整数据,但这在处理大量行时可能会很慢。为了解决这个问题,您可以使用 ListChangedEventArgsListChanged 事件提供一个 ListChangedEventArgs,其中包含一个属性 (ListChangedType),它告诉您对列表进行了何种类型的更改。ListChangedEventArgs 告诉您以下事件(描述来自 MSDN 库)

  • ItemAdded:一个项目已添加到列表中。NewIndex 包含已添加项目的索引。
  • ItemChanged:列表中有一个项目已更改。NewIndex 包含已更改项目的索引。
  • ItemDeleted:列表中有一个项目已删除。NewIndex 包含已删除项目的索引。
  • ItemMoved:项目在列表中移动。OldIndex 包含项目的先前索引,而 NewIndex 包含项目的新索引。
  • Reset:列表的大部分已更改。任何侦听控件都应从列表中刷新其所有数据。

除了这些类型,还有三种类型告诉您架构已更改(PropertyDescriptorAdded...Changed...Deleted)。如果出现这些类型,您必须重新计算您的列。

因此,您可以使用此信息显示当前数据,而无需在每次更改时都重新创建完整列表。通过 NewIndex 属性可以获取哪个项目已更改的信息,如您在描述中所示。为了添加更改和删除项目的功能,我实现了两个方法(updateItemdeleteItem)。我已经在上面展示了 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);
    }
}

此方法只选择项目的当前索引,并在需要时滚动到它。如果您想在用户单击控件中的某个项目时设置位置,则必须首先实现 SelectedIndexSelectedIndexChanged。在我的示例中,这是由 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。我已覆盖 ListViewOnAfterLabelEdit 方法。PropertyDescriptor 提供了一个名为 SetValue 的方法,它需要两个参数:保存数据的行对象(列已由 PropertyDescriptor 表示)和新值。您需要做的就是通过使用唯一的名称(您可以使用集合的 Find 方法搜索它)获取正确的 PropertyDescriptor。在此示例中,我只能使用第一列,因为 ListView 只允许在第一列中编辑。

使用 SetValue 后,CurrencyManager 处于“编辑”状态。这意味着您的更改在您更改行或调用 CurrencyManagerEndCurrentEdit 之前不会保存。在 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;
        }
    }
}

就是这样!现在您的控件像 DataGridDataGridView 一样进行数据绑定。您不需要为每个可能的数据源编写显式代码,因为 CurrencyManager 为您完成了所有这些工作。

摘要

您会发现实现复杂数据绑定并不困难。多亏了 CurrencyManager,才有可能显示所有数据,始终显示当前数据,显示和更改当前行,以及编辑行中的数据。

如果您想在自己的控件中使用此代码,您将需要 DataSourceDataMember、类似 Items 集合的东西、类似 Columns 集合的东西以及 SelectedIndex 属性。

历史

  • 首次发布。
© . All rights reserved.