简介:这是一份面向C#桌面开发者的DataGridView自定义控件列源码包,适用于需要在表格中嵌入时间选择器等复杂编辑控件的项目。默认的文本、下拉、复选列往往无法满足特殊显示和交互需求,开发者可参考源码自行扩展单元格、列与行的类型。压缩包共24个文件,以9个C#源码文件为核心,附带工程文件、资源配置与可执行程序,仅25KB,结构轻量,便于快速定位关键实现。已有1653人学习或下载,适合中高级Windows窗体开发者借鉴。源码重点展示了时间列的实现,示范如何从DataGridViewColumn与DataGridViewCell派生自定义列和单元格,并正确挂载编辑状态下弹出的时间控件;同时保留完整解决方案与窗体设计代码,可直接编译运行,便于研究所有者绘制、单元格行扩展以及编辑控件与网格交互的完整思路。
1. C# DataGridView 自定义控件:默认列不够用,从五个默认类型说起
C# 的 WinForms 里,DataGridView 自定义控件几乎是绕不开的一环:表格谁都要用,默认样式谁都不满意。默认列类型就五种——文本框、按钮、复选、图片、超链接,装普通业务数据够用,可一旦要展示进度、评分、状态灯这类"视觉化"内容,默认列就露馅了。我最早在 CellPainting 事件里手动画,一个窗体复制一份,三张界面写完,改一次样式要改三处,后悔没早点把逻辑抽出来。后来重构为自定义列:一个 DataGridViewColumn 配一个 DataGridViewCell,把绘制、取值、排序收进两个类,界面里 new 一个列对象就能复用。这份资源就是这套方案的完整源代码,核心是一个可绑定、可排序的进度条列,附带完整实现思路,适合在 WinForms 里做报表、任务看板、监控面板的开发者直接改着用。
2. 自定义列的架构:为什么是列加单元格而不是堆事件
2.1 CellPainting 事件与自定义列怎么选
先说实话,CellPainting 事件并不是不能用。它适合"只想在一张表上临时画个进度条"的场景,代码三五行就能跑:
// 用 CellPainting 事件画进度条,ColumnIndex 是写死的 dataGridView1.CellPainting += (s, e) => { if (e.ColumnIndex != 2 || e.RowIndex < 0) return; double percent = Convert.ToDouble(e.Value); // 1. 画灰色轨道 using (var track = new SolidBrush(Color.FromArgb(229, 229, 229))) { e.Graphics.FillRectangle(track, e.CellBounds.X + 2, e.CellBounds.Y + 4, e.CellBounds.Width - 4, e.CellBounds.Height - 8); } // 2. 画绿色进度 using (var fill = new SolidBrush(Color.FromArgb(33, 150, 243))) { int fillWidth = (int)((e.CellBounds.Width - 4) * percent); e.Graphics.FillRectangle(fill, e.CellBounds.X + 2, e.CellBounds.Y + 4, fillWidth, e.CellBounds.Height - 8); } e.Handled = true; // 告诉 DataGridView:这格我自己画完了 };这段代码能跑,但三张窗体就要复制三遍,哪天进度值从 double 改成字符串,要同步改三处;再新增一列,还得回来看列索引判断的逻辑。事件方案把"列长什么样"写死在窗体里,等于把知识散落在各个地方,改一次就想骂一次。
自定义列是把"列长什么样"收进一个类。DataGridView 本身的架构就是"列模板 + 单元格实例"的工作方式,顺着它的机制做扩展,排序、克隆、设计期支持都能继承到。两种方案对比下来:
| 对比项 | CellPainting 事件 | 自定义列(Column + Cell) |
|---|---|---|
| 上手成本 | 低,一段代码 | 中等,要写两个类 |
| 多窗体复用 | 差,只能复制粘贴 | 好,new 一个列对象即用 |
| 列参数化(颜色/尺寸) | 难,要加全局变量 | 容易,做成列属性 |
| 排序/编辑/提示 | 都要额外处理 | 顺着单元格机制做 |
| 适用场景 | 一次性展示 | 多页面复用、产品级控件 |
结论很直接:既然 DataGridView 的扩展机制就是 Column 加 Cell,那自定义控件就从这两个类入手,别绕到事件里。
2.2 Column、Cell、EditingControl 三件套的分工
DataGridView 的自定义列体系由三个角色组成,理解分工才能不写乱:
- DataGridViewColumn(列对象):描述列的元信息。HeaderText、DataPropertyName、Width、SortMode、CellTemplate 都挂在它身上。它不参与具体某个格的绘制。
- DataGridViewCell(单元格对象):真正干活的人。每个格都是列模板 Clone 出来的实例,有自己的 RowIndex、OwningColumn,绘制逻辑全部在这里重写。
- EditingControl(编辑宿主控件):进入编辑模式时,DataGridView 会用 Cell.EditType 指定的类型动态创建编辑控件。进度条列如果不想要编辑框,让 EditType 返回 null 即可。
列和 Cell 的关系可以理解成"模具和零件":列是模具,DataGridView 为每一行自动 Clone 一份单元格。所以列的 CellTemplate 属性是整套机制的命门——它一旦为 null,Columns.Add 就直接抛 InvalidOperationException,报错指向列模板无效。后面避坑章节会专门讲这个。
2.3 绘制入口:重写 Paint,不是 OnPaint
从 UserControl 转过来的开发者最容易踩的坑,就是去找 OnPaint 方法。DataGridViewCell 的绘制虚方法叫 Paint,不叫 OnPaint,签名也长得多:
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) // 要画哪几部分,位标志 { }paintParts 是 DataGridViewPaintParts 枚举,取值有 Background、Border、ContentBackground、ContentForeground、ErrorIcon、Focus、SelectionBackground。正确的画法是按位判断,只画 DataGridView 要求的部分,而不是一股脑全画:
if ((paintParts & DataGridViewPaintParts.Background) != 0) { // 画背景 } if ((paintParts & DataGridViewPaintParts.Border) != 0) { PaintBorder(graphics, clipBounds, cellBounds, cellStyle, advancedBorderStyle); }最后那个 PaintBorder 尤其容易漏。自绘时如果不手动调用它,表格线会全部消失,乍一看像样式 bug,实际是边框没人画了。这套参数在实现阶段会反复用到,建议先把每个参数干什么记牢,再动手写代码。
3. 实现进度条列:两个类一个模板跑通全流程
3.1 工程准备与最小骨架
新建一个 WinForms 项目,.NET Framework 4.7.2 或 .NET 6/8 都可以,只要引用 System.Windows.Forms 和 System.Drawing。工程里加两个类文件:ProgressCell.cs 和 ProgressColumn.cs。先把骨架搭出来:
using System; using System.Drawing; using System.Windows.Forms; // 单元格:真正的绘制在这里 public class ProgressCell : DataGridViewCell { // 关键:不让它进入编辑模式,进度条用双击/编辑框就白画了 public override Type EditType => null; public override Type ValueType { get => base.ValueType ?? typeof(double); set => base.ValueType = value; } 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) { // 分步绘制,先不做内容 } }EditType 返回 null 是这套方案里最省事的一招:单元格既不用重写编辑控件,也不怕默认文本框把自绘内容盖住。ValueType 默认取 double,后面排序、格式化都以 double 为准,省得 DataGridView 拿 object 去做类型推断。
3.2 ProgressCell 的绘制:背景、轨道、进度、文字
把 Paint 补全。绘制分四层:背景、边框、进度条、文字。进度条坐标全部基于 cellBounds 计算,不出现任何写死的屏幕像素:
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) { // 第 1 层:背景,选中时用 SelectionBackColor if ((paintParts & DataGridViewPaintParts.Background) != 0) { Color backColor = (cellState & DataGridViewElementStates.Selected) != 0 ? cellStyle.SelectionBackColor : cellStyle.BackColor; using (var backBrush = new SolidBrush(backColor)) { graphics.FillRectangle(backBrush, cellBounds); } } // 第 2 层:边框,不画会导致表格线消失 if ((paintParts & DataGridViewPaintParts.Border) != 0) { PaintBorder(graphics, clipBounds, cellBounds, cellStyle, advancedBorderStyle); } // 第 3 层:进度条 double percent = NormalizePercent(value); DrawProgressBar(graphics, cellBounds, percent, cellStyle); // 第 4 层:文字 if ((paintParts & DataGridViewPaintParts.ContentForeground) != 0) { string text = (percent * 100).ToString("0") + "%"; Color textColor = cellStyle.ForeColor; TextRenderer.DrawText(graphics, text, cellStyle.Font, cellBounds, textColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); } } // 不同来源的值统一收敛成 0~1 的百分比 private double NormalizePercent(object value) { if (value is double d) return Math.Max(0.0, Math.Min(1.0, d)); if (value is float f) return Math.Max(0f, Math.Min(1f, f)); if (value is int i) return Math.Max(0, Math.Min(100, i)) / 100.0; return 0.0; } // 画轨道和填充条 private void DrawProgressBar(Graphics graphics, Rectangle cellBounds, double percent, DataGridViewCellStyle cellStyle) { const int margin = 3; int barHeight = 10; if (OwningColumn is ProgressColumn pc) barHeight = pc.BarHeight; var barRect = new Rectangle( cellBounds.X + margin, cellBounds.Y + (cellBounds.Height - barHeight) / 2, cellBounds.Width - margin * 2, barHeight); if (barRect.Width <= 0 || barRect.Height <= 0) return; // 轨道:浅灰 using (var trackBrush = new SolidBrush(Color.FromArgb(229, 229, 229))) { graphics.FillRectangle(trackBrush, barRect); } // 填充:按百分比算宽度 int fillWidth = (int)Math.Round(barRect.Width * percent); if (fillWidth <= 0) return; Color fillColor = percent >= 1.0 ? Color.FromArgb(76, 175, 80) // 100% 用绿色 : percent < 0.5 ? Color.FromArgb(255, 152, 0) // 不到 50% 用橙色提醒 : Color.FromArgb(33, 150, 243); // 正常进度用蓝色 using (var fillBrush = new SolidBrush(fillColor)) { graphics.FillRectangle(fillBrush, barRect.X, barRect.Y, fillWidth, barRect.Height); } }这里有个关键设计:单元格通过 OwningColumn 反查列对象,读 ProgressColumn.BarHeight。DataGridView 的单元格和列本来就是双向关联的,绘制时拿列参数做配置,列就能当参数载体用。颜色这里先写死,第 5 章会改成列属性。
3.3 ProgressColumn 的模板与参数透传
列类本身很薄,核心就两件事:构造函数里必须给 CellTemplate 赋值;CellTemplate 的 setter 要做类型校验,防止把别的单元格类型塞进来:
using System; using System.Windows.Forms; public class ProgressColumn : DataGridViewColumn { public ProgressColumn() { // 不给模板,这个列一添加就抛 InvalidOperationException CellTemplate = new ProgressCell(); } // 列级参数,单元格绘制时通过 OwningColumn 读取 public int BarHeight { get; set; } = 10; public override DataGridViewCell CellTemplate { get => base.CellTemplate; set { // 只接受 ProgressCell,其它类型直接拒绝 if (value != null && value.GetType() != typeof(ProgressCell)) { throw new InvalidCastException("ProgressColumn 的模板必须是 ProgressCell"); } base.CellTemplate = value; } } public override Type ValueType { get => base.ValueType ?? typeof(double); set => base.ValueType = value; } }CellTemplate 的作用是给 DataGridView 做"克隆样本"。每次添加一行,DataGridView 都从模板 Clone 出一个新格子。模板本身不会直接参与显示,所以 setter 里的类型检查是保护后面所有行的安全阀。
3.4 绑定数据与无绑定两种用法
进度条列支持两种数据来源。推荐用绑定方式,把列挂到 DataPropertyName 上:
public class TaskItem { public string Name { get; set; } public double Progress { get; set; } // 0.0 ~ 1.0 } // 窗体加载时 var progressCol = new ProgressColumn { HeaderText = "完成度", Name = "colProgress", DataPropertyName = "Progress", // 必须和实体属性名一致 SortMode = DataGridViewColumnSortMode.Automatic, Width = 180 }; dataGridView1.Columns.Add(progressCol); // 数据源用 BindingList,增删会实时刷新 var tasks = new BindingList<TaskItem> { new TaskItem { Name = "导入数据", Progress = 1.0 }, new TaskItem { Name = "清洗字段", Progress = 0.72 }, new TaskItem { Name = "生成报表", Progress = 0.15 } }; dataGridView1.DataSource = tasks;不绑定的手工填值写法也兼容,DataGridView 拿到什么类型,NormalizePercent 就转什么类型:
var col2 = new ProgressColumn { HeaderText = "手工进度", Width = 140 }; dataGridView1.Columns.Add(col2); dataGridView1.Rows.Add(0.85); // 0~1 的 double dataGridView1.Rows.Add(60); // 0~100 的 int 也能直接放两种写法都验证过,关键点是 DataPropertyName 拼错不报错、也不显示数据,这是最常见的"绑定没生效"假象,先查列名和属性名是否完全一致。
3.5 悬停提示与非法值收敛
进度条列默认不显示文本,鼠标悬停时给个 Tooltip 能提升可用性。DataGridViewCell 的提示入口是 GetToolTipText,签名是 protected internal 的虚方法:
protected internal override object GetToolTipText(int rowIndex) { object value = GetValue(rowIndex); double percent = NormalizePercent(value); return $"完成度:{percent:P0}"; }同时注意非法值的收敛。数据库里常见的脏数据有三种:负数、超过 1、NULL。NormalizePercent 里已经做了 Clamp,负数统一当 0,超过 1 当 100%。NULL 会走最后的 return 0,不会抛异常。把数据边界在绘制层兜住,比在业务层到处 if 判断省心得多。
提示:Tooltip 只在单元格有值或出错时才显示,NULL 行不会弹出提示,这是 DataGridView 的默认行为,不是 bug。
4. 避坑:自定义列最常见的五类翻车现场
这一章的每个坑都是我实际踩过的,按"现象 → 原因 → 解决"写清楚,对照着排查最快。
4.1 一加列就报 CellTemplate 失效
现象:写完 ProgressColumn 后往 DataGridView 里 Columns.Add,运行到这一行直接抛 InvalidOperationException,错误信息含"CellTemplate"字样。
原因:DataGridViewColumn 的构造函数没有初始化 CellTemplate。DataGridView 在添加列时要用模板去 Clone 单元格,模板为空就炸。我见过有人把模板赋值写在窗体的 Load 事件里,顺序一乱同样报错。
解决:模板赋值放在列的构造函数里,保证任何时刻拿到的列都自带模板。同时 setter 里加类型校验,禁止把 DataGridViewTextBoxCell 一类的外来模板塞进来。一个习惯是:写完自定义列类后,先 new 一个列对象,调一次 Columns.Add,跑通再加其它功能,别攒到一起调试。
4.2 双击单元格,进度条被白色编辑框顶掉
现象:进度条列显示正常,但双击某个单元格后,进度条没了,出现一个白色文本框,里面还有"85%"之类的文本,按回车才恢复。
原因:没有重写 EditType。DataGridView 默认认为任何单元格都可以编辑,双击就创建 DataGridViewTextBoxEditingControl 贴在上面,把自绘内容盖住了。
解决:在 ProgressCell 里加一行public override Type EditType => null;。EditType 返回 null 表示该单元格没有编辑宿主,双击、F2 都不会进入编辑模式。如果连光标停留的虚框都不想要,再把列的 ReadOnly 设为 true,两招一起用效果最干净。
4.3 点列头排序直接抛异常
现象:SortMode 设成 Automatic,运行时点列头,抛 InvalidOperationException,说某个值无法比较,堆栈指向 DataGridView 的排序逻辑。
原因:排序要求单元格 Value 的实际类型实现 IComparable。如果绑定的实体属性是 object,或 DataTable 里该列是 string 类型,DataGridView 拿 string 和 double 比,直接炸。
解决:两种方案,选一种。一是让值类型统一,实体属性用 double,单元格 ValueType 也覆盖成 double,Automatic 排序就能正常跑。二是把 SortMode 设成 Programmatic,用 SortCompare 事件做兜底比较:
dataGridView1.SortCompare += (s, e) => { if (dataGridView1.Columns[e.Column.Index] is ProgressColumn) { double x = Convert.ToDouble(e.CellValue1); double y = Convert.ToDouble(e.CellValue2); e.SortResult = x.CompareTo(y); e.Handled = true; } };方案一最省事,方案二适合"值来源本来就杂"的场景。我一般优先方案一,因为它把类型问题在最源头就消灭了。
4.4 千行数据滚动卡顿
现象:几十行时一切正常,数据量到几千行,滚动时进度条一顿一顿的,CPU 占用飙高。
原因:最常见的是两个。一是 Paint 里每次都 new 一堆 Brush、Pen 不释放,GDI+ 对象被频繁创建销毁;二是画了不可见区域的格子,DataGridView 虽然只对可视区发 Paint,但如果代码没检查 clipBounds,遇到某些边界情况会重复绘制整列。
解决:刷子用静态字段缓存,尤其在虚拟模式或大数据量下效果明显:
private static readonly Brush TrackBrush = new SolidBrush(Color.FromArgb(229, 229, 229)); private static readonly Brush FillBrush = new SolidBrush(Color.FromArgb(33, 150, 243));绘制函数开头加一个可视区裁剪判断,和 cellBounds 没有交集就直接 return。实测从每次 new 刷子改成静态复用,5000 行数据滚动帧率能提升一倍以上。
4.5 125% 缩放下文字发虚、进度条错位
现象:系统缩放调到 125% 或 150% 后,百分比文字模糊,进度条和格子边缘对不齐。
原因:用 Graphics.DrawString 绘制文本,在非 100% 缩放下抗锯齿处理不好,发虚;进度条坐标用了写死的偏移,没有跟随 cellBounds 等比缩放。
解决:文字统一用 TextRenderer.DrawText,它内部处理 DPI 缩放,比 DrawString 清晰得多。所有坐标保持相对计算——开头定位用 cellBounds.X + margin,高度用 cellBounds.Height 参与居中计算,不要写死"离顶部 20 像素"这种绝对值。如果还要适配 PerMonitorV2 级别的缩放,把 barHeight 按当前 DPI 比例换算一下:
float dpiScale = graphics.DpiY / 96f; int scaledBarHeight = (int)(barHeight * dpiScale);这一条在笔记本外接显示器混合使用的工位上最容易暴露,上线前用 125% 和 150% 各看一眼,能省掉不少现场维护。
5. 进阶:让进度条列进化成一套可复用列库
5.1 把颜色和尺寸暴露成列属性
第 3 章的颜色是写死的,做列库就得参数化。在 ProgressColumn 上加三个属性,默认值保持原来的配色:
public int BarHeight { get; set; } = 10; public Color TrackColor { get; set; } = Color.FromArgb(229, 229, 229); public Color FillColor { get; set; } = Color.FromArgb(33, 150, 243);单元格绘制时从 OwningColumn 统一读取:
private void DrawProgressBar(Graphics graphics, Rectangle cellBounds, double percent, DataGridViewCellStyle cellStyle) { var column = OwningColumn as ProgressColumn; int barHeight = column?.BarHeight ?? 10; Color trackColor = column?.TrackColor ?? Color.FromArgb(229, 229, 229); Color fillColor = column?.FillColor ?? Color.FromArgb(33, 150, 243); // ... 其余绘制逻辑不变 }这样同一个 ProgressCell 源码,在不同窗体里可以配出蓝色、绿色、橙色不同风格的列,不用改绘制逻辑。null 条件赋值是保险:万一单元格脱离列独立存在,也有默认值兜底。
5.2 用 SortCompare 兜底排序
Automatic 排序在值类型统一时没问题,但列库要面对五花八门的数据源。稳妥做法是列的 SortMode 默认设成 Programmatic,然后在窗体里挂 SortCompare 事件做统一比较。这一步的好处是排序规则由使用方决定——按数值比、按百分比比、甚至按某个隐藏列比,都是事件里一行代码的事。
如果不想让每个窗体都写事件,可以把比较逻辑封装到列内部:重写 ProgressColumn 的 Sort 相关行为不现实,更轻的做法是提供一个静态辅助方法,窗体一行调用:
public static void HookSort(DataGridView grid) { grid.SortCompare += (s, e) => { if (grid.Columns[e.Column.Index] is ProgressColumn) { e.SortResult = Convert.ToDouble(e.CellValue1) .CompareTo(Convert.ToDouble(e.CellValue2)); e.Handled = true; } }; }5.3 重写 Clone 保证复制列不丢参数
DataGridViewColumn.Clone() 用来复制列结构,设计器拖拽、列模板复用都会走到它。默认 Clone 只复制基类字段,新增的 TrackColor、FillColor、BarHeight 全丢。不重写的话,守着默认蓝色调半天参数不生效,属于典型的"看起来没 bug 但行为不对"。
public override object Clone() { ProgressColumn copy = (ProgressColumn)base.Clone(); copy.BarHeight = BarHeight; copy.TrackColor = TrackColor; copy.FillColor = FillColor; return copy; }凡是给列加自定义属性,都要同步补 Clone。这个坑的隐蔽之处在于它不报错,只是复制出来的列"看起来没精神"。
5.4 同一个三件套再做一个评分星列
掌握了 Column + Cell 的套路,其它视觉列都是同一套骨架。评分星列的做法:值类型是 int,0~5;Paint 里按值画实心星,剩下的画空心星;EditType 返回 null 禁止编辑;列属性暴露 StarColor、StarCount。核心绘制就一个循环:
protected override void Paint(...) // 参数同 ProgressCell,省略 { // 画背景和边框,逻辑同进度条列 int score = NormalizeScore(value); // 0~5 的整数 int starCount = 5; if (OwningColumn is RatingColumn rc) starCount = rc.StarCount; float starWidth = cellBounds.Width / (float)starCount; for (int i = 0; i < starCount; i++) { var starRect = new RectangleF( cellBounds.X + i * starWidth, cellBounds.Y, starWidth, cellBounds.Height); bool filled = i < score; DrawStar(graphics, starRect, filled); // 用 GraphicsPath 画五角星 } }从进度条列到评分星列,改动量只在一个类的 Paint 方法,这就是把绘制逻辑收进单元格里的红利:换样式不换架构,新列类半个小时内能出第一版。
6. 验证:压测、断点与三个检查点
自定义列写完了,别急着说"能跑"。我现在的习惯是固定走三遍验证,第一遍压测,第二遍断点,第三遍肉眼对照。
压测的目的是看大数据量下的滚动表现。用 5 万行数据填进去,记录填充耗时和滚动时的帧率:
var sw = Stopwatch.StartNew(); for (int i = 0; i < 50000; i++) { dataGridView1.Rows.Add($"任务{i}", i / 50000.0); } sw.Stop(); Console.WriteLine($"填充 {sw.ElapsedMilliseconds} ms");填充耗时在百毫秒级属于正常,滚动流畅度重点看有没有掉帧。如果卡,先按 4.4 的静态刷子方案优化,再考虑虚拟模式。
断点验证是看 Paint 的调用边界。在 ProgressCell.Paint 第一行打断点,滚动表格观察:只有进入可视区的行才触发 Paint,不可见的行不触发。如果发现一次滚动了 20 行却触发了几百次 Paint,说明哪里在强制重绘整列,多半是列宽变化或 Invalidata 调用过频,属于要压下去的隐患。
最后是三个肉眼检查点,我用一张三行数据的表就能完成:
| 检查项 | 操作 | 通过标准 |
|---|---|---|
| 选中态 | 点一行 | 进度条外的背景变蓝,进度条不被遮盖 |
| 排序态 | 点列头 | 排序后每格的进度条和值仍对齐 |
| 缩放态 | 系统切 125%/150% | 文字不发虚,轨道和格子边缘对齐 |
这套顺序看起来笨,但帮我挡住了至少三次上线后的翻车。从那以后,我每次自定义列落地,都强制走一遍压测、断点、三项肉眼检查,再提交给同事。数据可视化这块,自绘代码的"玄学"问题不少,但九成都能被这三板斧揪出来。希望帮到你。
本文还有配套的精品资源,点击获取