☰
C# ListView自定义控件全解析:OwnerDraw自绘与嵌入方案实战
2026/10/7 15:33:40 网站建设 项目流程

简介:面向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 属性才是数据存储的正解

嵌入控件只是表现层,真正让列表有意义的是数据。源码包里用了典型的上位机架构:用一个List<DeviceInfo>存储业务数据,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(List<DeviceInfo> 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属性,它告诉你当前需要渲染的是哪一行。你从自己的List<DeviceInfo>里取出对应数据,构建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事件处理器,做法是记录当前排序列和方向,然后用List<T>.Sort对数据源排序,最后刷新列表:

private void listView_ColumnClick(object sender, ColumnClickEventArgs e) { // 如果点击的是同一列,翻转排序方向 if (e.Column == _sortColumn) { _sortAscending = !_sortAscending; } else { _sortColumn = e.Column; _sortAscending = true; } // 根据列索引选择排序 Key Comparison<DeviceInfo> 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); }

Comparison<T>委托比写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 控件。如果确实需要关联控件,用Dictionary<ListViewItem, Control>的弱引用表来维护,或者干脆用条件查找。

5.3 虚拟模式下用listView.Items[i].Text取值拿到空值

现象:开启VirtualMode后,在ButtonClicked事件里读hit.Item.Text,拿到的竟然是空字符串。

原因:虚拟模式下 ListView 不真实持有 Item,hit.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 写自绘逻辑,都会强制先加上这个可视区域短路判断,再谈别的优化手段,这也算是交了学费换来的习惯。希望这些思路能帮你少走一圈弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询