用于显示分层数据的嵌套网格

下载本文中的代码:CuttingEdge0310.exe (135 KB)

在“前沿技术”的 2003 年 8 月刊中,我讨论了如何扩展 ASP.NET DataGrid 服务器控件,以便将多表数据容器(如数据集对象)用作其数据源。如果数据集包含数对相关表,则只要所显示的表是其中某个关系的父级,该控件就会添加动态创建的按钮列。当单击该列按钮时,将显示子 DataGrid,并将根据此关系列出选定记录的子行。总体行为显示在图 1 中,此行为与 Windows®窗体 DataGrid 控件在类似情形下的工作方式类似。

fig01

图 1 父级和子级 DataGrids

图 1 中显示的应用程序是一个用户控件,它包含了两个一起工作的 DataGrid 控件。该用户控件(请参阅 2003 年 8 月的源代码)包含了使两个网格保持同步所需的全部逻辑。父 DataGrid 绑定到一个数据集,并显示父表的内容。当这一情况发生时,该用户控件可确保关系存在于数据集(所显示的表在其中充当父级)内部。子 DataGrid 绑定到一个数据视图,该视图包含子表中只与选定记录相关的所有记录。因此,如果您有一个数据集,并且它有两个已建立关系的表,那么该用户控件可节省您的编码时间,因为您不需要针对任何额外的显示机制来编写代码了。

那么这种方法有什么问题呢?如果您只关注基本功能,那么它没有问题。但是,一些读者已经注意到,不使用两个物理上分隔的 DataGrid 控件也许会更好。该用户控件在组成控件的周围构建了一个壁垒,从而使您只能通过映射属性和方法或者将内部控件作为一个整体公开,来访问这些组成控件。从可编程性的观点来看,使用一个 DataGrid 控件来显示分层数据要简单得多。首先,您不必担心父表的配置问题。只需使用 DataGrid 控件的标准接口即可。显示相关数据的任何子网格都可以动态创建,并可以显示在主网络的布局中。

fig02

图 2 嵌入式子 DataGrid

另一方面,请记住,设计 DataGrid 控件不是为了包含分层数据。其内部布局最适合于表格式数据。DataList 控件可能是一个不错的选择,但是它不提供原生分页支持,并且需要一些代码才能像 DataGrid 一样工作。在 Google 上快速搜索“嵌套 DataGrid”会返回一些讨论如何将 DataGrid 嵌入 DataList 控件的文章链接,这些文章给了我一些关于本专栏的启示。在这里,我将构造一个从 DataGrid 类继承的自定义控件。该控件实现一个自定义列类型 (ExpandCommandColumn),并包含显示与被单击的项关联的记录所需的全部逻辑。展开视图通过嵌入到父级中的子 DataGrid 表示。图 2 显示了此控件的外观。


构造嵌套网格

只有当数据源是包含表之间关系的数据集对象时,分层的 DataGrid 控件才有意义。例如,假定某个数据集具有 Customers 表和 Orders 表,并在 CustomerID 列上建立了这两个表之间的 DataRelation。只要 DataGrid 包含按钮列,那么当您单击它时就能够为选定的客户创建一个子视图,并将所生成的 DataView 对象绑定到子网格。

由于新控件(在示例代码中称为 NestedGrid)是从 DataGrid 类继承的,因此您可以在适合使用 DataGrid 对象的任何情况下使用它。但是,最后这一句还有待修饰。通常,从基类派生控件时,可能会存在以下情况:所派生的控件由于其特定的扩展名和附加项而无法替换原始控件。在本专栏中,我不会花太多的时间来使 NestedGrid 组件向后兼容基 DataGrid 类。为了简单起见,我假设您始终将它绑定到数据集对象。

关于 NestedGrid 控件,我还有其他几个假设,这将在后面的部分中说明。特别要说明的是,由您负责添加指示每一行的展开/折叠状态的按钮列。从理论上来讲,该列可以放在网格中的任何位置。但是,我在这里假设展开列是网格中的第一列。(正如 2003 年 8 月刊中讨论的那样,您可以适当地修改行为,以便只有当 DataGrid 绑定到具有相关表的数据集时才动态生成该列。)

如果您使用过 DataGrid 控件,就会知道尽管它的功能极其强大,并且可自定义性也非常强,但它无法很好地支持布局更改。网格布局表示表格式数据 — 按规则连续的若干个大小相等的行。怎样才能嵌入具有此限制的子网格呢?

