简介:面向使用 C# 开发 Windows Forms 界面的初中级开发者,这份 DataGridView 合并单元格示例围绕表格标题行、汇总行及复杂表头布局需求,演示如何通过逻辑隐藏与自定义绘制实现视觉合并。压缩包共 22 个文件、约 44KB,包含 6 个 cs 源码及 sln/csproj 项目文件、可执行 exe、resx/resources 资源和 pdb 调试符号,并附 txt 说明,便于直接打开解决方案对照调试。已有 349 人学习。读者可从中梳理 Paint/OnPaint 事件处理、单元格矩形重绘、内容对齐、列宽自适应设置以及行头合并等关键点,理解合并后各单元格数据仍独立存储的限制,并参考自定义 DataGridViewCell 扩展控件的思路,为实际项目中的复杂表格布局提供可复用实现参考。
1. 合并单元格:一个“看起来像”比“真的是”更常见的问题
C# 里做 DataGridView 合并单元格,第一次接触的人常会搜“MergeCells 怎么调”,然后发现框架根本没这个方法。DataGridView 只支持合并表头(ColumnHeader 的 Cell 合并),数据区单元格没有原生合并能力。你在业务系统里看到的那些“客户名跨三行”“订单号竖着占一格”的效果,九成是把相邻单元格的边框擦掉、再手工补画文本做出来的视觉合并,底层仍然是 N 个独立单元格。
这个方案能解决三类诉求:报表式展示、同类数据去重、打印前排版。适合已经用 WinForms 绑定 DataTable 或列表、需要只读呈现的分析型界面。不适合需要单元格内编辑、需要真正跨行列合并、或者数据量上万还要求流畅滚动的场景,这些得换思路。下面按“选型、实现、表头、踩坑”的顺序拆,代码都能直接落进项目。
2. 三种合并路线的取舍:先想清楚你要合的是哪一层
2.1 DataGridView 为什么没有 MergeCells
WinForms 的 DataGridView 从设计上就是个“严格二维表”控件,它内部维护的是行集合和列集合的笛卡尔积,每个单元格有独立索引、独立编辑状态、独立样式。合并单元格会破坏这个模型:一个跨三行的单元格,它的 RowIndex 该算几?编辑时焦点落在哪一行?选中一行的时候跨行单元格算不算选中?这些问题在框架层面没有统一答案,所以微软干脆不给。
对比一下 Excel 的合并:Excel 合并之后,被合并区域的左上角单元格保留值,其余清空,打印和公式都以左上角为准,模型是明确的。而 DataGridView 如果强行合并,业务代码里到处都在用Rows[i].Cells[j].Value取值,合并后一半的格子里没值,取数逻辑立刻全崩。
所以合格的做法是把“视觉合并”和“数据模型”彻底分开:数据层保持完整二维结构,一行一条记录;呈现层只负责把相邻同值的格子画成一个整块。这样导出、排序、选中、编辑都还按原模型走,只有绘制和点击命中需要特殊处理。
2.2 三条路线的对比:自绘、双网格、数据层去重
| 路线 | 做法 | 适合场景 | 主要代价 |
|---|---|---|---|
| CellPainting 自绘 | 接管单元格绘制,擦边框、合并矩形、画一次文本 | 只读报表、上千行以内 | 选中态、编辑、滚动都要自己补 |
| 双网格叠加 | 上面一个空网格当表头/分组列,下面放数据网格 | 复杂表头、行头分组 | 列宽、滚动要同步,窗口缩放容易错位 |
| 数据层去重 | 只显示分组的第一行,其余行留空值 | 简单统计表 | 排序筛选后要重算,用户看不到明细行 |
实际项目里我用得最多的是第一种,第二种只在表头有两级分组时用,第三种基本不碰——因为一旦业务方说“我要看到每条明细”,去重方案就得推倒重来。自绘方案的上限也明确:五千行以内、每帧重绘量可控的界面,流畅度没问题;上万行就得配合虚拟模式,这个话题放在最后一章收。
2.3 先把样本数据摆好:一个能撑起合并的最小结构
合并逻辑的正确性高度依赖数据形状,先造一份“客户-订单-商品”的三层明细,每层都有重复值,跑起来才看得见问题。
private static DataTable BuildDemoTable() { var dt = new DataTable("OrderDetail"); dt.Columns.Add("Customer", typeof(string)); dt.Columns.Add("OrderNo", typeof(string)); dt.Columns.Add("Product", typeof(string)); dt.Columns.Add("Qty", typeof(int)); string[] customers = { "华东甲", "华南乙", "华北丙" }; int seq = 1000; foreach (string customer in customers) { int orderCount = 2 + (seq % 2); // 每个客户 2~3 张订单 for (int i = 0; i < orderCount; i++) { string orderNo = $"SO{++seq}"; int lines = 2 + (i % 3); // 每张订单 2~4 个商品行 for (int j = 0; j < lines; j++) dt.Rows.Add(customer, orderNo, $"P{j + 1:00}", 10 * (j + 1)); } } return dt; }Customer列会出现连续重复,OrderNo列在同一个客户内重复,这就是合并的判定依据——注意是“连续相同”,不是“全局相同”。同一个客户如果因为排序把它的两组订单拆开,中间隔着别的客户,那这两段不能合成一块,这也是后面排序踩坑的根源。
绑定和初始化控制在四行以内,重点是关掉自动新增行:
var grid = new DataGridView { Dock = DockStyle.Fill, AutoGenerateColumns = true, AllowUserToAddRows = false, // 新行会让合并计算多算一行空数据 AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, DataSource = BuildDemoTable() }; Controls.Add(grid);AllowUserToAddRows这一项必须关。开着的话网格底部永远有一行IsNewRow == true的占位行,它的 Cell.Value 是 null,合并扫描时如果没跳过,会跟上一行的空值连成一片,界面上看着像“最后两行莫名合并了”。AutoSizeColumnsMode选 Fill 是为了让列宽随窗口变化,但要注意列宽变化会触发重绘,合并区域的矩形必须实时算,不能缓存。
3. 用 CellPainting 接管绘制:一份能直接跑的合并引擎
3.1 判定合并区间:一次 Rebuild,缓存行区间而不是矩形
合并的核心数据结构就一句话:对每一列,找出所有“连续且值相同”的行区间,把区间内每个格子都映射到这个区间上。缓存里存的是行号区间(Top, Bottom),不存像素矩形——矩形会随滚动、列宽变化、DPI 缩放而失效,缓存它等于给自己埋雷。
public sealed class MergeEngine { private readonly DataGridView _grid; private readonly HashSet<int> _mergeColumns = new HashSet<int>(); // (行号, 列号) -> (区间起始行, 区间结束行) private readonly Dictionary<(int Row, int Column), (int Top, int Bottom)> _span = new Dictionary<(int, int), (int, int)>(); public MergeEngine(DataGridView grid, params int[] mergeColumns) { _grid = grid ?? throw new ArgumentNullException(nameof(grid)); foreach (int c in mergeColumns) _mergeColumns.Add(c); _grid.CellPainting += OnCellPainting; _grid.CellBeginEdit += OnCellBeginEdit; } public void Rebuild() { _span.Clear(); int rowCount = _grid.Rows.Count; foreach (int col in _mergeColumns) { int row = 0; while (row < rowCount) { if (_grid.Rows[row].IsNewRow) break; string key = Normalize(_grid.Rows[row].Cells[col].Value); if (key == null) { row++; continue; } // 空值不参与合并 int end = row; while (end + 1 < rowCount && !_grid.Rows[end + 1].IsNewRow && Normalize(_grid.Rows[end + 1].Cells[col].Value) == key) { end++; } for (int r = row; r <= end; r++) _span[(r, col)] = (row, end); row = end + 1; } } _grid.Invalidate(); } private static string Normalize(object value) { if (value == null || value == DBNull.Value) return null; string s = Convert.ToString(value); return string.IsNullOrWhiteSpace(s) ? null : s; } }Normalize这层看着多余,其实很关键。数据库里字符串字段常见""、" "、DBNull.Value三种空值,直接ToString()比较会把它们当成同一个 key,于是整段空白行被合成一个巨大的空块。返回 null 表示“该格不参与合并”,扫描时直接跳过,视觉上就是独立单元格。如果业务上确实要求“空值也合并”,把 null 换成一个固定哨兵字符串就行。
Rebuild的复杂度是 O(行数 × 合并列数),一次全表扫描。调用时机有三个:数据绑定完成、排序/筛选之后、用户手动改了某列的值之后。不要挂在CellValueChanged上无脑调用,逐格编辑时会退化成 O(n²)。
3.2 合并区域的矩形:一定要实时算,并且过滤掉不可见行
绘制时要拿到一个跨越 top 到 bottom 的像素矩形。直觉写法是GetCellDisplayRectangle逐个 Union,但滚动之后部分行已经不在视口内,该方法对不可见行返回的是宽度或高度为 0 的矩形,Union 进去会让整个区域塌掉。
private Rectangle GetRegionRectangle(int column, int top, int bottom, Rectangle fallback) { Rectangle rect = Rectangle.Empty; for (int r = top; r <= bottom; r++) { Rectangle cr = _grid.GetCellDisplayRectangle(column, r, false); if (cr.Width <= 0 || cr.Height <= 0) continue; // 该行已滚出视口 rect = rect.IsEmpty ? cr : Rectangle.Union(rect, cr); } return rect.IsEmpty ? fallback : rect; }第三个参数cutOverflow传 false,表示返回完整单元格矩形而不是裁剪到列宽内的部分,合并区域的左右边界要靠它对齐。矩形为空时退回当前单元格的CellBounds,保证绘制不会画到 (0,0) 上去。
这里有个必须交代的边界:如果合并区域很长,首行和末行同时滚出视口,中间可见的那几行算出来的矩形只是可见部分,绘制文本时会以可见部分居中,看起来文字“跳”了一下。彻底解决要在区域完全不可见时跳过绘制、部分可见时用完整区域矩形来定位文本,代价是要自己维护行高累加。多数报表场景行数不多,用上面的写法已经够用。
3.3 首行负责画,其余行交出绘制权:文本、背景、三条边框
CellPainting是在框架默认绘制之前触发的,把e.Handled置 true 就等于说“这个格子我自己来”。合并区域里每个格子都会触发一次该事件,绘制逻辑相同,所以谁先谁后都不影响最终结果——这点很重要,滚动导致局部重绘时,某一格单独被重画也不会露出白底。
private void OnCellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex < 0) return; if (!_span.TryGetValue((e.RowIndex, e.ColumnIndex), out var span)) return; e.Handled = true; // 完全接管,框架不再画边框和文本 Rectangle rect = GetRegionRectangle(e.ColumnIndex, span.Top, span.Bottom, e.CellBounds); bool selected = IsRegionSelected(e.ColumnIndex, span.Top, span.Bottom); var cell = _grid.Rows[span.Top].Cells[e.ColumnIndex]; var style = cell.InheritedStyle; Region savedClip = e.Graphics.Clip; e.Graphics.SetClip(rect); try { using (var back = new SolidBrush(selected ? style.SelectionBackColor : style.BackColor)) e.Graphics.FillRectangle(back, rect); string text = Convert.ToString(e.FormattedValue) ?? string.Empty; var flags = TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter | TextFormatFlags.WordBreak | TextFormatFlags.EndEllipsis; TextRenderer.DrawText(e.Graphics, text, style.Font, rect, selected ? style.SelectionForeColor : style.ForeColor, flags); using (var pen = new Pen(_grid.GridColor)) { e.Graphics.DrawLine(pen, rect.Left, rect.Top, rect.Left, rect.Bottom - 1); e.Graphics.DrawLine(pen, rect.Right - 1, rect.Top, rect.Right - 1, rect.Bottom - 1); e.Graphics.DrawLine(pen, rect.Left, rect.Bottom - 1, rect.Right - 1, rect.Bottom - 1); } } finally { e.Graphics.Clip = savedClip; } }几个参数值得单独说。SetClip(rect)加 try/finally 恢复裁剪区,是因为CellPainting触发时 Graphics 的裁剪范围默认限制在当前单元格内,不放开的话,合并区域超出首行的那部分根本画不出来,表现为“只有第一行有文字,下面几行是空的”。这个坑我在两个项目里都踩过,表现一模一样,排查起来很费时间。
文本用TextRenderer.DrawText而不是Graphics.DrawString,因为 DataGridView 默认走的就是 GDI 文本渲染,混用会让合并格和普通格的字重、字距对不上,肉眼一眼能看出差别。TextFormatFlags.EndEllipsis保证窄列里长文本有省略号,不然会溢出到相邻列。
边框只画左、右、下三条,上边不画。因为区域顶部的横线由上方的普通单元格或列头负责,重复画会加深颜色。如果整个网格设置了CellBorderStyle = None,这里也应该跟着判断,最简单的方式是读_grid.CellBorderStyle决定是否走绘制分支。
3.4 选中态:整段都选中才上高亮,否则各画各的
IsRegionSelected的判定标准是“区间内每一行的该列都被选中”。只有全部选中才把整块刷成选中色,部分选中就按普通色画。这是有意的取舍:如果按行拆分高亮,合并区会被横线切成几段,视觉上又变回没合并的样子。
private bool IsRegionSelected(int column, int top, int bottom) { for (int r = top; r <= bottom; r++) { if (!_grid.Rows[r].Cells[column].Selected) return false; } return true; }配套要在 Form 里把SelectionMode设成FullRowSelect或RowHeaderSelect,让整行一起选中,这样合并区要么全亮要么全灭。用CellSelect的话,用户点一下只会选中首行那一格,合并区大面积还是普通色,看起来像“点不动”。如果业务上必须单元格级选中,那就得接受高亮断裂,或者自己接管选区绘制。
背景色读的是span.Top那行的InheritedStyle.BackColor,不是当前行。开了交替行颜色后,合并区跨了奇偶行,逐行取色会画出斑马纹的合并块。统一取首行颜色,整块才干净。代价是交替色在合并列上会“断档”,视觉上反而更容易识别分组,多数报表都这么处理。
3.5 点击与编辑:把行号翻译回区间首行
合并之后最后一个问题:用户点的是第 5 行,业务想拿到的是这个区域的第一行(第 3 行)。编辑更麻烦,合并区中间那几格如果允许进入编辑,光标会出现在一个没有值的格子里。
public int GetAnchorRow(int rowIndex, int columnIndex) { return _span.TryGetValue((rowIndex, columnIndex), out var span) ? span.Top : rowIndex; } private void OnCellBeginEdit(object sender, DataGridViewCellCancelEventArgs e) { if (_span.TryGetValue((e.RowIndex, e.ColumnIndex), out var span) && span.Top != e.RowIndex) { e.Cancel = true; // 禁止从中间格进编辑 _grid.CurrentCell = _grid.Rows[span.Top].Cells[e.ColumnIndex]; _grid.BeginEdit(true); } }CellContentClick、CellDoubleClick、右键菜单的定位,全部先过一遍GetAnchorRow再取业务 ID,否则删除、跳转详情这些操作会作用在“视觉上不存在”的行上。OnCellBeginEdit里 Cancel 之后立刻把 CurrentCell 挪到首行再 BeginEdit,用户感觉是“点哪都能编辑”,实际编辑的永远是首行那个真实的格子。
禁止中间行编辑还有一种更省事的做法:合并列整体设ReadOnly = true,可编辑的列不参与合并。报表场景里合并列通常就是客户名、订单号这类只读标识,直接锁掉最稳。真要编辑,就只能在CellEndEdit里把新值同步到区域内所有单元格,保证数据模型里那段值仍然一致——否则下次Rebuild时合并关系会突然断开。
4. 表头与行头:合并需求最容易被低估的两块区域
4.1 双层表头:用主表上方的一个空网格接管表头区域
数据区的合并靠CellPainting,但表头完全没有这个事件——DataGridViewColumnHeaderCell的绘制入口不对外暴露。直接在主表上盖一层没法做,因为列头区域是控件的非客户区,子控件盖不上去。
可行的做法是在主表上方 Dock 一个高度 26 像素的空网格当“表头画布”,它没有行、没有行头、不显示滚动条,只靠Paint事件手画。
private void InitGroupHeader(DataGridView main, DataGridView header, List<(int Start, int End, string Title)> groups) { header.Dock = DockStyle.Top; header.Height = 26; header.AllowUserToAddRows = false; header.RowHeadersVisible = false; header.ColumnHeadersVisible = false; header.ScrollBars = ScrollBars.None; header.AllowUserToResizeColumns = false; // 列宽统一由主表控制 header.ColumnCount = main.ColumnCount; for (int i = 0; i < main.ColumnCount; i++) header.Columns[i].Width = main.Columns[i].Width; header.Paint += (s, e) => DrawGroupHeader(header, e.Graphics, groups); main.Scroll += (s, e) => header.HorizontalScrollingOffset = main.HorizontalScrollingOffset; main.ColumnWidthChanged += (s, e) => { for (int i = 0; i < main.ColumnCount; i++) header.Columns[i].Width = main.Columns[i].Width; header.Invalidate(); }; }关键参数是ScrollBars.None和ColumnHeadersVisible = false——让 header 退化成一块只能提供列坐标的画布。HorizontalScrollingOffset有公开的 setter,主表横向滚动时同步给 header,两组列才能对齐。AllowUserToResizeColumns必须关,否则用户在上层拖列宽,下层不动,两根竖线立刻错位。
绘制部分自己按列宽累加算坐标,不要依赖GetColumnDisplayRectangle,因为 header 没有行,那个方法在某些状态下返回空矩形:
private void DrawGroupHeader(DataGridView header, Graphics g, List<(int Start, int End, string Title)> groups) { g.Clear(header.BackgroundColor); int[] xs = new int[header.ColumnCount + 1]; int x = -header.HorizontalScrollingOffset; for (int i = 0; i < header.ColumnCount; i++) { xs[i] = x; x += header.Columns[i].Width; } xs[header.ColumnCount] = x; foreach (var grp in groups) { var rect = new Rectangle(xs[grp.Start], 0, xs[grp.End + 1] - xs[grp.Start], header.Height); using (var b = new SolidBrush(SystemColors.Control)) g.FillRectangle(b, rect); TextRenderer.DrawText(g, grp.Title, header.Font, rect, header.ForeColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); g.DrawRectangle(SystemPens.ControlDark, rect.Left, rect.Top, rect.Width - 1, rect.Height - 1); } }xs数组把每列的左边界预先算出来,合并分组时直接取xs[Start]到xs[End + 1],减掉滚动偏移后坐标和主表完全一致。这种算法的好处是分组之间不需要相邻,可以隔列合并,业务上“金额和税额共用一个‘金额信息’大标题、中间夹一个不相关的列”也能支持。
4.2 行头合并:与其硬改 RowHeader,不如插一个冻结的分组列
RowHeadersVisible = true之后,行头区域在CellPainting里对应e.ColumnIndex == -1,理论上也能自绘合并。但行头宽度有限、没有 CellStyle 继承、还要处理行高变化,写出来的代码比数据区合并乱一倍。
我一般反过来做:关掉行头,在最左侧插入一个普通列充当分组标签,然后给网格设Frozen = true或者用SetFrozen。这样它就是一个数据区列,直接复用第 3 章的引擎,一行代码都不用改。
// 插入一列作为分组标签,绑到和 Customer 相同的字段 var label = new DataGridViewTextBoxColumn { Name = "GroupLabel", DataPropertyName = "Customer", HeaderText = "客户", Frozen = true, // 横向滚动时固定在最左 Width = 90, ReadOnly = true }; grid.Columns.Insert(0, label); // 重建引擎时把这一列也纳入合并范围 _engine = new MergeEngine(grid, 0, 1, 2);Frozen = true的列在横向滚动时保持不动,视觉上就是“行头”。它和普通列的唯一区别是冻结区在绘制时的坐标偏移由框架处理好了,GetCellDisplayRectangle返回的 X 已经是屏幕上的正确位置,不用自己减偏移。这比自己画行头省事得多,也避免了一个冷门坑:行头区域的CellPainting在某些主题下不触发。
4.3 冻结行列与滚动:坐标必须每次重算,缓存等于挖坑
只要涉及冻结列,合并矩形就不能缓存。冻结区和非冻结区的坐标计算方式不同,滚动条一动,非冻结区的 X 整体平移,冻结区不动,缓存下来的矩形会立刻错位,表现为“滚一下,合并块的文字跑到隔壁列去了”。
判断办法很简单:滚动事件里不重建_span(行区间不变),但要Invalidate()让所有格子重画,绘制时重新调GetCellDisplayRectangle。上面的GetRegionRectangle已经是实时计算,所以天然没这个问题——前提是你别为了“性能”在里面加一层缓存。
还有一个细节:GetCellDisplayRectangle对冻结列返回的是控件坐标,与非冻结列同一坐标系,两者可以放心 Union。但如果自己写x += Columns[i].Width累加来算 X,就必须先减掉HorizontalScrollingOffset,再单独处理冻结列不参与偏移。这个分叉最容易写错,能用框架方法就别自己算。
5. 避坑排查:七个把合并
本文还有配套的精品资源,点击获取