简介:本资源是一份面向C# Windows Forms开发者的实用技术方案,聚焦DataGridView控件的单元格合并难题,适用于需定制化表格展示(如多级表头、汇总行、报表式布局)的中高级桌面应用开发场景。压缩包共22个文件,含6个核心C#源码文件(实现自定义绘制与逻辑标记)、3个可执行示例程序(直观验证合并效果)、2个资源文件(.resx)及完整VS解决方案(.sln/.csproj),辅以调试符号(.pdb)和配置文件,结构完整、开箱即用,总大小仅44KB,轻量易集成。已有346人学习下载,读者可直接复用其视觉合并实现逻辑——包括重写Paint事件、动态计算合并区域、统一内容对齐与边框绘制,并获得针对行头分组、列宽自适应禁用、多控件嵌入等典型问题的工程化处理思路,显著降低自行实现的试错成本。
1. DataGridView合并单元格:不是靠重绘控件,而是用“逻辑分组+视觉对齐”骗过人眼的实用方案
你有没有试过在 WinForms 项目里,对着 DataGridView 的MergeCells属性翻遍文档、查遍 Stack Overflow,最后发现——它根本不存在?微软没加,第三方库又太重,硬上 OwnerDraw 搞得表格滚动卡顿、选中错位、焦点丢失,改三行代码崩两处。这个「DataGridView合并单元格」项目,就是为这种场景而生:它不碰底层渲染,不依赖 GDI+ 重绘黑匣子,而是用纯 C# 逻辑层干预 + 行高/列宽/单元格绘制时机的精准协同,在标准 DataGridView 上实现真实可用的跨行合并(如姓名+部门纵向合并)、跨列合并(如表头“2024年Q1销售汇总”横跨3列)。适合做内部管理工具、报表导出前端、数据核对界面的开发者——尤其当你被产品拍着桌子说“这行必须和下两行看起来是一体的”,而你手边只有 .NET Framework 4.7.2 和 Visual Studio 2019。项目已实测兼容 VS2015~VS2022、.NET Framework 4.0~4.8,无 NuGet 依赖,双击.sln即可编译运行,核心逻辑封装在MergedDataGridView.cs一个文件里,改起来比改 CSS 还直接。
2. 架构设计:三层拦截模型——数据层分组、布局层计算、绘制层渲染
这个项目没走“重写 DataGridView”的激进路线,而是把合并拆成三个可验证、可调试、可单独替换的环节:数据分组定义 → 合并区域计算 → 视觉渲染控制。每一层都暴露明确接口,避免“一改全崩”。下面逐层拆解。
2.1 数据分组定义:用 Attribute 标记字段,而非硬编码列索引
合并逻辑必须和业务数据强绑定,但又不能让 UI 层知道“第3列是部门”。项目采用[MergeGroup]特性标记实体属性,让合并规则随数据模型走:
public class SalesRecord { public string Product { get; set; } [MergeGroup(Order = 1)] // Order 决定分组优先级,数值越小越先合并 public string Region { get; set; } [MergeGroup(Order = 2)] public string Quarter { get; set; } public decimal Amount { get; set; } }提示:
Order不是合并顺序,而是分组层级。Region 合并优先级高于 Quarter,意味着当 Region 相同时,即使 Quarter 不同,Region 列也会强制合并;而 Quarter 只在 Region 已断开时才开始合并。这是解决“多级分组嵌套”的关键设计,避免出现“北京-2024Q1”和“北京-2024Q2”被错误合并的翻车现场。
2.2 合并区域计算:MergeCalculator类负责生成矩形坐标集
所有合并逻辑收口在MergeCalculator.CalculateMerges()方法。它接收BindingSource数据源和列映射关系,输出List<Rectangle>——每个Rectangle表示(leftCol, topRow, widthCols, heightRows),即“从第X列第Y行开始,向右合并N列、向下合并M行”。
public class MergeCalculator { public List<Rectangle> CalculateMerges(BindingSource source, Dictionary<string, int> columnNameToIndex) { var merges = new List<Rectangle>(); var data = source.List.Cast<object>().ToList(); // 按 MergeGroup.Order 升序获取待处理属性名 var groupProps = GetOrderedGroupProperties(); foreach (var prop in groupProps) { var colIndex = columnNameToIndex[prop.Name]; var currentStartRow = 0; while (currentStartRow < data.Count) { var currentValue = prop.GetValue(data[currentStartRow]); var mergeHeight = 1; // 向下扫描相同值的连续行数 for (int i = currentStartRow + 1; i < data.Count; i++) { if (Equals(prop.GetValue(data[i]), currentValue)) mergeHeight++; else break; } // 仅当高度 > 1 才记录合并(单行不合并) if (mergeHeight > 1) merges.Add(new Rectangle(colIndex, currentStartRow, 1, mergeHeight)); currentStartRow += mergeHeight; } } return merges; } }这段代码的关键在于:它只计算“跨行合并”,不处理跨列。为什么?因为跨列合并(如表头)本质是静态布局,应由ColumnHeadersDefaultCellStyle或自定义OnCellPainting控制,和动态数据无关。混在一起会导致表头合并被数据刷新重置——这是新手最常踩的坑。
2.3 视觉渲染控制:MergedDataGridView重写OnCellPainting实现“画布级欺骗”
真正的魔法发生在这里。MergedDataGridView继承原生控件,只重写OnCellPainting,在系统绘制每个单元格前,主动判断:“这个单元格是否属于某个合并区域的非首格?如果是,跳过绘制,让首格‘代为显示’”。
protected override void OnCellPainting(DataGridViewCellPaintingEventArgs e) { base.OnCellPainting(e); // 跳过表头绘制(表头合并另用 HeaderCellPainting 处理) if (e.RowIndex == -1) return; // 检查当前单元格是否在合并区域内,且不是左上角单元格 var isMergedCell = _mergeRegions.Any(r => r.Left == e.ColumnIndex && r.Top <= e.RowIndex && e.RowIndex < r.Top + r.Height && r.Width == 1 && // 当前只处理跨行合并 !(r.Top == e.RowIndex)); // 排除首行(首行要正常绘制内容) if (isMergedCell) { // 清空背景,不绘制文字,让上方首格内容自然“透下来” e.PaintBackground(e.ClipBounds, true); e.Handled = true; // 告诉系统:我已处理,别再画了 return; } // 首格正常绘制,但需拉伸背景色覆盖下方合并区域 var mergeForThisCell = _mergeRegions.FirstOrDefault(r => r.Left == e.ColumnIndex && r.Top == e.RowIndex); if (mergeForThisCell != null && mergeForThisCell.Height > 1) { // 绘制时拉伸背景高度 var fullRect = new Rectangle( e.CellBounds.X, e.CellBounds.Y, e.CellBounds.Width, e.CellBounds.Height * mergeForThisCell.Height ); using (var brush = new SolidBrush(e.CellStyle.BackColor)) { e.Graphics.FillRectangle(brush, fullRect); } // 文字仍只在首格居中 TextRenderer.DrawText(e.Graphics, e.FormattedValue?.ToString() ?? "", e.CellStyle.Font, e.CellBounds, e.CellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter); e.Handled = true; } }注意这里两个关键点:
e.Handled = true是必须的,否则系统会在你绘制后再次覆盖;- 背景拉伸用
fullRect计算,但文字只画在e.CellBounds内——这样既保证视觉上“一整块背景”,又避免文字被拉伸变形。这是“骗过人眼”的核心技术细节。
3. 运行与调试:从零启动到定位绘制异常的完整链路
项目结构极简:一个 WinForms 解决方案,含MergedDataGridView.cs(核心类)、DemoForm.cs(演示窗体)、SalesRecord.cs(示例模型)。没有配置文件,不依赖外部 DLL。下面是从下载到跑通的实操路径。
3.1 环境准备与首次编译
确保开发机已安装.NET Framework 4.5+(项目默认目标框架为 4.7.2,但降级到 4.5 仅需修改.csproj中<TargetFrameworkVersion>即可)。打开.sln后,Visual Studio 会自动还原(无 NuGet 包,所以秒完成)。编译前检查两处:
DemoForm.Designer.cs中dataGridView1的类型是否已改为MergedDataGridView(手动替换或拖入新控件);DemoForm.cs构造函数中,dataGridView1.DataSource是否绑定到new BindingSource { DataSource = GetSampleData() }。
提示:
GetSampleData()返回 12 条模拟销售记录,含重复 Region(北京×4、上海×3)和 Quarter(Q1/Q2/Q3),足够触发跨行合并。若想快速验证,可临时注释掉Quarter的[MergeGroup],只留Region,观察合并效果是否变窄。
3.2 启动调试与合并效果验证
按 F5 运行,窗体加载后你会看到:
- “Region” 列中,“北京” 占据连续 4 行,显示为一个垂直拉伸的单元格,文字垂直居中;
- “Quarter” 列中,“2024Q1” 在北京组内占 2 行,“2024Q2” 占 2 行,形成二级嵌套;
- 滚动表格时,合并区域保持完整,无闪烁、无错位;
- 点击任意非合并单元格(如 Amount 列),可正常编辑;点击合并单元格(如 Region 的“北京”),无法编辑——这是设计使然,合并单元格只读,避免数据歧义。
验证合并是否生效,最直接的方法是打断点到MergeCalculator.CalculateMerges()返回处,查看merges列表内容。例如北京组应生成Rectangle(1, 0, 1, 4)(列索引1=Region列,起始行0,高度4)。
3.3 动态刷新数据后的合并重计算
合并不是一次性操作。当调用bindingSource.ResetBindings(false)或bindingSource.Add(newItem)后,必须手动触发重计算:
// 在 DemoForm.cs 中添加 private void RefreshMerges() { var calculator = new MergeCalculator(); var merges = calculator.CalculateMerges(bindingSource, GetColumnIndexMap()); dataGridView1.SetMerges(merges); // MergedDataGridView 提供的公开方法 } // 调用时机示例:新增数据后 private void btnAdd_Click(object sender, EventArgs e) { bindingSource.Add(new SalesRecord { Product = "New", Region = "北京", Quarter = "2024Q4", Amount = 999 }); RefreshMerges(); // 关键!不调用这句,新数据不会参与合并 }SetMerges()内部会触发Invalidate(),强制重绘,因此无需手动Refresh()。这是很多开发者忽略的“后悔药”步骤——数据变了,但合并区域没更新,导致新行突兀地插在旧合并块中间。
4. 避坑指南:5 个真实踩过的坑与血泪修复方案
合并单元格是 WinForms 里的经典玄学区,表面简单,实则处处是坑。以下是我在三个不同客户项目中反复遇到、已验证修复的典型问题。
4.1 现象:滚动时合并区域“撕裂”,下半部分消失,只剩上半截
原因:OnCellPainting中未正确处理e.RowIndex == -1(列标题)和e.ColumnIndex == -1(行标题)的边界情况,导致绘制逻辑误判,部分区域被跳过。
解决:在OnCellPainting开头强制添加边界守卫:
if (e.RowIndex < -1 || e.ColumnIndex < -1 || e.RowIndex >= Rows.Count || e.ColumnIndex >= Columns.Count) { e.Handled = true; return; }4.2 现象:启用AutoSizeColumnsMode = Fill后,合并单元格宽度异常,文字被截断
原因:Fill模式会动态调整列宽,但合并区域的Rectangle坐标基于原始列宽计算,列宽变后坐标失效。
解决:监听ColumnWidthChanged事件,触发重计算:
dataGridView1.ColumnWidthChanged += (s, e) => { if (_autoRefreshOnResize) RefreshMerges(); };并在RefreshMerges()中加入防抖:if (DateTime.Now - _lastRefresh < TimeSpan.FromMilliseconds(200)) return;避免频繁触发。
4.3 现象:设置DefaultCellStyle.WrapMode = True后,合并单元格文字换行错乱,高度不一致
原因:WrapMode影响e.CellBounds.Height,但合并高度是按行数乘以默认行高计算的,未适配实际文本高度。
解决:在OnCellPainting中,对首格使用TextRenderer.MeasureText()计算真实高度,并动态调整fullRect.Height:
var textSize = TextRenderer.MeasureText(e.Graphics, e.FormattedValue?.ToString() ?? "", e.CellStyle.Font, e.CellBounds.Size, TextFormatFlags.WordBreak); var actualHeight = Math.Max(e.CellBounds.Height, textSize.Height * mergeForThisCell.Height);4.4 现象:导出 Excel 时,合并信息丢失,所有单元格恢复为独立单元格
原因:本项目只控制显示层,不修改DataGridViewCell.Value或DataSource结构,导出库(如 EPPlus)读取的是原始数据。
解决:导出前,用MergeCalculator生成 Excel 合并指令:
var excelMerges = _mergeRegions.Select(r => new ExcelRangeAddress(r.Top + 2, r.Left + 1, r.Top + r.Height + 1, r.Left + 1)); // Excel 行列从1起 worksheet.Cells.Merge(excelMerges.ToArray());(需在导出逻辑中集成,非本项目职责,但必须告知使用者)
4.5 现象:SelectionMode = FullRowSelect时,点击合并单元格,整行未被选中,仅首格高亮
原因:FullRowSelect依赖DataGridView内部的行选择逻辑,而合并单元格的e.RowIndex是首行索引,系统无法感知“该行属于合并块”。
解决:重写OnCellClick,主动扩展选择范围:
protected override void OnCellClick(DataGridViewCellEventArgs e) { base.OnCellClick(e); if (e.RowIndex >= 0 && _mergeRegions.Any(r => r.Left == e.ColumnIndex && r.Top == e.RowIndex)) { ClearSelection(); for (int i = e.RowIndex; i < e.RowIndex + _mergeRegions.First(r => r.Left == e.ColumnIndex && r.Top == e.RowIndex).Height; i++) { if (i < Rows.Count) Rows[i].Selected = true; } } }5. 二次开发:支持跨列合并与打印适配的改造实战
跨列合并(如表头“销售汇总”横跨“Q1”、“Q2”、“Q3”三列)是高频需求,但原项目只做跨行。改造它不难,关键是理解“跨列”和“跨行”的本质差异:跨列是静态布局,跨行是动态数据驱动。因此,我们不在MergeCalculator里加逻辑,而是新增HeaderMerger类,专管表头。
5.1 新增HeaderMerger类,接管表头绘制
创建HeaderMerger.cs,定义表头合并规则:
public class HeaderMerger { // key: 列索引起始位置, value: 合并宽度(列数) public Dictionary<int, int> ColumnSpanMerges { get; } = new(); public void AddMerge(int startColumn, int span) { ColumnSpanMerges[startColumn] = span; } public bool IsHeaderMerged(int columnIndex, out int span) { foreach (var kvp in ColumnSpanMerges) { if (columnIndex >= kvp.Key && columnIndex < kvp.Key + kvp.Value) { span = kvp.Value; return true; } } span = 1; return false; } }在MergedDataGridView中添加实例,并重写OnColumnHeaderMouseClick(用于演示点击合并表头):
private readonly HeaderMerger _headerMerger = new(); public void SetHeaderMerges(Dictionary<int, int> merges) { _headerMerger.ColumnSpanMerges.Clear(); foreach (var kvp in merges) _headerMerger.ColumnSpanMerges[kvp.Key] = kvp.Value; Invalidate(); // 强制重绘表头 }5.2 改造OnCellPainting,支持表头跨列绘制
在OnCellPainting中,补充表头处理分支:
if (e.RowIndex == -1) // 表头行 { int span; if (_headerMerger.IsHeaderMerged(e.ColumnIndex, out span)) { // 找到该合并组的最左列 var leftMostCol = e.ColumnIndex; while (_headerMerger.IsHeaderMerged(leftMostCol - 1, out _)) leftMostCol--; // 计算合并总宽度 var totalWidth = 0; for (int c = leftMostCol; c < leftMostCol + span; c++) { if (c < Columns.Count) totalWidth += Columns[c].Width; } // 只在最左列绘制合并内容 if (e.ColumnIndex == leftMostCol) { var headerRect = new Rectangle( Columns[leftMostCol].DisplayRectangle.X, e.CellBounds.Y, totalWidth, e.CellBounds.Height ); e.PaintBackground(headerRect, true); TextRenderer.DrawText(e.Graphics, Columns[leftMostCol].HeaderText, e.CellStyle.Font, headerRect, e.CellStyle.ForeColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); } e.Handled = true; return; } }5.3 打印适配:绕过PrintDocument的单元格限制
DataGridView自带的PrintDocument不识别合并,会按原始网格打印。正确做法是用Graphics直接绘制:
public void PrintGrid(PrintPageEventArgs e) { var grid = this; var y = e.MarginBounds.Top; // 先画表头(含跨列合并) for (int c = 0; c < grid.Columns.Count; c++) { int span; if (_headerMerger.IsHeaderMerged(c, out span) && c == GetLeftmostOfSpan(c)) { var x = e.MarginBounds.Left; for (int i = 0; i < span; i++) x += (i == 0 ? 0 : grid.Columns[c + i].Width); // ... 绘制合并表头 } } // 再画数据行(含跨行合并) for (int r = 0; r < grid.Rows.Count; r++) { var merge = _mergeRegions.FirstOrDefault(m => m.Top == r && m.Left == 0); if (merge != null) { // 拉伸行高绘制 y += grid.RowTemplate.Height * merge.Height; } else { y += grid.RowTemplate.Height; } } }从那以后我每次接到“要合并”的需求,第一反应不是搜“DataGridView merge cell”,而是打开这个项目,把
MergedDataGridView.cs拖进新工程,花10分钟配好[MergeGroup],再花5分钟加个HeaderMerger。它不完美,但稳定、透明、可调试——比起那些“一行代码搞定”却让你三天找不到 bug 的 NuGet 包,我宁愿多写十行可控的代码。希望帮到你。
本文还有配套的精品资源,点击获取