这里要提醒的重要一点是,网格是作为标准的 HTML 表呈现的。一旦单元格形成了规则的表布局,您就可以在其中的每个单元格中放入任何内容,包括表示子网格的子表(使用 rowspan 标记)。首先,删除包含命令按钮的单元格以外的其他所有单元格,以修改组成选定行(即,用户单击的展开命令按钮所在的行)的单元格的数量。如果假设展开命令列位于最左侧,这很容易实现。将所有单元格都删除之后,您可以创建一个横跨若干列(列数必须等于 DataGrid 控件的 Columns 集合中的项数)的全新单元格。

此时,您已经拥有了一个完全自定义的单元格,以便展开该行。可以通过编程方式在此自定义单元格中填入服务器控件的任意组合。例如,您可以插入这样一个表:最上面一行模拟已删除单元格的结构(通常是关于父行的信息),最下面一行包含子 DataGrid。图 2 中的控件是基于此方案创建的。


NestedGrid 类

正如我前面提到的那样,NestedGrid 类从 System.Web.DataGrid 类继承,并添加了几个额外的属性(请参见图 3)。此控件还将在需要数据绑定时引发自定义的 UpdateView 事件。要对指定给 DataSource 属性的对象类型加以严格的控制(并确保它是一个数据集),您可以重写 DataSource 属性,如下面的代码所示:

public override object DataSource
{
   get {return base.DataSource;}
   set {
      if (!(value is DataSet)) {
         // throw an exception
      }
      base.DataSource = value;
   }
}

当用户单击行按钮以展开某个记录(客户)并查看其详细信息(相关定单)时,NestedGrid 控件便实例化了。为此,嵌套的网格必须包含一个具有某些特定功能的按钮列。首先,网格必须提供针对 ItemCommand 事件的处理程序,以便处理展开/折叠请求。处理程序将 ExpandedItemIndex 属性设置为被单击记录的基于零的索引,并更新网格的视图。那么,应该在什么时候修改被单击行的布局呢?

在网格布局创建后,ItemDataBound 事件将在事件链的底部激发。在 ItemDataBound 激发后,数据绑定阶段已基本完成,所有单元格都可以显示了。此后,您所看到的布局和数据将不再发生任何变化。就是因为这个原因,我决定在处理 ItemDataBound 事件之前实现所有必要的更改。

在深入探讨控件的实现之前,还有几点注意事项需要提出来。首先,ExpandedItemIndex 属性是基于零的,但它表示所单击的行的绝对位置。此属性与类似的网格属性(如 SelectedItemIndex 和 EditItemIndex)的唯一不同之处在于,它表示的不是基于页的值。其次,NestedGrid 还在内部实现分页。要使该控件在成员表的各页之间移动,除了处理 UpdateView 事件并传递绑定数据以外,您不必做其他任何工作:

void UpdateView(object sender, EventArgs e) {
   BindData();
}
void BindData() {
   dataGrid.DataSource = (DataSet) Cache["MyData"];
   dataGrid.DataBind();
}

NestedGrid 类具有针对 PageIndexChanged 事件的内置处理程序,如下所示:

void PageIndexChanged(object sender, DataGridPageChangedEventArgs e)
{
   CurrentPageIndex = e.NewPageIndex;
   SelectedIndex = -1;
   EditItemIndex = -1;
   ExpandedItemIndex = -1;

   if (UpdateView != null)
      UpdateView(this, EventArgs.Empty);
}

NestedGrid 控件体系结构的关键要素是按钮列。为了简单起见,此版本的控件仅支持单个展开项。通过将 ExpandedItemIndex 属性从整数更改为数组或集合,可以轻松地扩展此功能。

ExpandCommandColumn 类

可以使用字符串(如“+/-”或“Expand/Collapse”)或位图来呈现展开列。您可能要对不同的应用程序使用不同的图片。要实现此功能,最灵活的方法是使用几个像 ExpandText 和 CollapseText 这样的属性。那么,应当在 NestedGrid 类上定义这些属性吗?在类似的方案(就地编辑)中,ASP.NET 小组创建了一个自定义的 DataGrid 列,并在该列中放入了诸如 EditText、CancelText 和 UpdateText 这样的属性。基于此,我创建了自己的 ExpandCommandColumn 类,并在其中放入了几个文本属性,以表示用于展开和折叠视图的 HTML 输出。下面的代码片段显示了如何将该自定义列与网格集成。

