简介:一份面向WPF开发者的Telerik DataGrid多行表头示例工程,以实际项目存档为基础,演示如何通过RadGridView实现多级表头、嵌套列组与自定义列布局,适合需要构建报表、数据分析或复杂表格界面的初级至中级开发人员学习参考。工程包含完整的Visual Studio解决方案,涵盖XAML界面定义、C#后端逻辑、数据模型及Telerik库引用,覆盖数据绑定、列模板设置、HeaderRowTemplate配置、分组与事件处理等关键环节,可帮助理解Telerik控件的高级用法并直接复用到自己的项目中。压缩包共53个文件,以cs源代码、xaml界面文件、dll引用库、config配置文件为主,另有少量exe、pdb、resources等附属文件,完整工程结构约3.34MB,目录清晰便于按需查看。已有292人学习下载,是快速掌握WPF多行表头设计与Telerik DataGrid配置的实用示例,对提升复杂界面开发能力有实际帮助,也可作为日常开发中的参考存档。
1. 多行表头不是伪需求:先从一次报表评审说起
上个月做一个WPF销售台账工具,列表要展示“产品大类/产品小类”和“1月/2月”四列,业务方却要求在顶部加一个“2025年上半年销量”的横向分组条。原生DataGrid没有跨列合并表头的能力,我用StackPanel硬拼了一版,结果评审会上被问“第一行和第二行都各写各的,这算什么合并表头”。后来我换成某第三方GridView控件库,用两行HeaderRows就把结构搭出来了。这个例子就是一套WPF DataGrid 简单 Demo,适合所有需要做复杂表头的人,尤其是报表、权限台账、多期对比这类数据密集场景。下面给你完整实现思路、参数设置和我踩过的五个坑。
2. 两种实现路线:原生DataGrid硬啃 vs 第三方控件库的HeaderRows
2.1 原生DataGrid的合并表头为什么是黑匣子
WPF自带DataGrid在数据展示上表现不错,但它的列头机制是逐列渲染的,每个Column只有一个Header。想让“产品信息”这个分组标题横跨两列,必须动到Header的可视化树。我试过两种常见做法:一是给DataGrid.ColumnHeaderStyle写触发器,二是在列头区域盖一个自定义Grid,再用ScrollViewer跟随滚动。前者只能做到每列内部显示多行文本,做不到跨列合并;后者看着可行,但一旦有排序、冻结列、列宽拖动,同步逻辑就崩溃。
根本原因是DataGrid的列头容器是一个内部ItemsControl,我们拿不到“第几行第几列”这类单元格坐标,更没有ColSpan属性。就算用VisualTreeHelper找到列头面板,也无法让某个Header跨过右边邻居的边界,因为每个列的Header都是独立模板实例。网上搜到的原生方案,大多是对特定列宽写死的一组固定Pixel值,稍微动一下就翻车。
2.2 第三方控件库的多行表头:API长什么样
商业级GridView控件通常把多行表头做成一级能力,核心思路是:把表头区域当作一个独立表格,第一行是分组标题,第二行是具体列名,允许用ColumnSpan让一个格子横跨多列,再把列宽和正文数据列联动。这也是我最终采用的路线。
等价XAML结构如下(不同控件库的命名空间可能有差异,但逻辑就是这三层):
<controls:GridView> <controls:GridView.HeaderRows> <controls:HeaderRow Height="28"> <controls:HeaderCell Content="产品信息" ColumnSpan="2" /> <controls:HeaderCell Content="2025年销量" ColumnSpan="2" /> </controls:HeaderRow> <controls:HeaderRow Height="28"> <controls:HeaderCell Content="大类" /> <controls:HeaderCell Content="小类" /> <controls:HeaderCell Content="1月" /> <controls:HeaderCell Content="2月" /> </controls:HeaderRow> </controls:GridView.HeaderRows> </controls:GridView>第一行定义两个跨两列的单元格,形成“产品信息”和“2025年销量”两个分组;第二行定义四列的具体名称。HeaderRow的Height控制表头行高度,ColumnSpan的值决定这个单元格吞并右边几列。要理解这里的联动机制:HeaderRows内部会生成一个虚拟Grid,每个HeaderCell都是Grid里的单元格,Grid的列数由所有数据列的ColumnSpan总和决定。当数据列增减时,HeaderRows只负责视觉合并,真正的数据列绑定在GridView.Columns里,两者通过列索引对齐。
参数方面有两个容易忽略的点:第一,HeaderRow的Height最好与数据行的MinHeight匹配,不然表头比正文高太多会显得“头重脚轻”;第二,如果某个分组标题要横跨三列,ColumnSpan写“3”即可,但前提是下方确实有3个子列,否则布局会错乱。我一般会把第二行HeaderCell的数量和GridView.Columns的数量做成一个常量,避免手写漂移。
2.3 选型对照表:什么时候用第三方,什么时候自己画
| 场景 | 原生DataGrid | 第三方GridView控件 |
|---|---|---|
| 单层表头 | 完全够用 | 杀鸡用牛刀 |
| 两层表头且需要跨列合并 | 几乎要重写渲染逻辑 | 原生支持 |
| 需要统一表头行高和字体 | 要写一堆Style | 内置属性 |
| 需要排序、筛选、冻结列 | 部分支持,容易出小问题 | 完整支持 |
| 需要导出Excel并保留表头 | 难,需要另做映射 | 大多支持导出 |
结论不是无脑选第三方。如果项目里只有一处需要多行表头,且版本控制严格,可以评估原生+变通方案的成本;如果像我们一样有七八个报表页面,那就该引入控件的HeaderRows能力。另外商业控件的授权策略需要提前确认,别等开发完再补License,那是比功能更痛的坑。
3. 搭一个能跑的Demo:数据模型 + XAML + 后台动态列
3.1 定义销售数据模型和DataTable
先建一个销售记录类,包含产品大类、产品小类、一月销量、二月销量四个字段。这里我用一个普通类加ObservableCollection,方便后续扩展。
using System.ComponentModel; public class SaleRecord : INotifyPropertyChanged { public string Category { get; set; } public string Product { get; set; } public int Jan { get; set; } public int Feb { get; set; } // 合计列,供后续扩展使用 public int Total => Jan + Feb; public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged(string name) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); } }这个模型里,Category和Product是字符串展示列,Jan和Feb是数值列。Total是只读合计,用来演示扩展列。注意使用INotifyPropertyChanged是因为在WPF数据绑定里,如果后续要动态更新单元格,没有这个接口,界面不会刷新。
3.2 在XAML里配置两行表头
把第三章开头的HeaderRows结构放进Window/Grid里面,同时配置Columns。完整的XAML如下:
<controls:GridView x:Name="salesGrid" AutoGenerateColumns="False" ItemsSource="{Binding Records}"> <controls:GridView.HeaderRows> <!-- 第一行:跨列分组 --> <controls:HeaderRow Height="30"> <controls:HeaderCell Content="产品信息" ColumnSpan="2" FontWeight="Bold" /> <controls:HeaderCell Content="2025年上半年销量" ColumnSpan="2" FontWeight="Bold" /> </controls:HeaderRow> <!-- 第二行:具体列名 --> <controls:HeaderRow Height="30"> <controls:HeaderCell Content="产品大类" /> <controls:HeaderCell Content="产品小类" /> <controls:HeaderCell Content="1月销量" /> <controls:HeaderCell Content="2月销量" /> </controls:HeaderRow> </controls:GridView.HeaderRows> <controls:GridView.Columns> <controls:GridViewDataColumn DataMemberBinding="{Binding Category}" Header="产品大类" /> <controls:GridViewDataColumn DataMemberBinding="{Binding Product}" Header="产品小类" /> <controls:GridViewDataColumn DataMemberBinding="{Binding Jan}" Header="1月销量" /> <controls:GridViewDataColumn DataMemberBinding="{Binding Feb}" Header="2月销量" /> </controls:GridView.Columns> </controls:GridView>这里有几个值得说明的点:HeaderCell里设置FontWeight="Bold",让分组标题和子列在视觉上分层;GridView.Columns的Header属性会显示在第二行对应位置,同时HeaderRows的第二行也定义了同样的文本,两边要一致。有的控件库支持第二行HeaderCell只作为空占位,直接用Columns的Header,但我在实际项目中更喜欢两边都显式写,因为这样导出Excel时表头文本更可控。
3.3 后台代码绑定和调整行高
在主窗口的后台代码里设置数据源。简单Demo直接造12条数据:
public partial class MainWindow : Window { public ObservableCollection<SaleRecord> Records { get; set; } public MainWindow() { InitializeComponent(); DataContext = this; Records = new ObservableCollection<SaleRecord>(); for (int i = 1; i <= 12; i++) { Records.Add(new SaleRecord { Category = i % 2 == 0 ? "A类" : "B类", Product = "产品" + i.ToString("00"), Jan = i * 10 + 3, Feb = i * 10 + 7 }); } } }绑定逻辑说明:在构造函数里先给DataContext赋值为当前窗口,这样XAML里{Binding Records}可以直接拿到集合。循环里用i % 2生成两类数据,方便测试排序后表头是否稳定。Jan和Feb用简单算式生成递增数值,避免空数据干扰。
如果你打开界面发现HeaderRow高度不够、中文被切成两半,可以在InitializeComponent之后加一行:
salesGrid.HeaderRows[0].Height = 32; salesGrid.HeaderRows[1].Height = 32;这一步是显式覆盖主题默认值。商业控件的默认主题通常为欧文字体设计,中文环境下需要手动留出更多垂直空间。注意HeaderRows索引从0开始,第一行是分组行,第二行是列名行。
4. 避坑:多行表头的五个常见翻车点
4.1 表头行高失效,中文被切成两半
现象:设置了HeaderRow的Height为30,但运行后表头里“2025年销量”几个字下半部分被裁掉,换行文字只显示一半。
原因:控件的主题里有一套默认ColumnHeaderStyle,它的MinHeight或Padding优先级高于HeaderRow上的直接Height设置。很多商业控件默认Height是24,加上Padding后内容区域只剩十几个像素,中文一多就被裁剪。
解决:用HeaderRowStyle全局替换,而不是单独设置Height。在GridView资源里加:
<controls:GridView.Resources> <Style TargetType="controls:HeaderRow"> <Setter Property="Height" Value="34"/> <Setter Property="Padding" Value="4,2"/> </Style> </controls:GridView.Resources>这样所有表头行都统一高度,避免Excel导入时出现不同行的表头高度忽高忽低。
4.2 点击第一行分组标题无法排序
现象:用户点击“2025年上半年销量”这个分组单元格,没有任何排序反应;点击第二行的“1月销量”却能正常排序。
原因:第一行HeaderCell是合并单元格,它的HitTest事件默认不关联到数据列的排序命令。商业控件会把排序触发器挂在第二行的具体列上,第一行只是视觉分组,不参与交互。
解决:给第一行HeaderCell设置IsHitTestVisible="False",让点击穿透到下方第二行。或者在后台上绑定一个排序命令,手动触发salesGrid.SortByColumn。我习惯用前者,因为分组行本身就只是标题,不承担排序职责。
4.3 拖动列宽后,分组标题与下方列错位
现象:用户拖动“产品小类”列分隔线,拖动后第二行第2列变宽了,但第一行“产品信息”这个分组标题的宽度还是旧值,视觉上差出几个像素。
原因:多行表头内部实际上是两组虚拟Grid,第一行Grid的列宽和正文GridView.Columns的列宽不是同一套依赖属性。ColumnSpan只是声明合并范围,不会自动监听列宽变化。
解决:启用控件的“HeaderCells同步列宽”开关。绝大多数商业控件都有一个类似SynchronizeHeaderWidth的属性,默认可能关闭。在XAML中设置:
<controls:GridView SynchronizeHeaderCellsWidth="True" />如果控件没有这个属性,可以订阅ColumnWidthChanged事件,手动更新对应HeaderCell的Width。注意这里要区分“确定性宽度”和“星号宽度”,星号列在窗口Resize时会再次触发该事件,必须用ActualWidth而不是Width。
4.4 纵向滚动时表头和正文不同步
现象:数据发到几千行后,向下滚动鼠标滚轮,正文滚动正常,但HeaderRows还停留在原位,产生错层。
原因:为了优化性能,部分GridView控件会把表头放在独立的ScrollViewer外部,正常情况下Header会自然固定在顶部。如果你的外层窗口模板里手动给GridView包了ScrollViewer,或者把GridView放在某个设置了ScrollViewer.HorizontalScrollBarVisibility的容器里,就会打破内部同步机制。
解决:不要给GridView外层套任何ScrollViewer。如果要限制高度,直接在Grid布局里设定RowDefinition的MaxHeight,让GridView自己处理滚动。如果某个布局必须用ScrollViewer,就改用控件的内置滚动属性ScrollMode="Scroll"。
4.5 ItemsSource重新绑定后,表头还留在原地,列结构全乱
现象:菜单切换查询条件时,重新设置salesGrid.ItemsSource = newData,结果数据变了,但旧的分组标题还挂在表格上方,并且列宽对应不上。
原因:GridView的HeaderRows是独立于Columns的可视元素集合,在AutoGenerateColumns=False的情况下,ItemsSource更新不会自动清空HeaderRows。如果之前手动添加过HeaderCell,重新设置数据源时要一并重置。
解决:在更新ItemsSource前后,对HeaderRows做一次重建。我一般这样写:
salesGrid.HeaderRows.Clear(); BuildHeaderRows(); // 根据新的列配置重新构造 salesGrid.ItemsSource = newData;养成习惯:每次动态更新列结构,首选先把HeaderRows和Columns都清空,再按顺序填充。不要试图复用旧表头,省那几行代码可能会换来一次诡异的错位。
5. 进阶:封装成一个可复用的HeaderDescriptor
5.1 用描述模型驱动表头生成
多行表头用多了就会遇到一个问题:每个页面的分组行不同,有的两行,有的三行,有的分组跨度不一样。每次都改XAML太容易漏。我选择做一个描述模型,把表头结构抽象成List<HeaderRowDesc>,在后台循环生成HeaderCell。
public class HeaderCellDesc { public string Content { get; set; } public int ColumnSpan { get; set; } public int RowSpan { get; set; } // 如果需要纵向跨行也可以扩展 } public class HeaderRowDesc { public double Height { get; set; } public List<HeaderCellDesc> Cells { get; set; } }这个模型的好处在于:它不依赖具体控件库类型,任何WPF控件只要支持单元格合并,都能用同一个描述去生成。ColumnSpan在这里显式声明,生成逻辑只需遍历并实例化HeaderCell。
5.2 一个简单的封装类
下面是一个通用的构建方法。需要注意命名空间的差异,我用controls:HeaderCell作为占位写法,实际换成你所用控件库的对应类型。
public static void BuildHeaderRows(GridView grid, List<HeaderRowDesc> rows) { grid.HeaderRows.Clear(); foreach (var row in rows) { var headerRow = new HeaderRow(); headerRow.Height = row.Height; foreach (var cell in row.Cells) { var headerCell = new HeaderCell(); headerCell.Content = cell.Content; headerCell.SetColumnSpan(cell.ColumnSpan); headerRow.Cells.Add(headerCell); } grid.HeaderRows.Add(headerRow); } }这段代码的要点有三个:HeaderRow是独立实例,不要复用,否则生成重复内容时会出现引用错乱;HeaderCell的ColumnSpan用SetColumnSpan而不是直接设置Width,因为ColumnSpan是附加属性(或独立属性),会参与布局合并;整个方法设为static,放在公用类里,供所有报表页面调用。
5.3 在MVVM里如何更新表头
MVVM环境下不建议ViewModel直接引用GridView对象。正确做法是定义一个HeaderDescriptorViewModel,包含HeaderRows集合和Columns集合,然后通过绑定或行为来更新界面。
public class ReportViewModel { public ObservableCollection<HeaderRowDesc> HeaderRows { get; set; } public ObservableCollection<ColumnDesc> Columns { get; set; } public ObservableCollection<SaleRecord> Records { get; set; } public void InitializeHeader() { HeaderRows = new ObservableCollection<HeaderRowDesc>(); HeaderRows.Add(new HeaderRowDesc { Height = 32, Cells = new List<HeaderCellDesc> { new HeaderCellDesc { Content = "产品信息", ColumnSpan = 2 }, new HeaderCellDesc { Content = "销量", ColumnSpan = 2 } } }); // 第二行... } }然后在XAML里用ItemsSource="{Binding HeaderRows}"绑定到一个HeaderRows控件。但有些商业控件的HeaderRows不是ItemsControl,不能直接绑定。遇到这种情况,可以在CodeBehind里监听ViewModel的PropertyChanged事件,调用BuildHeaderRows方法。虽然不算纯净的MVVM,但至少把表头结构和业务逻辑分离了。数据量大时,建议只在初始化或列结构变更时调用生成方法,不要每次刷新数据都重建表头。
6. 性能验证与最后的一个技巧
验证多行表头渲染是否正常,我一般用两个土办法:一是用Stopwatch统计从设置ItemsSource到界面可交互的耗时;二是截图放大200%看像素边界。
var sw = Stopwatch.StartNew(); salesGrid.ItemsSource = data; sw.Stop(); Console.WriteLine($"加载耗时: {sw.ElapsedMilliseconds}ms");如果加载超过800ms,多半不是表头的问题,而是数据源做了过多UI线程操作。表头本身是轻量级控件,几百列也不会成为瓶颈。重点检查分组标题的ColumnSpan是否出现半像素偏移,放大截图后一眼就能看出。
另外我养成了一个习惯:每次构建多行表头前,先把最终需要的列顺序和分组层级写在纸上,再写代码。因为HeaderRows和Columns是两套集合,靠索引对齐,一旦中间插入一列,分组标题的跨度就会集体向后错位。这种错位编译期根本不会报错,运行时看起来只是“某个标题偏了”。从那以后我每次都强制走一遍“列清单 → HeaderRows标注Span → Columns绑定”这个流程,再也没遇到过表头错列的诡异问题。希望这篇文章能帮你在做WPF多行表头时少走几步弯路。
本文还有配套的精品资源,点击获取