简介:这是一份基于DevExpress控件库实现多行表头(复合表头)的完整示例工程,面向.NET WinForms开发人员,解决GridControl默认单行表头难以呈现分级分类的问题。复合表头常见于统计报表,用来展示大类别、子类别等多层级信息,能显著提升数据可读性。资源包为rar压缩格式,共包含33个文件,解压后仅67KB,其中9个C#源文件负责界面逻辑与表头构建,配合项目配置文件、可执行程序、调试信息以及窗体资源文件,构成一个可直接运行的演示项目。另有解决方案与属性配置,便于用Visual Studio打开后对照学习。目前已有1273人学习,说明此类需求普遍存在。示例不只演示了HeaderRowCount、HeadersVisibility等关键属性的用法,还展示了HeaderGroupInfo分组表头如何组织层级,并提供相关事件处理思路,可作为复杂报表开发时的实用脚手架。
1. 复合表头不是合并单元格:先想清楚列树,再动DevExpress
做报表系统的人迟早会遇到这个需求:财务台账要按「年份 → 季度 → 月份」分层展示,供应链要按「供应商 → 产品线 → 型号」分组,排班表要按「周 → 班组 → 人员」输出。在DevExpress里做这种多行表头,官方称为复合表头(Banded Header),本质是把GridView的列结构从一排变成一棵树,而不是把标题文字拆开再拼单元格。很多人第一步就走错,去调列的HeaderCaption或者用单元格合并硬凑,结果排序、筛选、导出表格文件时全部原形毕露。这篇文章从桌面端到Web端,把WinForms、WPF、ASPxGridView三条路的实现姿势、参数边界和踩坑点一次拆透。
2. WinForms多行表头:用BandedGridView把列挂成树
2.1 为什么复合表头要用BandedGridView,而不是靠单元格合并
WinForms里最常见的误区是直接在GridView的列上加两行文字,或者用GridView的合并单元格功能把两个相邻列的表头视觉上拼在一起。前者只是换行,不构成层级;后者的问题是,合并单元格只是渲染层的视觉技巧,数据层并不知道「年份」下面挂着哪几列。一旦用户点击列头排序,合并区域会闪断;导出表格文件时,表头恢复成每个单元格一行;打印预览更是直接错位。
BandedGridView的思路完全不同。它把「列」和「组」统一成同一类对象——BandedGridColumn。一个Band(组)可以拥有子Band或子列,子列再挂数据字段,这样表头天然形成一棵树。树的好处是:排序时叶子列自己知道上级是谁,筛选时数据列和表头层级保持一致,导出时也按树的层级输出。代价是你要接受一个概念转换:GridView里的Columns集合不再只是一排数据列,而是Band和列混排的集合,需要习惯这种结构。
2.2 最小实现代码:AddBand + AddField + Band赋值
先把一个小例子跑通。以下代码把GridView切换成BandedGridView,然后建一个「年份 → 季度 → 月份」的两层表头,并挂上三个数据列。
var grid = new GridControl(); var bandedView = new BandedGridView(grid); grid.MainView = bandedView; // 关键:关闭自动生成列,否则绑定数据源时Band结构会被冲掉 bandedView.OptionsBehavior.AutoGenerateColumns = false; // 建顶层Band:年份 BandedGridColumn yearBand = bandedView.Columns.AddBand("YearBand"); yearBand.Caption = "2024年"; // 在年份Band下建子Band:季度 BandedGridColumn quarterBand = bandedView.Columns.AddBand("QuarterBand", yearBand); quarterBand.Caption = "第一季度"; // 建三个数据列,并挂到季度Band下 BandedGridColumn colJan = bandedView.Columns.AddField("JAN"); colJan.Caption = "1月"; colJan.Visible = true; colJan.Band = quarterBand; BandedGridColumn colFeb = bandedView.Columns.AddField("FEB"); colFeb.Caption = "2月"; colFeb.Visible = true; colFeb.Band = quarterBand; BandedGridColumn colMar = bandedView.Columns.AddField("MAR"); colMar.Caption = "3月"; colMar.Visible = true; colMar.Band = quarterBand; // 最后再绑定数据源 grid.DataSource = salesTable; // DataTable,字段名必须包含 JAN/FEB/MAR逻辑说明:AddBand(string name)创建的是一个没有绑定字段的BandedGridColumn,它专门用来做表头层级;AddBand(string name, parent)则把新Band挂到已有Band下面,返回的对象同样可以继续挂子列。AddField("JAN")按数据源字段名创建数据列,列本身的Caption才是表头显示文本。最后通过设置colJan.Band = quarterBand把叶子列挂进Band,这一步也可以写成quarterBand.Columns.Add(colJan),两者等价。
参数说明:Band的Name属性是内部唯一标识,不在界面上显示,重命名时要保证唯一;Caption是实际显示在表头里的文本;Visible = true才让列进入可见列序列,否则Band建得再漂亮,数据列不显示也是白搭。AutoGenerateColumns = false这行必须放在DataSource赋值之前,否则绑定数据源时引擎会重建Columns集合,把手动建好的Band树清空。
2.3 数据绑定与列索引:先关自动生成列,再谈VisibleIndex
数据绑定后的列索引规则和普通GridView不一样,这里有坑。BandedGridView里Columns["JAN"].VisibleIndex的编号是按所有叶子列从左到右连续编号的,Band本身不参与编号。也就是说,如果你有三个Band,每个Band下面各有三列,那么可见索引是从0排到8,而不是每个Band内部从0开始。
调整列顺序时,常见做法是设置VisibleIndex,但在BandedView里建议优先操作Band的Columns集合:
// 把2月列从季度Band里移出来,放到顶层 quarterBand.Columns.Remove(colFeb); colFeb.Visible = true; colFeb.Band = yearBand;这个操作比直接改VisibleIndex更安全,原因在于Band内部的排列顺序和全局VisibleIndex是两套独立逻辑。直接给叶子列设VisibleIndex,列确实会移动到目标位置,但它的Band归属不会自动重新计算,经常出现列跑到另一个Band下面、表头层级错乱的现象。如果非要全局重排,排完再逐个检查col.Band是否还指在正确的Band上。
实际项目中我一般在DataSource赋值前先把所有列和Band建好,绑定后不做结构性调整,只做宽度的微调。结构性调整放在绑定前,能省掉大部分列索引错乱的麻烦。
3. WPF GridControl复合表头:切到BandedView,XAML与后台两种加Band的姿势
3.1 TableView没有Band概念,先切到BandedView
WPF里的GridControl默认使用TableView,这个视图只有一个表头行,没有复合表头的概念。要实现多行表头,第一步不是写代码,而是把View切换成BandedView。这个操作和WinForms里把GridView换成BandedGridView是同一件事,只是入口不同。
切换方式有两种:在XAML里给GridControl指定<dxg:BandedView>,或者在代码里给grid.View赋值。切换后,表格的默认行为会变:列的排序、编辑、宽度调整仍然可用,但列的组织方式从平面列表变成了树。此时表头的每一行由Band撑起来,Band可以嵌套Band,最深可以支持到用户能接受的程度,实际项目里三到四层已经是极限,再深表头高度会失控,打印时也难看。
3.2 用XAML声明复合表头:Band嵌套GridColumn
最直观的写法是在XAML里把Band树直接写出来。下面是一个完整的骨架示例:
<dxg:GridControl x:Name="grid" AutoGenerateColumns="None"> <dxg:GridControl.View> <dxg:BandedView> <dxg:BandedView.Bands> <dxg:Band Header="2024年"> <dxg:Band.Bands> <dxg:Band Header="第一季度"> <dxg:Band.Columns> <dxg:GridColumn FieldName="JAN" Header="1月" /> <dxg:GridColumn FieldName="FEB" Header="2月" /> <dxg:GridColumn FieldName="MAR" Header="3月" /> </dxg:Band.Columns> </dxg:Band> </dxg:Band.Bands> </dxg:Band> </dxg:BandedView.Bands> </dxg:BandedView> </dxg:GridControl.View> </dxg:GridControl>逻辑说明:BandedView.Bands是根Band集合,集合里的每个Band就是一个表头分组;Band.Bands实现无限嵌套,Band.Columns则放真正的数据列。GridColumn的FieldName绑定数据源字段,Header是显示在表头里的文字。XAML声明的好处是表头结构可视化,谁在谁的下面一目了然,适合表头层级固定、不常变的报表页面。
参数说明:Band Header和GridColumn Header是显示文本,二者可以同名但建议区分,否则排查时不好定位;GridColumn Width如果没设,Band会根据列内容自动分配宽度,在多列场景下容易忽宽忽窄,建议显式给每个数据列设MinWidth。AutoGenerateColumns="None"同样不能省,否则数据源一绑定,自动生成的列全部排在Band树外面,形成一个「Band表头 + 一堆游离列」的怪样子。
3.3 后台代码动态建Band:顺序敏感,别重复挂载
表头结构如果来自数据库配置或用户自定义,就必须在后台代码里动态构建。WPF里动态添加Band的常见写法是:
// 在代码里动态创建一个Band和三个数据列 var band = new Band { Header = "第二季度" }; var colApr = new GridColumn { FieldName = "APR", Header = "4月" }; var colMay = new GridColumn { FieldName = "MAY", Header = "5月" }; var colJun = new GridColumn { FieldName = "JUN", Header = "6月" }; band.Columns.Add(colApr); band.Columns.Add(colMay); band.Columns.Add(colJun); // 取当前视图并挂上这个Band var view = grid.View as BandedView; view.Bands.Add(band);逻辑说明:GridColumn先被创建,再通过band.Columns.Add挂到Band下,最后把Band挂到视图的Bands集合。这个顺序不能乱:如果先把Band挂到视图,再往Band里加列,界面通常会刷新两次,运行时会看到列逐条蹦出来,表头闪烁明显。先组装完整再挂载,是最稳妥的。
参数说明:Band.Header支持字符串,也支持通过Band.HeaderTemplate放入任意控件,比如带单位符号的文本块;GridColumn.FieldName必须和数据源里的字段名严格一致,大小写不敏感但建议保持一致。band.Columns.Add之后,该列会自动出现在grid.Columns集合里,不需要再手动加一次,否则列会被重复挂载,表头里出现两个相同字段列。
4. Web端ASPxGridView复合表头:HeaderItem嵌套与Callback后的列宽陷阱
4.1 ASPxGridView复合表头的三种实现入口
Web端的ASPxGridView和桌面端不一样,它没有BandedView这种独立视图,而是通过表头条目对象建立层级。常见的实现入口有三个:HeaderItem对象树、HeaderTemplate模板、以及最省事的Caption换行拼接。三者的取舍用一个表说清楚:
| 实现方式 | 表头层级 | 跨列能力 | Callback稳定性 | 维护成本 |
|---|---|---|---|---|
| HeaderItem对象树 | 支持多级嵌套 | 支持 | 高,但需每次请求重建 | 中 |
| HeaderTemplate模板 | 每个列独立渲染 | 较弱,跨列需自己做 | 中 | 高 |
| Caption换行拼接 | 只支持简单两行 | 不支持 | 较高 | 低,表现力差 |
实际项目中,如果业务方要求「年份」跨整条季度区域,我会优先用HeaderItem对象树,因为它本质上是为复合表头设计的;如果你的DevExpress版本里找不到这个属性(不同大版本命名差异较大),退而求其次用HeaderTemplate,至少层级可控制。Caption换行拼接只适合内部管理页、不需要打印导出的场景,不建议作为正式方案。
4.2 服务端构建HeaderItem树:先建数据列,再套表头层
用HeaderItem对象树时,数据列和表头层级是分开建的。数据列负责字段映射、排序、筛选,HeaderItem负责视觉层级。两者通过索引顺序对应,这个顺序错了,表头就会错位。以下是一个可复现的骨架:
// 每次请求都重建表头树,不要放在 if(!IsPostBack) 里 protected void Page_Init(object sender, EventArgs e) { grid.AutoGenerateColumns = false; // 先建数据列 var colJan = new GridViewDataTextColumn { FieldName = "JAN", Caption = "1月" }; var colFeb = new GridViewDataTextColumn { FieldName = "FEB", Caption = "2月" }; var colMar = new GridViewDataTextColumn { FieldName = "MAR", Caption = "3月" }; grid.Columns.Add(colJan); grid.Columns.Add(colFeb); grid.Columns.Add(colMar); // 再建HeaderItem树,HeaderItem类型名以你用的版本为准 var yearItem = new HeaderItem { Caption = "2024年" }; var quarterItem = new HeaderItem { Caption = "第一季度" }; quarterItem.NestedItems.Add(new HeaderItem { Caption = "1月" }); quarterItem.NestedItems.Add(new HeaderItem { Caption = "2月" }); quarterItem.NestedItems.Add(new HeaderItem { Caption = "3月" }); yearItem.NestedItems.Add(quarterItem); grid.HeaderItems.Add(yearItem); }逻辑说明:代码先清掉自动列生成的入口,再手动添加三个数据列,顺序固定为JAN、FEB、MAR。接着构造HeaderItem树:年份是根,季度是中间层,月份是叶子。HeaderItem只描述表头显示,不绑字段,所以它和GridViewDataTextColumn之间没有显式关联,靠的是排列顺序一致——第几个HeaderItem叶子对应第几个数据列,错一个就整体错位。
参数说明:AutoGenerateColumns = false必须在Page_Init里设置,如果放到Page_Load,数据源赋值时列已经被自动生成完,再关就晚了。HeaderItem树必须每次请求重建,因为ASPxGridView不会在服务端缓存表头结构;只写在if(!IsPostBack)里,你会发现第一次打开页面正常,点一次分页后表头塌成一行。
4.3 Callback与列宽:复合表头在请求后翻车的处理
Web端复合表头最容易翻车的场景是Callback之后列宽错位。现象是:页面首次加载,多行表头正常;用户点排序或分页,表头第二行消失,或者表头格子宽度和第一行对不上。原因有两个:一是HeaderItem树没有在每次请求时重建,这个在上面已经说过;二是列宽设置混用了百分比和像素,浏览器在Callback后重新计算列宽时,第一行表头按百分比分配,第二行按像素渲染,两排宽度自然对不上。
处理办法是让列宽单位统一。ASPxGridView里每个数据列显式设置固定像素宽度,并且不要用Width="100%"这种写法,除非你能保证所有列都走同一个宽度模型。Callback前如果发现表头错位,优先检查两处:HeaderItem是否重建、列宽单位是否统一。这两个点都排查完后,复合表头在Web端的稳定性基本就有保障了。
5. 复合表头高频翻车点:五条排查与解决记录
5.1 自动生成列把Band冲掉
现象:WinForms里把DataSource赋值给GridControl后,复合表头还在,但所有Band消失,列变成普通单层表头;WPF和Web端表现类似,甚至列会重复出现。
原因:AutoGenerateColumns默认开启,数据绑定过程会重新生成Columns集合,手动创建的Band和子列被重建逻辑清掉或重复添加。
解决:绑定数据源之前关闭自动生成列。WinForms设置OptionsBehavior.AutoGenerateColumns = false;WPF设置AutoGenerateColumns="None";Web端在Page_Init里设置grid.AutoGenerateColumns = false。关闭后再手动建列,绑定顺序就不会冲击表头结构。
5.2 设置VisibleIndex后列跑到别的Band下面
现象:把某个叶子列设成VisibleIndex = 0想让它排最左,运行后发现它脱离原来的Band,或者原Band标题下出现空白区域。
原因:Band内部顺序和叶子列全局索引是两套序列。直接改VisibleIndex只影响叶子列在表头整体排列中的位置,不会自动更新它的Band归属。
解决:调整前后顺序时,优先操作band.Columns集合,用Remove和Add调整列在Band内部的位置,而不是设置VisibleIndex。如果确实需要全局重排,排完再逐个检查col.Band指向是否正确,必要时重新赋值。
5.3 导出表格文件时复合表头缺失
现象:界面上完整显示两行表头,导出xlsx或打印预览时表头塌成一行,「年份」「季度」等顶层标题全部丢失。
原因:导出引擎按数据列集合输出表头,不会自动读取Band的树形关系;打印管线也是另一套渲染逻辑,和屏幕渲染不同。
解决:导出前手动把Band层级写入导出模板。我一般做法是维护一个和Band结构对应的导出表头配置,导出时先写顶层标题行,再写数据列标题行,最后写数据。如果你的DevExpress版本里OptionsPrint支持包含Band的打印模式,优先打开;否则就按配置补一层合并单元格标题行,别指望导出引擎自动搞懂树结构。
5.4 表头文字被裁切或两层重叠
现象:Band标题显示不全,或者第二行表头被第一行挤压,边框挤在一起。
原因:表头行高按默认单行计算,Band的高度不够容纳嵌套层级;另一种情况是Band的宽度小于子列宽度总和,子列被压缩成细条。
解决:给Band设置最小宽度,子列宽度保持一致;表头行高设成固定值,避免默认按单行计算。WPF里用HeaderTemplate换一个可以自动换行的TextBlock,比直接调高度更灵活。
5.5 用单元格合并伪装复合表头,筛选导出全乱
现象:界面上看起来有两行表头,但点击筛选时下拉选项不是顶层标题,导出文件里第一行表头变成通用列名。
原因:GridView的单元格合并只是视觉合并,数据层不知道「年份」下面有哪几列,筛选、排序、导出都按原始数据列处理。
解决:如果现有代码用单元格合并实现了复合表头,上线前统一改成BandedGridView或BandedView下的Band树。这个改造要评估所有引用原列索引的代码,因为切换到Band树后,列索引计算方式变了,排序、聚焦、样式设置的代码可能都要跟着调。
6. 进阶:把复合表头做成JSON描述器,一套递归代码跑通三层表头
如果表头结构经常变,比如每年要加季度、每季度要加月份,把表头硬编码在界面里效率太低。我习惯把表头结构做成JSON描述器,交给递归代码去建。表头层级越复杂,这个做法越值得。
表头描述器长这样:
[ { "caption": "2024年", "children": [ { "caption": "第一季度", "children": ["JAN", "FEB", "MAR"] }, { "caption": "第二季度", "children": ["APR", "MAY", "JUN"] } ] } ]叶子节点是字符串字段名,父节点是带caption和children的对象。递归代码在WinForms里的实现如下:
void BuildBands(BandedGridView view, JToken node, BandedGridColumn parent = null) { string caption = (string)node["caption"]; BandedGridColumn band = parent == null ? view.Columns.AddBand(caption) : view.Columns.AddBand(caption, parent); var children = node["children"]; if (children == null) return; foreach (var token in children) { if (token is JValue leaf) { // 叶子节点:真实数据列 var col = view.Columns.AddField(leaf.ToString()); col.Caption = leaf.ToString(); col.Visible = true; col.Band = band; } else { // 非叶子节点:继续递归建Band BuildBands(view, token, band); } } }逻辑说明:递归入口判断当前节点是叶子还是分支。叶子节点是一个JSON字符串值,对应数据字段,直接创建数据列并挂到当前Band下;分支节点是一个JSON对象,带caption和children,创建Band后继续递归。这个函数跑完,整棵Band树就建好了。
参数说明:示例里直接用了caption作为Band的Name,实际项目中如果两个Band文字相同会冲突,建议JSON里再带一个name字段,caption单独做显示文本。这个递归逻辑移植到WPF时,把AddBand换成new Band { Header = caption },然后把GridColumn给band.Columns.Add即可;移植到Web端则把AddField换成构造GridViewDataTextColumn,再对应建立HeaderItem树。
我在这套方案上交过不少学费。最初所有表头都是硬编码在界面里,后来需求方说「新增一个季度」,光找某个列到底挂在哪个Band下面、改哪几处索引就花了半天。改成JSON描述器后,加季度只是在配置里加几行,代码一行不用动。复合表头说到底就是一棵列树:先想清楚叶子列,再一层层套Band,比先画好表格再强行合并要稳得多。希望帮到你。
本文还有配套的精品资源,点击获取