<cc1:NestedDataGrid id="dataGrid" runat="server" ...>
<Columns>
   <cc1:ExpandCommandColumn 
      CollapseText="<img src=images/collapse.gif>" 
      ExpandText="<img src=images/expand.gif>" >
      <ItemStyle Width="15px" /> 
   </cc1:ExpandCommandColumn>
•••
</Columns>
</cc1:NestedDataGrid>

绑定自定义的 DataGrid 列并不困难。除了新建一个从 DataGridColumn 继承的类以外,不需要其他太多的工作。在新类中,您必须针对所需的任何额外属性编写代码,并重写 InitializeCell 方法。只要为该列创建单元格,就会调用此方法。默认出现在列单元格中的任何内容都由此方法控制。下面的代码演示了 ExpandText 属性的实现:

public class ExpandCommandColumn : DataGridColumn
{
  public string ExpandText {
    get {
      object data = ViewState["ExpandText"];
    if (data != null)
          return (string) data;
    return "+"; 
    }
    set {
       ViewState["ExpandText"] = value;
    }
  }
  •••
}

CollapseText 属性只在视图状态槽的名称及其默认值(“-”)上不同。

值得注意的是,服务器控件属性的默认值应该在 get 访问器中设置,而不是在构造函数或初始化事件(如 Init 或 Load)中设置。这是 Microsoft 在整个 ASP.NET 中使用的惯例。通过将此类代码隔离在属性的 get 访问器内部,可以实现代码封装,并使属性值后面的逻辑与控件的其余部分更清楚地分隔开来。尤其是,当属性的默认值受到复杂规则的影响时,这种方法可以为您提供单一的控制点,从而使得整个代码更易于维护。说到最佳做法,应谨记必须检查为属性返回的值是否为空,并在必要时对其进行正常化。例如,string 类型的属性绝不能返回空值,而应该返回空字符串。

DataGrid 列以 InitializeCell 方法为中心。此方法被声明为 public 和 virtual(也就是说,能够在派生类中重写),并且 DataGrid 控件内部的代码会在需要呈现该列时调用它。尽管被声明为 public,但是此方法通常只有控件开发人员使用。现在来看一下签名:

public override void InitializeCell(
   TableCell cell, 
   int columnIndex, 
   ListItemType itemType)

DataGrid 代码调用此方法,并为其传递表示要创建的单元格的对象、该列在网络的 Columns 集合中的索引以及要呈现的单元格的类型(标头、脚注、项等等)。您可以看到,没有有关单元格在网格页中的索引的任何信息。此信息真的非常重要吗?看一下预定义类型的网格列,回答似乎是“否”。(事实上,此信息不单独传递。)预定义的网格列(绑定列、按钮列、超链接列、模板列)使用两种算法中的一种来填充单元格。如果设置了其 Text 属性,那么所有单元格都将包含固定的常数值;或者,如果设置了数据绑定属性(如 DataField),将通过数据绑定过程来解析每个单元格的内容。

那么,应将 ExpandCommandColumn 类型归到哪个类别呢?确切地说,不应该归到任何类别。理想情况下,此类型将使用 ExpandText 或 CollapseText(取决于所呈现的项的状态)来呈现单元格文本。如果单元格索引与 ExpandedItemIndex 属性相匹配(或者属于展开项的集合),将使用 CollapseText 值。否则,将使用默认的 ExpandText 属性。那么列的 InitializeCell 方法怎样才能知道单元格索引呢?

在我脑海里闪现的第一个念头就是获取传递给该方法的 TableCell 对象,调用其 NamingContainer 属性,并将结果强制转换为 DataGridItem。如果获取的对象不为空,那么它将是单元格的容器,并且它的 ItemIndex 属性将包含所需的信息。遗憾的是,事情没有那么简单。单元格对象的命名容器为空,因为当调用 InitializeCell 时,TableCell 对象尚未添加到网格项容器中。因此,它不属于任何父容器,从而使 NamingContainer 属性返回空。

为了找到解决办法,我将目光转向 DataGrid 控件的内部可重写方法列表。DataGrid 的 InitializeItem 方法被证明就是我想要的方法。该方法负责在创建网格布局时初始化网格列。在 MSDN ® 上的 ASP.NET 文档中提到过 InitializeItem 方法,但没有对它进行完整的介绍。从 ASP.NET 1.x 开始,此方法的行为已非常简单。InitializeItem 采用两个参数:表示要呈现的网格行的 DataGridItem 对象,以及 DataGridColumn 对象的数组(该行的各列):

protected virtual void InitializeItem(
   DataGridItem item,
   DataGridColumn[] columns
);

