ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WinForms ListView嵌入自定义控件:自绘与宿主管理器方案详解

WinForms ListView嵌入自定义控件:自绘与宿主管理器方案详解 简介面向使用 C# 开发 Windows 窗体程序的开发者这份源码专注于在列表视图控件中嵌入自定义控件解决复选框、下拉框等交互元素无法直接添加到列表项中的问题适用于需要多选或下拉选择的桌面软件场景。压缩包内共包含 28 个文件以 9 个 C# 源代码文件为核心同时带有项目与解决方案文件、界面资源文件、程序集可执行文件及调试信息文件等整体大小仅为 89KB结构紧凑且便于直接打开查看。代码提供了扩展的列表视图类和扩展下拉框类通过继承列表项基类实现了复选框与下拉框的动态挂载并包含位置布局、事件处理、虚拟模式性能优化以及列宽样式调整等完整技巧可直接迁移到自己的项目中。已有 2028 人学习浏览这套完整工程不仅适合初学者理解自定义控件嵌套的实现原理也能为需要复杂交互列表的进阶项目提供可靠参考。1. 往 ListView 里塞自定义控件三个真实痛点先摆出来用 C# 做 WinForms 上位机或桌面工具早晚会遇到一个需求ListView 列表里除了文字还要放按钮、进度条、勾选框。C# 自带的 ListView 只擅长显示文本和图标不像 DataGridView 自带列类型想添加多种自定义控件得自己搭一套方案。真正动手后你会发现难点不是“放不进去”而是放进去后整行选中失效、滚动条一拖控件满屏乱飞、行数过百界面卡成幻灯片。这篇文章给出一份可直接抄走的源码骨架从控件宿主管理器到 OwnerDraw 自绘兜底讲清楚为什么这么拆、参数怎么设、坑在哪。适合正在做 c#上位机 设备列表、任务队列或配置表的 WinForms 开发者新手能从最小示例直接开始熟手可以直接拿第五章的避坑清单和第六章的生产级方案。2. 选型先想清楚自绘只解决“像”嵌入控件才解决“能点”2.1 自绘方案能画出按钮外观但交互全靠命中测试ListView 的 OwnerDraw 模式OwnerDraw true会接管每一列的绘制你可以在DrawColumnHeader和DrawSubItem事件里用 GDI 画出任何外观按钮、进度条、状态灯、标签。很多第一次接触这个需求的人以为这就是“往 ListView 里放控件”其实完全不是——它只是接管了绘制控件对象并不存在。自绘方案的优势是控件数量为零不占窗口句柄滚动性能天然好。但代价是交互全部要自己写按钮的按下弹起状态、鼠标移开后的重绘、键盘焦点、甚至高 DPI 下的点击区域都得靠命中测试HitTest手动算。常见的翻车现场是自绘画了一个漂亮的“启动”按钮鼠标点上去毫无反应因为DrawSubItem只是往屏幕上画了张图根本没有 Button 对象去接收 Click 事件。所以我的结论是自绘适合“只展示不交互”的场景比如进度条显示、运行状态灯、自定义图标一旦按钮需要点击、CheckBox 需要勾选、TextBox 需要编辑就得走嵌入真实控件的路线或者自绘外观加自己实现的命中测试第六章会给一套完整方案。2.2 真实控件嵌入原理是 GetItemRect 定位 Bounds要在 ListView 里放一个真实控件常见做法是把控件加进ListView.Controls集合然后用item.GetItemRect(columnIndex)拿到某个子项的矩形再把控件的Bounds设成这个矩形。ListView 继承自 ScrollableControl本身就支持承载任意子控件所以这一步技术上没有障碍。真正的问题出在滚动和重绘上。ListView 滚动时它只移动和重绘自己的客户区内部的子控件并不会跟着滚动——控件是窗台子级位置是绝对坐标不是 ListViewItem 的相对坐标。于是滚动条一拖按钮还停在原来的屏幕位置就出现了“控件飞了”的经典画面。这也是为什么不能只写一行control.Bounds rect就完事必须有一个管理类在滚动、列宽变化、窗体 Resize 时重新对所有控件做一次定位。参数上要注意两点GetItemRect的参数是子项索引从 0 开始不是 ListViewItem 的 Index调用前 ListView 的 View 必须是View.Details否则拿不到多列矩形。另外返回的矩形是客户区坐标所以控件直接 SetBounds 或赋 Bounds 都行不需要再转屏坐标。2.3 DataGridView 才是复杂交互的更优解但别急着换如果你还没开始写代码需求又是“每行有按钮、勾选框、下拉框、还要能排序编辑”那更省力的路线其实是 DataGridView。它原生带DataGridViewButtonColumn、DataGridViewCheckBoxColumn、DataGridViewComboBoxColumn进度条虽然要自己写一个列类型但整体工作量比 ListView 自绘加宿主控件小得多。方案交互能力滚动同步性能边界适合场景OwnerDraw 自绘弱需手工命中测试不需要无控件极高万行无压力只读展示、状态灯、装饰真实控件嵌入强原生交互必须自己同步几十行以内流畅少量按钮、进度条、CheckBoxDataGridView强内置列类型框架自带千行级稳定排序、编辑、多种列混合那什么时候还是坚持用 ListView存量代码不好动、界面风格已经统一、只需要两三个操作按钮且数据量不大。我在实际项目中一般这样判断列超过四种交互控件直接换 DataGridView只有一列按钮加一列进度条就用下面的 ListView 嵌入方案。别为了“技术上更优雅”去把已经跑通的模块重写一遍成本不划算。3. 最小可运行示例ListView 每行嵌入按钮和进度条3.1 宿主准备ListViewEx 与列结构ListView 原生没有公开的 Scroll 事件这是嵌入控件方案第一道坎。常见做法是写一个ListViewEx继承 ListView在WndProc里拦截水平和垂直滚动消息再把事件抛出去。这样滚动条拖动、鼠标滚轮、键盘翻页都会触发统一的同步回调。public class ListViewEx : ListView { private const int WM_HSCROLL 0x0114; private const int WM_VSCROLL 0x0115; private const int WM_MOUSEWHEEL 0x020A; private const int WM_MOUSEHWHEEL 0x020E; public event EventHandler ScrollChanged; protected override void WndProc(ref Message m) { base.WndProc(ref m); if (m.Msg WM_HSCROLL || m.Msg WM_VSCROLL || m.Msg WM_MOUSEWHEEL || m.Msg WM_MOUSEHWHEEL) { ScrollChanged?.Invoke(this, EventArgs.Empty); } } }逻辑并不复杂先调用base.WndProc让 ListView 完成默认滚动行为然后判断消息类型。WM_HSCROLL 是横向滚动条WM_VSCROLL 是纵向滚动条WM_MOUSEWHEEL 是鼠标滚轮WM_MOUSEHWHEEL 是横向滚轮。很多示例只拦前两个结果鼠标滚轮滚动时控件照样不跟所以我建议四个消息一起处理。为什么不用Scroll事件WinForms 的 ListView 根本没有这个事件网上有些写法是给GetContainerControl()挂事件或者用Panel包一层都不如直接继承来得干净。注意ScrollChanged是普通事件没有事件参数因为定位需要的数据都是现成的。3.2 控件宿主管理器核心数据结构与定位有了ListViewEx之后需要一个管理类来维护“哪个 ListViewItem 的哪一列挂着哪个控件”。这里我踩过一个坑一开始用行号做 key结果删除中间行之后所有 Index 都变了控件全部对错了位。后来一律改用ListViewItem引用做 key行号变化不影响映射关系。public class ControlHostManager { private readonly ListView _listView; private readonly DictionaryListViewItem, Dictionaryint, Control _controls; public ControlHostManager(ListView listView) { _listView listView; _controls new DictionaryListViewItem, Dictionaryint, Control(); _listView.Resize (s, e) UpdatePositions(); _listView.ColumnWidthChanged (s, e) UpdatePositions(); if (listView is ListViewEx lvx) { lvx.ScrollChanged (s, e) UpdatePositions(); } } public void AddControl(ListViewItem item, int columnIndex, Control control) { if (!_controls.TryGetValue(item, out var rowControls)) { rowControls new Dictionaryint, Control(); _controls[item] rowControls; } if (rowControls.TryGetValue(columnIndex, out var existing)) { _listView.Controls.Remove(existing); existing.Dispose(); } rowControls[columnIndex] control; _listView.Controls.Add(control); PlaceControl(item, columnIndex, control); } public Control GetControl(ListViewItem item, int columnIndex) { return _controls.TryGetValue(item, out var rowControls) rowControls.TryGetValue(columnIndex, out var control) ? control : null; } public void RemoveRow(ListViewItem item) { if (!_controls.Remove(item, out var rowControls)) return; foreach (var control in rowControls.Values) { _listView.Controls.Remove(control); control.Dispose(); } } public void Clear() { foreach (var rowControls in _controls.Values) { foreach (var control in rowControls.Values) { _listView.Controls.Remove(control); control.Dispose(); } } _controls.Clear(); } public void UpdatePositions() { foreach (var pair in _controls) { foreach (var col in pair.Value) { PlaceControl(pair.Key, col.Key, col.Value); } } } private void PlaceControl(ListViewItem item, int columnIndex, Control control) { if (item.ListView null) return; Rectangle rect item.GetItemRect(columnIndex); control.Bounds rect; control.Visible _listView.ClientRectangle.IntersectsWith(rect); } }这个类是整套方案的核心几个设计点值得展开说。ListViewItem做 key配合GetControl(item, column)取控件比到处用行号索引安全得多同一格重复添加控件时先移除旧的并 Dispose避免句柄泄漏UpdatePositions在滚动、列宽变化、Resize 三个时机被调用覆盖了所有会引起位置变化的操作。PlaceControl里有个关键细节先判断item.ListView null因为 ListViewItem 从集合移除后引用还在但已经不在列表里强行GetItemRect会抛异常。另一处是Visible _listView.ClientRectangle.IntersectsWith(rect)滚动出可视区域的控件直接隐藏既减少绘制开销也避免控件在 ListView 边框外面闪烁。参数 columnIndex 从 0 开始对应 Columns 集合的序号跟 SubItems 的索引一致。3.3 组装按钮触发进度条刷新的最小联动现在把这些零件拼起来做一个能直接跑的完整示例。界面上只有一个ListViewEx三列设备名、进度、操作。每一行挂一个 ProgressBar 和一个 Button点按钮启动一个 Timer 去刷进度条。private ListViewEx _listView; private ControlHostManager _host; private void BuildList() { _listView new ListViewEx { Dock DockStyle.Fill, View View.Details, FullRowSelect true }; _listView.Columns.Add(设备, 140); _listView.Columns.Add(进度, 180); _listView.Columns.Add(操作, 100); Controls.Add(_listView); _host new ControlHostManager(_listView); for (int i 1; i 20; i) { var item new ListViewItem(设备 i); item.SubItems.Add(); item.SubItems.Add(); _listView.Items.Add(item); var progress new ProgressBar { Minimum 0, Maximum 100, Value 0 }; _host.AddControl(item, 1, progress); var button new Button { Text 启动, FlatStyle FlatStyle.Flat }; button.Click (s, e) { var timer new Timer { Interval 50 }; timer.Tick (ts, te) { progress.Value Math.Min(100, progress.Value 2); if (progress.Value 100) timer.Stop(); }; timer.Start(); }; _host.AddControl(item, 2, button); } }这段代码把整个流程串起来了构建列结构时操作列和进度列的 SubItem 文本都留空因为视觉上由控件接管_host.AddControl负责把控件加到 ListView 并立即定位按钮的 Click 里直接引用闭包变量progress不需要再查一次 GetControl闭包在这种局部场景里反而简单。参数上注意FlatStyle.Flat默认的 3D 按钮嵌入 ListView 后绘制和擦除会有明显边框闪烁Flat 风格能消掉大半。Timer 的 Interval 是 50 毫秒每次值加 2一条进度条 2.5 秒走满方便观察滚动同步效果。如果你想测试重绘性能把循环里的 20 改成 200再拖滚动条就能感觉到前面说的问题开始出现了。4. 把“多种自定义控件”整理成可复用控件包4.1 不同控件的接入参数差异平时项目里用得多的无非是 Button、ProgressBar、CheckBox、TextBox、ComboBox 这几种它们接入ControlHostManager的方式和注意点不一样。控件类型嵌入方式关键参数与交互注意点典型场景Button直接 AddControlFlatStyle 建议 Flat 或 System一列要放两个按钮时先放 Panel启动/停止、详情、删除ProgressBar直接 AddControlMinimum/Maximum/Value 三个值先设好默认绿色要变色需自绘数据导入、任务进度CheckBox直接 AddControlText 留空勾选状态要写回数据源整行点击选择不要被它干扰启用/禁用、批量选择TextBox/ComboBox双击进入编辑再创建失焦时把值写回 SubItems 并销毁不要常驻设备参数、批量修改Button 那一列有个常见需求每行要同时放“启动”和“停止”两个按钮。ListVIew 的一个子项矩形里只能容纳一个宿主控件所以要把两个按钮放进一个 Panel再把 Panel 作为宿主控件加进去间距用按钮的 Location 控制。ProgressBar 如果想换颜色WinForms 原生控件默认使用系统主题色ForeColor改了也不一定生效简单做法是把进度条视觉改成自绘。4.2 控件和业务数据怎么绑定控件布好之后下一个问题是点按钮时怎么知道这行对应哪个业务对象我见过不少代码在按钮 Click 里用_listView.SelectedItems[0]去猜一旦用户没选中行就直接崩。正确做法是用 ListViewItem 的 Tag 字段挂业务对象再把按钮也关联到同一个 item 上。public class DeviceInfo { public string Name { get; set; } public int Progress { get; set; } public bool Enabled { get; set; } } var item new ListViewItem(device.Name) { Tag device }; item.SubItems.Add(); item.SubItems.Add(); var check new CheckBox { Checked device.Enabled, Text }; check.CheckedChanged (s, e) device.Enabled check.Checked; _host.AddControl(item, 1, check); var button new Button { Text 详情, Tag item }; button.Click (s, e) { var currentItem (ListViewItem)((Button)s).Tag; var device (DeviceInfo)currentItem.Tag; MessageBox.Show(${device.Name} 当前进度 {device.Progress}); };这套绑定的核心是Button 的 Tag 存 ListViewItemListViewItem 的 Tag 存业务对象链路永远是“控件 → item → 业务对象”。不要用 SelectedItems更不要用行号因为用户完全可能不选中行就点按钮。CheckBox 的状态变化直接写回device.Enabled界面只是数据源的一个投影这样后续保存、序列化都从业务对象上取不会出现界面和数据源分叉。4.3 增删行、清空、临时编辑控件的生命周期嵌入式控件是真实控件不释放就是句柄泄漏。删除一行时先调_host.RemoveRow(item)再执行_listView.Items.Remove(item)顺序不能反。清空列表时用_host.Clear()它会遍历所有控件移除并 Dispose。临时编辑控件是另一种生命周期双击单元格创建一个 TextBox用户输入完失去焦点时提交并销毁。这类控件不进ControlHostManager因为它只活几秒钟单独维护一个字段就够了。private TextBox _activeEditor; private void OnDoubleClick(object sender, EventArgs e) { var hit _listView.HitTest(_listView.PointToClient(Cursor.Position)); if (hit.Item null || hit.SubItem null) return; int col hit.Item.SubItems.IndexOf(hit.SubItem); if (col ! 3) return; _activeEditor new TextBox { Text hit.SubItem.Text, Bounds hit.Item.GetItemRect(col) }; _activeEditor.LostFocus (s, ev) CommitEditor(_activeEditor, hit.Item); _listView.Controls.Add(_activeEditor); _activeEditor.Focus(); } private void CommitEditor(TextBox editor, ListViewItem item) { item.SubItems[3].Text editor.Text; var device (DeviceInfo)item.Tag; device.Name editor.Text; _listView.Controls.Remove(editor); editor.Dispose(); _activeEditor null; }这段逻辑里值得注意的一个点HitTest的SubItem可能是 null所以先判空IndexOf拿到的是实际列索引别拿 Columns 集合的标题去猜。CommitEditor被 LostFocus 触发时先写回界面和业务对象再移除编辑框顺序不能反——先 Dispose 再写数据会读到已经释放的句柄异常。这套模式在做 c#上位机 的参数配置列表时非常好用比一整列常驻 TextBox 干净得多。5. 避坑ListView 嵌控件最常见的五处翻车现场5.1 滚动条一拖控件全飞了现象数据超过一屏后拖滚动条按钮和进度条停在原地不动跟 ListView 的文字错位。原因ListView 的子控件用的是绝对坐标ListView 滚动重绘并不会连带移动子控件。鼠标滚轮滚动尤其隐蔽因为很多示例只拦截了滚动条消息没拦 WM_MOUSEWHEEL。解决用 3.1 里的ListViewEx四个消息全拦然后调_host.UpdatePositions()。另外列宽变化也会让控件错位所以ColumnWidthChanged事件也要挂同步。如果你发现控件在滚动后闪一下才到正确位置把UpdatePositions改成在滚动结束后用BeginInvoke延时执行能避开滚动过程中的连续重排。5.2 控件把整行选中吃掉了现象ListView 设置了FullRowSelect true但点按钮或 CheckBox 那一列时行选不中。原因控件接收了鼠标消息ListView 压根没收到点击SelectedItems自然不会更新。这不是 Bug是 WinForms 的消息路由机制决定的。解决按交互预期分两种情况。CheckBox 这种用户勾选后通常也希望行同时被选中就在 Click 事件里手动设置选中。Button 这种点击时选中行反而是合理体验在 MouseDown 里同步选中即可button.MouseDown (s, e) { item.Selected true; _listView.Focus(); };重点是不要指望用户会先选中行再点按钮按钮交互和数据选中要自己兜住。如果不想点按钮时选中整行那就什么都不用做接受这个区域的“点不到行”行为即可。5.3 OwnerDraw 开启后嵌入式控件格子出现文字重叠现象开启了OwnerDraw true之后嵌入 Button 的那一列控件上面还隐约印着一行文字或者滚动时文字残影闪烁。原因OwnerDraw 模式下DrawSubItem会对每一列都执行绘制。嵌入控件的格子虽然 SubItem 文本是空字符串但你可能在别处设置了e.DrawDefault true或者误画了背景和边框。解决在DrawSubItem事件里明确判断哪些列是嵌入控件列这些列直接跳过绘制_listView.DrawSubItem (s, e) { if (e.ColumnIndex 1 || e.ColumnIndex 2) { e.DrawDefault false; return; } e.DrawDefault true; };注意e.DrawDefault false之后不是就完了还要确保你不绘制任何东西。有些同事会把所有列的默认绘制都关掉然后自己在代码里画文字背景结果嵌入控件列被重绘成其他颜色跟控件底色对不上。简单做法是嵌入列直接返回非嵌入列交还给系统默认绘制。5.4 虚拟模式VirtualMode下控件集体失效现象数据量大开了VirtualMode true和RetrieveVirtualItem事件结果控件一个都不显示或者显示的位置全错。原因虚拟列表没有一个真正持久的 ListViewItem 对象——RetrieveVirtualItem每次滚动都新构造一个临时 item 给你。以 ListViewItem 引用做 key 的ControlHostManager完全失效因为每次拿到的都是新的引用。解决VirtualMode 下先放弃 ListView 这条路。要么把界面改成 DataGridView要么退回普通模式配合第六章的可见区控件方案。如果一定要在虚拟模式下用宿主控件必须改成一个以行号为 key、且按可见范围动态创建的独立缓存逻辑会比这里展示的复杂得多性价比不高我不推荐。实际项目里真遇到万行数据又要交互按钮我的习惯是直接换 DataGridView。5.5 控件一多滚动卡成幻灯片现象循环加到 200 行每行两个控件总共 400 多个句柄滚动时 CPU 占用飙升拖动卡顿明显。原因WinForms 控件是窗口句柄大户一个 Button 就是一个窗口。四百个窗口已经不少滚动时UpdatePositions还要对每个控件做SetBounds每次都触发 WM_MOVE 和重绘叠加起来就是灾难。解决只让可见区域的行创建控件。在ScrollChanged事件里计算当前TopItem.Index和可见行数对范围内的行按需创建范围外的行用RemoveRow回收。这样界面上同一时刻只有十几个控件一万行数据也能流畅滚动。具体的创建回收逻辑在下一章给出因为它本质上是生产级方案的骨架。6. 生产级验证万行数据下的性能对比与自绘交互进阶6.1 宿主控件方案的可视区域版本把全量控件改成可视区域版本核心是维护一个按行号映射的缓存滚动时只保留窗口内的行。粗略计算_listView.TopItem拿首行索引_listView.ClientSize.Height除以第一行的高度得到可见行数再加两行缓冲。对缓冲外的行做RemoveRow对缓冲内缺失的行重新AddControl。实测中一百行数据只会同时存在十几个控件内存和句柄数都稳定。这个方案的边界是增删行行号映射在中间插入或删除行时会整体错位需要重建整个缓存。我的习惯是列表在导入数据期间禁止编辑导入完成后再启用操作列。目前的做法不算完美但配合“只绘制可见行”的原则已经能应付大多数上位机场景。6.2 零控件方案自绘按钮外观加鼠标命中测试如果你连“十几个控件”都嫌多想追求极致的万行滚动那就回到自绘方案用DrawSubItem画按钮外观用MouseDown做命中测试触发逻辑。控件数量为零滚动性能完全由 GDI 绘制速度决定。private void OnDrawSubItem(object sender, DrawListViewSubItemEventArgs e) { if (e.ColumnIndex 2) { Rectangle r ButtonRect(e.Item, e.ColumnIndex); e.Graphics.FillRectangle( e.Item.Selected ? Brushes.SteelBlue : Brushes.DodgerBlue, r); TextRenderer.DrawText(e.Graphics, 启动, e.SubItem.Font, r, Color.White, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); e.DrawDefault false; } else { e.DrawDefault true; } } private Rectangle ButtonRect(ListViewItem item, int columnIndex) { Rectangle r item.GetItemRect(columnIndex); return new Rectangle(r.X 4, r.Y 2, r.Width - 8, r.Height - 4); } private void OnMouseDown(object sender, MouseEventArgs e) { var hit _listView.HitTest(e.Location); if (hit.Item null || hit.SubItem null) return; int col hit.Item.SubItems.IndexOf(hit.SubItem); if (col 2 ButtonRect(hit.Item, col).Contains(e.Location)) { StartTask(hit.Item); // 业务逻辑入口 } }两段代码的分工很明确绘制负责“像按钮”命中负责“是按钮”。ButtonRect里缩进的 4 像素和 2 像素是为了模拟按钮的边框留白实际项目中可以根据视觉效果调整。HitTest加Contains的判断鼠标点偏了不会误触。这套方案五万行都不会卡代价是按钮的按下状态、键盘焦点这类细节要自己实现——对我来说纯点击型操作列用这个方案是最省事的它有原生控件的即时反馈但零句柄开销。早期我贪方便给所有行都挂了真实控件数据一多被滚动卡到怀疑人生后来才明白把交互拆成“绘制加命中”才是 ListView 嵌自定义控件的正确打开方式。希望帮到你。本文还有配套的精品资源点击获取
返回列表