WPF DataGrid多行表头实战:两行模板、外挂层与列宽同步
简介这是一份面向 WPF 开发者、尤其是需要处理报表或数据分析界面的中高级读者的实战示例包聚焦 Telerik RadGridView 的多行多级表头实现。项目以车辆数据为演示对象展示如何通过列组嵌套、模板与数据绑定把层次化表头正确呈现并附带后端事件处理逻辑可直接作为自建项目的代码存档与参考起点。压缩包为 rar 格式共 53 个文件约 3.34MB包含 2 个 xaml 界面文件、13 个 cs 源码、8 个 dll 依赖库以及 csproj、sln 工程配置与 App.config 等运行配置文件另有 baml、resources 等编译产物工程结构完整解压即可用 Visual Studio 打开对照研究。目前已有 292 人学习下载说明其在同类控件用法上有一定参考价值。借助这份示例读者可以快速弄清多级表头的列布局组织方式、数据视图与表头层级的对应关系以及排序、分组等交互的接入位置从而少走摸索弯路把 Telerik 控件的高级特性迁移到自己的 WPF 项目中。1. 财务报表里的表头逼出了这份 WPF 多行表头示例做 WPF 报表的同行迟早会撞上一个需求列头上要长第二层。一张应收台账第一行是「本期应收」「本期实收」「期末欠款」每个下面还得拆「金额」「笔数」挤成单行就变成「本期应收-金额」「本期应收-笔数」用户看一眼就皱眉。这份资源就是一个把 WPF DataGrid 多行表头跑通的示例工程两层表头跟着列宽一起伸缩拖拽、排序、横向滚动都不散架代码量不大但把分组模型、列头模板、宽度同步这三件事各放在一个文件里改起来有迹可循。它适合已经写过 DataGrid、想把表头这层做扎实的 WPF 开发也适合接手老报表模块、被单行表头顶得没脾气的同学如果你只想找个能直接改的 WPF DataGrid 简单 Demo 起步这份工程的目录结构也够干净。2. 项目结构与技术选型三层各管一件事2.1 目录骨架视图、控件、模型分开摆先看骨架。这份示例没有把东西全堆在 MainWindow 里而是按「视图 → 控件 → 模型 → 样式」切成四块这个切法在多行表头这种需要自绘的场合很关键因为表头逻辑一旦和业务视图混在一起改一次列宽就要动三个文件。MultiRowHeaderDemo/ ├─ MultiRowHeaderDemo.sln ├─ src/ │ ├─ MultiRowHeaderDemo/ │ │ ├─ App.xaml │ │ ├─ MainWindow.xaml # 只负责挂载 ReportView │ │ ├─ Views/ │ │ │ └─ ReportView.xaml(.cs) # DataGrid 本体 列定义入口 │ │ ├─ Controls/ │ │ │ ├─ MergedHeaderGrid.cs # 外挂表头层负责跨列合并 │ │ │ └─ HeaderGroupPanel.cs # 组内列的排布面板 │ │ ├─ Models/ │ │ │ ├─ ColumnHeaderInfo.cs # 表头分组模型 │ │ │ └─ ReportRow.cs # 行数据 │ │ ├─ ViewModels/ │ │ │ └─ ReportViewModel.cs # 列配置 数据源 │ │ └─ Themes/ │ │ └─ DataGridStyles.xaml # 列头样式与模板 └─ tests/ └─ HeaderLayoutTests/ # 列宽累加、分组边界的单元测试Views里只放界面装配Controls里放与业务无关的布局逻辑Models里放纯数据Themes里放可被整体替换的样式。要换成别的业务理论上只动Views和ViewModelsControls一行不改。这种分法在 WPF 里不算新鲜但多行表头特别吃这个结构因为表头宽度计算是个纯数学问题把它关进Controls才好写测试。2.2 技术选型三条路各有各的代价WPF 原生 DataGrid 的列头是单层的DataGridColumnHeader只承载一个Content。要长出第二层落地上无非三条路方案实现方式优点代价两行模板改写DataGridColumnHeader的ControlTemplateHeader 里塞两行改动小、不脱框架、排序拖拽原样可用组标题只能显示在组内首列跨列居中做不到外挂表头层隐藏原生列头外面叠一层自绘表头同步列宽与滚动真正跨列合并、居中、任意层数宽度与滚动同步要自己维护交互事件要转发商业控件套件用套件自带的列分组能力声明ColumnGroup即可开箱即用导出、冻结、拖拽联动都做过授权成本样式定制受限于套件这份示例以前两条为主商业套件的列分组作为对照思路来讲——很多团队手上就是有套件授权那直接把列配置换成套件的分组写法即可逻辑等价。但如果你没授权或者老项目不许引第三方前两条路才是能落地的。我一般建议先上「两行模板」把业务跑通等到产品经理提出「组名必须居中、还要显示组的汇总数」时再换成外挂层。一步到位上外挂层是能行但前期为了一个只在 Excel 里才看出来的居中效果多写两百行同步代码不划算。2.3 跑起来环境、还原、第一次 F5环境上没什么玄学WPF 只跑 Windows。示例目标框架是net8.0-windows用 VS 2022 打开 sln 直接 F5 即可命令行党走下面这套# 确认 SDK 版本WPF 需要 Windows 桌面工作负载 dotnet --list-sdks # 还原依赖示例只引了 WPF 自带程序集理论上秒过 dotnet restore MultiRowHeaderDemo.sln # 编译并运行主程序 dotnet build src/MultiRowHeaderDemo/MultiRowHeaderDemo.csproj -c Debug # 测试工程里那几个列宽累加的用例 dotnet test tests/HeaderLayoutTests/HeaderLayoutTests.csproj三个参数值得留意-c Debug保留 PDB调试表头布局时能直接断到MeasureOverridedotnet test跑的是纯布局逻辑不依赖 UI 线程所以能在 CI 上过如果你的机器上装的是 .NET Framework 老版本把 csproj 里的TargetFramework改成net48也行代码里没用到 .NET 6 之后的 API。跑起来后左边是一张报表表头两行横向拖到最右边时表头不会掉队拖动列宽上面那行的组标题会跟着重新居中对齐——如果这一步就开始错位说明同步逻辑没接上第 3 章会讲到。提示第一次 F5 如果 XAML 设计器报「找不到样式键」多半是Themes/DataGridStyles.xaml没在App.xaml的MergedDictionaries里注册加一行ResourceDictionary SourceThemes/DataGridStyles.xaml/即可。3. 多行表头核心实现从 DataGridColumnHeader 模板说起3.1 默认列头模板为什么撑不开两行WPF 自带的DataGridColumnHeader模板结构极简去掉滚动、筛选这些可选部件核心就是一个ContentPresenter!-- WPF 默认列头模板的骨架精简后大致是这样 -- ControlTemplate TargetTypeDataGridColumnHeader Grid Border Background{TemplateBinding Background} BorderBrush{TemplateBinding BorderBrush} BorderThickness{TemplateBinding BorderThickness} !-- 只有一个 ContentPresenterHeader 给什么就显示什么高度由内容撑 -- ContentPresenter HorizontalAlignmentCenter VerticalAlignmentCenter Content{TemplateBinding Content} / /Border !-- 左右两个拖拽手柄负责列宽调整 -- Thumb x:NamePART_LeftHeaderGripper HorizontalAlignmentLeft / Thumb x:NamePART_RightHeaderGripper HorizontalAlignmentRight / /Grid /ControlTemplate问题出在两处。一是ContentPresenter垂直居中一旦你往Header里塞两行内容整个表头行高会被内容撑高但DataGrid.ColumnHeaderHeight默认锁死在 28 左右结果就是第二行被裁掉一半——这是新手最常翻的车。二是列头之间是彼此独立的视觉元每列各画各的边框你想让「金额」和「笔数」共用一个大边框表达「它们属于同一组」原生模板没有列跨度的概念。所以改模板只是第一步真正的工作在于让每列的 Header 知道自己「属不属于某个组、是不是组里第一列、这一组总宽是多少」。这三件事必须由外部模型提供模板本身算不出来。3.2 把 Header 换成对象分组模型先行既然模板要读分组信息Header就别再传字符串了传一个对象。这是整份示例里改动最小、收益最大的一步// Models/ColumnHeaderInfo.cs public sealed class ColumnHeaderInfo { /// summary组标题如「本期应收」为 null 表示这一列不属于任何组/summary public string? GroupTitle { get; init; } /// summary列标题如「金额」/summary public string ColumnTitle { get; init; } string.Empty; /// summary是否为组内第一列模板用它决定要不要画出组标题文字/summary public bool IsGroupStart { get; init; } /// summary是否为组内最后一列模板用它决定右边框的粗细/summary public bool IsGroupEnd { get; init; } /// summary组内列数外挂表头层算合并宽度时用得到/summary public int GroupColumnCount { get; init; } 1; /// summary组的稳定标识用于排序、导出时归并/summary public string? GroupKey { get; init; } }参数说明一下IsGroupStart/IsGroupEnd是给模板画边框用的——组内中间列不画竖线视觉上就连成一片GroupColumnCount现在没用上但换成外挂表头层时它就是合并格子的列跨度提前放进模型省得后面改字段GroupKey是为了导出 Excel 时能按组重新 merge不要用标题文字当 key标题会改。构造这些对象的地方在 ViewModel 里通常是一次遍历列配置// ViewModels/ReportViewModel.cs 片段 var headers new ListColumnHeaderInfo(); foreach (var group in _columnGroups) // 按组顺序遍历保证列顺序稳定 { for (var i 0; i group.Columns.Count; i) { headers.Add(new ColumnHeaderInfo { GroupKey group.Key, GroupTitle group.Title, ColumnTitle group.Columns[i].Title, IsGroupStart i 0, IsGroupEnd i group.Columns.Count - 1, GroupColumnCount group.Columns.Count }); } }这段代码的关键在于列的顺序由分组结构决定而不是列定义里各自声明组名。前者天然保证同组的列挨在一起后者一旦有人把列顺序调乱合并就会断开——这是个很容易踩的坑第 5 章会细说。3.3 两行表头模板改写与合并线的处理有了模型模板就好写了第一行放组标题第二行放列标题。!-- Themes/DataGridStyles.xaml两行表头样式 -- Style x:KeyTwoRowHeaderStyle TargetTypeDataGridColumnHeader Setter PropertyHeight ValueAuto / Setter PropertyMinHeight Value52 / Setter PropertyTemplate Setter.Value ControlTemplate TargetTypeDataGridColumnHeader Grid SnapsToDevicePixelsTrue UseLayoutRoundingTrue Grid.RowDefinitions RowDefinition Height26 / !-- 组标题行固定高度便于对齐 -- RowDefinition HeightAuto / !-- 列标题行跟着字体缩放 -- /Grid.RowDefinitions !-- 第一行组标题只有组内首列显示文字 -- Border Grid.Row0 BorderBrush#D0D5DD BorderThickness1,1,0,1 TextBlock Text{Binding GroupTitle} HorizontalAlignmentCenter VerticalAlignmentCenter FontWeightSemiBold Visibility{Binding IsGroupStart, Converter{StaticResource BoolToVisibility}} / /Border !-- 第二行列标题组内末列补右边框中间列不画 -- Border Grid.Row1 BorderBrush#D0D5DD BorderThickness0,0,0,1 ContentPresenter HorizontalAlignmentCenter VerticalAlignmentCenter Content{TemplateBinding Content} ContentTemplate{TemplateBinding ContentTemplate} / /Border /Grid /ControlTemplate /Setter.Value /Setter /StyleBoolToVisibility就是 WPF 自带的BooleanToVisibilityConverter在资源字典里注册一个实例即可。注意HeightAuto和MinHeight必须同时给。只给 Auto空数据时表头会塌成一条线只给固定高度高 DPI 下字体放大就会裁字。这段模板的取舍要说清楚组标题只画在首列宽度就是那一列宽所以「本期应收」四个字如果比首列宽会被裁。解决办法有两个一是在首列上把TextBlock设成HorizontalAlignmentLeft加Margin让它往右溢出一部分二是干脆把它做成ToolTip兜底。我在实际项目里一般选前者加TextTrimmingCharacterEllipsis不怎么好看但足够稳。真要居中就得走 3.4 的外挂层。模板里那两个Border的BorderThickness是有讲究的第一行左中右都画线第二行只画下边线靠这个组合让组内列在视觉上「上边连成一条、中间不断开」。这部分没有代码可测只能靠眼睛所以改样式时一定要在 125% 缩放下也看一眼。3.4 真合并外挂表头层的宽度与滚动同步当需求变成「组标题必须跨列居中」时两行模板就没戏了——每列各画各的谁也跨不到隔壁去。这时候走外挂层把 DataGrid 的ColumnHeaderHeight设成 0或者整个HeadersVisibility设为Column再用负 margin 藏掉上面叠一层自己画的表头。外挂层本质是一个Grid列宽等于 DataGrid 对应列的ActualWidth所以核心只有两个动作宽度同步和滚动同步。// Controls/MergedHeaderGrid.cs 片段 private void HookGrid(DataGrid grid) { _grid grid; // 用户拖拽列宽时触发这是最可靠的宽度变化信号 grid.ColumnWidthChanged (_, __) ScheduleSync(); // 列重排、列的增删也会影响布局 grid.ColumnReordered (_, __) ScheduleSync(); grid.Loaded (_, __) Sync(); // 横向滚动要在第一个可滚动祖先上监听 if (grid.FindVisualChildScrollViewer() is { } sv) sv.ScrollChanged (_, e) { if (e.HorizontalChange ! 0) SyncScrollOffset(sv.HorizontalOffset); }; } /// summary把每列的实际宽度抄到表头对应格子上/summary private void Sync() { if (_grid is null) return; for (var i 0; i _grid.Columns.Count i HeaderCells.Count; i) HeaderCells[i].Width new GridLength(_grid.Columns[i].ActualWidth); // 合并格子的宽度 组内各列宽度之和重新算一遍 RebuildMergedSpans(); } /// summary用 Dispatcher 把连续触发的同步合并成一帧一次避免拖拽时卡顿/summary private void ScheduleSync() { if (_syncPending) return; _syncPending true; Dispatcher.BeginInvoke(DispatcherPriority.Render, new Action(() { _syncPending false; Sync(); })); }ScheduleSync里的节流是血泪经验。拖列宽时ColumnWidthChanged会连发几十次每次都同步全部列并重算合并跨度列表一长就是明显的拖影。用DispatcherPriority.Render把多次调用压成一次手感立刻回来。RebuildMergedSpans()做的是另一件事遍历HeaderCells把相邻且GroupKey相同的格子合成一个设置Grid.ColumnSpan再把文字放在跨列后的居中位置。这样一来「跨列居中」就是免费的——TextBlock的HorizontalAlignmentCenter在合并后的格子里自然居中。代价也很明确外挂层的表头不再响应 DataGrid 的排序点击、筛选按钮、拖拽重排。要补回来的话得在表头格子上挂MouseLeftButtonUp手动调DataGridColumn.SortDirection和Items.SortDescriptions。这部分示例里只做了排序筛选按钮留了扩展点没实现——如果你们项目要筛选别硬塞直接考虑商业套件套件的列分组和筛选是联动的自己补要写不少。4. 列生成与数据绑定从静态 XAML 到运行时列4.1 静态声明的写法与边界示例里最先跑通的是静态版本列手写在 XAML 里方便对着调试!-- Views/ReportView.xaml 片段 -- DataGrid ItemsSource{Binding Rows} AutoGenerateColumnsFalse ColumnHeaderStyle{StaticResource TwoRowHeaderStyle} ColumnHeaderHeightAuto CanUserResizeColumnsTrue HeadersVisibilityColumn DataGrid.Columns DataGridTextColumn Width90 Binding{Binding Period} Header{Binding PeriodHeader} / DataGridTextColumn Width80 Binding{Binding ReceivableAmount} Header{Binding ReceivableAmountHeader} / DataGridTextColumn Width80 Binding{Binding ReceivableCount} Header{Binding ReceivableAmountHeader} / /DataGrid.Columns /DataGrid这里有个容易忽略的点Header绑的是 ViewModel 上预先构造好的ColumnHeaderInfo对象而不是字符串。两个同组的列故意绑同一个对象实例IsGroupStart就得在这两个实例里分别设值——所以更常见的做法是每列一个独立实例只是GroupKey相同别偷懒复用。静态声明的边界很清楚列数固定、结构不变的报表够用一超过十列就难维护而且用户要求「按角色显示不同列」时完全没法做。这时候就得上动态生成。4.2 配置驱动的动态列生成动态生成的核心是把「列定义」变成数据然后一次性灌进DataGrid.Columns。示例里的配置长这样// ViewModels/ReportViewModel.cs 片段 public sealed record ColumnDef(string BindingPath, string Title, double Width 90); public sealed record ColumnGroupDef(string Key, string Title, IReadOnlyListColumnDef Columns); /// summary按分组配置重建所有列并顺带生成表头模型/summary private void BuildColumns() { Grid.Columns.Clear(); var headers new ListColumnHeaderInfo(); foreach (var group in _columnGroups) { for (var i 0; i group.Columns.Count; i) { var def group.Columns[i]; // 用 TemplateColumn 兜一层方便后续给单元格加样式或按钮 var column new DataGridTemplateColumn { Width new DataGridLength(def.Width), CellTemplate BuildCellTemplate(def.BindingPath), Header new ColumnHeaderInfo { GroupKey group.Key, GroupTitle group.Title, ColumnTitle def.Title, IsGroupStart i 0, IsGroupEnd i group.Columns.Count - 1, GroupColumnCount group.Columns.Count } }; Grid.Columns.Add(column); headers.Add((ColumnHeaderInfo)column.Header); } } Headers headers; // 外挂表头层订阅这个集合重建格子 }BuildCellTemplate用FrameworkElementFactory或 .NET 6 之后推荐的做法是XamlReader.Parse拼字符串生成一个TextBlock绑定到BindingPath。这一层封装的意义在于后面要给某列加「超链接样式」「红字告警」时只改这一个工厂方法不用碰业务代码。Width给的是初始值用户拖过之后ActualWidth会变但Width属性本身也会被 DataGrid 更新所以下次重建列时想保留用户手感得先把当前ActualWidth存回配置里。这个细节不做切个用户权限回来列宽就全复位了用户会来找你。4.3 排序、冻结列、拖拽在两层表头下的表现用两行模板方案时这三件事基本都是免费的——因为你没改 DataGrid 的交互链路只是换了列头长什么样。排序点第二行的列标题能正常触发Sorting事件但点击第一行的组标题也会触发而且因为是同一列排序看起来「莫名其妙地生效了」。要拦就在ColumnHeaderStyle上挂PreviewMouseLeftButtonDown判断事件源在不在第一行区域内是就e.Handled true。冻结列FrozenColumnCount照常工作但冻结边界那条竖线会穿过表头而两行模板画的边框是横线为主视觉上会有点乱。我一般把冻结边界单独用一个Border盖上去比调模板里每个BorderThickness省事。拖拽重排CanUserReorderColumns为 true 时列可以拖但拖完GroupKey的相邻关系就断了合并会错乱。示例里直接把重排关了因为分组报表重排没有业务意义。如果确实要开得在ColumnReordered里重算一遍IsGroupStart/IsGroupEnd并通知刷新——这是一条隐藏成本选型时就该算进去。外挂表头层方案下这三件事都要手动补前面提过。所以我的判断标准是能不能接受「组名不跨列居中」能就用两行模板省下至少两天不能就准备好为同步逻辑写一周。5. 避坑与排查多行表头最容易翻的五个地方5.1 布局与渲染类问题现象一表头第二行被裁掉一半只露出上半截字。原因DataGrid.ColumnHeaderHeight默认是个固定数值28 左右它优先级高于模板里的Auto内容再高也只按这个高度画超出部分被裁。 解决把ColumnHeaderHeight显式设成Auto同时给样式里的MinHeight兜底防止空表时塌陷。这一步不做后面调多少行高参数都是白费。现象二高 DPI 下边框发虚1 像素的线看起来像 2 像素的灰带。原因WPF 的布局单位和物理像素不是 1:1125% 或 150% 缩放下 1 单位的边框会落在半个像素上被抗锯齿糊开。 解决在表头根元素上加SnapsToDevicePixelsTrue和UseLayoutRoundingTrue边框用整数单位。这两句在示例的模板里已经带上但很多从网上抄的模板没有接进来之后一定要补。现象三横向滚动到右边表头上面的组标题还停在原地。原因外挂表头层没有跟随ScrollViewer的HorizontalOffset它自己不在滚动容器里。 解决监听ScrollChanged把HorizontalOffset转成表头层的TranslateTransform.X取负值。注意要监听 DataGrid 内部那个ScrollViewer不是外层随便哪个用FindVisualChildScrollViewer()找第一个。5.2 交互与数据类问题现象四拖动列宽后组标题和下面的列对不齐越拖越偏。原因宽度同步只监听了SizeChanged而拖列宽时 DataGrid 并不会改自己的尺寸SizeChanged根本不触发另外合并跨度的重算被漏掉了。 解决改监听DataGrid.ColumnWidthChanged并在同一次回调里既刷格子宽度、又重算ColumnSpan。这两件事必须原子完成分两次做会出现中间态闪烁。现象五导出 Excel 时表头变成单行分组信息全丢。原因导出逻辑直接遍历DataGrid.Columns取Header.ToString()而Header现在是个ColumnHeaderInfo对象ToString()出来的是类型名。 解决导出时改走Headers集合按GroupKey分组先写一行合并的组名再写一行列名。合并用 NPOI 的AddMergedRegion跨度直接取GroupColumnCount。这块示例里给了工具方法抄过去改一下列起始索引就能用。提示这五条里前三条是「看一眼就知道坏了」后两条是「上线才被业务发现」。验收的时候别只看默认列宽把每列都拖一遍、横向拉到最右再截图比对能省掉大部分返工。6. 二次开发把 Demo 抽成能进项目的表头组件示例跑通之后真正的工作是把它变成一个别人也能用的东西。我的做法是先抽附加属性让 DataGrid 声明式地开启多行表头而不是每个页面都复制一遍模板// Controls/DataGridHeaderBehavior.cs public static class DataGridHeaderBehavior { public static readonly DependencyProperty GroupsProperty DependencyProperty.RegisterAttached( Groups, typeof(IEnumerableColumnGroupDef), typeof(DataGridHeaderBehavior), new PropertyMetadata(null, OnGroupsChanged)); public static void SetGroups(DependencyObject d, IEnumerableColumnGroupDef value) d.SetValue(GroupsProperty, value); public static IEnumerableColumnGroupDef? GetGroups(DependencyObject d) (IEnumerableColumnGroupDef?)d.GetValue(GroupsProperty); private static void OnGroupsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is not DataGrid grid || e.NewValue is not IEnumerableColumnGroupDef groups) return; // 挂载时统一接管列生成与表头同步页面侧不再关心细节 var controller new MergedHeaderController(grid); controller.Apply(groups); } }页面里就剩一行controls:DataGridHeaderBehavior.Groups{Binding ColumnGroups}其余全在MergedHeaderController里。这么抽的好处是两行模板和外挂层可以做成策略用个枚举切换业务页面完全不用改。验证方法我固定跑三个用例写在tests/HeaderLayoutTests里一是「单列组」——GroupColumnCount为 1 时合并跨度不能退化成 0否则格子直接消失二是「全列同组」——整行合并成一个格子这时候列宽拖到极窄会不会崩三是「空组」——配置里写了组但一列都没配上要能安静跳过而不是抛异常。三个用例都不需要真实 UI纯算布局参数几十毫秒跑完改同步逻辑时特别有用。还有一个技巧调试表头对不齐的时候别急着一行行读代码先把外挂层的每个格子加个半透明背景色跟 DataGrid 的列边界一对比是哪一列累计误差一眼就看出来了。比打断点快得多。说个教训。早些年我在一个生产报表里图省事把两行表头的样式直接写进了页面的Resources没抽出来后来项目里五张报表都要多行表头我复制粘贴改了五遍其中一张的边框粗细还不一致被测试提了个「UI 不统一」的缺陷单。从那以后我每次碰表头、单元格样式这类会被复用的视觉组件都强制先抽到Themes下再动手哪怕只有一处用。希望帮到你。本文还有配套的精品资源点击获取