InitializeItem 方法循环调用各列,并为每一列新建一个 TableCell 对象。此对象传递给列特定的 InitializeCell 方法,然后添加到 DataGridItem 对象的 Cells 集合中。(您可能已猜到,TableCell 的命名容器只有在此时才设置为非空值。)图 4 中的代码显示了 InitializeItem 的重写版本,该版本将一个额外标志传递给 ExpandCommandColumn 列类。

图 5 包含用于初始化 ExpandCommandColumn 类的单元格的代码。每个单元格都呈现为一个带有文本的链接按钮,该文本由额外的布尔型参数决定(请参见图 6)。与 DataGrid 控件的其他许多元素一样,此元素也完全支持 HTML 文本,因此您可以使用图像来实现展开/折叠功能。

fig06

图 6 作为链接按钮的单元格

当单击该列的链接按钮时会发生什么情况呢?如果您需要列特定的行为,那么应添加一个针对 Click 事件的处理程序。该代码将在单击事件后第一个执行。接下来,该事件将通过 DataGridItem 类上升,并将导致 DataGrid 级别的 ItemCommand 事件。

呈现子网格

尽管 DataGrid 控件具有很强的可自定义性,但它不提供用来修改行的 HTML 布局的功能。DataGrid 的自定义逻辑是围绕这样一个思想来建立的:网格由列组成,而行仅仅是彼此相邻的列所产生的结果。但是,在这里,您需要修改选定行的结构以包含子 DataGrid 控件。在此过程中只能在两个位置更改网格的布局:ItemCreated 事件或(最好是)ItemDataBound 事件。从网格项的生存期来看,ItemDataBound 事件激发的时间稍晚,并且是您在将新行添加到最终 HTML 表之前看到的最后一个事件。

当用户单击命令列中的某个链接按钮时,ItemCommand 事件将上升到网格。如果命令名等于 Expand(该列的按钮的命令名),代码将首先查看所单击的项的索引是否与 ExpandedItemIndex 属性匹配。如果是,则说明用户单击了已展开的项,该项接下来将折叠。图 7 显示了实现此机制的代码。前面已提到,ExpandedItemIndex 属性是一个绝对索引,其范围在 0 到数据源中的项数之间。这就是为什么在撰写网格的页时,需要将其模数与项的索引进行比较的原因。

图 7 中所示的代码的最后一步激发了 UpdateView 事件。对于 NestedGrid 控件而言,此事件表示处理 UI 呈现的入口点。处理事件、绑定任何必要的数据以及最终调用网格的 DataBind 方法的工作有望由客户端来执行。此时,控件的生存期是连续的若干个事件,其中的第一个事件是 DataBinding。接下来,为网络中的每一项(包括标头、数据行和脚注)激发 ItemCreated 和 ItemDataBound。在此阶段,将调用 InitializeItem 来填充每个绑定列的单元格。

图 2 中,您可以看到该网格为另一个嵌入式网格(显示展开记录的子行)腾出空间。假设展开列是最左侧的列,删除所有后续单元格,并用一个新的单元格(在其中正确设置 RowSpan 属性)来替换这些单元格。此新单元格的内容可以由您决定,但至少应包含下列信息:已删除的单元格(即,有关要展开的记录的信息)和子网格。删除若干个单元格之后又再次将其添加,这听起来可能令人费解,但是这种折衷的办法对于同时满足两个对照鲜明的要求是必要的:插入一个子表,同时保留表布局的其余部分。

在我的实现中,我缓存了要删除的每个单元格的文本和宽度。作为备选方法,您可以考虑将 TableCell 对象从一个 Cells 集合移到另一个 Cells 集合(请参见图 8)。新单元格包含一个两行的表,其中第一行再现原始的单元格,第二行横跨整个宽度以显示子 DataGrid。

在我第一次测试此代码时,就产生了问题。在编码到 ASP.NET 之前,我用纯 HTML 验证了这一想法。我非常确信前面描述的这种布局是有意义的,因此我在 ASP.NET DataGrid 的 ItemDataBound 事件内部对它进行了编码。令我大为吃惊的是,它竟没有正确地横跨整个宽度。我花了一些时间来了解内部的过程。问题在于我对每一列(包括展开列后面的第一列)都指定了一个用像素表示的显式宽度:

<asp:boundcolumn runat="server" 
   headertext="ID" 
   datafield="ID" 
   itemstyle-width="150px" />

