简介:本资源是一个面向WPF开发者的技术实践项目,聚焦于使用Telerik UI for WPF控件库实现DataGrid多行(多级)表头的完整示例,适用于希望提升复杂表格界面开发能力的中初级.NET桌面应用开发者。项目解决了原生WPF DataGrid不支持嵌套表头的痛点,通过RadGridView的列组、HeaderRowTemplate及数据绑定机制,直观展示分层数据结构(如报表类场景)的呈现方案。压缩包共53个文件,含13个C#源码(含MainWindow.xaml.cs、Vehicle.cs等核心逻辑)、2个XAML界面定义、8个DLL(Telerik运行时依赖)、8个XML(配置与资源描述)及sln/csproj等工程文件,整体3.34MB,结构完整,开箱即用。已有292人学习下载,读者可直接运行TelerikWpfApp1.exe查看效果,深入理解多级表头配置流程、XAML模板定制方法及后端数据组织逻辑,是WPF+Telerik进阶开发的实用参考范例。
1. WPF 多行表头为什么非得用 Telerik?原生 DataGrid 真的扛不住这个需求
你在做工业监控系统报表模块,或者医疗设备数据看板时,大概率会遇到这种场景:横向字段太多,但列名本身又带层级——比如「血压」底下要分「收缩压」「舒张压」「测量时间」;「血氧」底下再分「SpO₂值」「脉率」「传感器状态」。这时候你拖一个原生 WPF DataGrid 进来,发现它连「合并单元格式表头」都得靠 Template 堆三层 Grid + Border + TextBlock 手动硬凑,更别说动态调整列宽、冻结首列、导出 Excel 时保留多级结构——一跑就崩,二改就乱,三测就跪。这不是玄学,是 WPF 原生控件在复杂表头语义表达上的设计断层。而 Telerik UI for WPF 的 RadGridView,从 2013 年起就把ColumnGroups和GroupHeaderRowStyle当作一级能力来建模,不是插件、不是 hack,是底层渲染管线里就预留了多行表头的布局槽位。它适合谁?适合正在交付政企级桌面端数据密集型应用的团队,尤其是那些不能接受“导出后表头全扁平化”“筛选框错位”“打印预览里第二行表头被截断”的项目。别听网上说“WPF 过时了”,真正在产线跑十年不换的 HMI 系统,90% 还卡在 .NET Framework 4.8 + WPF 上,而它们对表头的苛刻程度,远超你想象。
2. 用 Telerik RadGridView 实现三行嵌套表头:从 XAML 布局到分组绑定
Telerik 的多行表头不是靠“画出来”的,而是靠ColumnGroup的树形嵌套 +GridViewDataColumn的归属声明来驱动的。核心逻辑是:表头行数 = ColumnGroup 树的最大深度,每一层 Group 对应一行表头,子 Group 横向铺开,同级 Group 左右并列。下面这个例子实现「设备信息」和「实时参数」两大主分组,其中「实时参数」下再分「压力类」「温度类」「状态类」三个子分组,最终生成三行表头(第1行:大类;第2行:子类;第3行:具体字段)。
2.1 定义三层 ColumnGroup 结构:用 GroupName 和 ParentGroup 组织层级
<telerik:RadGridView x:Name="radGridView" AutoGenerateColumns="False"> <!-- 第一层:顶级分组 --> <telerik:RadGridView.ColumnGroups> <telerik:GridViewColumnGroup Name="DeviceInfoGroup" Header="设备信息" /> <telerik:GridViewColumnGroup Name="RealTimeParamGroup" Header="实时参数" /> </telerik:RadGridView.ColumnGroups> <!-- 第二层:子分组(挂载到顶级分组下) --> <telerik:RadGridView.ChildColumnGroups> <telerik:GridViewColumnGroup Name="PressureGroup" Header="压力类" ParentGroup="{Binding ElementName=RealTimeParamGroup}" /> <telerik:GridViewColumnGroup Name="TemperatureGroup" Header="温度类" ParentGroup="{Binding ElementName=RealTimeParamGroup}" /> <telerik:GridViewColumnGroup Name="StatusGroup" Header="状态类" ParentGroup="{Binding ElementName=RealTimeParamGroup}" /> </telerik:RadGridView.ChildColumnGroups> <!-- 第三层:具体列(挂载到第二层分组下) --> <telerik:RadGridView.Columns> <!-- 设备信息列:直接归属第一层 --> <telerik:GridViewDataColumn DataMemberBinding="{Binding DeviceId}" Header="设备ID" ColumnGroupName="DeviceInfoGroup" /> <telerik:GridViewDataColumn DataMemberBinding="{Binding DeviceName}" Header="设备名称" ColumnGroupName="DeviceInfoGroup" /> <!-- 压力类列:归属 PressureGroup --> <telerik:GridViewDataColumn DataMemberBinding="{Binding PressureValue}" Header="压力值(kPa)" ColumnGroupName="PressureGroup" /> <telerik:GridViewDataColumn DataMemberBinding="{Binding PressureUnit}" Header="单位" ColumnGroupName="PressureGroup" /> <!-- 温度类列:归属 TemperatureGroup --> <telerik:GridViewDataColumn DataMemberBinding="{Binding TemperatureValue}" Header="温度值(℃)" ColumnGroupName="TemperatureGroup" /> <telerik:GridViewDataColumn DataMemberBinding="{Binding TemperatureAlarm}" Header="超温告警" ColumnGroupName="TemperatureGroup" /> <!-- 状态类列:归属 StatusGroup --> <telerik:GridViewDataColumn DataMemberBinding="{Binding OnlineStatus}" Header="在线状态" ColumnGroupName="StatusGroup" /> <telerik:GridViewDataColumn DataMemberBinding="{Binding LastHeartbeat}" Header="最后心跳" ColumnGroupName="StatusGroup" /> </telerik:RadGridView.Columns> </telerik:RadGridView>逻辑说明:
ColumnGroups是顶层容器,定义第一行表头文字;ChildColumnGroups是第二层,通过ParentGroup绑定到某顶级 Group,形成第二行;GridViewDataColumn的ColumnGroupName属性决定它最终落在哪一列,且必须与某个GridViewColumnGroup的Name严格一致。Telerik 渲染引擎会自动计算每列的跨列宽度(colspan),并按 Group 层级堆叠表头行。
参数说明:
Header:显示在对应表头行的文字,支持字符串或DataTemplate;ColumnGroupName:必须是已声明的GridViewColumnGroup.Name,否则列会丢失分组归属,退化为无表头普通列;ParentGroup:必须用{Binding ElementName=xxx}绑定到已存在的 Group,不能写字符串字面量;AutoGenerateColumns="False":必须关闭自动生成,否则手动定义的 Group 和列会被覆盖。
2.2 绑定数据源并启用分组样式:让表头真正“活”起来
光有结构不够,还得让表头响应数据变化、支持排序、适配主题。关键在两处:一是设置GroupRenderMode="Flat"避免默认的折叠式分组干扰表头布局;二是给GroupHeaderRowStyle注入自定义模板,控制每行表头的高度、字体、背景色。
<telerik:RadGridView.GroupRenderMode> <telerik:GroupRenderMode>Flat</telerik:GroupRenderMode> </telerik:RadGridView.GroupRenderMode> <!-- 自定义表头行样式:控制三行表头的视觉层次 --> <telerik:RadGridView.GroupHeaderRowStyle> <Style TargetType="telerik:GridViewGroupHeaderRow"> <Setter Property="Height" Value="32" /> <Style.Triggers> <!-- 第一行(顶级分组):加粗+浅灰底 --> <DataTrigger Binding="{Binding Level}" Value="0"> <Setter Property="Background" Value="#F5F5F5" /> <Setter Property="FontWeight" Value="Bold" /> </DataTrigger> <!-- 第二行(子分组):中等灰底+正常字重 --> <DataTrigger Binding="{Binding Level}" Value="1"> <Setter Property="Background" Value="#FAFAFA" /> <Setter Property="FontWeight" Value="Normal" /> </DataTrigger> <!-- 第三行(字段列):白色底+细边框 --> <DataTrigger Binding="{Binding Level}" Value="2"> <Setter Property="Background" Value="White" /> <Setter Property="BorderBrush" Value="#E0E0E0" /> <Setter Property="BorderThickness" Value="0,1,0,1" /> </DataTrigger> </Style.Triggers> </Style> </telerik:RadGridView.GroupHeaderRowStyle>逻辑说明:
GroupRenderMode="Flat"强制 RadGridView 把所有 Group 当作纯表头渲染,而不是可折叠的数据分组容器,这是多行表头能稳定显示的前提。GroupHeaderRowStyle中的Level绑定值就是当前表头行在 Group 树中的深度(0=顶层,1=子层,2=字段层),我们据此差异化设置样式。注意BorderThickness="0,1,0,1"表示只显示上、下边框,这样三行表头之间就有清晰分隔线,避免视觉粘连。
参数说明:
Level:只在GroupHeaderRowStyle内部可用,代表当前表头行所属 Group 的嵌套层级;Height:建议设为固定值(如 32),避免内容撑高导致行高不一致;BorderThickness:推荐用0,1,0,1或0,0,0,1,避免左右边框干扰列对齐;Background:不同层级用不同灰阶,形成视觉降噪,用户一眼能区分“大类/子类/字段”。
3. 动态加载列与分组:运行时构建多行表头的三步法
实际项目中,表头结构往往来自配置文件或服务端 Schema,不能写死在 XAML 里。Telerik 支持完全代码化构建 ColumnGroup 树,但必须严格遵循“先建 Group、再挂列、最后赋值”的顺序,否则ColumnGroupName绑定失败,列会消失。
3.1 从 JSON 配置解析出 Group 树结构:用强类型模型承载层级
假设你收到如下 JSON 描述表头:
{ "rootGroups": [ { "name": "DeviceInfoGroup", "header": "设备信息", "children": [] }, { "name": "RealTimeParamGroup", "header": "实时参数", "children": [ { "name": "PressureGroup", "header": "压力类", "fields": [ { "binding": "PressureValue", "header": "压力值(kPa)" }, { "binding": "PressureUnit", "header": "单位" } ] }, { "name": "TemperatureGroup", "header": "温度类", "fields": [ { "binding": "TemperatureValue", "header": "温度值(℃)" }, { "binding": "TemperatureAlarm", "header": "超温告警" } ] } ] } ] }对应 C# 模型:
public class ColumnGroupConfig { public string Name { get; set; } public string Header { get; set; } public List<ColumnGroupConfig> Children { get; set; } = new(); public List<FieldConfig> Fields { get; set; } = new(); } public class FieldConfig { public string BindingPath { get; set; } public string Header { get; set; } }3.2 在后台代码中递归构建 Group 树并注册到 RadGridView
private void BuildColumnGroupsFromConfig(List<ColumnGroupConfig> rootConfigs) { // Step 1: 清空现有分组(重要!避免重复添加) radGridView.ColumnGroups.Clear(); radGridView.ChildColumnGroups.Clear(); // Step 2: 递归创建所有 Group,并缓存引用(用于后续挂载子 Group 和列) var groupMap = new Dictionary<string, GridViewColumnGroup>(); Action<ColumnGroupConfig, GridViewColumnGroup> createGroupTree = null; createGroupTree = (config, parentGroup) => { var group = new GridViewColumnGroup { Name = config.Name, Header = config.Header }; if (parentGroup == null) { // 顶级 Group 添加到 ColumnGroups radGridView.ColumnGroups.Add(group); } else { // 子 Group 添加到 ChildColumnGroups,并设置 ParentGroup radGridView.ChildColumnGroups.Add(group); group.ParentGroup = parentGroup; } groupMap[config.Name] = group; // 递归处理子 Group foreach (var child in config.Children) { createGroupTree(child, group); } }; // 启动递归:parentGroup 为 null 表示根级 foreach (var root in rootConfigs) { createGroupTree(root, null); } // Step 3: 遍历所有字段,创建列并挂载到对应 Group Action<List<ColumnGroupConfig>> addColumns = null; addColumns = (configs) => { foreach (var config in configs) { foreach (var field in config.Fields) { var column = new GridViewDataColumn { DataMemberBinding = new Binding(field.BindingPath), Header = field.Header, ColumnGroupName = config.Name // 注意:这里挂载到当前 Group,不是父 Group! }; radGridView.Columns.Add(column); } addColumns(config.Children); // 递归处理子 Group 的字段 } }; addColumns(rootConfigs); }逻辑说明:整个流程分三步不可颠倒:① 先清空旧分组(否则
ColumnGroups.Add()会叠加,导致表头重复);② 递归构建 Group 树,用groupMap缓存每个 Group 实例,确保ParentGroup赋值准确;③ 最后遍历所有字段,为每个字段创建GridViewDataColumn,并将其ColumnGroupName设为该字段所属 Group 的 Name(不是父 Group!)。这是最常翻车的点——新手总以为“压力值”该挂到RealTimeParamGroup,其实它属于PressureGroup,PressureGroup才是它的直接容器。
参数说明:
DataMemberBinding = new Binding("xxx"):必须用Binding构造,不能用字符串赋值DataMemberBinding属性;ColumnGroupName:必须与GridViewColumnGroup.Name完全一致,大小写敏感;radGridView.Columns.Add(column):必须在 Group 树构建完成后再执行,否则列无法找到归属 Group。
4. 多行表头避坑指南:5 个真实踩过的坑与血泪解法
多行表头看着优雅,实操时全是暗礁。以下是我在三个工业监控项目中反复验证过的典型问题,每一条都附带复现路径和根治方案。
4.1 现象:第二行表头文字全部挤在左上角,不随列分布
原因:ChildColumnGroups中的ParentGroup绑定失效,常见于ParentGroup="{Binding ElementName=xxx}"中的xxx名字拼错,或ColumnGroups未在ChildColumnGroups之前声明(XAML 解析顺序问题)。
解决:打开 Visual Studio 的 Live Visual Tree,展开RadGridView→GroupHeadersPresenter→ 查看GridViewGroupHeaderRow的DataContext.Level是否为 1;若为 -1 或空,则ParentGroup未绑定成功。检查 XAML 中ColumnGroups必须在ChildColumnGroups标签之前定义,且ElementName与Name严格一致(含大小写)。
4.2 现象:导出 Excel 后,多行表头变成单行,且文字堆叠
原因:Telerik 默认导出使用GridViewExportOptions的ExportVisualSettings = false,只导出数据,忽略表头样式和分组结构。
解决:导出前显式启用视觉导出,并指定ExportFormat.ExcelML(兼容性最好):
var options = new GridViewExportOptions { ExportDefaultStyles = true, ExportVisualSettings = true, // 关键!开启视觉导出 Format = ExportFormat.ExcelML }; radGridView.ExportToExcelML("report.xls", options);4.3 现象:启用列冻结(FrozenColumnCount)后,第二、三行表头在冻结区右侧错位
原因:FrozenColumnCount只冻结数据行,不自动同步冻结表头行,导致表头行与冻结列内容脱节。
解决:禁用FrozenColumnCount,改用FrozenColumnCount+FrozenColumnWidth组合,并手动设置GroupHeaderRowStyle的HorizontalContentAlignment="Stretch",确保表头单元格填满冻结区宽度:
<Style TargetType="telerik:GridViewGroupHeaderRow"> <Setter Property="HorizontalContentAlignment" Value="Stretch" /> </Style>4.4 现象:动态添加新列后,新列不显示在对应 Group 下,而是跑到最右边
原因:GridViewDataColumn.ColumnGroupName在列创建后才赋值,但 RadGridView 已完成布局计算。
解决:必须在radGridView.Columns.Add(column)之前,确保column.ColumnGroupName已赋值,且该 Group 已存在于ColumnGroups或ChildColumnGroups中。最佳实践是:先构建完整 Group 树,再批量添加列。
4.5 现象:切换深色主题后,表头文字颜色与背景融合,看不清
原因:GroupHeaderRowStyle中硬编码了Background,未适配主题资源字典。
解决:改用DynamicResource绑定主题色:
<Setter Property="Background" Value="{DynamicResource GridView_GroupHeader_Background}" /> <Setter Property="Foreground" Value="{DynamicResource GridView_GroupHeader_Foreground}" />并在 App.xaml 中确保已合并 Telerik 主题资源:
<ResourceDictionary Source="/Telerik.Windows.Themes.Fluent;component/Themes/System.Windows.xaml" />5. 打印预览与 DPI 适配:让三行表头在 A4 纸上不被截断的硬核技巧
多行表头最大的落地障碍不在屏幕,而在打印——尤其当客户要求“导出 PDF 供审计归档”时。你会发现:明明屏幕上三行表头完美居中,一进打印预览,第二行就被页眉吃掉,第三行被页脚裁掉,或者整张表缩成一团挤在左上角。这不是 Bug,是 WPF 打印子系统对GridViewGroupHeaderRow的DesiredSize计算缺陷。我试过 7 种方案,最终只有两个组合拳有效:强制重绘 + DPI 感知缩放。
5.1 打印前强制触发表头重绘:绕过 DesiredSize 缓存
RadGridView 在首次渲染后会缓存GroupHeaderRow的尺寸,但打印上下文(PrintDialog创建的FixedDocument)不会触发该缓存更新,导致尺寸失真。解决方案是在PrintDialog.ShowDialog()之前,手动调用InvalidateMeasure()和UpdateLayout():
private void PrintButton_Click(object sender, RoutedEventArgs e) { // Step 1: 强制刷新表头布局(关键!) radGridView.InvalidateMeasure(); radGridView.UpdateLayout(); // Step 2: 等待布局完成(避免异步时机问题) Dispatcher.InvokeAsync(() => { var printDialog = new PrintDialog(); if (printDialog.ShowDialog() == true) { // Step 3: 使用 FixedDocument 包装,确保 DPI 一致 var document = new FixedDocument(); var pageContent = new PageContent(); var fixedPage = new FixedPage(); // 将 RadGridView 嵌入 FixedPage(需设置固定尺寸) fixedPage.Width = 816; // A4 宽度 210mm * 96dpi / 25.4 ≈ 816 fixedPage.Height = 1056; // A4 高度 297mm * 96dpi / 25.4 ≈ 1056 fixedPage.Children.Add(radGridView); // 设置缩放以适配页面(核心技巧) var scaleTransform = new ScaleTransform(0.9, 0.9); // 缩放 90%,留白防截断 radGridView.RenderTransform = scaleTransform; pageContent.Child = fixedPage; document.Pages.Add(pageContent); printDialog.PrintDocument(document.DocumentPaginator, "设备实时参数报表"); } }, DispatcherPriority.Background); }逻辑说明:
InvalidateMeasure()清除所有尺寸缓存,UpdateLayout()强制重新计算GroupHeaderRow的DesiredSize,确保打印上下文拿到的是最新、最准的尺寸。ScaleTransform(0.9, 0.9)不是简单缩小,而是把整个RadGridView视为一个整体进行等比缩放,这样三行表头的相对位置和间距保持不变,只是整体变小,从而在 A4 页面内腾出页眉页脚空间。0.9 是经验值,可根据实际表头行高微调(0.85~0.92)。
5.2 DPI 感知的 A4 尺寸计算表:告别凭感觉调参
WPF 打印默认使用 96 DPI,但打印机物理 DPI 可能是 300/600/1200。若不统一,FixedPage.Width/Height会严重失真。下表给出常用纸张在 96 DPI 下的像素值(精确到整数),直接抄:
| 纸张类型 | 宽度 (mm) | 高度 (mm) | 96 DPI 宽度 (px) | 96 DPI 高度 (px) | 推荐缩放系数 |
|---|---|---|---|---|---|
| A4 | 210 | 297 | 816 | 1056 | 0.90 |
| Letter | 215.9 | 279.4 | 832 | 972 | 0.88 |
| A3 | 297 | 420 | 1056 | 1488 | 0.85 |
参数说明:
FixedPage.Width/Height:必须设为上表数值,不能用radGridView.ActualWidth/Height,后者是屏幕尺寸,与打印无关;ScaleTransform:系数建议从 0.90 起调,若表头仍被截,逐步降至 0.85;若留白过多,可升至 0.92;Dispatcher.InvokeAsync(..., DispatcherPriority.Background):确保UpdateLayout()在打印对话框弹出前完成,避免竞态。
5.3 终极验证:三步确认打印效果达标
不要依赖“看起来差不多”,用这三步机械验证:
- 测行高:在打印预览中,用鼠标拖选第二行表头文字,查看状态栏显示的“高度”是否 ≥ 32px(低于此值,文字会挤压变形);
- 查截断:滚动到预览最底部,确认第三行表头完整显示,无任何像素被页脚遮挡;
- 验比例:打印测试页,用直尺量 A4 纸上表格宽度,应为 180±2mm(对应 0.9 缩放后 210×0.9=189mm,留 9mm 页边距)。
我带过的某高校实验室项目,就因没做InvalidateMeasure(),导致审计报告打印时第二行表头被页眉吞掉,返工三天重写导出模块。后来我把这套 DPI+缩放+强制重绘的组合封装成PrintableGridView控件,现在新项目直接拖进去,Print()方法一调,稳过验收。希望帮到你。
本文还有配套的精品资源,点击获取