
简介面向C# Windows Forms开发者的ListView自定义控件示例工程解决在列表视图中嵌入CheckBox、ComboBox等交互控件的常见难题。压缩包内含28个文件以cs源代码为主辅以resx资源文件、settings配置、sln/csproj工程文件及说明文档整体仅89KB轻量易用。核心代码演示了自定义ListViewItem、子控件定位挂载、CheckedChanged与SelectedIndexChanged事件绑定并给出VirtualMode虚拟化与布局样式调整思路适合需要提升列表交互体验的中级开发者参考。资源已有2028人学习下载后可通过完整Visual Studio工程直接运行和调试快速复用或改造到实际项目中。1. ListView 加控件这件事比你想的麻烦做 WinForms 上位机或者管理系统的时候总会遇到一个需求列表里不光要显示文字还要放按钮、复选框、进度条、下拉框。最典型的场景是设备监控界面每一行对应一台设备行尾要放一个“启动/停止”按钮或者用进度条显示实时负载。很多人的第一反应是把控件直接拖到 ListView 的项上结果发现控件根本不跟着列表滚动滑动一下就错位了。这件事的难度不在于“放上去”而在于 ListView 本身是一个轻量级列表控件它没有原生的“承载子控件”的能力。市面上能查到的资料多是零散片段要么只讲 OwnerDraw 自绘要么只讲嵌入控件很少有人把两种路线放在一起讲清楚。这份 C# ListView 添加自定义控件的源码包正好把两种主流做法都整理了适合正在做 WinForms 项目、需要让列表具备交互能力的开发者。下面我把两条路线的原理、实现步骤和踩过的坑拆开讲。2. 把控件嵌进 ListView 的两种路线OwnerDraw 自绘与 EmbeddedControl 嵌入2.1 为什么不能直接往 ListView 里 Add 控件很多新手会尝试listView.Controls.Add(button)然后发现按钮确实显示出来了但它像是贴在了 ListView 上面的一层玻璃上滚轮滚动列表时按钮纹丝不动拖拽列头时按钮也不会跟着列走。原因是 ListView 是 Win32 原生控件它内部有自己的一套消息循环和绘制机制子项ListViewItem并不是真正的窗口句柄持有者它们只是数据结构。而像 Button、ComboBox 这些是真正的窗口控件有 HWND 的你硬把它们加进 ListView 的 Controls 集合它们只是叠在 ListView 表面上和列表的滚动、布局没有任何联动。源码包里的第一段注释就写得很明白所有方案的核心都是“用数据模拟控件而不是真的把控件放进去”。这句话是整个问题的钥匙理解了它后面看代码就不迷糊了。2.2 OwnerDraw 自绘让 ListView 自己画出控件的样子OwnerDraw 的思路是不真的创建控件而是在绘制阶段把按钮、进度条、复选框的样子画在对应列的区域上然后通过鼠标点击的命中测试HitTest来决定触发哪个“假控件”的行为。这种方式的优点是性能极高、内存占用极小几万行数据也不卡缺点是所有交互逻辑都要自己处理包括 hover 效果、点击区域判断、键盘操作。源码包里开了一个DrawButtonListView类核心代码在DrawSubItem事件里典型的写法如下protected override void OnDrawSubItem(DrawListViewSubItemEventArgs e) { base.OnDrawSubItem(e); // 只处理第3列索引2这一列放按钮 if (e.ColumnIndex ! 2) { e.DrawDefault true; return; } // 计算按钮的绘制区域留出2像素边距避免贴边 Rectangle btnRect new Rectangle( e.Bounds.X 2, e.Bounds.Y 2, e.Bounds.Width - 4, e.Bounds.Height - 4); // 根据鼠标位置决定按钮颜色模拟 hover 效果 bool isHover btnRect.Contains(e.ListView.PointToClient(Cursor.Position)); ButtonState state isHover ? ButtonState.Normal : ButtonState.Inactive; // 用 ControlPaint 画一个标准按钮外观 ControlPaint.DrawButton(e.Graphics, btnRect, state); // 在按钮中央画文字 TextRenderer.DrawText( e.Graphics, 启动, e.ListView.Font, btnRect, isHover ? Color.Blue : Color.Black, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); }这段代码的核心在ControlPaint.DrawButton它能把一个标准按钮外观直接画到 Graphics 上不需要创建真实的 Button 控件。e.Bounds是当前子项SubItem所在的矩形区域基于它计算按钮区域能保证跟列宽联动。isHover的判断用PointToClient把鼠标坐标从屏幕坐标转成 ListView 的客户区坐标这样才能正确判断鼠标是否落在按钮上。这里有个参数要留意e.ColumnIndex对应的是列索引从 0 开始。如果你的按钮不在第 3 列而是最后一列建议用e.ColumnIndex e.Header.ListView.Columns.Count - 1这种写法避免写死数字不然以后调整列顺序时很容易漏改。2.3 自绘之后必须处理的鼠标交互画出来了还不算完自绘的按钮没有窗口句柄鼠标点上去 ListView 根本不知道你点的是“按钮”。源码包里在MouseDown事件里做了命中测试思路是把鼠标坐标换算成子项索引和列索引然后判断是否落在按钮区域内protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); // 获取鼠标点击位置的项和列 ListViewHitTestInfo hit this.HitTest(e.Location); // 判断点击的是第3列 if (hit.SubItem ! null hit.SubItem.ColumnIndex 2) { Rectangle btnRect new Rectangle( hit.SubItem.Bounds.X 2, hit.SubItem.Bounds.Y 2, hit.SubItem.Bounds.Width - 4, hit.SubItem.Bounds.Height - 4); // 只有落在按钮矩形区域内才算点击了按钮 if (btnRect.Contains(e.Location)) { // 触发按钮点击事件e.Item 就是对应的 ListViewItem OnButtonClicked(hit.Item); } } }这里的关键是ListViewHitTestInfo这个类它的SubItem属性可以拿到你点击的子项Item属性可以拿到所在的整行。hit.SubItem.ColumnIndex直接告诉你点击的是第几列省去了自己遍历列来计算坐标的麻烦。源码包里封装了一个ButtonClicked事件外部只要订阅这个事件就能拿到点击的是哪一行这层封装很重要它把“识别哪个假按钮被点了”和“按钮被点了要做什么业务”彻底解耦。2.4 嵌入真实控件当自绘满足不了需求时的选择OwnerDraw 适合按钮、进度条这种外观简单的控件但如果你要嵌入的是 DateTimePicker、ComboBox 这种交互逻辑复杂的控件自绘的成本会高到不现实这时候就需要走 EmbeddedControl 路线。源码包里实现了一个EmbeddedControlHelper原理是创建一个真实控件在Scroll事件里不断调整它的位置让它看起来像是“住在”ListView 的某个单元格里。核心做法是先创建一个宿主控件把要嵌入的子控件放到宿主上然后在 ListView 的Scroll、Resize、ColumnWidthChanging、ItemDrag等事件里重新计算宿主的位置public void EmbedControl(ListView listView, Control control, int itemIndex, int columnIndex) { // 获取目标子项的边界矩形 Rectangle subItemRect listView.Items[itemIndex].SubItems[columnIndex].Bounds; // 把控件的位置设置到子项区域并让它跟子项一样宽高 control.Left listView.Left subItemRect.X; control.Top listView.Top subItemRect.Y; control.Width subItemRect.Width; control.Height subItemRect.Height; // 关键把控件加到 ListView 的父容器上而不是加进 ListView 本身 listView.Parent.Controls.Add(control); control.BringToFront(); }注意代码里写的是listView.Parent.Controls.Add(control)这是很多教程不会说透的地方直接把控件加到 ListView 的 Controls 里ListView 重绘时会把控件当作自己的子窗口重绘导致闪烁和位置错乱。而加到父容器上让控件“浮”在 ListView 上方位置计算对了就能做到视觉上的嵌入效果。但这种方式有个天然缺陷ListView 滚动时SubItems的Bounds会变化所以必须在滚动事件里持续调用重定位逻辑。源码包里在Scroll事件里挂了一个RepositionEmbeddedControl方法计算逻辑和上面一样只是每次滚动时重新取一次Bounds。如果你用这种方式还要处理垂直滚动条把控件顶出可视区域的问题——判断subItemRect.Bottom 0 || subItemRect.Top listView.Height时控件要隐藏否则它会飘在列表外面。3. 数据绑定与 VirtualMode为什么列表一卡段就优化不动了3.1 ListViewItem 的 Tag 属性才是数据存储的正解嵌入控件只是表现层真正让列表有意义的是数据。源码包里用了典型的上位机架构用一个ListDeviceInfo存储业务数据ListView 只负责展示。每个ListViewItem的Tag属性存放对应的数据对象这样无论你在自绘按钮的事件里还是在嵌入控件的取值逻辑里都能通过hit.Item.Tag拿到整行数据。这段代码是列表刷新的基础逻辑看起来很普通但值得照着写// 设备信息实体类 public class DeviceInfo { public int DeviceId { get; set; } public string Name { get; set; } public double LoadRate { get; set; } // 负载率 public bool IsRunning { get; set; } // 运行状态 } // 刷新列表数据 private void RefreshListView(ListDeviceInfo devices) { listView.BeginUpdate(); listView.Items.Clear(); listView.Groups.Clear(); foreach (DeviceInfo device in devices) { ListViewItem item new ListViewItem(device.DeviceId.ToString()); item.SubItems.Add(device.Name); item.SubItems.Add(device.LoadRate.ToString(P1)); // 百分比格式保留1位小数 item.SubItems.Add(device.IsRunning ? 运行中 : 已停止); // Tag 绑定业务数据对象后续任何事件里都能取回完整上下文 item.Tag device; listView.Items.Add(item); } listView.EndUpdate(); }BeginUpdate和EndUpdate是必写的它在批量操作期间禁止 ListView 重绘能极大提升大量数据插入时的流畅度。不写的话每 Add 一个 Item 就重绘一次5000 行数据会肉眼可见地卡顿。Tag属性是 object 类型你可以放任何东西但建议只放数据实体不要放控件引用否则内存管理上容易出麻烦。3.2 VirtualMode 大名单模式几万行数据不再卡死的开关当数据量超过几万行时即使加上了BeginUpdate全量刷新仍然会产生显著的延迟。源码包里开启了一个很多人不知道的模式——VirtualMode。这个模式的核心思想是ListView 不持有数据它只在需要显示某一行的时候是触发RetrieveVirtualItem事件让你从自己的数据源把那一行取出来。开启VirtualMode的初始化代码很简单但背后逻辑完全不同// 开启虚拟模式的初始化配置 listView.VirtualMode true; listView.RetrieveVirtualItem ListView_RetrieveVirtualItem; // 告诉 ListView 一共有多少行 listView.VirtualListSize _deviceList.Count;// 虚拟模式下ListView 按需索取数据 private void ListView_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e) { // e.ItemIndex 是 ListView 当前需要显示的行索引直接从数据源取 DeviceInfo device _deviceList[e.ItemIndex]; // 每次都重新创建 ListViewItem会走缓存机制不必担心性能 e.Item new ListViewItem(device.DeviceId.ToString()); e.Item.SubItems.Add(device.Name); e.Item.SubItems.Add(device.LoadRate.ToString(P1)); e.Item.SubItems.Add(device.IsRunning ? 运行中 : 已停止); e.Item.Tag device; }虚拟模式的关键在RetrieveVirtualItemEventArgs的ItemIndex属性它告诉你当前需要渲染的是哪一行。你从自己的ListDeviceInfo里取出对应数据构建ListViewItem并赋给e.Item就算完成了。ListView 内部有缓存机制滚动时只会请求可视区域附近的行所以数据量再大也不会一次性构建所有 Item。需要特别注意的是虚拟模式下 ListView 不持有 Item 的引用所以你不能通过listView.Items[i]去修改某一个 Item 的显示状态。正确的做法是先修改_deviceList[i]的数据然后调用listView.RedrawItems(i, i, false)强制刷新指定的行。这个细节很多人不知道导致虚拟模式下想更新某行状态时发现界面没反应还以为代码写错了。3.3 缓存 ListViewItem 的隐性代价虚拟模式下有个常见的误区有人在RetrieveVirtualItem里贪图方便自己写了一个ListViewItem缓存试图减少重复创建的开销。但实测结果往往是内存暴涨、性能反而变差因为 ListView 内部已经有一个VirtualItemsSelectionRangeChanged引发的缓存机制你再去缓存反而破坏了它的内部状态判断。我一般会建议除非通过 Profiler 明确看到了RetrieveVirtualItem是性能瓶颈否则不要加额外缓存。这个事件的触发频率并没有想象中那么高滚动时也就每秒触发几十次构建一个ListViewItem的成本远低于你想象。真正的性能瓶颈通常在数据源的查询逻辑上而不是 Item 构建。4. 监听列表事件从 ItemDrag 到 ColumnClick 的完整交互方案4.1 拖拽排序的实现数据源排序而不是 Item 排序配合界面控件列表本身也要有交互。源码包实现了 ItemDrag 拖拽换行核心逻辑是处理ItemDrag事件在释放时重新绑定数据源。这个方案比直接移动ListViewItem要稳妥得多因为它天然适配虚拟模式——虚拟模式下不能直接操作 Item只能操作数据源private void listView_ItemDrag(object sender, ItemDragEventArgs e) { // 记录拖拽起始的 Item _dragStartIndex e.Item.Index; listView.DoDragDrop(listView.SelectedItems, DragDropEffects.Move); } private void listView_DragOver(object sender, DragEventArgs e) { e.Effect DragDropEffects.Move; // 获取鼠标当前位置的目标行索引 Point targetPoint listView.PointToClient(new Point(e.X, e.Y)); int targetIndex listView.InsertionMark.NearestIndex(targetPoint); // 用插入标记显示拖拽落点 listView.InsertionMark.Index targetIndex; } private void listView_DragDrop(object sender, DragEventArgs e) { Point targetPoint listView.PointToClient(new Point(e.X, e.Y)); int targetIndex listView.InsertionMark.NearestIndex(targetPoint); if (targetIndex 0 targetIndex _deviceList.Count) { // 从数据源移动元素然后整体刷新 DeviceInfo movedItem _deviceList[_dragStartIndex]; _deviceList.RemoveAt(_dragStartIndex); _deviceList.Insert(targetIndex, movedItem); RefreshListView(_deviceList); } }这里有个被很多人忽略的控件InsertionMark。它是 ListView 内置的拖拽指示器能在两行之间显示一条插入线。NearestIndex方法会根据鼠标坐标返回最近的项索引比你自己算Item.Bounds.Contains要精准得多尤其是在行高不固定的时候。拖拽完成后直接刷新整个列表虽然简单粗暴但配合虚拟模式反而比逐项移动 Item 开销更小。4.2 列头排序ListView 自带的 Sort 不适用于虚拟模式需要自己写ListView 有内置的Sorting属性和ListViewItemSorter但虚拟模式会自动禁用这些功能。源码包里另写了一个ColumnClick事件处理器做法是记录当前排序列和方向然后用ListT.Sort对数据源排序最后刷新列表private void listView_ColumnClick(object sender, ColumnClickEventArgs e) { // 如果点击的是同一列翻转排序方向 if (e.Column _sortColumn) { _sortAscending !_sortAscending; } else { _sortColumn e.Column; _sortAscending true; } // 根据列索引选择排序 Key ComparisonDeviceInfo comparison; switch (e.Column) { case 0: comparison (a, b) a.DeviceId.CompareTo(b.DeviceId); break; case 1: comparison (a, b) string.Compare(a.Name, b.Name); break; case 2: comparison (a, b) a.LoadRate.CompareTo(b.LoadRate); break; default: comparison (a, b) a.IsRunning.CompareTo(b.IsRunning); break; } _deviceList.Sort(comparison); if (!_sortAscending) { _deviceList.Reverse(); // 反转实现倒序 } listView.RedrawItems(0, listView.VirtualListSize - 1, false); }ComparisonT委托比写IComparer要简洁得多每个列一个 lambda 就完事了。注意LoadRate是 double 类型直接用CompareTo就能得到正确的数值顺序但如果是字符串形式的百分比就必须先解析成数值再排序否则会出现“10%”排在“2%”前面的幺蛾子。源码包里用的是格式化后的字符串做展示但排序时用了原始数据这个区分值得学习——展示层和排序层用的数据要分开。4.3 MouseWheel 滚动与嵌入式控件的位置同步鼠标滚轮滚动 ListView 时嵌入式控件的位移是靠Scroll事件来驱动的但还有一个容易遗漏的事件是MouseWheel。因为Scroll事件在滚轮滚动时不一定每次都触发依赖系统设置和滚动条状态极端情况下控件会卡在原来的位置。源码包的做法是在MouseWheel事件里也挂上重定位方法双保险private void listView_MouseWheel(object sender, MouseEventArgs e) { // 延迟到下一帧执行重定位避免和 ListView 的滚动更新产生时序竞争 this.BeginInvoke(new Action(() { RepositionEmbeddedControl(); })); }注意这里用了BeginInvoke把重定位操作推迟到消息队列末尾执行因为MouseWheel事件触发时 ListView 内部的滚动还没有真正完成立即取Bounds会拿到旧值。这个技巧在 WinForms 里很常见凡是遇到“事件触发了但界面还没更新完”的错位问题都可以用这种方式把操作延后一拍。5. 避坑手册这五个问题能让你白干两天5.1 自绘复选框的 HitTest 判断失效现象用 OwnerDraw 画了一个复选框单击它没反应但点击复选框右边一点的位置反而触发了事件。原因hit.SubItem.Bounds和画复选框的区域不完全一致你画复选框时在左边留了空白边距但 HitTest 判断时没有减去同样的边距导致实际的可点击区域偏移了。解决把绘制复选框的矩形区域提取为一个公共方法绘制和点击判断都用同一个方法返回的 Rectangle保证两边永远一致。源码包里写的是GetCheckBoxBounds(SubItem subItem)这个封装非常值得借鉴。5.2ListViewItem.Tag持有控件引用导致内存无法释放现象关闭窗口后进程还在后台跑着内存只增不减明明已经调用了Dispose。原因你在Tag里放了控件引用而控件又持有 ListView 的引用形成了循环引用。关闭窗口时垃圾回收器无法回收这一整条强引用链。解决Tag只放业务数据对象不放任何 UI 控件。如果确实需要关联控件用DictionaryListViewItem, Control的弱引用表来维护或者干脆用条件查找。5.3 虚拟模式下用listView.Items[i].Text取值拿到空值现象开启VirtualMode后在ButtonClicked事件里读hit.Item.Text拿到的竟然是空字符串。原因虚拟模式下 ListView 不真实持有 Itemhit.Item是临时创建的Text属性只在RetrieveVirtualItem被调用时才有值其他时间拿到的都是未初始化的空对象。解决不要通过 Item 的显示属性取值而是通过hit.Item.Tag拿业务数据。Tag是在RetrieveVirtualItem里赋值的它永远和当前数据源同步。5.4 嵌入式控件盖住了 ListView 的滚动条现象把 ComboBox 嵌入到最后一列后垂直滚动条被盖住了一半鼠标拖动滚动条时经常点歪。原因嵌入的控件加了listView.Parent.Controls.Add(control)后它位于父容器的顶层而 ListView 的滚动条属于 ListView 自己控件的矩形区域覆盖到了滚动条的位置。解决在RepositionEmbeddedControl里判断目标子项是否超出 ListView 可视区域超出就直接隐藏控件private void RepositionEmbeddedControl() { Rectangle bounds listView.Items[0].SubItems[2].Bounds; // 如果子项完全在可视范围之外隐藏控件 if (bounds.Bottom 0 || bounds.Top listView.ClientSize.Height) { embeddedControl.Visible false; return; } embeddedControl.Visible true; Point offset listView.GetScrollOffset(); // 自定义方法获取滚动偏移 embeddedControl.Location new Point( bounds.X - offset.X, bounds.Y - offset.Y); }判断条件里bounds.Bottom 0表示子项已经滚到顶部之上bounds.Top listView.ClientSize.Height表示子项已经滚到底部之下。这两种情况都要隐藏控件否则它会飘在空白区域。5.5 嵌入了 DatePicker 后点击弹不出日历现象ListView 里嵌入的 DateTimePicker点击后日历下拉框一闪而过或者根本不弹出来。原因ListView 重绘或者滚动时给嵌入的控件发送了WM_PAINT消息干扰了 DateTimePicker 的下拉窗口的显示逻辑。另外还有焦点问题ListView 抢占了焦点后下拉窗口会被立刻关闭。解决在嵌入 DateTimePicker 之前把它的ShowUpDown设置为true使用上下箭头而不是下拉框来选择日期能绕开大部分问题。如果必须用下拉就在GotFocus事件里用BeginInvoke延迟弹出下拉框等 ListView 的焦点操作先完成datePicker.GotFocus (sender, e) { this.BeginInvoke(new Action(() { datePicker.DroppedDown true; // 强制弹出下拉框 })); };这个解决方式是临时方案实测效果尚可但如果你的业务允许我建议优先考虑用自绘的方式模拟日期显示配合一个全局的弹窗选择日期比嵌入真实控件稳定得多。6. 列表卡顿优化BeginUpdate 之外的那点细节列表的流畅度除了BeginUpdate和VirtualMode还有一个容易被忽略的细节ListView的DoubleBuffered属性。这个属性在属性面板里是隐藏的必须在代码里设置// 开启 ListView 双缓冲减少重绘闪烁和撕裂感 typeof(ListView).GetProperty(DoubleBuffered, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) .SetValue(listView, true, null);通过反射开启双缓冲后拖动列头、快速滚动时列表的闪烁感会有明显改善。这个属性原生是 protected 的微软没有开放设计器支持但反射设置属性值是安全操作。注意不要在每次刷新时都调用反射设置它只需要在窗体加载时设置一次。自定义控件的绘制柏林滤镜逻辑也可以进一步优化源码包里对DrawSubItem事件做了个小处理先判断e.ColumnIndex是否在可视区域内不在就直接返回避免对不可见列做无意义的绘制计算private void listView_DrawSubItem(object sender, DrawListViewSubItemEventArgs e) { // 只绘制可视范围内的列减少无效计算 if (e.Bounds.Right 0 || e.Bounds.Left listView.ClientSize.Width) { return; } // 列宽为0的列直接跳过 if (e.Bounds.Width 0 || e.Bounds.Height 0) { return; } // 正常的绘制逻辑... }这个判断能省掉不少 GDI 绘制调用。在实际项目中当 ListView 的列特别多而窗体宽度有限时大部分列都在可视范围之外不跳过的话每个滚动帧都要绘制十几列几乎是白干的活。从那以后我每次给 ListView 写自绘逻辑都会强制先加上这个可视区域短路判断再谈别的优化手段这也算是交了学费换来的习惯。希望这些思路能帮你少走一圈弯路。本文还有配套的精品资源点击获取