WPF DataGrid多行表头与列组布局实战:双Grid同步与商业控件迁移
简介这份资源是面向 WPF 开发者的一份 Telerik DataGrid 多行表头实践示例适合已具备一定 XAML 与数据绑定基础、希望在桌面端实现多级分类表头的初中级工程师参考。资源以完整可编译工程形式给出围绕 RadGridView 的多级列表头、列组划分、表头模板与数据源绑定展开可用于报表、数据分析等需要层次化展示字段的场景。压缩包为 rar 格式共 53 个文件、约 3.34MB包含 13 个 cs 后端代码、2 个 xaml 界面文件、8 个 dll 引用库以及 csproj、sln、config、resx 等工程配置与资源文件另带少量可执行与缓存产物目录保持原始项目结构便于直接还原和调试。目前已有 292 人学习下载。读者可通过该示例理解多行表头的搭建思路、列模板定制方式与前后端配合逻辑并以其为参照改造自有项目界面。1. 多行表头不是换模板一张年度预算表暴露的列组缺口财务同事把年度预算表甩过来时最刺眼的不是数字而是表头上半年、下半年各跨三个月下面才是月份。用 WPF DataGrid 简单 Demo 复现这张表第一天就会卡住——原生 DataGrid 只给你一行列头。WPF 多行表头不是把 Header 换行而是列组布局上层组头要跨列下层列头要跟列宽、滚动、冻结列同步。这个资源包围绕某商业 WPF Grid 控件的列组例子展开也给了原生 DataGrid 双 Grid 兜底方案适合正在做报表、台账、审批流列表的 WPF 开发者。下面按“先看懂渲染链再跑通 Demo最后排错”的顺序拆能直接抄的代码我都放在中间章节。2. 从 Header 到 ColumnHeaderWPF DataGrid 表头渲染链拆解2.1 谁在画列头DataGridColumnHeadersPresenter 与 DataGridColumnHeaderWPF DataGrid 的列头不是一个简单的ItemsControl。它内部用DataGridColumnHeadersPresenter管理列头容器每一列生成一个DataGridColumnHeader。这个类继承自ButtonBase所以列头天生带点击、排序、拖拽列宽这些交互。你平时写的Header、HeaderTemplate、ColumnHeaderStyle最终都落到DataGridColumnHeader的模板里。模板里通常有两个关键部件ContentPresenter负责显示列头内容PART_LeftHeaderGripper和PART_RightHeaderGripper两个Thumb负责列宽拖拽热区。把模板改坏最先失效的往往不是文字而是拖拽列宽。关键类/属性职责多行表头要动的地方DataGridColumnHeadersPresenter生成并排列所有列头一般不直接改跨列组头要放在它外层DataGridColumnHeader单列列头容器继承ButtonBase改ColumnHeaderStyle可调高度、对齐、模板DataGridColumn.Header列头内容可放字符串也可放对象配合HeaderTemplateDataGridColumn.HeaderTemplate列头内容模板能在单列内做两行文字但做不了跨列DataGrid.ColumnHeaderHeight列头高度多行表头要配合上层组头高度一起调PART_RightHeaderGripper列宽拖拽Thumb自定义模板时必须保留否则列宽拖不动先看一个最常见的“伪多行表头”写法DataGrid.ColumnHeaderStyle Style TargetTypeDataGridColumnHeader Setter PropertyHeight Value52/ Setter PropertyHorizontalContentAlignment ValueCenter/ Setter PropertyHeaderTemplate Setter.Value DataTemplate StackPanel !-- 第一行放组名第二行放列名但组名会在每一列重复 -- TextBlock Text{Binding GroupName} FontSize11 Foreground#FF666666 HorizontalAlignmentCenter/ TextBlock Text{Binding ColumnName} FontSize12 FontWeightSemiBold HorizontalAlignmentCenter/ /StackPanel /DataTemplate /Setter.Value /Setter /Style /DataGrid.ColumnHeaderStyle这段 XAML 能让列头变成两行但它只是“每列自己有两行”不是“上半年跨三列”。HeaderTemplate的数据上下文是列头内容不是整张表它拿不到相邻列的宽度也拿不到列组对象。参数上Height52是列头固定高度单位是 DIPFontSize和Foreground只影响文字不影响布局。真正要跨列必须有一个外层容器按列宽画组头或者用商业控件内置的列组。提示改ColumnHeaderStyle时不要把默认模板整个删掉再重写。先基于默认模板复制一份保留两个Thumb再调ContentPresenter的位置。血泪经验是列宽拖拽失效十有八九是模板里少了PART_RightHeaderGripper。2.2 改模板与做列组两条路线的分水岭多行表头有三条常见路线选型时别看宣传图看列宽和滚动谁来管。第一条是“单列内多行”。适合列头文字太长、需要换行的场景比如“客户名称/所属区域”这种同一列的两层说明。它的成本最低但跨列组头做不了组名会在每列重复视觉上很假。第二条是“双 Grid 叠放”。上层放一个Grid画组头下层放DataGrid显示列头和数据。上层组头的列定义数量与DataGrid.Columns一一对应组头用Grid.ColumnSpan跨列。列宽变化时用代码把DataGridColumn.ActualWidth同步到上层ColumnDefinition.Width水平滚动时把DataGrid内部ScrollViewer的偏移同步给上层。这个方案完全用原生控件能跑、能调、能控代价是列拖拽、列重排、冻结列、筛选行这些边角要自己补。第三条是“商业控件列组”。某商业 WPF Grid 控件通常把列组做成一等公民ColumnGroups集合、组头Header、组内Columns、组头模板都是现成的。列宽、滚动、冻结列、列重排由控件内部处理你主要配属性和模板。代价是授权成本和控件绑定迁移时要把原生列定义改成组内列定义。对比项单列内多行双 Grid 叠放商业控件列组跨列组头不支持支持支持列宽同步不需要要自己写控件内部处理水平滚动同步不需要要自己写控件内部处理冻结列不涉及要拆冻结区和非冻结区一般内置列重排不涉及要重建组头列定义一般内置排序/筛选图标原生支持原生支持控件支持实现成本低中低到中取决于授权和迁移我的选型习惯是如果只是一张内部台账列组不超过三组双 Grid 方案够用如果表格要支持列拖拽、冻结列、导出、筛选行而且后续还有人接手商业控件列组更稳。不是为了省代码是为了少背列宽同步的锅。2.3 最小 Demo 的骨架6 列 2 组先别急着绑数据。把窗口缩到最小只放一个上层组头Grid和一个下层DataGrid列数固定为 7 列部门列不参与分组后面六个月分成上半年、下半年两组。这个骨架跑通再谈数据绑定和样式。Grid Grid.RowDefinitions RowDefinition HeightAuto/ RowDefinition Height*/ /Grid.RowDefinitions !-- 上层只放组头滚动条隐藏偏移由代码同步 -- ScrollViewer x:NameHeaderScroll Grid.Row0 HorizontalScrollBarVisibilityHidden VerticalScrollBarVisibilityDisabled Grid x:NameGroupHeaderGrid Height36 HorizontalAlignmentLeft UseLayoutRoundingTrue Grid.ColumnDefinitions ColumnDefinition Width120/ ColumnDefinition Width100/ ColumnDefinition Width100/ ColumnDefinition Width100/ ColumnDefinition Width100/ ColumnDefinition Width100/ ColumnDefinition Width100/ /Grid.ColumnDefinitions !-- 部门列不参与组头组头从第 1 列开始跨 3 列 -- Border Grid.Column1 Grid.ColumnSpan3 BorderBrush#FFD0D0D0 BorderThickness1 Background#FFF3F3F3 TextBlock Text上半年 HorizontalAlignmentCenter VerticalAlignmentCenter/ /Border Border Grid.Column4 Grid.ColumnSpan3 BorderBrush#FFD0D0D0 BorderThickness1 Background#FFF3F3F3 TextBlock Text下半年 HorizontalAlignmentCenter VerticalAlignmentCenter/ /Border /Grid /ScrollViewer DataGrid x:NameDetailGrid Grid.Row1 AutoGenerateColumnsFalse CanUserAddRowsFalse CanUserResizeRowsFalse HeadersVisibilityColumn ColumnHeaderHeight32 GridLinesVisibilityNone UseLayoutRoundingTrue SnapsToDevicePixelsTrue DataGrid.Columns DataGridTextColumn Header部门 Binding{Binding Department} Width120/ DataGridTextColumn Header一月 Binding{Binding Jan} Width100/ DataGridTextColumn Header二月 Binding{Binding Feb} Width100/ DataGridTextColumn Header三月 Binding{Binding Mar} Width100/ DataGridTextColumn Header四月 Binding{Binding Apr} Width100/ DataGridTextColumn Header五月 Binding{Binding May} Width100/ DataGridTextColumn Header六月 Binding{Binding Jun} Width100/ /DataGrid.Columns /DataGrid /Grid参数说明上层GroupHeaderGrid的ColumnDefinitions数量必须和DetailGrid.Columns数量一致这里都是 7。Grid.Column1 Grid.ColumnSpan3表示组头从第二列开始跨三列对应一月到三月。Height36是组头固定高度DataGrid.ColumnHeaderHeight32是第二行列头高度两个高度加起来才是完整表头高度。GridLinesVisibilityNone先关掉网格线避免列边框宽度干扰对齐后面要边框再统一加。HorizontalAlignmentLeft很重要ScrollViewer里的Grid默认拉伸宽度设了也会被布局拉扯。3. 原生 DataGrid 多行表头落地双 Grid 同步与共享尺寸组3.1 列宽同步ColumnWidthChanged 与 ActualWidth 的时序上层组头画好后第一件事是列宽同步。DataGridColumn.Width是DataGridLength可以设固定值、Auto、Star但DataGridColumn不在可视化树里直接拿它做元素绑定经常找不到源。我一般用事件同步监听DataGrid.ColumnWidthChanged在事件里读每列的ActualWidth写给上层ColumnDefinition.Width。using System; using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Media; namespace MultiHeaderDemo { public partial class MultiHeaderWindow : Window { private ScrollViewer? _detailScroll; public MultiHeaderWindow() { InitializeComponent(); Loaded OnLoaded; DetailGrid.ColumnWidthChanged OnColumnWidthChanged; DetailGrid.SizeChanged OnDetailGridSizeChanged; } private void OnLoaded(object sender, RoutedEventArgs e) { SyncHeaderWidths(); HookDetailScrollViewer(); } private void OnColumnWidthChanged(object? sender, DataGridColumnEventArgs e) { // 用户拖拽列宽时 ActualWidth 还没完成布局延后一帧再同步 Dispatcher.BeginInvoke(new Action(SyncHeaderWidths)); } private void OnDetailGridSizeChanged(object sender, SizeChangedEventArgs e) { // 窗口缩放时 Star 列宽会变重新同步 SyncHeaderWidths(); } private void SyncHeaderWidths() { if (DetailGrid.Columns.Count ! GroupHeaderGrid.ColumnDefinitions.Count) return; double total 0; for (int i 0; i DetailGrid.Columns.Count; i) { double w DetailGrid.Columns[i].ActualWidth; if (double.IsNaN(w) || w 0) continue; GroupHeaderGrid.ColumnDefinitions[i].Width new GridLength(w); total w; } // 让上层 Grid 的滚动范围与下层列总宽一致 GroupHeaderGrid.Width total; } private void HookDetailScrollViewer() { _detailScroll FindVisualChildScrollViewer(DetailGrid); if (_detailScroll null) return; _detailScroll.ScrollChanged (_, e) { // 只同步水平偏移垂直滚动与组头无关 HeaderScroll.ScrollToHorizontalOffset(e.HorizontalOffset); }; } private static T? FindVisualChildT(DependencyObject parent) where T : DependencyObject { int count VisualTreeHelper.GetChildrenCount(parent); for (int i 0; i count; i) { DependencyObject child VisualTreeHelper.GetChild(parent, i); if (child is T t) return t; T? result FindVisualChildT(child); if (result ! null) return result; } return null; } } }这段代码的逻辑很直ColumnWidthChanged是列宽拖拽后的信号ActualWidth是布局后的实际宽度GridLength(w)把实际宽度写回上层列定义。Dispatcher.BeginInvoke是为了避开布局还没完成的瞬间直接同步会读到旧宽度表现为组头慢半拍。DetailGrid.SizeChanged处理窗口缩放尤其是列宽用 Star 时ActualWidth会随视口变化。GroupHeaderGrid.Width total让上层Grid的滚动范围和下层列总宽一致否则水平滚动条范围对不上。FindVisualChildScrollViewer找的是DataGrid内部滚动容器ScrollChanged里只同步HorizontalOffset垂直偏移和组头无关。注意ActualWidth是只读依赖属性别试图给它赋值。列宽同步写的是上层ColumnDefinition.Width不是改DataGridColumn.ActualWidth。3.2 水平滚动与冻结列两个 ScrollViewer 的同步列宽同步完第二个翻车点是水平滚动。上层组头在独立的ScrollViewer里下层DataGrid自己也有滚动容器。用户拖下面的水平滚动条上层不动组头就和列错位。上面的HookDetailScrollViewer已经做了偏移同步但还有两个边界要处理。第一FindVisualChildScrollViewer可能找到多个ScrollViewer比如DataGrid内部的单元格滚动容器和外部嵌套滚动容器。常规做法是取第一个或者用DataGrid.Template.FindName(DG_ScrollViewer, DetailGrid)拿默认模板里的滚动容器。后者依赖默认模板名称换主题可能失效前者更通用但要确认找到的是列头所在的滚动容器。调试时可以在ScrollChanged里打印e.ExtentWidth和e.ViewportWidth看范围是不是整张表的列总宽。第二冻结列。原生DataGrid的FrozenColumnCount会把左侧若干列冻结水平滚动时冻结列不动。但上层组头是整个Grid一起滚动冻结列对应的组头会跟着动错位就出来了。常见做法是冻结列边界不要落在组内比如部门列冻结组头从第二列开始冻结区和非冻结区分成两个上层Grid分别同步偏移。如果业务一定要冻结到组内双 Grid 方案会越写越重这时候商业控件的列组更省心。private void HookDetailScrollViewer() { _detailScroll FindVisualChildScrollViewer(DetailGrid); if (_detailScroll null) return; _detailScroll.ScrollChanged (_, e) { // 冻结列场景下非冻结区的偏移要减去冻结列总宽 double frozenWidth DetailGrid.FrozenColumnCount 0 ? DetailGrid.Columns.Take(DetailGrid.FrozenColumnCount).Sum(c c.ActualWidth) : 0; double offset Math.Max(0, e.HorizontalOffset - frozenWidth); HeaderScroll.ScrollToHorizontalOffset(offset); }; }参数说明FrozenColumnCount是冻结列数量Take(...).Sum(c c.ActualWidth)算冻结区总宽。e.HorizontalOffset是下层滚动偏移减去冻结区宽度才是非冻结组头该滚的距离。Math.Max(0, ...)防止负偏移。这个写法只适合“冻结区不参与组头”或“冻结区单独画组头”的情况如果冻结区和组头有交叉老老实实拆两个上层容器。3.3 数据绑定与列头高度别让第二行列头吃掉组头双 Grid 方案里下层DataGrid的列头就是第二行。ColumnHeaderHeight32控制它的高度上层GroupHeaderGrid.Height36控制组头高度。两个值不要拍脑袋按字体大小和边距算列头文字 12 到 13 像素上下边距各 6 到 8高度 32 比较稳组头文字可以稍大36 到 40。高 DPI 下不要写死整数像素用 DIP开UseLayoutRoundingTrue和SnapsToDevicePixelsTrue边框才不糊。数据绑定用普通ObservableCollection就行列组头不绑定行数据上下文。示例数据类只保留字段public class BudgetRow { public string Department { get; set; } ; public decimal Jan { get; set; } public decimal Feb { get; set; } public decimal Mar { get; set; } public decimal Apr { get; set; } public decimal May { get; set; } public decimal Jun { get; set; } }DetailGrid.ItemsSource new ObservableCollectionBudgetRow { ... }即可。组头TextBlock不要绑定BudgetRow的属性它在外层Grid里数据上下文是窗口绑不到行数据。需要动态组名就单独放一个HeaderViewModel别把行数据和组头混在一个上下文里。这个坑很常见组头文字一开始是静态的后来想改成动态年份直接写{Binding Year}结果空白因为上层Grid的DataContext根本不是行对象。4. 商业控件列组方案怎么接属性映射、冻结列与虚拟化边界4.1 列组 API 的四个核心属性ColumnGroups、Header、Columns、HeaderTemplate商业 WPF Grid 控件的列组方案核心不是“多行表头”四个字而是把列组当成布局单元。常见 API 结构是控件上有一个ColumnGroups集合每个组有Header、Columns、HeaderTemplate列本身放在组内。控件内部根据组内列宽算组头宽度列拖拽、滚动、冻结列都由控件处理。接的时候先关自动生成列再建组再把列塞进组最后调组头模板。属性/对象作用常见配法容易踩的点ColumnGroups列组集合按业务分组建 2 到 4 个组组顺序和列顺序不一致时组头会乱Group.Header组头文字直接写字符串或绑视图模型绑行数据上下文会空白Group.Columns组内列集合把原有列定义迁进来列不能同时挂在两个组HeaderTemplate组头模板放TextBlock、边框、背景模板里别绑行数据绑组对象FrozenColumnCount冻结列数冻结列落在组边界跨冻结边界的组会渲染异常ColumnWidth列宽模式固定、Auto、Star组头宽度依赖列宽Star 下要验证缩放迁移步骤我一般按这个顺序第一步备份原生列定义把DataGrid.Columns里的列按组拆成清单第二步在商业控件里建ColumnGroups先只配Header不配模板确认组头能出来第三步把列按清单塞进组列宽先全部固定验证组头跨列宽度第四步打开列拖拽和水平滚动看组头是否跟随第五步再加组头模板、背景、边框、字体。顺序反过来样式一多出了问题分不清是列组没配对还是模板写错。4.2 冻结列、虚拟化与列重排商业控件也不是全自动商业控件省的是列宽同步和滚动同步不是所有边界都自动。冻结列是第一个边界。列组跨冻结边界时控件通常只保证冻结区列头固定组头可能被裁切或者重复绘制。稳妥做法是让冻结列数落在组边界上比如部门列冻结组从第二列开始如果必须冻结到组内先做一个小 Demo 验证当前控件版本的行为别在正式表上直接上。虚拟化是第二个边界。行虚拟化对组头影响不大组头不参与行滚动列虚拟化如果开启列头可能在滚动时才创建组头宽度计算会滞后。排查时先把列虚拟化关掉看组头是否正常再逐列打开。常见现象是快速横向滚动时组头短暂错位停下來又对齐这就是列虚拟化下宽度计算时序的问题。解决要么关列虚拟化要么在滚动结束后强制刷新组头布局。列重排是第三个边界。用户拖动列头换顺序原生双 Grid 方案要监听ColumnReordered重建上层列定义商业控件一般会跟着组内列顺序走。但跨组拖拽要看控件支持程度有的控件允许列在组间移动有的只允许组内排序。接需求时先问清楚“列能不能拖出组”不能拖就在CanUserReorderColumns上做限制别等上线后用户把列拖乱了再补。4.3 从原生双 Grid 迁移到商业控件的检查表迁移不是把 XAML 换个命名空间就完事。下面这张表是我实际迁移时会逐项过的清单少一项都可能在上线后冒出来。检查项原生双 Grid 方案商业控件列组方案验收方法列宽同步监听ColumnWidthChanged写ColumnDefinition.Width控件内部处理拖拽每列边界组头跟着变水平滚动同步内部ScrollViewer.HorizontalOffset控件内部处理拖到最右组头与列头对齐冻结列拆冻结区和非冻结区两个上层容器配FrozenColumnCount验证组边界冻结列滚动时组头不动列重排监听ColumnReordered重建列定义看控件是否支持跨组拖拽拖列后组头仍跨对列排序/筛选原生列头命令控件列头命令点列头排序后组头不跑导出自己按列组写表本文还有配套的精品资源点击获取