ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WinForms DataGridView合并单元格实战:CellPainting自定义绘制与避坑指南

WinForms DataGridView合并单元格实战:CellPainting自定义绘制与避坑指南 简介这份资源面向使用 C# 开发 Windows Forms 应用的开发者聚焦 DataGridView 控件的单元格合并问题。当需要在表格中创建标题行、汇总行或复杂表头时普通单元格布局往往难以满足需求资源围绕逻辑合并与视觉合并两种思路展开重点讲解通过重写 OnPaint 事件自定义绘制来实现视觉合并并涉及对齐方式设置、AutoSizeColumnsMode 调整、行头主副表头处理以及自定义 DataGridViewCell 等关键细节。压缩包为 rar 格式共 22 个文件约 44KB包含 6 个 cs 源码文件、3 个 exe 可执行程序、2 个 resx 与 2 个 resources 资源文件、2 个 pdb 调试符号以及 csproj、sln、suo、settings、manifest 等工程配置另附 1 个 txt 说明便于直接运行与二次开发。目前已有 344 人学习下载适合希望掌握表格合并绘制技巧、提升界面表现力的中初级开发者参考。1. DataGridView合并单元格为什么你的表格看起来总像没对齐的草稿做过 WinForms 的人大概率都遇到过这种场景一张订单明细表同一个客户下了五条商品记录客户名和订单号在五列里重复了五遍打印出来像复读机。产品经理看了一眼说能不能把相同的单元格合并一下像 Excel 那样。你打开 DataGridView 的属性面板翻了半天发现它根本没有MergeCells这种属性。这就是 DataGridView 合并单元格这件事的起点——控件本身不提供原生合并你得自己在CellPainting事件里画。这篇文章要解决的就是这个问题在 WinForms 的 DataGridView 里怎么按某一列的值把相邻行的单元格合并成视觉上的一整块同时保证选中、滚动、排序、编辑这些交互不崩。适合已经能用 DataGridView 绑数据、但对自定义绘制不太熟的中级开发者也适合被“填充数据合并单元格”这个需求反复折磨、想找一套稳定方案的人。下面从原理到代码到踩坑一步步拆开讲。2. 合并的底层逻辑CellPainting 接管绘制而不是真的合并2.1 为什么 DataGridView 没有原生合并DataGridView 的设计哲学是“每个单元格独立”它的行高列宽、选中区域、编辑控件都是按单元格粒度管理的。你如果强行把两个单元格的边界去掉选中逻辑就会出问题——点一下选中半个合并块键盘导航也会跳错位置。所以微软干脆不提供这个功能留给开发者自己处理。常见的做法是在CellPainting事件里做文章判断当前单元格是否属于一个合并区间如果是区间的起始单元格就画一个跨越多行/多列的矩形把值画在中间如果是区间内的其他单元格就把它们的绘制全部跳过只画背景色或者干脆不画。这样视觉上看起来是一个大单元格但底层数据模型没变选中和编辑还是按原来的单元格走。这里有一个关键点合并只影响绘制不影响数据。你的 DataSource 里该有几行还是几行排序、过滤、导出都不受影响。这也是为什么我推荐这种做法——它不会污染你的业务数据。2.2 合并区间的计算按列值分组记录起止行号在画之前你得先知道哪些行要合并。假设我们按第一列客户名合并逻辑就是遍历所有行如果当前行的第一列值和上一行相同就归入同一个区间不同就结束上一个区间开一个新的。这个计算不能每次 CellPainting 都做一遍那样滚动起来会卡。我一般会在数据绑定完成后、或者用户触发排序/过滤后预先算好一个合并区间列表缓存起来。结构大概是这样// 合并区间记录每一段连续相同值的起始行和结束行 public class MergeRange { public int ColumnIndex { get; set; } // 按哪一列合并 public int StartRow { get; set; } // 起始行索引 public int EndRow { get; set; } // 结束行索引含 } // 计算指定列的合并区间 private ListMergeRange BuildMergeRanges(DataGridView grid, int columnIndex) { var ranges new ListMergeRange(); if (grid.Rows.Count 0) return ranges; int start 0; for (int i 1; i grid.Rows.Count; i) { // 到达末尾或者当前行与上一行的值不同就结算一个区间 bool isEnd i grid.Rows.Count; bool isDifferent !isEnd !string.Equals( grid.Rows[i].Cells[columnIndex].Value?.ToString(), grid.Rows[start].Cells[columnIndex].Value?.ToString()); if (isEnd || isDifferent) { // 只有连续两行以上才值得合并单行不处理 if (i - 1 start) { ranges.Add(new MergeRange { ColumnIndex columnIndex, StartRow start, EndRow i - 1 }); } start i; } } return ranges; }这段代码里start是当前区间的起始行i是扫描指针。当发现值变了或者扫到最后一行就把start到i-1这段收进结果。注意i grid.Rows.Count这个边界循环条件写成i Count就是为了让最后一段也能被结算。string.Equals用?.ToString()是为了防止 null 值直接抛异常。参数方面columnIndex就是你要按哪一列合并比如客户名在第 0 列就传 0。如果你要多列联合判断比如客户名订单号都相同才合并把isDifferent的判断改成多列比较就行。2.3 在 CellPainting 里画合并块e.Handled 是关键有了区间列表接下来就是在CellPainting事件里根据当前单元格的位置决定怎么画。核心逻辑是如果当前单元格是某个区间的起始单元格就计算跨越整个区间的矩形画背景、画边框、画值如果是区间内的非起始单元格直接e.Handled true跳过绘制。private ListMergeRange _mergeRanges new ListMergeRange(); private void Grid_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; // 查找当前单元格是否落在某个合并区间内 var range _mergeRanges.FirstOrDefault(r r.ColumnIndex e.ColumnIndex e.RowIndex r.StartRow e.RowIndex r.EndRow); if (range null) return; // 不在合并区间走默认绘制 if (e.RowIndex range.StartRow) { // 起始单元格画一个跨越多行的矩形 e.Graphics.FillRectangle( new SolidBrush(e.CellStyle.BackColor), e.CellBounds); // 计算合并后的总高度 int totalHeight 0; for (int i range.StartRow; i range.EndRow; i) totalHeight grid.Rows[i].Height; var mergedRect new Rectangle( e.CellBounds.X, e.CellBounds.Y, e.CellBounds.Width, totalHeight - 1); // 画边框 e.Graphics.DrawRectangle( Pens.LightGray, mergedRect); // 画值垂直居中 TextRenderer.DrawText( e.Graphics, e.Value?.ToString() ?? , e.CellStyle.Font, mergedRect, e.CellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); e.Handled true; // 告诉控件这个格子我画完了 } else { // 区间内的非起始单元格只画背景不画内容 e.Graphics.FillRectangle( new SolidBrush(e.CellStyle.BackColor), e.CellBounds); e.Handled true; } }这里有几个细节值得说。第一totalHeight是把区间内所有行的高度累加因为 DataGridView 允许每行高度不同不能简单用e.CellBounds.Height * 行数。第二mergedRect的高度减了 1是为了让边框不重叠。第三TextRenderer.DrawText比Graphics.DrawString更贴近 WinForms 原生渲染效果字体清晰度更好。e.Handled true是整段代码的灵魂。如果你忘了设它控件会在你画完之后再画一遍默认内容结果就是文字重叠、边框错乱。这个坑我踩过不止一次。3. 把合并接进真实项目绑定、刷新与交互处理3.1 数据绑定后触发合并计算合并区间不是一成不变的数据变了、排序变了、过滤变了区间都得重算。我一般会在DataBindingComplete事件里调用BuildMergeRanges然后Invalidate()触发重绘。private void Grid_DataBindingComplete(object sender, DataGridViewBindingCompleteEventArgs e) { // 数据绑定完成后重新计算合并区间 _mergeRanges BuildMergeRanges(grid, 0); // 按第0列合并 grid.Invalidate(); // 强制重绘 }DataBindingComplete的好处是它在数据源赋值、排序、过滤之后都会触发你不需要在每个操作后面手动调。但要注意如果你在代码里逐行Rows.Add这个事件不会触发得手动调一次BuildMergeRanges。3.2 排序和过滤后合并区间必须重算用户点列头排序行的顺序变了原来相邻的相同值可能不再相邻合并区间就失效了。如果你不重算会出现“该合并的没合并、不该合并的合在一起”的玄学现象。处理方式是在Sorted事件和过滤逻辑结束后重新调用BuildMergeRanges。如果你用的是BindingSource做过滤可以在ListChanged事件里处理。private void Grid_Sorted(object sender, EventArgs e) { // 排序后行顺序变了合并区间必须重算 _mergeRanges BuildMergeRanges(grid, 0); grid.Invalidate(); }这里有个性能提醒如果你的表有几万行每次排序都全量重算区间会有明显卡顿。优化方向是只对可见区域做合并计算或者用二分查找定位区间。但对大多数业务表几千行以内全量算完全够用。3.3 选中和键盘导航怎么不出戏因为底层没有真正合并用户点合并块的中间行时选中的还是那一行的单元格视觉上会出现“选中框只框了半个合并块”的情况。这个没法完全消除但可以缓解在SelectionChanged里判断当前选中的行是否在某个合并区间内如果是就把整个区间的背景色都改一下让用户感知到“我选中的是这一整块”。另一个问题是键盘上下移动时光标会在合并区间内的每一行停留。如果你希望跳过中间行可以在KeyDown里拦截方向键手动把CurrentCell跳到区间的起始行或下一段的起始行。这个交互看产品需求不是必须做。提示合并单元格的选中行为是这类方案最大的体验短板建议在需求评审时就和使用方对齐预期避免做完被说“选中怎么怪怪的”。4. 避坑与排查合并单元格最常见的五个翻车现场4.1 滚动时合并块闪烁或错位现象表格滚动时合并的大单元格一会儿画出来一会儿消失或者边框跑到别的行上。原因CellPainting是按需触发的滚动时只重画可见区域。如果你的合并区间起始行滚出了可视区域但结束行还在那么当前可见的结束行单元格会走else分支只画背景看起来就像合并块断了。解决在绘制逻辑里判断如果当前单元格属于某个区间但起始行不可见仍然要画一个从可视区域顶部开始的矩形。简单做法是在CellPainting里用grid.FirstDisplayedScrollingRowIndex判断把mergedRect的 Y 坐标钳制到可视范围内。4.2 合并后单元格的值显示不全或被截断现象合并块里的文字只显示了一部分或者垂直方向没有居中。原因TextRenderer.DrawText的矩形用的是mergedRect但如果你的totalHeight计算时漏掉了行与行之间的分隔线高度矩形就会偏小。另外TextFormatFlags没设VerticalCenter也会导致文字顶到上边。解决累加高度时把grid.Rows[i].Height加上grid.RowTemplate.DividerHeight如果有的话并且确保TextFormatFlags里带上VerticalCenter和SingleLine。4.3 编辑单元格时合并块“裂开”现象用户双击合并块进入编辑状态编辑框只覆盖了起始行那一格下面的合并部分露出来了。原因DataGridView 的编辑控件是按单元格定位的它不知道你视觉上合并了。解决两个方向。一是禁止编辑合并列在CellBeginEdit里判断如果该单元格在合并区间内就e.Cancel true。二是如果必须编辑就在EditingControlShowing里把编辑控件的高度手动撑到合并块的高度但这需要额外处理定位比较麻烦。我一般选第一种。4.4 合并区间计算把空值也合并了现象连续几行的合并列都是空字符串或 null结果被合并成一个大空块。原因BuildMergeRanges里用string.Equals比较null 和 null 相等空串和空串相等自然就合并了。解决在判断isDifferent时加一个条件如果值为 null 或空串就不参与合并。把isDifferent改成isEnd || isDifferent || string.IsNullOrEmpty(value)之类的逻辑。4.5 数据量大时滚动卡顿现象几千行以上的表格滚动时明显掉帧。原因每次CellPainting都调FirstOrDefault遍历整个_mergeRanges列表区间多了就是 O(n) 查找。解决把区间列表按StartRow排序然后用二分查找定位当前行属于哪个区间。或者更简单建一个Dictionaryint, MergeRangekey 是行号把区间内每一行都映射到同一个区间对象查找变成 O(1)。内存换速度几千行完全划算。5. 进阶技巧用双缓冲和可见区域裁剪把合并做顺滑前面讲的方案在几百行规模下没问题但如果你要处理上万行的合并或者合并列不止一列就得在性能上再抠一抠。我自己的习惯是加两个东西双缓冲和可见区域裁剪。双缓冲是 WinForms 自定义绘制的后悔药。DataGridView 默认不开双缓冲滚动时闪烁是常态。你可以用反射打开它或者继承一个子类public class BufferedDataGridView : DataGridView { public BufferedDataGridView() { // 通过反射开启双缓冲减少滚动闪烁 this.DoubleBuffered true; SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true); } }把设计器里的 DataGridView 替换成这个子类滚动闪烁会明显改善。注意DoubleBuffered是 protected 属性在构造函数里直接设就行。可见区域裁剪的意思是在CellPainting里先判断e.RowIndex是否在FirstDisplayedScrollingRowIndex到LastDisplayedScrollingRowIndex之间不在就直接 return。虽然控件本身只会触发可见行的绘制但你的区间查找逻辑可以提前短路省掉不必要的字典查询。还有一个实用技巧如果你有多列需要合并不要为每列单独写一套绘制逻辑而是把MergeRange的ColumnIndex用起来在CellPainting里统一处理。这样代码量不会随列数线性增长。验证合并是否正确我一般会做三件事一是把表格数据导出成 CSV确认底层数据没被改动二是快速滚动到底再滚回来看合并块有没有错位三是切换排序字段确认合并区间跟着重算。这三步走完基本能覆盖 90% 的翻车场景。最后说个血泪教训合并单元格这件事能不做就不做。如果产品只是想让表格“看起来清爽”优先考虑用交替行背景色或者分组标题行来替代。一旦上了合并选中、编辑、导出、打印每个环节都可能冒出新的边界问题。真要做就把合并逻辑封装成一个独立的帮助类别散落在 Form 代码里不然后面改需求你会想哭。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进