这样,新单元格(旨在包含子网格的单元格)仍然是展开列之后的第一列,因此继承了原始 ID 列的以像素为单位的宽度。为了使此单元格横跨可用空间,不应设置 Width 属性,而应将其保留为空。无论您在代码中做什么(请参阅 ItemDataBound 事件处理程序),DataGrid 内部框架始终会生成一个保留以像素为单位的原宽度的 style 属性。如果您查看单元格的源 HTML,就会看到类似的设计:

style="width:150px;...;width='';"

width 属性设置了两次(第二次指定与 ItemDataBound 中的指定一样),但是对于浏览器而言,第一个值才是唯一有影响的值。对于这个问题,除了删除静态宽度指定以外,我没有找到更好的解决办法。如果该列需要一个宽度,您可以定义一个用主列(展开列之后的第一列)的单位来表示宽度的自定义属性(在示例中称为 HostColumnWidth)。下面的代码片段展示了如何动态地设置列的宽度,从而获得与设置项的样式等同的效果:

if (e.Item.ItemIndex != (ExpandedItem % this.PageSize)) {
   // Equivalent to setting itemstyle-width declaratively
   e.Item.Cells[1].Width = HostColumnWidth;
   return;
}

创建子视图

此时,整个开发过程已近尾声,但还有最后一步没有完成,那就是填充子 DataGrid。完成这一步的方法取决于您所管理的分层数据的内在布局。但是,如果您将多级别数据保留在具有数对相关表的 ADO.NET 数据集中,那么使用子 DataView 对象是一种可行的方法。(此方法与我在 2003 年 8 月的专栏中讨论的方法类似。)

用户选择要显示在网格中的父表以及用于决定子视图的关系。子视图是通过调用表示父记录的 DataRowView 对象上的 CreateChildView 方法来创建的:

DataTable dt = ds.Tables[this.DataMember];
DataView theView = new DataView(dt);
DataRowView drv = theView[ExpandedItemIndex]; 
DataView detailsView = drv.CreateChildView(this.RelationName);

与展开行关联的一组记录分组在一个新的 DataView 对象中。例如,如果建立了客户-定单关系,那么子记录集将是给定客户所发出的定单。子网格是动态创建和配置的,如下所示:

detailsGrid = new DataGrid();
detailsGrid.ID = "detailsGrid";
detailsGrid.Font.Name = this.Font.Name;
detailsGrid.Font.Size = this.Font.Size;
detailsGrid.Width = Unit.Percentage(100);
detailsGrid.AllowPaging = true;
detailsGrid.PageSize = 5;
detailsGrid.PageIndexChanged += new DataGridPageChangedEventHandler(
       detailsGrid_PageIndexChanged);
BindDetails(detailsGrid);

特别要指出的是,子网格在内部管理分页。它被指定了一个针对 PageIndexChanged 事件的内置处理程序,在用户单击页导航按钮时,该处理程序会自动将网格移至下一页。要使此功能工作,程序员这一端不需要再编写更多的代码。

在嵌入式网格内部生成的任何事件在最外侧的网格外部都不可见。除此之外,这意味着永远不能编写用户代码来处理子网格的页导航栏上的单击事件。是否存在一种方法可以解决结构上的这一限制呢?一种可能性是从内部处理程序中激发新事件。

如果您不喜欢对子网格进行分页,则可以使用滚动栏,并将网格包装到可滚动的面板中。在 HTML 4.0 中,如果内容超出了固定大小,则 overflow CSS 属性会将一些 HTML 元素转换为可滚动的区域。要使子网格可滚动,只需将它包装到一个面板(对应于 <div> 标记)中,并为该面板指定 overflow 属性即可(请参见图 9)。现在,网格的标头可以随控件的其余部分一起滚动。此行为是设计出来的,它需要非常复杂的技巧,这在本专栏中不加以讨论。

小结

ADO.NET 数据集对象的工作方式与具有表和关系集合的内存中数据库类似,它允许您创建分层的数据表示。结合使用迭代和数据绑定控件(如 DataList、Repeater 或 Label),您可以轻松地模拟关系,从而可以通过网格来呈现此类数据。此方法的缺点是,您必须显式地编写代码来执行分页。DataGrid 提供了许多有趣的功能,但不提供任何呈现分层的多表数据的功能。在 2003 年 8 月的“前沿技术”专栏中,我讨论了一个基于用户控件的解决方案。在本专栏中,我介绍了如何通过继承来扩展 DataGrid 控件本身。至此,这个讨论话题宣告结束。

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页