
简介面向 C# 开发者的 DataGridView 表格控件高级用法示例重点讲解单元格合并和二维表头两大常见功能。在桌面窗体程序中若需要合并相同值的连续单元格或设计多层分类表头这套示例可直接参考。压缩包共 48 个文件约 97KB包括 16 个 C# 源码文件、资源文件、解决方案与项目工程文件还提供可运行的程序和 GIF 演示图便于快速查看实际效果。目前已有 864 人学习浏览适合正在处理复杂表格展示、希望增强数据可读性的中初级开发者。通过阅读源码与运行示例可掌握单元格合并的调用方式、表头绘制事件中的样式设置与分割线处理并了解子表头文本与背景色的实现思路对实际项目中的数据网格界面设计具有直接借鉴价值。 前阵子做WinForms项目遇到一个特别常见的需求客户觉得报表里同一列连续相同的值重复显示太占地方希望像Excel那样把单元格合并起来整块跨行显示。结果DataGridView偏偏没有原生的RowSpan和ColSpan翻遍属性面板也找不到一个合并单元格的开关。网上搜一圈方案倒是不少但很多都只讲了画边框实际接上去发现点击没反应、排序错乱、固定列出现锯齿踩坑踩到怀疑人生。这篇文章就把我完整做完这套合并逻辑的过程、思路和翻车经验摊开讲给后面接这个需求的同学一条能直接抄的路线。先说明一下下面所有代码基于 .NET 6 的WinForms项目DataGridView用系统默认版本涉及的知识点同样适用于 .NET Framework 4.x。文里会用到三个核心操作自绘CellPainting、命中测试HitTest、以及重写控件行为。1. 为什么DataGridView偏偏没有合并这个原生功能在动手写代码之前花五分钟搞清楚DataGridView的运行机制远比直接在网上复制一段Paint代码要值。1.1 WinForms表格控件的渲染模型DataGridView采用的是逐网格绘制的模型每个单元格DataGridViewCell独立渲染控件内部有一个虚拟化的滚动机制只绘制当前可视区域内的单元格而不是像Excel那样一次性布局整张表。这种模型带来一个关键限制——DataGridView本身并不知道哪些单元格属于同一个区域所以它压根没有合并这个概念。你拿不到类似 HTML 表格里的 colspan 或 rowspan 属性所有合并逻辑都得靠你在自绘事件里自己画出来。这也是为什么搜索资料时会看到大量意大利面式的代码有人重写 OnPaint有人用 DGV 的 CellPainting 事件有人干脆给每个单元格设置边框颜色造成假装合并的效果。本质都是用逻辑去模拟一个视觉区域。1.2 三种常见的合并业务场景实际项目里碰到的合并需求大致可以归成三类跨行同值合并A列是产品大类下面几十行都是同一个值希望只显示一次从第一行纵向撑满整个块。这是最常见、也最好处理的场景。跨列合并表头复杂的二维表头需要把多层表头用合并方式展示。这种通常用独立的表头控件处理DataGridView自带的ColumnHeadersHeightSizeMode真顶不住。不规则区域合并比如某些统计报表里左上角有个跨两行三列的大区块里面放汇总信息。这种最麻烦网上能直接用的代码也最少。1.3 先决策你要的是看起来合并还是数据真合并这是整个方案最关键的分叉点。项目周期紧的话很多开发会先选择只做视觉合并让被合并的中间单元格不画背景、不画边框把起始单元格的背景和文字拉长覆盖过去。视觉合并实现成本最低代码也最短但有一个大坑——数据还在底层表格里单元格仍然可以被点击、选中、甚至编辑。你画上去的背景只是遮丑实际上底下还是一格格独立的Cell鼠标点上去会出现一个孤零零的选中框客户一看就知道是假的。真合并则要连交互一起做掉点击合并区域任意位置都能正确选中起始单元格键盘方向键不会跳进被合并的单元格里覆盖区域的单元格全部变成只读。只有做到这个层级客户才会觉得这个合并是真的。我在下文选择的就是这条完整路线虽然代码量多但交付之后不用反复擦屁股。2. 基于CellPainting自绘合并跨行跨列的基础实现2.1 核心思路一次绘制掩盖一片DataGridView的每个单元格绘制时机不一样但CellPainting事件给了我们一个拦截点。在事件里针对每个即将绘制的单元格我们先判断这个单元格是不是某个合并区域的起始单元格如果是就手动跨行跨列计算一块大矩形画上背景、边框、文字然后通过 e.Handled true 终止默认绘制如果不是起始单元格而是合并区域内部的普通单元格那就什么都不画直接用 e.Handled 把默认绘制吞掉让上层起始单元格画出来的背景自然透过来。这个思路就像刷墙起始单元格是刷子中间的被合并单元格是墙上的旧漆你只需要把整面墙一次性刷完旧漆就不再影响视觉。2.2 合并区域的描述结构接需求的时候我习惯把合并规则抽象成一张列表而不是写死一两个if分支。因为实际报表里经常同时存在多个不同的合并区域有的跨三行有的跨两行两列。我维护了一个简单的类public class CellMergeRange { public int RowIndex { get; set; } public int ColumnIndex { get; set; } public int RowSpan { get; set; } public int ColumnSpan { get; set; } public int EndRowIndex RowIndex RowSpan - 1; public int EndColumnIndex ColumnIndex ColumnSpan - 1; public bool Contains(int row, int col) { return row RowIndex row EndRowIndex col ColumnIndex col EndColumnIndex; } }然后定义一个 List 每次表格数据刷新时重新计算合并区域。比如一个城市列表省份相同的连续行就把从起始行到结束行作为一个 CellMergeRange 塞进列表。2.3 跨行列的绘制代码接下来是核心的自绘逻辑。我在自定义控件里重写了 OnCellPaintingprotected override void OnCellPainting(DataGridViewCellPaintingEventArgs e) { // 取出起点行和结束行的坐标 var merges GetVisibleMergeRanges(); var range merges.FirstOrDefault(m m.Contains(e.RowIndex, e.ColumnIndex)); if (range null) { base.OnCellPainting(e); return; } if (e.RowIndex range.RowIndex e.ColumnIndex range.ColumnIndex) { // 计算合并后的大矩形 Rectangle mergeRect this.GetCellDisplayRectangle(range.ColumnIndex, range.RowIndex, true); for (int col range.RowIndex 1; col range.EndRowIndex; col) { // 这里注意GetCellDisplayRectangle 返回的矩形需要叠加 } for (int col range.ColumnIndex 1; col range.EndColumnIndex; col) { mergeRect Rectangle.Union(mergeRect, this.GetCellDisplayRectangle(col, range.RowIndex, true)); } // 画背景 using (var brush new SolidBrush(e.CellStyle.BackColor)) { e.Graphics.FillRectangle(brush, mergeRect); } // 画单元格边框只画合并区域的外框 ControlPaint.DrawBorder(e.Graphics, mergeRect, e.CellStyle.ForeColor, ButtonBorderStyle.Solid); // 绘制文字居中显示 TextRenderer.DrawText(e.Graphics, e.FormattedValue?.ToString(), e.CellStyle.Font, mergeRect, e.CellStyle.ForeColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); e.Handled true; } else { // 被覆盖的中间单元格直接吞掉默认绘制 e.Handled true; } }这里用 GetCellDisplayRectangle 拿到每个单元格在屏幕上的实际坐标再通过 Rectangle.Union 把多个矩形合并成大区域。有一个细节GetCellDisplayRectangle 的最后一个参数叫 cutOverflow如果传 false返回的矩形甚至会带偏移和负坐标所以我统一传 true保证拿到的都是真实显示矩形。2.4 跨行列的矩形计算方式上面代码里我对跨行和跨列各写了一个 for 循环但要注意方向如果区域跨两列就需要把同一行的右边单元格矩形并进来跨三行则把下面两行的起始列单元格矩形并进来。这里需要再加一个循环把所有行和所有列的矩形完全叠加。为了清晰我会把合并矩形计算独立成一个方法private Rectangle GetSpanRect(CellMergeRange range) { Rectangle rect GetCellDisplayRectangle(range.ColumnIndex, range.RowIndex, true); for (int r range.RowIndex; r range.EndRowIndex; r) { for (int c range.ColumnIndex; c range.EndColumnIndex; c) { if (r range.RowIndex c range.ColumnIndex) continue; rect Rectangle.Union(rect, GetCellDisplayRectangle(c, r, true)); } } return rect; }这个方法把合并区域统一换算成一块屏幕矩形后续画背景、画边框、做命中测试都可以复用避免各写一套。3. 合并最翻车的地方点击、选中与键盘导航光把画面画出来只能骗过眼睛骗不过鼠标。合并区域点击后出现的单格选中框、上下键跳到空白格、双击弹出编辑框这些暴露假合并的行为一个比一个难处理。3.1 命中测试重定向点击合并区域任意位置都选中起始格DataGridView 默认的命中测试逻辑是鼠标点击哪个物理网格就激活哪个Cell。我们要做的第一步是拦截鼠标事件把坐标换算成逻辑起始单元格。思路是在自定义控件里重写 OnCellMouseDown判断点击位置落在哪个合并区域内如果落在区域内但不是起始格就把 CurrentCell 重新设置成起始格protected override void OnCellMouseDown(DataGridViewCellMouseEventArgs e) { if (e.RowIndex 0 e.ColumnIndex 0) { var merge _mergeRanges.FirstOrDefault(m m.Contains(e.RowIndex, e.ColumnIndex)); if (merge ! null (e.RowIndex ! merge.RowIndex || e.ColumnIndex ! merge.ColumnIndex)) { this.CurrentCell this.Rows[merge.RowIndex].Cells[merge.ColumnIndex]; return; // 不再向下传递默认行为 } } base.OnCellMouseDown(e); }这个重定向有一个关键点如果不拦截默认行为CurrentCell 虽然设置到起始格了但鼠标物理点击的还是中间格会产生奇怪的双重选中效果。直接 return 掉不调用 base可以让事件完全由我们自己接管。3.2 被合并单元格的只读与选中状态处理前面提到被合并的单元格虽然视觉上被盖住了但交互层面还活着。最省事的做法是把它们全部设为只读并且让 Selected 状态不生效。在构建合并区域的时候直接对被覆盖的单元格做标记foreach (var merge in _mergeRanges) { for (int r merge.RowIndex; r merge.EndRowIndex; r) { for (int c merge.ColumnIndex; c merge.EndColumnIndex; c) { if (r merge.RowIndex c merge.ColumnIndex) continue; var cell this.Rows[r].Cells[c]; cell.ReadOnly true; cell.Style.BackColor mergeStartCell.Style.BackColor; } } }把中间格子的背景色直接设成和合并起始格一致有一个额外好处在合并区域还没被绘制到、或者滚动出现短暂闪烁的时候底色不会穿帮。3.3 方向键导航避开被合并单元格默认情况下用户按键盘向下箭头CurrentCell 会切到下一行同一列。如果下一行正好是被合并区域内的单元格光标就会钻进一个看不见的格子里。解决方式是覆写 ProcessDownKey 和 ProcessUpKey 等键盘处理方法protected override bool ProcessDownKey(Keys keyData) { if (this.CurrentCell ! null) { int nextRow this.CurrentCell.RowIndex 1; var merge _mergeRanges.FirstOrDefault(m m.ColumnIndex this.CurrentCell.ColumnIndex m.Contains(nextRow, this.CurrentCell.ColumnIndex)); if (merge ! null) { this.CurrentCell this.Rows[merge.EndRowIndex 1] .Cells[this.CurrentCell.ColumnIndex]; return true; } } return base.ProcessDownKey(keyData); }这里要注意边界条件merge.EndRowIndex 1 可能超过数据源行数得先判断一下指针没越界不然方向键一按到底就抛异常。4. 排序、编辑、固定行列四个最容易连环炸的坑当你辛辛苦苦把合并和交互做通了以为万事大吉结果客户随手点了一下列头排序整个表格立刻变成脸着地的场面。排序只是其中一个坑下面四个我全踩过。4.1 排序触发后的合并区域重建默认情况下DataGridView 的列头排序只对当前列的数据排序不会管你的合并区域列表。原来按省份顺序计算出来的 Span排序后行号全乱了合并区域错位到完全陌生的地方。我的做法是把 DataGridView 的 SortCompare 事件用在自定义数据源上不合适更靠谱的是在数据源层面排序。先给存储行的数据源实现 IBindingList 或直接用 BindingSource在点击列头排序时触发 ListChanged 事件重新计算 _mergeRanges。一个更省心的方案在用户点击列头后先调用一个 RebuildMerges() 方法方法内部读取当前排序后的数据重新计算合并区域再触发 Invalidate 重绘。注意这个调用要放在排序完成后否则拿到的还是旧顺序。4.2 合并单元格和编辑框的矛盾合并区域的视觉覆盖会让用户误以为整块都能编辑。但底层其实只有一个真单元格能编辑其他格子即使设了只读也不一定完全禁掉双击事件。有个坑是双击被合并的中间行DataGridView 仍然会尝试进入编辑模式即使ReadOnly为true在一些边界场景下仍然会闪一下编辑框。我的处理是彻底关闭合并区域的编辑入口拦截 EditingControlShowing 事件如果 CurrentCell 的区域 ColumnSpan 或 RowSpan 大于1就禁止编辑框显示private void Grid_EditingControlShowing(object sender, DataGridViewEditingControlShowingEventArgs e) { if (this.CurrentCell null) return; var merge _mergeRanges.FirstOrDefault(m m.Contains( this.CurrentCell.RowIndex, this.CurrentCell.ColumnIndex)); if (merge ! null) { e.Cancel true; // 直接取消编辑控件 } }这个方案会让整个合并区域彻底变成只读。如果业务要求允许编辑合并区域的首格那就需要自己做复杂的区域扩展逻辑复杂度会翻倍一般项目不值得。4.3 固定列Frozen的锯齿和残影DataGridView 的固定列功能Frozentrue会把左边几列钉住横向滚动时固定列保持不动。但我们的合并绘制是纯手动算矩形固定列和滚动列之间的遮挡关系经常处理不好滚动时会出现文字残影或者合并区域的边框一截有一截没有。这个问题没有完美的通用解法我用的办法是当存在固定列时把绘制范围限制在可滚动区域内Rectangle clipRect this.ClientRectangle; if (this.RowHeadersVisible) { // 留出行头区域 } e.Graphics.SetClip(clipRect);同时把固定列相关的合并区域单独处理如果合并区域跨越固定列和非固定列就拆成两个视觉区域来画别指望一笔画到底。4.4 滚动时的性能问题DataGridView 的滚动会频繁触发 CellPainting如果每次绘制都去遍历整个 _mergeRanges 列表数据量大时会出现明显的卡顿。我的优化手段有两个一是按可视区域过滤合并范围只绘制当前可见的行列相交的 merge 区域。二是把每个 merge 区域的绘制缓存成 MemoryBitmap 或直接缓存矩形结构避免每次重复计算。实测数据量两万行、合并区域三百多个时不优化卡到能感觉到迟滞优化后丝滑很多。5. 不规则合并与汇总场景一张表多种合并并存热搜词里有非规则单元格怎么合并汇总这正好对应统计报表里的大区块。比如汇总表里左上角需要跨两行三列放一个合计标签下面又套几个小的跨行合并形成复杂的不规则格子。这类需求看着吓人拆开其实有规律。5.1 不规则区域拆解为多个规则矩形DataGridView 的合并底层只支持矩形区域。所谓不规则一定可以被拆分成若干个矩形区域的组合。比如一个 L 形的合并区本质上就是上面一个横向矩形、下面两个纵向矩形拼出来的视觉效果。你只需要保证这些矩形在绘制时不重叠重叠会导致边框线条混乱。我在实际项目里的做法是画完所有 merge 区域后追加一步描边框的收尾逻辑把所有矩形的外边界重新绘制一遍这样即使相邻矩形有重叠外框也是完整的。5.2 数据填充与同值合并的组合热搜词填充数据合并单元格对应的场景也很典型数据源里同一个大类值在连续行重复出现需要自动把这些连续行合并成一块。实现思路是在绑定数据后遍历数据行比较当前行与上一行的目标列值。如果相同就扩展当前 merge 的 RowSpan如果不同就关闭当前 merge开启一个新 merge。这个同值合并逻辑和前面的 CellMergeRange 完全兼容我写了一个通用函数 RebuildAutoMerge(columnIndex) 来扫描整列public void RebuildAutoMerge(int columnIndex) { _mergeRanges.Clear(); CellMergeRange current null; for (int r 0; r this.Rows.Count; r) { var value this.Rows[r].Cells[columnIndex].Value; if (current ! null object.Equals(current.Value, value) this.Rows[r].Cells[columnIndex].Style.BackColor this.Rows[current.RowIndex].Cells[columnIndex].Style.BackColor) { current.RowSpan; } else { if (current ! null) _mergeRanges.Add(current); current new CellMergeRange { RowIndex r, ColumnIndex columnIndex, RowSpan 1, ColumnSpan 1, Value value }; } } if (current ! null) _mergeRanges.Add(current); this.Invalidate(); }值得注意的一个细节同值合并不能只看相邻行 value 相等还要额外判断样式是否一致。如果两行数据颜色不同强行合并会造成视觉信息丢失客户会质疑你的表格数据错了。5.3 取消合并与导出Excel的兼容问题报表做完合并客户下一步一定是要求导出Excel。这里有个隐藏雷程序里画出来的合并在导出 Excel 时必须转换成 Excel 原生的 MergeCells否则导出的表格是乱的。Excel 用 COM 或 NPOI 都支持 MergeCells 处理做法是遍历 _mergeRanges把每个区域下标记录到一个列表导出时对每个区域调用一次 merge 操作。如果项目里同时有合并导出和取消合并的需求建议把 _mergeRanges 作为唯一数据源所有区域统一从它读取不要出现两套合并列表否则后期维护一定踩到自己埋的雷。6. 一些实用经验补充到这里DataGridView 合并单元格的主干方案已经完整了。这一段补充几个我在开发中觉得特别有价值的经验不成体系但都值回票价。第一个经验合并区域的起始单元格默认情况下未必是可见的尤其是跨行合并且用户滚动到中间位置时起始格可能已经滚出可视区域这时整块区域会突然变白。解决办法是前面提到的合并矩形计算只针对可视区域的单元格做并且每次滚动结束后强制 Invalidate 一次。第二个经验如果你要画合并文字并且文字内容特别长垂直居中水平居中会限制可读性。我的建议是合并区域宽度足够时用 TextFormatFlags.EndEllipsis 加省略号鼠标悬停时通过 ToolTip 显示完整内容比硬画全文字体验好很多。第三个经验关于控件性能跨行合并如果做到几百行数据问题不大但如果达到上万行并且每个 view 都有多个合并区域建议换成 VirtualMode虚拟模式把取值和合并计算都改成按需触发。在虚拟模式下CellPainting 里访问 Cell.Value 会因为 IO 开销爆炸一定要用数据源直接取值而不是通过 Rows[index].Cells[column].Value。第四个经验拿到第三方组件库授权的话真的可以少掉很多头发。市面上 DevExpress、ComponentOne、Telerik 都有成熟的合并表格方案。我对比过组件库它们的合并功能稳定度高但代价是引入包体积大且风格需要适配现有 UI。如果只是临时报表需求用本文的方案成本更低如果整个系统全是报表那给组件库掏钱其实是省钱的。最后一个小坑也是我在开发尾期踩的合并列如果同时是主键列或者数据绑定列DataGridView 的 CurrentCellDirtyStateChanged 事件可能会触发奇奇怪怪的刷新导致合并区域闪烁。保险起见把合并区域涉及的单元格统一设为只读能从源头上避开绝大多数疑难杂症。我自己的体会是DataGridView 单元格合并本身不算复杂难在把所有交互细节都捏合到一块。只要把绘制、命中、导航、排序、导出这五条线都理顺交给客户的就是一块看不出破绽的表格反之任何一条线没接上演示的时候一定在最尴尬的时刻暴露。希望这篇能帮你在接手同类需求的时候少走几步弯路。本文还有配套的精品资源点击获取