Dino Esposito
下载本文的代码: CuttingEdge0310.exe (135KB)
在“前沿技术”的 2003 年 8 月刊,我讨论了如何扩展 ASP.NET DataGrid 服务器控件,以便将多表数据容器(如 DataSet 对象)用作其数据源。 如果 DataSet 包含数对相关表,则只要所显示的表是其中某个关系的父级,该控件就将添加动态创建的按钮列。 当单击此列按钮时,将显示子 DataGrid,并将根据此关系列出选定记录的子行。 总体行为显示在图 1 中,此行为与 Windows® 窗体 DataGrid 控件在类似情形下的工作方式相同。
图 1 父级和子级 DataGrids
图 1 中显示的应用程序是包含两个一起工作的 DataGrid 控件的用户控件。 该用户控件(请参阅 2003 年 8 月的源代码)包含使两个网格保持同步所需的全部逻辑。 父 DataGrid 绑定到 DataSet 并显示父表的内容。 当这一情况发生时,该用户控件确保 DataSet(所显示的表在其中充当父级)内部存在关系。 子 DataGrid 绑定到数据视图,该视图包含仅仅与选定记录相关的子表中的所有记录。 因此,如果您有一个 DataSet,并且它有两个已建立关系的表,那么该用户控件将为您节省时间,因为您不需要针对任何额外的显示机制来编写代码。
那么这种方法有什么问题呢? 如果您仅仅关注基本功能,那么它没有问题。 但是,一些读者已注意到不使用两个物理上分隔的 DataGrid 控件也许会更好。 该用户控件在组成控件的周围构建了一个壁垒,从而使您只能通过映射属性和方法或者通过公开整个内部控件来访问这些组成控件。 从可编程性的观点来看,使用一个 DataGrid 控件来显示分层数据要简单得多。 首先,您不必担心父表的配置问题。 只需使用 DataGrid 控件的标准接口即可。 显示相关数据的任何子网格都可以动态创建,并可以显示在主网络的布局内部。
图 2 嵌入式子 DataGrid
另一方面,需要提醒的是,设计 DataGrid 控件不是为了包含分层数据。 其内部布局最适合显示表格式数据。 DataList 控件可能是一个不错的选择,但它不提供固有的分页支持,并且需要一些代码才能像 DataGrid 一样工作。 当在 Google 上快速搜索“嵌套 DataGrid”时,返回了讨论如何将 DataGrid 嵌入到 DataList 控件的文章的链接,这些文章给了我关于本专栏的一些启示。 这里,我将构造一个从 DataGrid 类继承的自定义控件。 该控件实现一个自定义列类型 (ExpandCommandColumn),并包含显示与被单击的项关联的记录所需的全部逻辑。 展开视图通过嵌入到父级中的子 DataGrid 表示。 图 2 显示了此控件的外观。
构造嵌套网格
只有当数据源是包含表之间的关系的 DataSet 对象时,分层的 DataGrid 控件才有意义。 例如,假定某个 DataSet 具有 Customers 表和 Orders 表,并在 CustomerID 列上建立了这两个表之间的 DataRelation。 只要 DataGrid 包含按钮列,那么当您单击它时就能够为选定的客户创建一个子视图,并将所生成的 DataView 对象绑定到子网格。
由于新控件(在示例代码中称为 NestedGrid)是从 DataGrid 类继承的,因此可以在适合使用 DataGrid 对象的任何情况下使用它。 但是,最后这一句还有待修饰。 通常,当从基类派生控件时,可能存在这样的情况:所派生的控件由于其特定的扩展和附加项而无法替换原始控件。 在本专栏中,我不会花太多的时间来使 NestedGrid 组件向后兼容基 DataGrid 类。 为了简单起见,我假设您始终将它绑定到 DataSet 对象。
关于 NestedGrid 控件,还有其他几个假设,这将在后面的部分逐渐说明。 特别要说明的是,由您负责添加规定每一行的 expanded/collapsed 状态的按钮列。 从理论上来讲,该列可以放在网格中的任何位置。 但是,我在这里假设展开列是网格中的第一列。 (2003 年 8 月刊已讨论,可以适当地修改行为,以便只有当 DataGrid 绑定到具有相关表的 DataSet 时才动态生成该列。)
如果您有过一点使用 DataGrid 控件的经验,会知道尽管它功能极其强大,可自定义性也非常强,但它无法很好地支持布局的变化。 网格布局表示表格式数据 — 按规则连续的若干个大小相等的行。 怎样才能嵌入具有此限制的子网格呢?
这里要提醒的重要一点是,网格是作为标准的 HTML 表呈现的。 一旦单元格形成了规则的表布局,就可以在其中的每个单元格中放入任何内容,其中包括表示子网格的子表(使用 rowspan 标记)。 首先,删除包含命令按钮的单元格以外的其他所有单元格,以修改组成选定行(即用户单击的展开命令按钮所在的行)的单元格的数目。 如果假设展开命令列位于最左侧,这很容易实现。 所有单元格都删除后,可以创建一个横跨若干列(列数必须等于 DataGrid 控件的 Columns 集合中的项数)的全新单元格。
此时,您已拥有了使该行可展开的完全自定义单元格。 可以通过编程的方式在此自定义单元格中填入服务器控件的任意组合。 例如,可以插入这样一个表:最上面一行模拟已删除的单元格的结构(通常是关于父行的信息),最下面一行包含子 DataGrid。 图 2 中的控件是基于此方案创建的。
NestedGrid 类
前面已提到,NestedGrid 类从 System.Web.DataGrid 类继承,并添加了额外几个属性(见图 3)。 此控件还将在需要数据绑定时引发自定义的 UpdateView 事件。 要对分配给 DataSource 属性的对象类型加以严格的控制(并确保它是 DataSet),可以重写 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 文本,因此您可以使用图像来实现展开/折叠功能。
图 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 DataSet 中,那么使用子 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 对象中。 例如,如果建立了 customer-to-orders 关系,那么子记录集合将是给定客户发出的订单。 子网格是动态创建和配置的,如下所示:
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 元素转换为可滚动的区域。 要使子网格可滚动,只需将它包装到一个 Panel(与标记对应)中并为该面板指定 overflow 属性即可(见图 9)。 现在,网格的标头随着控件的其余部分一起滚动。 此行为是设计出来的,它需要非常复杂的技巧,在本专栏中不予以讨论。
小结
ADO.NET DataSet 对象的工作方式与具有表和关系集合的内存内数据库类似,它允许创建分层的数据表示。 使用迭代和数据绑定控件(如 DataList、Repeater 或 Label)的组合可以轻松地模拟关系,从而可以通过网格来呈现此类数据。 此方法的缺点是要求明确地编写代码来执行分页。 DataGrid 提供了许多有趣的功能,但不提供呈现分层的多表数据的任何功能。 在 2003 年 8 月的“前沿技术”专栏中,我讨论了一个基于用户控件的解决方案。 在本专栏中,我介绍了通过继承来扩展 DataGrid 控件本身。至此,这个讨论话题宣告结束。
请将与 Dino 有关的问题和评论发送至 [email protected]。
Dino Esposito 是在意大利罗马工作的教师兼顾问。 作为 Programming ASP.NET 和 Applied XML Programming for .NET(均由 Microsoft Press 出版)的作者,他花了大量的时间讲授 ADO.NET 和 ASP.NET 中的类,并在各种会议中发表过大量的演讲。 请通过 [email protected] 与他联系。
转到原英文页面
本文地址:http://com.8s8s.com/it/it7736.htm