多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

WinForm DataGridView自定义列实战:封装按钮、进度条与状态灯

WinForm DataGridView自定义列实战:封装按钮、进度条与状态灯 简介面向C# WinForms开发者的DataGridView自定义控件源代码包主要解决标准DataGridView列类型无法直接嵌入时间选择控件等编辑需求的问题。代码演示了通过派生DataGridViewColumn、DataGridViewCell创建日历列并在编辑状态下嵌套DateTimePicker控件同时覆盖了文本、组合框、复选框等常见列类型的扩展思路适合有一定WinForms基础、希望提升表格交互定制能力的开发者参考。资源包共24个文件包含9个C#源码文件、项目工程文件、资源文件及可运行的exe示例压缩包仅25KB结构紧凑。已有1650人学习下载。下载后可直接打开CustomDataGridView.sln查看Form1、CalendarColumn、CalendarCell和CalendarEditingControl等核心类快速理解自定义列的实现要点也可将日历列封装复用到自己的项目中。 在DataGridView里塞按钮、图片、进度条第一反应都是微软官方的DataGridViewButtonColumn、DataGridViewImageColumn。但真到了项目里这些内置列往往不够用——按钮要带状态颜色、图片要圆角裁剪、进度条要显示百分比文字、单元格要根据业务数据显示不同控件。很多人的做法是到处订阅CellPainting事件用一堆Region、GraphicsPath去手绘代码又长又难维护换个样式就要改一遍。今天分享的是我自己的封装思路把控件列做成完全自定义的DataGridViewColumn核心逻辑放在独立的Cell里用的时候就跟内置列一样直接添加到Columns集合就行源码会给出完整结构。这篇适合谁看如果你正在做C#上位机、WinForm管理系统或者天天跟DataGridView打交道想摆脱CellPainting地狱、让列逻辑能和控件本身解耦那这篇文章就是给你准备的。整个方案的思路不依赖第三方库纯.NET WinForm实现.NET Framework 4.5以上都能直接跑。1. 为什么内置列满足不了真实项目先理清自定义的方案主线说句实在话微软给的DataGridViewButtonColumn算是最接近自定义控件的内置列了但它的能力边界也很清晰按钮只能显示文字不能加图标、不能改圆角、不能针对不同行设置不同颜色。DataGridViewImageColumn也简单就是一张图缩放方式就那么几样想做缩略图加边框加状态角标根本没戏。有人会说可以靠DataGridView.CellPainting全权接管绘制这条路我也走了大半年最终放弃了。CellPainting的问题不在于画不出来而在于每次重绘都要把所有状态重新算一遍代码全部揉在一个事件里。如果你的列有5种状态、3种交互行为那这个事件方法可能上千行上线之后任何一个状态漏判就是bug调试的时候根本分不清是哪行画错了。更麻烦的是双击编辑、键盘操作、ToolTip这些交互逻辑跟绘制逻辑是完全分开的你得把它们缝在一起拆了一处另一处就崩。顺着这个问题往下想正确方向很清晰把一列当作一个完整的自定义控件单元。这个单元既有独立的绘制逻辑也有独立的事件处理和状态管理DataGridView只负责布局和滚动列内部完全自治。这也是我推荐用DataGridViewColumn DataGridViewCell子类来做自定义列的原因。DataGridView的设计本身就支持扩展合理路径是派生DataGridViewColumn在构造函数里把CellTemplate设置为对应的Cell子类派生DataGridViewCell重写绘制、命中测试、编辑相关方法在GetPreferredSize里返回合适尺寸。自定义列内部还需要一个ViewState来记录每个单元格的状态吗不一定做成独立类但至少在Cell内部维护一个状态字典或者直接放在Tag里推荐用Cell内部字段。下面这个结构就是整个方案的骨架DeriveDataGridViewColumn负责定义列外观默认属性、默认宽度、是否只读并承载CellTemplate。DeriveDataGridViewCell负责绘制、尺寸计算、鼠标事件、键盘操作、ToolTip。CellValue实际数据值不需要额外包装直接用Cell.Value承载绘制时根据Value计算样式。宿主集成把列实例Add进DataGridView.Columns赋值DataPropertyName或者手动给每行Cell赋值。这套结构和内置列的行为模型是一致的所以接入已有项目基本零成本。下面我会把实现过程一步步铺开包含具体代码和关键踩坑点。2. 核心封装Column类与Cell类怎么分好工整个自定义列的技术核心就是分清哪个类负责搭骨架、哪个类负责画血肉。如果只写一个类硬扛所有事情表现就是DataGridViewColumn子类里塞了一大堆绘制代码每次刷新都隐隐觉得哪里不对。按照DataGridView的扩展模型边界应该画得非常清楚。2.1 Column类的职责模板、默认属性、数据绑定入口先来看Column类的主体代码我把它叫做MyActionColumn它是一列可点击操作按钮列带文字和状态颜色后续的Cell类都围绕这个业务来写。public class MyActionColumn : DataGridViewColumn { public MyActionColumn() { // 关键CellTemplate必须是指定Cell类型的实例否则DataGridView会抛异常 this.CellTemplate new MyActionCell(); this.ReadOnly true; // 操作列一般不允许单元格文本编辑 this.Resizable DataGridViewTriState.True; this.SortMode DataGridViewColumnSortMode.NotSortable; this.Width 90; } // 重写CellTemplate让属性面板和运行时拿到的模板类型更明确 public override DataGridViewCell CellTemplate { get base.CellTemplate; set { if (value ! null !(value is MyActionCell)) throw new InvalidCastException(必须使用 MyActionCell 作为单元格模板); base.CellTemplate value; } } // 复制列时保证模板也被复制否则设计器容易出现空模板 public override object Clone() { var col base.Clone() as MyActionColumn; col.CellTemplate new MyActionCell(); return col; } }这里面有个特别容易被忽略的坑如果你不重写CellTemplate属性在VS设计器里这个列也许能正常显示但一旦运行到某些代码路径比如列头拖动排序或者DataGridView重新Build内部可能因为模板类型不匹配而直接抛出InvalidOperationException。而且DataGridView在实例化新单元格时用的是CellTemplate.Clone()如果Template为null那一整列都会变成死的既不显示也不可点击。所以构造函数里先塞一个模板实例是第一个必须养成的习惯。Clone方法重写也很重要。DataGridView在运行时复制列例如通过Columns集合的复制操作会调用Clone如果你不重写基类的Clone返回的列还是老模板类型而模板是引用类型多个列共享同一个CellTemplate会导致状态污染。重写一次确保新列拿到自己的模板实例是最省事的解法。2.2 Cell类的职责绘制、命中、尺寸、交互Cell类才是整个自定义列的引擎。先给出代码主体然后我会拆解每步逻辑。public class MyActionCell : DataGridViewCell { // 定义按钮在单元格内的边距 private static readonly Padding ButtonPadding new Padding(2, 3, 2, 3); public MyActionCell() { this.Style.Alignment DataGridViewContentAlignment.MiddleCenter; } protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates cellState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) { // 先让基类画背景和边框避免自己重画造成双层底色 base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); // 如果单元格没有实际数据可以画占位符或者直接返回 if (value null) return; Rectangle btnRect GetButtonRect(cellBounds); bool hovered (this.DataGridView?.Tag as Dictionaryint, bool)?.ContainsKey(rowIndex) true; using (var bgBrush new SolidBrush(hovered ? Color.FromArgb(90, 160, 255) : Color.FromArgb(65, 140, 240))) using (var borderPen new Pen(Color.FromArgb(40, 90, 160))) { GraphicsPath path RoundedRect(btnRect, 4); graphics.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; graphics.FillPath(bgBrush, path); graphics.DrawPath(borderPen, path); } TextRenderer.DrawText(graphics, value.ToString(), this.Style.Font ?? this.DataGridView.Font, btnRect, Color.White, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); } private Rectangle GetButtonRect(Rectangle cellBounds) { int width cellBounds.Width - ButtonPadding.Horizontal; int height cellBounds.Height - ButtonPadding.Vertical; int x cellBounds.X ButtonPadding.Left; int y cellBounds.Y ButtonPadding.Top; return new Rectangle(x, y, width, height); } private GraphicsPath RoundedRect(Rectangle bounds, int radius) { int d radius * 2; GraphicsPath path new GraphicsPath(); path.AddArc(bounds.X, bounds.Y, d, d, 180, 90); path.AddArc(bounds.Right - d, bounds.Y, d, d, 270, 90); path.AddArc(bounds.Right - d, bounds.Bottom - d, d, d, 0, 90); path.AddArc(bounds.X, bounds.Bottom - d, d, d, 90, 90); path.CloseFigure(); return path; } protected override Size GetPreferredSize(Graphics graphics, DataGridViewCellStyle cellStyle, int rowIndex, Size constraintSize) { Size size base.GetPreferredSize(graphics, cellStyle, rowIndex, constraintSize); size.Width Math.Max(size.Width, 80); size.Height Math.Max(size.Height, 28); return size; } protected override void OnMouseEnter(int rowIndex) { base.OnMouseEnter(rowIndex); MarkHover(rowIndex, true); } protected override void OnMouseLeave(int rowIndex) { base.OnMouseLeave(rowIndex); MarkHover(rowIndex, false); } private void MarkHover(int rowIndex, bool hovering) { var grid this.DataGridView; if (grid null) return; var dict grid.Tag as Dictionaryint, bool; if (dict null) { dict new Dictionaryint, bool(); grid.Tag dict; } dict[rowIndex] hovering; grid.InvalidateRow(rowIndex); } protected override void OnMouseClick(DataGridViewCellMouseEventArgs e) { base.OnMouseClick(e); if (e.Button MouseButtons.Left this.RowIndex 0) { // 触发业务事件外部订阅 this.DataGridView?.RaiseCellClick(new DataGridViewCellEventArgs(e.ColumnIndex, e.RowIndex)); // 或者直接通过列上的事件回调这里用最朴素的方式借助Cell.Tag传递CommandName var command this.Tag?.ToString(); if (!string.IsNullOrEmpty(command)) { // 实际项目可以定义事件下面演示一种轻量方式 if (this.DataGridView?.Tag is Actionstring, int action) action(command, this.RowIndex); } } // 点击后刷新去掉可能遗留的悬停状态虽然悬停已经会在Leave时清掉但以防状态错乱 this.DataGridView?.InvalidateRow(this.RowIndex); } }核心设计点有三个先调base.Paint再画控件内容这样背景、网格线、选中效果基本由DataGridView负责我只需要画按钮区域代码量少且不容易跟系统的交互状态打架。GetButtonRect重新计算按钮的矩形让按钮与单元格保持一定边距。若不做Padding按钮直接顶到单元格边缘观感很生硬也会有误触。这里给出2到3像素的Padding实际按自己项目调节。悬停状态用DataGridView.Tag挂了一个Dictionaryint, bool虽然不算最优雅但胜在轻量。如果你要做生产级控件建议在Column内部搞一个HashSet 记录悬停行再通过事件回调避免Tag占用业务场景。文章后面会讲到更规矩的做法。关于点击事件的传递代码里展示了两种思路。实际我建议不要在Cell内部直接调用外部业务逻辑而是定义事件委托public class MyActionColumn : DataGridViewColumn { public event EventHandlerMyActionCellClickEventArgs CellButtonClick; protected internal void OnCellButtonClick(DataGridViewCellEventArgs e) { CellButtonClick?.Invoke(this.DataGridView, new MyActionCellClickEventArgs(e.RowIndex, e.ColumnIndex, 指令)); } }Cell内部通过列引用触发OnCellButtonClick。这样外部窗体只需要订阅myActionColumn.CellButtonClick业务逻辑完全从控件里提出去。我给的源码里体现的是Tag模式但在实际项目里用事件会清爽得多。改起来代价也不大就是把OnMouseClick里的那几行换成调用列的事件。3. 宿主DataGridView的配合设置别让默认行为拖后腿自定义列写完了放到DataGridView里如果不设置几个关键属性会有各种莫名其妙的表现。首当其冲的是行高度。很多项目里DataGridView的RowTemplate.Height默认是25或者更低按钮根本展示不完全。建议在初始化DataGridView时设置dataGridView1.RowTemplate.Height 36; dataGridView1.RowTemplate.MinimumHeight 30; dataGridView1.AllowUserToAddRows false; dataGridView1.SelectionMode DataGridViewSelectionMode.FullRowSelect; dataGridView1.MultiSelect false; dataGridView1.EnableHeadersVisualStyles false; // 保留自定义列头样式否则列头会跟随系统主题变成灰底视觉上不统一AllowUserToAddRows必须设成false否则表格底部会多出一行新记录行这一行没有数据但在绘制时会走到Cell.Paintvalue为空时直接return倒还好可是鼠标悬停上去还是会触发InvalidateRow表现就是底部空行偶尔闪一下。SelectionMode设成FullRowSelect是管理类系统的常见习惯操作按钮列不需要支持单元格级别的选中设置整行选中更安全避免用户误以为按钮单元格可以被单独选中编辑。EnableHeadersVisualStyles这个属性特别容易被忽略。默认是true列头使用系统视觉样式也就是浅灰色渐变。但如果你自定义列头背景色通过Column.HeaderCell.Style.BackColortrue状态下这个颜色不生效把列内的按钮颜色调得再好看表头却还是灰的颜色一下就脱节了。设置成false后所有的列头样式都走你定义的Style包括Column.DefaultCellStyle、HeaderCell.Style风格才统一。还有一点关于DoubleBuffered。DataGridView的行数量几百上千时滚动重绘会明显出现闪烁。你可以在窗体构造函数里通过反射开启双缓冲typeof(DataGridView).GetProperty(DoubleBuffered, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) .SetValue(dataGridView1, true);这是我试过的最有效的防闪烁手段。尤其是用了圆角绘制GDI的填充成本比填纯色矩形高在低配工控机上没开双缓冲会肉眼可见地卡顿。开启后顺滑不止一个等级。4. 进阶扩展把按钮列变成多样式控件列一旦跑通Column Cell的骨架往上加能力就非常顺畅了。不要每次新需求都新建一个Column子类那样会把类数量抬到失控。更推荐的做法是做一个通用列内部支持枚举样式然后根据样式分支绘制。下面给出两个最常见的扩展方向。4.1 状态指示灯同一列兼容多态数据比如订单状态可能是未处理处理中已完成失败。如果用普通文本列用户必须读文字才知道状态如果用户对颜色不敏感体验就不够清晰。做成状态列之后单元格左边是圆点右边是状态文字。代码和按钮列的不同点在于绘制时根据Value的ToString()去映射颜色以及不需要MouseClick事件处理通常状态列是只读的。关键实现片段protected override void Paint(...) { base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); string status value?.ToString() ?? 未知; Color dotColor Color.Gray; switch (status) { case 未处理: dotColor Color.FromArgb(220, 180, 20); break; case 处理中: dotColor Color.FromArgb(50, 120, 220); break; case 已完成: dotColor Color.FromArgb(60, 170, 80); break; case 失败: dotColor Color.FromArgb(210, 70, 60); break; } const int dotSize 10; var textSize TextRenderer.MeasureText(status, cellStyle.Font ?? dataGridView.Font); int totalWidth dotSize 5 textSize.Width; int startX cellBounds.X (cellBounds.Width - totalWidth) / 2; int centerY cellBounds.Y cellBounds.Height / 2; using (var brush new SolidBrush(dotColor)) { graphics.SmoothingMode SmoothingMode.AntiAlias; graphics.FillEllipse(brush, startX, centerY - dotSize / 2, dotSize, dotSize); } TextRenderer.DrawText(graphics, status, cellStyle.Font ?? dataGridView.Font, new Rectangle(startX dotSize 5, cellBounds.Y, textSize.Width 5, cellBounds.Height), cellStyle.ForeColor, TextFormatFlags.VerticalCenter); }逻辑很朴素根据状态映射颜色画圆点再画文字。这种列做出来以后界面上信息获取效率会提升一大截。稍微再扩展一下你还可以在圆点外圈加个浅色描边让图形更有层次。4.2 带进度条的单元格绘制和交互的细节坑进度条列适合展示任务完成度、内存占用、下载进度。绘制逻辑也不复杂先画底槽再按百分比画填充条然后居中显示文字。这里有个隐藏的坑如果Cell.Value是int或double类型DataGridView内部默认的格式化可能会在值变化时触发CellFormatting如果你还需要ToolTip显示百分比就得自己处理。一个通用进度列的核心绘制protected override void Paint(...) { base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); double percent 0; if (value is double d) percent d; else if (value is int i) percent i / 100.0; else if (value is float f) percent f; else double.TryParse(value?.ToString(), out percent); percent Math.Max(0, Math.Min(1, percent)); var barRect new Rectangle( cellBounds.X 4, cellBounds.Y (cellBounds.Height - 14) / 2, cellBounds.Width - 8, 14); using (var backBrush new SolidBrush(Color.FromArgb(235, 235, 235))) using (var fillBrush new SolidBrush(percent 0.8 ? Color.FromArgb(220, 80, 70) : Color.FromArgb(70, 150, 220))) { graphics.FillRectangle(backBrush, barRect); int fillWidth (int)(barRect.Width * percent); if (fillWidth 0) { var fillRect new Rectangle(barRect.X, barRect.Y, fillWidth, barRect.Height); graphics.FillRectangle(fillBrush, fillRect); } } string text ${(percent * 100):F0}%; TextRenderer.DrawText(graphics, text, dataGridView.Font, barRect, Color.Black, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); }进度条列有几个细节值得注意百分比0.8时我把颜色变成红色暗示高占用/高完成度预警。这个阈值可以做成Column属性让外部可以配。文字颜色不要用纯白在浅色底槽上白色会看不清用黑色或者跟填充色呈对比的颜色。如果进度更新是高频的比如上位机里采集数据刷新一定要只刷新对应行而不是整个grid。调用dataGridView1.InvalidateRow(rowIndex)比Invalidate()高效得多否则大批量更新时会卡。4.3 综合示例把多个自定义列塞进一个表格假设你正在做一个工单管理系统表格里需要三列状态列、按钮列操作、进度列完成度。初始化代码长这样var grid dataGridView1; grid.Columns.Clear(); var colStatus new StatusColumn { HeaderText 状态, Width 100, DataPropertyName Status }; var colProgress new ProgressColumn { HeaderText 完成度, Width 120, DataPropertyName Progress }; var colAction new MyActionColumn { HeaderText 操作, Width 90 }; colAction.CellButtonClick (s, e) { MessageBox.Show($点击了第 {e.RowIndex} 行的按钮指令{e.CommandName}); }; grid.Columns.Add(colStatus); grid.Columns.Add(colProgress); grid.Columns.Add(colAction); // 绑定数据 var dt new DataTable(); dt.Columns.Add(Status, typeof(string)); dt.Columns.Add(Progress, typeof(double)); dt.Rows.Add(未处理, 0.2); dt.Rows.Add(处理中, 0.6); dt.Rows.Add(已完成, 1.0); dt.Rows.Add(失败, 0.0); grid.DataSource dt;一列是状态展示、一列是数值进度、一列是操作入口三列的职责清晰互不干扰。以后要把处理中改成进行中只需要改状态列的映射不需要动其他任何东西。5. 避坑清单这些问题我是在客户现场才发现的很多坑不是写代码的时候碰到而是交付后跑到客户机器上才炸出来的。我整理了几个高频雷区都跟自定义列的运行机制有关。5.1 CellTemplate为null导致的崩溃这个问题很隐蔽。如果你在Column构造函数里没有初始化CellTemplate而项目里又恰好有人调用了dataGridView1.Columns.Clone()或者VS设计器打开窗体时没触发构造函数某些情况下设计器直接序列化了字段值那运行时就会报CellTemplate为null。解决办法就是我前面强调的构造函数里实例化模板且重写Clone时执行同样的实例化逻辑。双保险基本能堵住99%的坑。5.2 行高太小导致控件绘制被裁切WinForm的DataGridView默认行高不算高我的按钮列Height要28到30像素才好看。如果整体行高只有24按钮底部会被裁掉一小条圆角看起来像被砍了一刀。遇到这种问题先检查RowTemplate.Height再检查自定义Cell的GetPreferredSize值。GetPreferredSize方法返回的Height并不直接决定行高它只是一个建议值。真正决定行高的是RowTemplate.Height乘以AutoSizeRowsMode。如果你设置了AutoSizeRowsMode AllCells行高会自适应内容否则以RowTemplate.Height为主。建议显示设置行高不要依赖自适应。5.3 点击事件偶尔不触发有一个现象是快速点击自定义列的按钮响应不稳定。原因通常是DataGridView的EditMode和Cell状态竞争。如果该列不是ReadOnlyDataGridView会优先进入编辑状态吞掉你的鼠标点击事件。最简单的修复自定义列一律设ReadOnlytrue并在Column构造函数里设置。如果业务必须允许编辑那就用CellPainting绘制一个按钮外观但内部仍然进入编辑态这时候别指望OnMouseClick按常规触发应该用EditingControlShowing事件配合宿主控件来处理。5.4 选中行背景色把按钮颜色盖掉DataGridView的默认选中行背景色是蓝灰色如果按钮列绘制时没有把选中背景完全覆盖就会露馅。解决方法是在Paint里不要只画ButtonRect区域而是先画一个覆盖整个cellBounds的不透明矩形作为底板再往上画按钮。我在代码里先调base.Paint基类会画背景但如果你觉得颜色冲突明显可以直接用cellStyle.SelectionBackColor填充整个cellBounds再画按钮。两者恰好是我推荐的自定义绘制策略要么信任base.Paint要么完全接管不要两套混着画导致颜色错位。5.5 GDI对象泄漏导致内存涨每个单元格每帧绘制都会new Pen、Brush、GraphicsPath。如果不及时Dispose在大量重绘场景下内存会持续增长。我上面代码里都用了using这是底线。还有一个容易漏的是GraphicsPathRoundedRect方法必须确保路径被Dispose否则每次悬停刷新就泄漏一点。在长期运行的上位机上这足以在一天内把内存吃到数百MB。5.6 在DataGridView滚动时出现残影如果启用了双缓冲还是出现残影多半是绘制时使用了cellBounds之外的元素比如把文字画到下一格的区域。任何绘制物都必须裁切在cellBounds内。如果确实要画超出矩形范围的阴影或者描边用graphics.SetClip(cellBounds)限制绘制区域否则滚动结束后残留的图形会以意外的样式留在别的行上。6. 我的一次真实脱坑经历按钮列在工控机上的显示异常今年做一个设备管理系统的上位机界面时我把这套自定义列用在了设备状态总览表格里。刚开始开发机Windows 11显卡正常跑起来一切完美结果部署到客户工控机Windows 7 Embedded集显系统DPI设置是125%之后整个表格出现了两个怪现象第一按钮圆角变成方角看起来像GDI精度不够第二按钮上的文字偏移了1像素肉眼看起来有点重影。排查过程一开始怀疑是.NET Framework版本差异结果不是同样的代码在同一台工控机上只要把DPI缩放调到100%现象就消失。又试了设置Application.EnableVisualStyles()、SetProcessDPIAware()效果有限。最后定位到问题根源Windows 7 Embedded在非100%缩放下GDI的TextRenderer绘制和图形填充用的坐标系存在细微偏移。我的GetButtonRect是按cellBounds整数坐标算的但TextRenderer在DPI缩放时内部做了像素对齐两者对不齐就产生了重影。解决方式很朴素在自定义列的绘制里把所有图形坐标先转成整数本来就是整数但是在绘制文本时强制使用TextFormatFlags.NoPadding并把文字绘制的矩形往下偏移1像素再对文字抗锯齿方式做统一。实测在125%缩放下视觉基本恢复清晰。另一个办法是直接对.exe设置DPI Awareness为System让整个程序交给系统按比例缩放位图GDI在内部缩放时一致性更好。两者都做之后工控机上不再出现奇怪偏移。这类问题在开发环境很难复现只有到客户现场才炸出来所以如果你的程序会跑在老旧工控机上建议一开始就在窗体里加上if (Environment.OSVersion.Version.Major 6) { SetProcessDPIAware(); }配合自定义列绘制时统一处理DPI缩放能省很多售后精力。7. 源码组织建议和直接可用的发布结构最后聊一下怎么把源码组织得既能跑又能扩展。我会在项目里甩出这几个文件结构清晰后面维护成本低/Controls /Columns MyActionColumn.cs StatusColumn.cs ProgressColumn.cs /Cells MyActionCell.cs StatusCell.cs ProgressCell.cs /Events MyActionCellClickEventArgs.csColumn和Cell一一对应文件按类型分目录。事件参数独立放一个文件是习惯问题如果项目里事件不多也可以塞进Column文件里不影响逻辑。但分开能避免以后加交互参数时频繁改文件。在Column类里建议提供以下公开属性方便使用方配置ButtonText当某一行的Value为null时显示默认按钮文字。NormalColor/HoverColor按钮普通态和悬停态的背景色。Radius圆角半径。CommandName当前按钮列对应的操作指令比如edit、delete。CellButtonClick事件外部订阅操作。这种可配置思路比硬编码颜色和文字更贴近实际开发。用到第三个项目时基本就不需要再改Cell代码了有新的交互模式就再添加一个枚举或扩展属性而不是复制一份Cell类去改。对于想直接拿到源码跑起来的朋友你在项目里只需要做三件事把上面控件目录的文件加入工程在窗体上放一个DataGridView初始化时加两行代码绑定列和数据源。整个过程大约十分钟就能看到效果。自定义DataGridView列说到底就是一次把绘制逻辑和交互逻辑收拢到列内部的重构。这一步走完后续加任何视觉样式或交互行为都不需要再动别的代码。希望这份实现思路能帮你少走弯路如果你在自己的项目里也用到了类似方案欢迎一起交流踩坑经验。本文还有配套的精品资源点击获取
返回列表