简介:这份源码资源面向具备一定C#基础的Windows Forms开发者,聚焦ListView控件中嵌入CheckBox、ComboBox等自定义控件的实现方式,帮助解决原生ListView交互能力有限、难以满足复杂数据操作需求的问题。压缩包共28个文件,约89KB,以cs源码文件为主,辅以resx资源、csproj工程与sln解决方案文件,并包含编译后的exe与pdb,便于直接运行调试或二次开发。内容围绕自定义ListViewItem的继承与扩展、子控件定位、CheckedChanged与SelectedIndexChanged事件处理,以及VirtualMode虚拟化性能优化和列宽、字体、颜色等布局样式调整展开,读者可据此掌握在列表项中嵌入多种控件的完整思路,并灵活扩展按钮、文本框等元素。目前已有2028人学习下载,适合希望提升WinForms界面交互与数据展示能力的开发者参考。
1. 从一次产线报工界面改造说起:ListView 里塞进多种控件到底难在哪
去年帮一家做机加工的中小厂改一套 C# 上位机报工界面,需求很朴素:一个工单列表,每行要显示工单号、产品名、进度条、一个「暂停」按钮,状态异常时还要挂一个下拉框让操作工选原因。我第一反应是用 DataGridView,结果客户现场那台工控机内存只有 4G,DataGridView 一上两百行就开始卡顿,滚动条拖起来像在拉磨。后来换成 ListView 的 Details 视图,把进度条、按钮、下拉框这些自定义控件一个个塞进 SubItem,界面瞬间轻快,操作工也说顺手。这就是「C# ListView 中添加多种自定义控件源码」这个标题背后最真实的场景——不是炫技,是工控上位机、MES 客户端、内部管理工具这类 WinForm 项目里,既要列表轻量、又要每行能交互的刚需。
很多人第一次做会踩的坑是:以为 ListView 像 Panel 一样能直接Controls.Add,结果控件全叠在左上角,滚动时错位、重绘闪烁、点按钮没反应。这篇就把我这些年反复用过的做法拆开讲清楚:ListView 到底能不能放控件、怎么放才不闪、多种控件混排时事件怎么绑、数据量大了怎么不翻车。适合正在写 C# 上位机、WinForm 内部工具,或者被 DataGridView 性能折磨过的同行。源码层面的东西我会给可直接抄的片段,参数和边界也会标出来,新手能跟着跑,熟手能直接拿去改。
2. ListView 嵌控件的底层逻辑与选型判断
2.1 为什么 ListView 不是容器,却还能放控件
ListView 继承自 Control,但它内部并不是靠Control.Controls集合来管理子控件的。Details 视图下每一行是一个 ListViewItem,每个单元格是一个 ListViewSubItem,这些都不是 Control 对象,所以你不能直接listView1.Controls.Add(progressBar)然后指望它跟着某一行走。真正可行的做法是:把自定义控件添加到 ListView 的父容器(比如一个 Panel 或者 Form)上,然后根据目标单元格的Bounds手动设置控件位置,并在 ListView 滚动、列宽变化、窗口缩放时同步更新位置。这就是所谓「浮动控件」方案,也是绝大多数 ListView 嵌控件源码的核心思路。
理解这一点很关键,因为它决定了后面所有坑的根源:控件和 ListView 是「两层皮」,ListView 只负责画行和单元格,控件是浮在上面的独立窗口。你看到的「嵌进去」只是视觉对齐,不是父子关系。所以滚动时 ListView 自己重绘,控件不会自动跟着动,必须靠事件驱动去同步。另一个常见做法是自绘(OwnerDraw),用DrawItem和DrawSubItem直接画进度条、按钮样式,但自绘只能画不能交互,按钮点击、下拉展开都得自己算坐标模拟,复杂度高,除非你追求极致性能,否则不推荐。
选型上我的判断标准很简单:如果每行需要真实交互(按钮点击、下拉选择、文本输入),用浮动控件;如果只是展示型进度条、状态色块,自绘更省资源。混合场景可以两者结合,比如进度条自绘、按钮浮动。下面这张表是我实际项目里对比过的几个方案,供你决策。
| 方案 | 交互能力 | 性能(500行) | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 浮动控件 | 完整 | 中等,需优化 | 中 | 按钮、下拉、输入框 |
| OwnerDraw 自绘 | 无(需模拟) | 高 | 高 | 纯展示进度、状态 |
| DataGridView 控件列 | 完整 | 低 | 低 | 行数少、内存充足 |
| ListView + 自定义列 | 有限 | 高 | 中 | 简单图标、文本 |
2.2 最小可运行示例:把一个按钮放进 ListView 单元格
先跑通一个最小例子,再谈多种控件。假设你有一个listView1,Details 视图,两列:工单号、操作。我们要在第二列每行放一个按钮。核心步骤是:添加 ListViewItem 时记录行索引,按钮添加到 ListView 的父容器,然后在ListView.DrawItem或Resize、Scroll事件里重新计算位置。下面这段代码可以直接放进 Form_Load 里跑。
// 假设 listView1 已添加到 form,View = Details,Columns 已添加两列 listView1.View = View.Details; listView1.FullRowSelect = true; listView1.GridLines = true; // 添加三行数据 for (int i = 0; i < 3; i++) { var item = new ListViewItem("WO-" + (1000 + i)); item.SubItems.Add(""); // 第二列留空,给按钮占位 listView1.Items.Add(item); } // 创建按钮并添加到 ListView 的父容器(这里是 Form) var btn = new Button(); btn.Text = "暂停"; btn.Size = new Size(60, 22); btn.Tag = 0; // 记录行索引 btn.Click += Btn_Click; this.Controls.Add(btn); // 注意:添加到 Form,不是 listView1 btn.BringToFront(); // 定位按钮到第一行第二列 void PositionButton() { if (listView1.Items.Count == 0) return; var rect = listView1.Items[0].SubItems[1].Bounds; btn.Location = new Point(rect.Left + listView1.Left, rect.Top + listView1.Top); } // 在 ListView 布局变化时重新定位 listView1.Resize += (s, e) => PositionButton(); listView1.Scroll += (s, e) => PositionButton(); PositionButton(); void Btn_Click(object sender, EventArgs e) { var b = sender as Button; int rowIndex = (int)b.Tag; MessageBox.Show("点击了第 " + rowIndex + " 行"); }这段代码的逻辑说明:SubItems[1].Bounds返回的是相对于 ListView 客户区的矩形,所以加上listView1.Left和listView1.Top才是相对于 Form 的坐标。BringToFront确保按钮浮在 ListView 上面。Tag存行索引是最简单的绑定方式,行数少时够用。参数上,按钮大小要和行高匹配,ListView 默认行高约 17-20 像素,按钮高度建议 22 以内,否则会盖住下一行。如果你发现按钮位置偏了,先检查 ListView 是否设置了OwnerDraw,以及SubItems[1].Bounds在列宽为 0 时返回空矩形。
提示:这个最小示例只放了一个按钮,实际项目里每行都要放,就需要用列表管理所有控件,并在
Scroll和Resize时批量更新。下一章讲多种控件混排时怎么组织。
3. 多种自定义控件混排的完整实现与参数调优
3.1 用控件池管理每行的进度条、按钮和下拉框
当每行要放三种控件时,最忌讳的是每次滚动都 new 一堆控件再销毁,那样 GC 压力大,界面会一卡一卡。我一般用「控件池」思路:预先创建固定数量的控件实例,按需显示和隐藏,滚动时只更新位置和绑定数据,不重建。具体做法是定义一个RowControlSet类,包含一个 ProgressBar、一个 Button、一个 ComboBox,然后维护一个List<RowControlSet>,数量等于可见行数加缓冲。ListView 滚动时,计算当前第一个可见行索引,把控件池里的控件依次贴到可见行上,超出范围的隐藏。
下面是一个可复现的骨架代码,重点看控件池的创建和滚动同步逻辑。
public class RowControlSet { public ProgressBar Progress { get; set; } public Button ActionBtn { get; set; } public ComboBox ReasonBox { get; set; } public int BoundRowIndex { get; set; } = -1; } private List<RowControlSet> _controlPool = new List<RowControlSet>(); private const int PoolSize = 30; // 可见行数 + 缓冲 private void InitControlPool() { for (int i = 0; i < PoolSize; i++) { var set = new RowControlSet(); set.Progress = new ProgressBar { Size = new Size(80, 16), Minimum = 0, Maximum = 100 }; set.ActionBtn = new Button { Text = "暂停", Size = new Size(50, 20) }; set.ReasonBox = new ComboBox { Size = new Size(80, 20), DropDownStyle = ComboBoxStyle.DropDownList }; set.ReasonBox.Items.AddRange(new[] { "缺料", "设备故障", "换刀", "待检" }); // 事件绑定,用 Tag 或闭包记录控件集 set.ActionBtn.Click += (s, e) => OnRowButtonClick(set); set.ReasonBox.SelectedIndexChanged += (s, e) => OnReasonChanged(set); this.Controls.Add(set.Progress); this.Controls.Add(set.ActionBtn); this.Controls.Add(set.ReasonBox); set.Progress.BringToFront(); set.ActionBtn.BringToFront(); set.ReasonBox.BringToFront(); _controlPool.Add(set); } } private void SyncControls() { if (listView1.Items.Count == 0) return; int firstVisible = listView1.TopItem?.Index ?? 0; int visibleCount = listView1.ClientSize.Height / listView1.GetItemRect(0).Height + 1; for (int i = 0; i < _controlPool.Count; i++) { var set = _controlPool[i]; int rowIndex = firstVisible + i; if (rowIndex >= listView1.Items.Count || i >= visibleCount) { set.Progress.Visible = false; set.ActionBtn.Visible = false; set.ReasonBox.Visible = false; set.BoundRowIndex = -1; continue; } var item = listView1.Items[rowIndex]; // 假设列顺序:0 工单号,1 进度,2 操作,3 原因 var progressRect = item.SubItems[1].Bounds; var btnRect = item.SubItems[2].Bounds; var comboRect = item.SubItems[3].Bounds; set.Progress.Location = new Point(progressRect.Left + listView1.Left, progressRect.Top + listView1.Top); set.ActionBtn.Location = new Point(btnRect.Left + listView1.Left, btnRect.Top + listView1.Top); set.ReasonBox.Location = new Point(comboRect.Left + listView1.Left, comboRect.Top + listView1.Top); set.Progress.Visible = true; set.ActionBtn.Visible = true; set.ReasonBox.Visible = true; set.BoundRowIndex = rowIndex; // 绑定数据,实际项目从数据源取 set.Progress.Value = GetProgress(rowIndex); set.ActionBtn.Text = GetButtonText(rowIndex); set.ReasonBox.SelectedIndex = GetReasonIndex(rowIndex); } }逻辑说明:TopItem返回当前第一个可见项,GetItemRect(0).Height拿到行高,两者结合算出可见行数。控件池大小要略大于可见行数,避免滚动时来不及创建。BoundRowIndex记录当前控件绑定的行,事件里通过它反查数据。参数上,PoolSize建议设为「可见行数 + 5」,比如你的 ListView 高度能显示 20 行,就设 25。ProgressBar的Maximum根据业务设,工单进度一般是 0-100。ComboBox用DropDownList防止手输脏数据。
3.2 滚动与重绘的性能参数:双缓冲和 BeginUpdate
浮动控件方案最大的敌人是闪烁。ListView 本身滚动时会重绘,控件跟着移动时也会重绘,两者不同步就闪。解决办法有两个:一是给 ListView 开双缓冲,二是滚动时用BeginUpdate/EndUpdate包住控件位置更新。ListView 的DoubleBuffered属性是 protected 的,不能直接设,常见做法是反射或者自定义子类。我一般直接写一个BufferedListView继承 ListView,在构造函数里设DoubleBuffered = true。
public class BufferedListView : ListView { public BufferedListView() { this.DoubleBuffered = true; this.SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true); this.UpdateStyles(); } }然后在滚动事件里这样写:
private void listView1_Scroll(object sender, ScrollEventArgs e) { listView1.BeginUpdate(); try { SyncControls(); } finally { listView1.EndUpdate(); } }参数说明:OptimizedDoubleBuffer减少闪烁,AllPaintingInWmPaint禁止擦除背景,两者一起用效果最好。BeginUpdate期间 ListView 不重绘,EndUpdate后一次性刷新,控件位置更新放在中间,视觉上就是同步的。注意SyncControls里不要做耗时操作,比如查数据库,否则滚动会卡。数据提前加载到内存,滚动只做绑定。
注意:如果你发现滚动时控件「跳一下」才对齐,通常是
Scroll事件触发时机晚于 ListView 自身重绘。可以在SyncControls开头调用listView1.Update()强制立即重绘,但会牺牲一点性能,行数少时可用。
3.3 事件绑定与行数据反查的可靠做法
浮动控件的事件里怎么知道是哪一行?前面最小示例用Tag存索引,但滚动后控件复用,Tag会变,容易出错。可靠做法是用控件池里的BoundRowIndex,事件触发时读这个值,再去数据源取行数据。但要注意,ComboBox.SelectedIndexChanged在SyncControls里设置SelectedIndex时也会触发,导致误判为用户操作。解决办法是加一个_isSyncing标志,同步期间忽略事件。
private bool _isSyncing = false; private void OnReasonChanged(RowControlSet set) { if (_isSyncing) return; if (set.BoundRowIndex < 0) return; int row = set.BoundRowIndex; string reason = set.ReasonBox.SelectedItem?.ToString(); // 更新数据源 UpdateReason(row, reason); } private void SyncControls() { _isSyncing = true; try { // ... 前面的定位和绑定逻辑 } finally { _isSyncing = false; } }参数上,BoundRowIndex在控件隐藏时要重置为 -1,避免事件里拿到脏数据。按钮点击同理,用set.BoundRowIndex反查。如果行数据是对象列表,建议在ListViewItem.Tag里存对象引用,控件事件里通过BoundRowIndex拿到ListViewItem,再取Tag,这样比纯索引更稳。
4. 避坑与排查:ListView 嵌控件最常见的五个翻车现场
4.1 控件叠在左上角不动
现象:添加控件后,所有控件都堆在 ListView 左上角,滚动也不跟着走。原因:控件添加到了 ListView 的Controls集合,而不是父容器。ListView 不把子控件当行内容布局,所以全部默认在 (0,0)。解决:把控件添加到 ListView 的父容器(Form 或 Panel),用SubItems[i].Bounds加偏移量手动定位,并在Scroll、Resize事件里重新计算。
4.2 滚动时控件残影、闪烁严重
现象:拖动滚动条,控件移动后留下白色残影,或者整个列表闪得眼花。原因:ListView 默认不双缓冲,控件移动和列表重绘不同步。解决:用BufferedListView开双缓冲,滚动事件里用BeginUpdate/EndUpdate包住SyncControls,并确保SyncControls里不做耗时操作。如果还有轻微闪烁,可以在定位前调用listView1.Update()。
4.3 下拉框一滚动就自动触发选中事件
现象:滚动列表时,ComboBox 的SelectedIndexChanged被频繁触发,导致数据被意外修改。原因:SyncControls里给 ComboBox 赋SelectedIndex会触发事件,被误认为用户操作。解决:加_isSyncing标志,同步期间直接 return。另外,BoundRowIndex为 -1 时也要忽略。
4.4 行数超过 500 后界面卡顿
现象:数据量到几百行时,滚动明显掉帧,按钮点击响应慢。原因:控件池太小,滚动时频繁创建销毁;或者SyncControls里查了数据库。解决:控件池大小设为可见行数加缓冲,数据一次性加载到内存,滚动只做绑定。如果还卡,考虑把纯展示的进度条改成 OwnerDraw 自绘,减少控件数量。
4.5 列宽调整后控件位置错乱
现象:用户拖动列宽,控件还停在旧位置,和单元格对不上。解决:监听ColumnWidthChanged事件,在里面调用SyncControls。另外,SubItems[i].Bounds在列宽为 0 时返回空矩形,定位前要判断rect.Width > 0,否则控件会跑到左上角。
5. 进阶技巧:用 OwnerDraw 混合浮动控件把性能再压一档
浮动控件方案在 200 行以内很舒服,但到了 500 行以上,每行三个控件就是 1500 个窗口句柄,Windows 消息循环压力不小。我后来在一个工单量常年 800+ 的项目里做了混合方案:进度条和状态色块用 OwnerDraw 自绘,只有按钮和下拉框用浮动控件。这样每行控件数从 3 降到 2,滚动流畅度提升明显。自绘进度条的核心是重写DrawSubItem,用e.Graphics.FillRectangle画背景和进度,参数从数据源取。
listView1.OwnerDraw = true; listView1.DrawColumnHeader += (s, e) => { e.DrawDefault = true; }; listView1.DrawSubItem += (s, e) => { if (e.ColumnIndex == 1) // 进度列 { e.DrawBackground(); int percent = GetProgress(e.ItemIndex); var rect = e.Bounds; rect.Inflate(-2, -2); e.Graphics.FillRectangle(Brushes.LightGray, rect); var fill = new Rectangle(rect.X, rect.Y, rect.Width * percent / 100, rect.Height); e.Graphics.FillRectangle(Brushes.SeaGreen, fill); e.Graphics.DrawString(percent + "%", this.Font, Brushes.Black, rect); } else { e.DrawDefault = true; } };参数说明:e.ItemIndex是当前绘制的行索引,直接用来取数据,比浮动控件反查更直接。rect.Inflate(-2, -2)留出边距,视觉上不贴边。进度色用SeaGreen,异常时换成OrangeRed,在DrawSubItem里根据状态判断。注意OwnerDraw = true后,所有列都会走自绘,不需要自绘的列要设e.DrawDefault = true,否则文字不显示。
混合方案里,浮动控件只保留按钮和下拉框,控件池大小可以减半。滚动时SyncControls只更新这两类控件的位置,进度条完全由DrawSubItem负责,Invalidate触发重绘即可。实测 800 行数据,滚动帧率从 20 提升到 45 左右,操作工反馈「跟手了」。验证方法很简单:在SyncControls里加一个Stopwatch,打印每次同步耗时,超过 10ms 就要考虑优化。我一般会把这个耗时打到日志里,上线前跑一遍压力测试。
最后说个我自己的习惯:每次做 ListView 嵌控件,先写最小示例跑通一个按钮,再逐步加控件、加数据、加滚动,每加一步就测滚动和点击。血泪经验是,别一上来就写完整业务逻辑,否则出了问题你分不清是控件定位错了还是数据绑定错了。这个方案值不值得做?如果你的项目是 WinForm 上位机、内部工具,行数在几百到一千,又需要每行交互,那它比 DataGridView 轻、比纯自绘灵活,值得投入。如果行数上万,还是考虑虚拟列表或者换技术栈。希望帮到你。
本文还有配套的精品资源,点击获取