资讯详情

WPF DataGrid 多行表头实现:列宽同步、滚动冻结与分组合并

📅 2026/10/28 12:09:20 | 华诺云谱 👁 阅读
WPF DataGrid 多行表头实现:列宽同步、滚动冻结与分组合并
简介这是一份面向 WPF 桌面开发者的 Telerik DataGrid 多行表头示例工程适合想掌握多级表头、列组嵌套、RadGridView 数据绑定与基础交互处理的初中级开发者参考。压缩包共 53 个文件约 3.34MB包含 13 个 cs 源码、8 个 xml、8 个 dll、2 个 xaml以及 sln、csproj、config、resx 等工程与配置项较完整地保留了一个可运行的 TelerikWpfApp1 项目结构。已有 292 人学习说明其在 WPF 复杂表格界面实践方面具有一定参考价值。工程内容便于读者查看界面 XAML 组织、后台事件逻辑、数据模型和 Telerik 依赖库引用方式也可作为项目代码存档、控件用法对照、多行表头布局拆解与二次改造的起点。1. 从一张挤爆的报表页说起WPF DataGrid 多行表头要解决什么做报表页面的人大概都遇到过一个场景一张 WPF DataGrid 要同时显示「本月」「上月」「环比」三组每组下面还挂着四五个明细列列一多单行表头就彻底不够用了文字被截断成「本…」「上…」用户第一眼就懵。这个示例工程就是专门解决这件事的——一个能跑起来的 WPF DataGrid 多行表头例子把两级表头的合并、列宽对齐、横向滚动同步、冻结列补偿这几件事都做进了可运行代码里还附了一页商业控件库的分组列写法做对照。它适合两类人一类是被报表页表头挤成一行卡住、想直接拿一份能跑代码去改的开发者另一类是想搞清楚表头层和 DataGrid 列宽到底怎么同步、为什么总是差那么几个像素的人。代码量不大难点全在细节里尤其是布局时序和滚动偏移这两块比较容易出问题。2. 工程结构三个项目如何把列定义、表头层和宿主窗口拆开2.1 目录分层与各自的职责边界这个示例没有把所有东西塞进一个窗口的 code-behind 里而是拆成 Core、Controls、App 三层。拆的理由很实际表头分组的计算逻辑哪几列属于同一个 Band、跨度是多少是纯数据运算跟 WPF 没有半点关系放在 Core 里可以单独写单元测试将来要导出 Excel 表头或者给别的 UI 框架复用时直接搬走。MultiHeaderDemo/ ├─ MultiHeaderDemo.sln ├─ src/ │ ├─ MultiHeaderDemo.Core/ # 纯 C#不引用 PresentationFramework │ │ ├─ ReportColumn.cs # 叶子列定义Band / Header / Path / Width │ │ ├─ HeaderGroup.cs # 一级表头分组Title / StartIndex / Span │ │ ├─ HeaderBandBuilder.cs # 把叶子列折叠成分组 │ │ └─ ColumnFactory.cs # ReportColumn - DataGridColumn │ ├─ MultiHeaderDemo.Controls/ # 表头层控件与转换器 │ │ ├─ MultiRowHeaderPanel.cs # 生成表头 Grid 的核心逻辑 │ │ ├─ DoubleToGridLengthConverter.cs │ │ └─ Themes/Generic.xaml # 默认样式避免每个窗口重复写 │ └─ MultiHeaderDemo.App/ # 宿主窗口与示例数据 │ ├─ MainWindow.xaml # 三个方案页签的容器 │ ├─ Views/NativeHeaderView.xaml # 方案一只改 ColumnHeaderStyle │ ├─ Views/OverlayHeaderView.xaml # 方案二独立表头层 滚动同步 │ ├─ Views/CommercialView.xaml # 方案三商业控件库分组列 │ └─ Data/DemoDataProvider.cs # 造 200 行 12 列的假数据 └─ docs/ └─ header-layout.md # 表头层布局约束的手写笔记Controls这一层的关键是MultiRowHeaderPanel它继承Grid而不是UserControl。原因在于表头层要被塞进ScrollViewer里跟着横向滚动UserControl多包一层会引入额外的测量开销而且在ActualWidth传递上容易出偏差。做成Grid的子类列定义直接落在自己身上父级 ScrollViewer 给的可用宽度就是真实宽度省掉一层换算。docs/header-layout.md不是凑数的里面记的是表头行高、边框粗细、单元格内边距这几个写死的数值。示例里一级表头 30 像素、二级表头 34 像素为什么不是一样高因为一级表头是「大类」字重更粗留白要更多两个高度一致反而显得挤。这类数值散落在 XAML 里最难改集中记一份文档接手的人不用满工程搜Height30。2.2 技术栈与版本约束示例锁的是 WPF C#双目标net48和net8.0-windows。为什么要双目标因为实际项目里这两类工程同时存在老系统还压在 .NET Framework 4.8 上跑新开的项目基本都上了 .NET 8。表头层这套逻辑在两个框架下都能跑但有几处差异必须提前知道项目.NET Framework 4.8.NET 8 (windows)项目文件格式老式 csprojSDK 风格 csproj启动方式需要 Visual Studio 或 msbuilddotnet run直接跑默认 DataGrid 样式Aero2 主题表头内边距偏大Fluent 主题行高更紧凑绑定引擎行为部分只读依赖属性绑定会静默失败报错更明确那个「只读依赖属性绑定静默失败」的问题在第 3 章绑定ActualWidth时会重点讲因为这是整套方案最容易出问题的地方。MVVM 方面没有引入任何框架DemoDataProvider造出来的就是一个ObservableCollectionReportRow直接赋给ItemsSource。表头层涉及大量视觉树操作创建 Border、设置 Grid 行列、挂 Transform强行套 MVVM 只会让代码绕一大圈再回到 code-behind这个示例选择务实数据部分走绑定视觉部分走 code-behind两边通过ReportColumn列表这一个契约衔接。提示如果你所在的项目必须严格遵守 MVVM 分层可以把MultiRowHeaderPanel的生成逻辑包装成一个附加属性Attached Property在Columns属性变化时触发重建这样 ViewModel 就不需要引用任何控件类型。代价是多一层依赖属性注册调试时堆栈会更深。2.3 跑起来的第一步还原、目标框架与首屏数据拿到工程之后先别急着改代码按顺序跑一遍确认环境没问题。# 1. 还原依赖并编译.NET 8 目标 dotnet restore MultiHeaderDemo.sln dotnet build MultiHeaderDemo.sln -c Debug # 2. 指定启动项目运行注意要带上 -f 指定框架 dotnet run --project src/MultiHeaderDemo.App -f net8.0-windows如果你要跑net48目标dotnet run是不行的得用 Visual Studio 打开 sln把MultiHeaderDemo.App设为启动项目再把活动解决方案配置切到 Debug Any CPU。命令里的-f net8.0-windows不能省双目标工程不带框架参数会直接报错说存在多个目标。程序启动后默认停在「方案二」那一页因为这是三种做法里最通用的一种。首屏会看到 12 列数据分三组营收、成本、毛利每组四列。往右拖横向滚动条表头应该和数据列严丝合缝地一起动——如果表头不动或者动得有延迟说明滚动事件没挂上先去看第 4 章的AddHandler那一段。DemoDataProvider生成 200 行数据是刻意的行数太少看不出滚动时的重绘问题行数太多启动会慢200 行刚好能在低配机器上暴露虚拟化相关的毛病。想改行数直接调Generate(200)里的参数就行不用动别的地方。3. 表头层实现按 Band 合并单元格并同步列宽的完整链路3.1 先定数据契约ReportColumn 与分组折叠整套方案的地基是一个扁平列定义而不是嵌套的树形结构。树形结构看起来更「正统」但实际报表里二级表头下面的列绝大多数是平级的用树反而每次遍历都要递归生成表头时还要处理「某一组只有一个子列」的退化情况。扁平结构 一个 Band 字段同样的表达能力代码少一半。// Core/ReportColumn.cs public sealed class ReportColumn { public string Band { get; set; } // 一级表头如 营收 public string Header { get; set; } // 二级表头如 本月 public string Path { get; set; } // 绑定字段名 public double Width { get; set; } 110; public bool Frozen { get; set; } // 是否冻结对应 FrozenColumnCount }Band是唯一决定合并关系的字段相邻的、Band值相同的列会被合成一个一级表头单元格。这里有个刻意的设计选择——只合并「连续」的同名 Band不合并「全局同名」的。假如你的列顺序是「营收-本月、成本-本月、营收-上月」那两个「营收」会各自变成一个独立单元格而不是硬凑成一个跨三列的大格子。报表里这种交错排列一旦被强行合并表头会覆盖到不相干的列上画出来的东西根本没法看。这个取舍写在HeaderBandBuilder的注释里改之前先想清楚。折叠逻辑本身很短// Core/HeaderBandBuilder.cs public static IReadOnlyListHeaderGroup Build(IReadOnlyListReportColumn columns) { var groups new ListHeaderGroup(); int index 0; foreach (var column in columns) { // 与前一个分组同名则扩展跨度否则新起一组 var last groups.Count 0 ? groups[groups.Count - 1] : null; if (last ! null last.Title column.Band) last.Span; else groups.Add(new HeaderGroup { Title column.Band, StartIndex index, Span 1 }); index; } return groups; }StartIndex和Span这两个值后面直接喂给Grid.SetColumn和Grid.SetColumnSpan所以必须是基于叶子列下标的连续值不能用「第几个分组」来计算。index在循环体外累加而不是用for的下标是为了让foreach版本的代码读起来跟列顺序完全一致改起来不容易错。3.2 用代码生成表头 Grid行、列、合并三件事表头不是写在 XAML 里的静态结构而是运行时按列定义生成。原因很直接列定义通常来自配置或接口列数不固定XAML 里写死ColumnDefinition根本没法应付动态列。// Controls/MultiRowHeaderPanel.cs节选 private void Rebuild(IReadOnlyListReportColumn columns) { Children.Clear(); ColumnDefinitions.Clear(); RowDefinitions.Clear(); RowDefinitions.Add(new RowDefinition { Height new GridLength(30) }); // 一级 RowDefinitions.Add(new RowDefinition { Height new GridLength(34) }); // 二级 // 每个叶子列先占一个 ColumnDefinition宽度留给 SyncColumnWidths 绑 for (int i 0; i columns.Count; i) ColumnDefinitions.Add(new ColumnDefinition { Width new GridLength(0) }); var groups HeaderBandBuilder.Build(columns); foreach (var group in groups) Children.Add(CreateCell(group.Title, row: 0, column: group.StartIndex, span: group.Span, isBand: true)); for (int i 0; i columns.Count; i) Children.Add(CreateCell(columns[i].Header, row: 1, column: i, span: 1, isBand: false)); }三个参数决定一切row决定是几级表头0 是一级1 是二级column是起始列下标span是横跨几列。一级表头的span来自分组折叠二级永远是 1。CreateCell里最值得说的是边框的写法——只画右边和下边不画左边和上边private Border CreateCell(string text, int row, int column, int span, bool isBand) { var border new Border { Background isBand ? BandBrush : LeafBrush, BorderBrush LineBrush, BorderThickness new Thickness(0, 0, 1, 1), // 只画右、下 Padding new Thickness(6, 0, 6, 0), Child new TextBlock { Text text, FontWeight isBand ? FontWeights.SemiBold : FontWeights.Normal, HorizontalAlignment HorizontalAlignment.Center, VerticalAlignment VerticalAlignment.Center, TextTrimming TextTrimming.CharacterEllipsis } }; Grid.SetRow(border, row); Grid.SetColumn(border, column); Grid.SetColumnSpan(border, span); return border; }四个边框都画的话相邻两个单元格会各画一条边视觉上变成 2 像素粗的线而且在高 DPI 下两条 1 像素线各自舍入到 0.5 像素会出现深浅不一的「双线」。只画右和下再把最外圈的封面用外层容器的BorderThickness补齐视觉上就是干净的单线网格。TextTrimmingCharacterEllipsis是必需的。多行表头的二级单元格通常比较窄列宽一改文字就溢出没有省略号会直接顶到边框上。但要注意二级表头即使有省略号用户仍然看不到完整列名所以实际项目里我一般会额外挂一个ToolTip显示全称这一句在示例里被注释掉了需要的话放开就行。3.3 列宽同步把 DataGridColumn.ActualWidth 绑到 ColumnDefinition 上到这里表头长得像表头了但宽度还是 0因为ColumnDefinition.Width被设成了 0等着绑。这一步是整套方案的核心也是最容易出错的地方。做法是给每个ColumnDefinition.WidthProperty挂一个单向绑定源是DataGrid.Columns[i]的ActualWidth// Controls/MultiRowHeaderPanel.cs public void SyncColumnWidths(DataGrid grid) { if (ColumnDefinitions.Count ! grid.Columns.Count) return; // 列没对齐就别绑 for (int i 0; i grid.Columns.Count; i) { var binding new Binding(nameof(DataGridColumn.ActualWidth)) { Source grid.Columns[i], Mode BindingMode.OneWay, // ActualWidth 是只读依赖属性只能单向 Converter new DoubleToGridLengthConverter() }; ColumnDefinitions[i].SetBinding(ColumnDefinition.WidthProperty, binding); } }ActualWidth是doubleColumnDefinition.Width是GridLength类型对不上所以必须有转换器// Controls/DoubleToGridLengthConverter.cs public sealed class DoubleToGridLengthConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { // 首帧布局前 ActualWidth 可能是 0 或 NaN统一归零避免异常 var width value is double d !double.IsNaN(d) d 0 ? d : 0; return new GridLength(width, GridUnitType.Pixel); } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) throw new NotSupportedException(); }为什么要处理NaN第一帧布局时ActualWidth有可能是NaN直接new GridLength(NaN)会在某些框架版本上抛异常或者画出一个宽度无穷大的格子把整个布局撑爆。转换器里统一归零绑定下一次更新会带来正确值视觉上就是闪一帧。这里有个必须知道的前提列宽必须是像素值不能用星号。DataGridLength支持SizeToCells、SizeToHeader、Star但只有像素模式和Auto模式会给出一个有意义的ActualWidth变化星号宽度虽然也会算出实际值但如果表头层和 DataGrid 各自参与一次星号分配两边的可用宽度基准不同算出来的结果就会差一截。示例里所有列都显式给了Width new DataGridLength(110, DataGridLengthUnitType.Pixel)就是为了绕开这个不确定性。要「自适应填满」正确做法是算好容器总宽减去固定列宽再均分给需要撑开的列而不是交给星号。注意如果你在.NET Framework 4.8下发现绑定完全不生效、列宽一直是 0多半是绑定引擎拿不到DataGridColumn.ActualWidth这个只读依赖属性的变更通知。退路是订阅DataGrid.ColumnWidthChanged和SizeChanged在事件里手动给ColumnDefinition.Width赋值new GridLength(grid.Columns[i].ActualWidth)效果一样只是多写几行。4. 滚动与冻结表头层跟着 DataGrid 走的同步方案4.1 横向滚动同步给 DataGrid 挂上冒泡的 ScrollChanged表头层放在一个独立的ScrollViewer里自己隐藏滚动条位置完全由主表驱动。核心问题是主表的ScrollViewer藏在DataGrid的模板内部从外面拿不到引用。常见做法是用VisualTreeHelper一层层往下找找到那个ScrollViewer再订阅ScrollChanged。这种写法能用但很脆弱——控件模板一改换主题、升级框架版本可视树结构就变了找不到了就静默失效连日志都没有。示例里用的是路由事件的冒泡特性直接在主表上挂// Views/OverlayHeaderView.xaml.cs private void HookScrollSync() { // 先摘再挂避免重复订阅页面重建时尤其重要 DataGrid.RemoveHandler(ScrollViewer.ScrollChangedEvent, new ScrollChangedEventHandler(OnGridScrollChanged)); DataGrid.AddHandler(ScrollViewer.ScrollChangedEvent, new ScrollChangedEventHandler(OnGridScrollChanged), handledEventsToo: true); } private void OnGridScrollChanged(object sender, ScrollChangedEventArgs e) { if (e.HorizontalChange 0) return; // 纵向滚动不管 HeaderScroll.ScrollToHorizontalOffset(e.HorizontalOffset); }ScrollChanged是路由事件内部ScrollViewer触发后会沿着可视树往上冒泡冒到DataGrid这一层就能捕获。第三个参数handledEventsToo: true是关键——内部处理逻辑可能已经把这个事件标记成已处理不加这个参数外面就收不到。这个知识点救过一次现场当时排查了半天「为什么事件挂了没反应」最后发现就是少了这个参数。if (e.HorizontalChange 0) return;这行也不能省。ScrollChanged在纵向滚动、内容尺寸变化时都会触发每次重设表头的横向偏移会导致表头轻微抖动看起来像卡顿。另有一种更轻的实现不给表头的ScrollViewer而是直接给表头Grid挂TranslateTransform在事件里设X -e.HorizontalOffset。好处是绕开了ScrollToHorizontalOffset的像素对齐滚动时表头和数据不会出现 1 像素错位坏处是表头层不再参与滚动条逻辑如果你要给表头加自定义滚动条就得自己算。4.2 冻结列与列重排表头层的二次对齐冻结列一开前面那套同步方案立刻失效。因为冻结列在 DataGrid 里是不随横向滚动偏移移动的而表头层是一整块被整体平移一拖滚动条冻结列的表头会跟着跑数据列却不动视觉上表头和数据就彻底错位了。解决思路是把表头层拆成两半// 冻结区表头不挂任何 Transform FrozenHeaderHost.RenderTransform Transform.Identity; // 非冻结区表头整体左移位移量就是 ScrollViewer 的横向偏移 ScrollHeaderHost.RenderTransform new TranslateTransform(-e.HorizontalOffset, 0);两个表头容器各自有一份独立的ColumnDefinition集合冻结区那份只放前FrozenColumnCount列的宽度绑定非冻结区那份放剩下的。两块拼在一个水平排列的Grid里宽度分别是「冻结列宽之和」和「剩余宽度」。列重排是另一码事。CanUserReorderColumnsTrue时用户拖动列头DataGrid.Columns的顺序会变但表头层的ColumnDefinition顺序没变结果就是表头文字和数据列的对应关系全乱了。示例里默认把CanUserReorderColumns关了如果业务上非要允许重排正确做法是订阅ColumnReordered事件事件触发后拿新的列顺序重建表头层DataGrid.ColumnReordered (s, e) { // 重排后列下标已变必须按新顺序重建表头不能只换文字 RebuildHeaderFromGrid(); SyncColumnWidths(DataGrid); };RebuildHeaderFromGrid是从DataGrid.Columns反推ReportColumn列表——这要求在DataGridColumn上留了个「后门」把原始ReportColumn塞进Tag属性重建时从Tag里取。这个技巧不优雅但极其实用比维护一份「列对象到定义对象」的字典省事也不会因为列被移除而漏掉引用。4.3 什么时候该换商业控件Banded 写法和自绘方案的取舍示例的第三个页签用的是某商业控件库的分组列Banded Columns写法。它的思路和自绘完全不同分组列是控件原生支持的你只需要在列定义外面再包一层「组」把Title和包含的子列写进去合并、列宽、滚动、冻结全部由控件内部处理一行绑定代码都不用写。这个页签存在的意义不是让人去采购控件而是提供一个「正确答案长什么样」的参照。自己写表头层时很多边界情况不知道该怎么处理看一眼商业控件的表现就有底了——比如它合并单元格后组标题是居中还是左对齐组内列宽变化时组标题会不会跟着重算冻结列和分组列同时存在时它的表现是什么样。维度自绘表头层商业控件分组列开发量300500 行含转换器和滚动同步配置文件里加一层分组即可动态列完全自由运行时可任意重建受控件 API 约束重建成本较高冻结 分组需要自己拆两块表头原生支持主题样式完全可控改 Border 就行需要覆盖控件模板改起来较重授权成本无需要商业授权部署时要注册许可证升级风险自己维护跟控件库版本绑定结论很清楚列结构相对固定、只是想要两级表头的自绘完全够用后续维护成本最低列结构复杂、还要列冻结、拖拽、列筛选、导出一起上的商业控件的综合成本反而更低。示例把两条路都摆出来就是为了让人自己判断而不是一上来就说「必须用某某控件」。顺带提一个部署问题商业控件在开发机上跑得好好的打包到客户机器上弹试用提示或者直接报许可证异常这是最常见的求助场景。原因一般是许可证没有在应用启动时注册需要在App.OnStartup里加一行注册代码具体写法看控件库文档的「Licensing」章节。自绘方案没有这个问题这也是它在这类小需求里的隐性优势。5. 踩坑与排查多行表头最容易翻车的五个地方5.1 布局与列宽类的三个坑现象一程序刚启动时表头全是空白点一下窗口才恢复正常。原因SyncColumnWidths在窗口构造阶段调用此时DataGrid还没完成第一次布局所有DataGridColumn.ActualWidth都是 0绑上去的就是 0 宽。等布局完成ActualWidth变了绑定理论上应该更新但如果源属性没发变更通知第 3 章说的只读依赖属性问题就永远停在 0。解决把SyncColumnWidths的调用时机挪到DataGrid.Loaded之后或者监听DataGrid.SizeChanged事件在第一次拿到非零宽度时再绑。示例里用的是后者SizeChanged里加了一句if (e.NewSize.Width 0 !_widthSynced)只绑一次避免后续每次尺寸变化都重建绑定。现象二表头和数据列整体差半个像素鼠标划过时能看到一条细细的错位线。原因DataGrid的列宽是double可能是 110.3333 这种值而ScrollViewer.ScrollToHorizontalOffset内部会做一次像素对齐SnapsToDevicePixels两边的舍入规则不一致累积几列之后就偏了。解决给表头层的ScrollViewer显式关掉像素对齐ScrollViewer x:NameHeaderScroll HorizontalScrollBarVisibilityHidden VerticalScrollBarVisibilityDisabled UseLayoutRoundingFalse SnapsToDevicePixelsFalse如果还差一点就改用TranslateTransform方案位移量直接用-e.HorizontalOffset这个原始 double中间不经过任何对齐逻辑。现象三高 DPI 缩放 150% 时表头单元格的边框出现深浅不一的「双线」。原因BorderThickness0,0,1,1在 150% 缩放下1 个逻辑像素要占 1.5 个物理像素两条相邻边框各自舍入一条画成 2 物理像素、一条画成 1 物理像素看起来就像双线。解决干脆不用Border画分隔线改用背景色块——每个单元格一个Background容器背景设成线条色单元格之间留 1 像素的Margin露出来的容器背景就是线。这样线条粗细完全由容器背景决定缩放时表现一致。示例里两种写法都留了MultiRowHeaderPanel顶部有一个UseBackgroundLines开关切换对比一下就知道差别。5.2 数据与交互类的两个坑现象四开了FrozenColumnCount之后一拖横向滚动条冻结列的表头跟着跑了。原因表头层是一整块被整体平移没有区分冻结区和滚动区而 DataGrid 内部的冻结列是固定不动的。解决按第 4.2 节拆成两个表头容器冻结区那份不挂Transform非冻结区那份才平移。拆的时候注意两个容器的Grid.Row要对齐行高必须一致否则两块表头的水平线对不上看起来像断了一截。现象五切换数据源换一个报表之后表头文字更新了但点表头排序失灵或者排错列。原因重建表头时只换了DataGrid.Columns没有同步SortMemberPath或者Binding排序箭头还挂在旧列上另一种情况是重建时DataGrid.Columns.Clear()后立即Add中间触发了ItemsSource的重算绑定路径瞬间为空导致排序描述符丢失。解决重建走一个统一入口先把ItemsSource置 null清空列、重建列、重建表头、重新绑定ItemsSource最后恢复SortDescriptions。顺序不能乱中间任何一步反了都会翻车。这个函数示例里叫RebuildAll()注释里写了一句「顺序错了会静默失效」因为这类问题不报错只是行为不对最耗时间。提示五个坑里最难查的是现象一和现象五因为它们都不抛异常。养成习惯表头相关的问题先打印HeaderGrid.ColumnDefinitions.Count和DataGrid.Columns.Count是否相等再看每个ColumnDefinition.ActualWidth是否非零两步就能定位到是绑定没生效还是重建没走全。6. 二次开发把写死的表头换成配置驱动示例里三个页签的列定义都是硬编码在DemoDataProvider里的实际项目肯定不能这么干。改造方向很明确把ReportColumn列表的来源从代码换成配置表头层只认契约不认来源。我一般会定义一个 JSON 结构列定义从接口或者本地配置文件读{ columns: [ { band: 营收, header: 本月, path: RevenueCurrent, width: 110 }, { band: 营收, header: 上月, path: RevenuePrevious, width: 110 }, { band: 成本, header: 本月, path: CostCurrent, width: 110 } ] }反序列化成ListReportColumn之后把所有重建动作收进一个函数里任何地方要换表头只调它private void RebuildAll(IReadOnlyListReportColumn definitions) { DataGrid.ItemsSource null; // 先断开避免重建列时重算绑定 DataGrid.Columns.Clear(); foreach (var def in definitions) DataGrid.Columns.Add(ColumnFactory.Create(def)); HeaderPanel.Rebuild(definitions); // 生成行、列、合并单元格 HeaderPanel.SyncColumnWidths(DataGrid); // 重新挂宽度绑定 HookScrollSync(); // 先摘再挂防止重复订阅 DataGrid.ItemsSource _rows; // 最后接回数据 }改造时有三个点要特别注意。第一本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