C# WinForms ListView嵌入自定义控件:实现行内编辑与操作 📅 发布时间:2026/8/31 14:46:32 👁 浏览次数: 简介本资源是一份面向C# Windows Forms开发者的实用代码包聚焦解决ListView控件原生不支持嵌入交互式子控件如CheckBox、ComboBox的常见痛点适用于中高级开发者提升界面交互能力与数据编辑效率。压缩包共28个文件含9个核心C#源码文件如Form1.cs、EXListView.cs、EXComboBox.cs等、2个可执行程序exe、1个Visual Studio解决方案sln及配套项目配置文件csproj、resx、settings整体仅89KB轻量易读结构清晰便于快速理解自定义ListViewItem继承机制、子控件动态挂载逻辑与事件绑定实践。已有2027人学习下载资源完整呈现从基础类扩展、控件定位布局、CheckedChanged/SelectedIndexChanged事件响应到虚拟模式性能优化的全流程实现附带实际运行截图png与调试符号pdb是深入掌握WinForms高级列表交互开发的优质参考范例。 在 WinForms 上位机项目里ListView 这个控件我用了很多年。它轻量、列式布局清晰做设备列表、采集通道管理、日志记录都顺手但真到了“行内编辑”或者“行内操作”的时候原生 ListView 就像个没长手的木头人——你只能改改文本想放个按钮、下拉框、勾选框原生根本不支持。刚入行那会儿我也被这个问题卡过很久网上资料大多是零碎的片段要么只讲一种控件要么代码跑起来各种崩。今天这篇就把我实际项目里摸索出来的方案完整拆开怎么在 C# 的 ListView 中嵌入 Button、CheckBox、ComboBox、TextBox、DateTimePicker、ProgressBar 等多种自定义控件源码可以直接复制改改就用。适合正在做 C# 上位机、串口调试助手、设备管理界面或者被“ListView 单元格可编辑”折磨的 WinForms 开发者看。1. 需求场景与方案选型为什么非要在 ListView 里塞控件1.1 哪些项目会碰到这种需求先说说什么场景会遇到“ListView 里加控件”这种需求。最常见的是上位机开发一个设备管理界面左侧或者顶部是设备列表每一行除了显示设备名称、IP、状态之外还需要一个“启动”“停止”按钮或者一个下拉框用来切换工作模式。用原生 ListView 做状态列只能显示一行文本操作列什么都没有用户就得右键弹菜单或者双击弹窗体交互体验很糟糕。串口调试助手也很典型串口号、波特率、打开状态你希望每一路通道都有一个 “打开/关闭” 开关甚至一个“清空计数”小按钮。这种东西用 ListView 做得好看又整齐关键问题就是怎么把控件放进去。还有些老项目界面框架已经定型底层大量逻辑都绑定在 ListView 的 Item 上不能轻易把控件整体替换成 DataGridView。这时候你能做的就是在不推翻现有代码的前提下给 ListView 增加“行内编辑/行内操作”的能力。我见过不少从 VB6 时代迁移过来的工控程序名单控件就是 ListView业务代码几千行全指着它活着。让老板接受“重写数据网格”不现实最稳妥的方案就是写一个通用的“控件嵌入器”把自定义控件一个个挂到 ListView 上。1.2 几种实现方案的横向对比遇到这种需求常见做法有四类。第一类是直接用 DataGridView它天生支持单元格内嵌 ComboBox、Button、CheckBox、TextBox不用造轮子。但缺点是如果你已经有大量 ListView 业务代码迁移成本高而且 DataGridView 的单元格控件本质是“编辑器”显示和编辑分离很多细节调起来反而琐碎。第二类是 OwnerDraw 自绘滚动性能极好不依赖真实控件但实现 Button 鼠标反馈、ComboBox 下拉列表、TextBox 输入这种交互非常痛苦等于自己写一套控件库。第三类是 WPF 的 ListView DataTemplate这个从架构上最优雅但要求项目本身是 WPF很多老 WinForms 项目根本不可能转。我说的第四类就是真实控件“浮层嵌入”。原理很简单把 Button、ComboBox 这些控件作为 ListView 的子控件根据对应 ListViewItem 的行列矩形设置它们的位置和大小滚动时同步刷新。这个方案能最大程度复用现成控件完整功能下拉、点击、输入都不需要自己写也是在 WinForms 里做行内编辑最“性价比”的做法。缺点是控件数量多的时候要管理好显示/隐藏和位置同步否则会出现控件错位、残留、闪烁。我下面讲的就是这条路线。2. 核心原理给 ListView 当“外科医生”的准备工作2.1 必须先搞懂 ListView 的绘制和命中机制在动手写代码之前先要纠正很多人一个误区ListView 本身不是一个“控件容器”它内部每一项都由系统绘制不提供类似 TableLayoutPanel 那样的子控件挂载点。你直接listView.Controls.Add(button)之后如果只是设置坐标根本不会自动跟随行、列和滚动条走。因为 ListView 表层是一个窗口句柄子控件是覆盖在它上面的独立窗口两者没有联动关系。所以要实现“嵌入”本质上就是做一件事持续计算某个 ListViewItem 的某个 SubItem 在屏幕上的矩形然后把你的 Button 或 ComboBox 移动到这个矩形的位置再设置同样的大小。ListView 滚动、窗口移动、列宽调整、Item 增删都会导致这个矩形变化所以你需要在这些时机统一刷新所有已嵌入控件的位置。听起来不复杂但实际项目里最容易出问题的就是“计算矩形”这一步很多人直接用了错误的方法。2.2 子项矩形怎么拿LVM_GETSUBITEMRECT 是关键WinForms 的 ListView 提供了一个方法GetItemRect(int index)但注意它返回的是整个 Item 的边界也就是整行矩形。如果我们要把控件放在第 2 列的单元格里光靠这个矩形是不够的得精确到“某个子项”的矩形。正确的做法是调用系统消息LVM_GETSUBITEMRECT让 ListView 自己算出指定子项的矩形。这条消息的参数是一个RECT结构其中Left字段填子项索引输入输出都靠这个结构。P/Invoke 代码如下using System; using System.Runtime.InteropServices; using System.Windows.Forms; public static class NativeMethods { public const int LVM_GETSUBITEMRECT 0x1000 56; // LVM_FIRST 56 [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } [DllImport(user32.dll, EntryPoint SendMessage)] public static extern IntPtr SendMessageRect(IntPtr hWnd, int msg, IntPtr wParam, ref RECT rect); } public static class ListViewHelper { public static Rectangle GetSubItemRectangle(ListView listView, int itemIndex, int subItemIndex) { NativeMethods.RECT rect new NativeMethods.RECT(); rect.Left subItemIndex; rect.Top 0; rect.Right 0; rect.Bottom 0; NativeMethods.SendMessageRect(listView.Handle, NativeMethods.LVM_GETSUBITEMRECT, new IntPtr(itemIndex), ref rect); return new Rectangle(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top); } }这里面有一个容易踩的坑LEFT字段在调用前不是坐标而是“你想获取第几列”的索引系统在返回后才会把矩形坐标填进结构。如果你把rect.Left当成横向坐标传进去得到的结果会完全乱掉。列索引从 0 开始第 0 列代表第一列和 ListView 的 Columns 索引一致。2.3 滚动、缩放、刷新时如何让控件紧贴行拿到子项矩形之后位置同步的“触发器”还要齐全。我通常会在以下几个时机统一调用一个刷新函数ListView 的Scroll事件触发时但注意这个事件在连续滚动时不会频繁触发最好同时重写WndProcListView 的ClientSizeChanged、Resize事件中列宽调整ColumnWidthChanged事件中Items.Add、Items.Remove、Items.Clear之后BeginUpdate/EndUpdate之后强制刷新一次。更稳妥的做法是直接写一个ListViewEx类重写WndProc拦截WM_VSCROLL滚动条垂直滚动消息和WM_HSCROLL水平滚动消息在消息处理完后立刻刷新控件位置。这样即使鼠标按住滚动条拖动也能做到实时同步不会等松手才跳位置。下面这段代码就是拦截滚动消息的骨架public class EmbeddedControlsListView : ListView { private const int WM_VSCROLL 0x0115; private const int WM_HSCROLL 0x0114; private const int WM_MOUSEWHEEL 0x020A; private const int WM_PAINT 0x000F; public event EventHandler ScrollPositionChanged; protected override void WndProc(ref Message m) { base.WndProc(ref m); if (m.Msg WM_VSCROLL || m.Msg WM_HSCROLL || m.Msg WM_MOUSEWHEEL) { ScrollPositionChanged?.Invoke(this, EventArgs.Empty); } // 绘画后也刷新避免列表重绘把控件盖掉或漏出旧位置 if (m.Msg WM_PAINT) { ScrollPositionChanged?.Invoke(this, EventArgs.Empty); } } }这里要注意WM_PAINT触发的频率非常高如果你的刷新函数里有大量控件创建和销毁性能会崩。所以真正的刷新函数只做“移动已有控件”不做“创建控件”。控件的创建和绑定只发生在 Item 数量变化或者调用方主动添加时。2.4 一段通用宿主类源码核心骨架有了上面的基础我们可以写一个通用的ListControlHost类专门负责管理“哪个 Item 的哪个 SubItem 挂了哪个控件”。这个类我封装过好几个版本最后留下的功能点有三个支持添加/移除控件、支持整批刷新位置、支持清空。核心字段是一个字典public class ListControlHost { private readonly EmbeddedControlsListView _listView; private readonly Dictionarystring, Control _map new Dictionarystring, Control(); public ListControlHost(EmbeddedControlsListView listView) { _listView listView; _listView.ScrollPositionChanged (s, e) RefreshPositions(); _listView.Resize (s, e) RefreshPositions(); _listView.ColumnWidthChanged (s, e) RefreshPositions(); } public void AddControl(int itemIndex, int subItemIndex, Control control) { string key BuildKey(itemIndex, subItemIndex); if (_map.ContainsKey(key)) { _listView.Controls.Remove(_map[key]); _map[key].Dispose(); _map.Remove(key); } _map[key] control; _listView.Controls.Add(control); control.Visible true; RefreshPosition(itemIndex, subItemIndex, control); } public void RemoveControl(int itemIndex, int subItemIndex) { string key BuildKey(itemIndex, subItemIndex); if (_map.ContainsKey(key)) { _listView.Controls.Remove(_map[key]); _map[key].Dispose(); _map.Remove(key); } } public void ClearAll() { foreach (var control in _map.Values) { _listView.Controls.Remove(control); control.Dispose(); } _map.Clear(); } public void RefreshPositions() { foreach (var kv in _map) { string[] parts kv.Key.Split(_); int itemIndex int.Parse(parts[0]); int subItemIndex int.Parse(parts[1]); RefreshPosition(itemIndex, subItemIndex, kv.Value); } } private void RefreshPosition(int itemIndex, int subItemIndex, Control control) { if (itemIndex 0 || itemIndex _listView.Items.Count) { control.Visible false; return; } Rectangle rect ListViewHelper.GetSubItemRectangle(_listView, itemIndex, subItemIndex); // 加一点内边距防止控件和单元格文字重叠 rect.Inflate(-2, -2); control.SetBounds(rect.Left, rect.Top, rect.Width, rect.Height); control.Visible true; } private string BuildKey(int itemIndex, int subItemIndex) { return itemIndex _ subItemIndex; } }这个类的价值在于把所有脏活集中处理。比如AddControl里如果同一单元格已经挂过控件先把旧的移除再换新的避免内存泄漏。RefreshPositions会遍历所有已挂载控件逐一定位。注意rect.Inflate(-2, -2)是我测试下来比较舒服的默认内边距如果你的 ListView 行高很小可以改成-1甚至0。3. 五种常用自定义控件的嵌入实现3.1 Button行内操作按钮最常用也最简单的就是往行里放 Button。比如每个设备行最后一列放一个“启动”按钮。实现过程是先创建 Button设置文本和事件再调用宿主类的AddControl。关键是按钮点击的时候怎么知道它对应哪一行。我习惯把按钮的Tag设置成 itemIndexprivate void AddStartButton(int itemIndex) { Button btn new Button(); btn.Text 启动; btn.Width 60; btn.Height 24; btn.Tag itemIndex; btn.Click BtnStart_Click; _host.AddControl(itemIndex, 3, btn); } private void BtnStart_Click(object sender, EventArgs e) { Button btn (Button)sender; int itemIndex (int)btn.Tag; ListViewItem item listView1.Items[itemIndex]; string deviceName item.Text; // 这里写启动设备的业务逻辑 }一个大坑是如果调用了ListView.Items.Clear()或者重新绑定了数据源原来的 itemIndex 会失效。如果你确实需要经常重建列表可以不用Tag itemIndex而是用Tag ListViewItem本身或者用一个自定义对象的唯一 ID 作为键。只要能通过按钮反查到对应数据怎么存都行。另外Button在单元格里如果宽度超过列宽会被截断所以列宽要预留好或者在刷新位置时根据列宽自动调整控件宽度。3.2 CheckBox行内勾选/启停行内放 CheckBox 也特别常见用来表达“启用”“在线”“选中”等状态。在 ListView 里原生也有CheckBoxes true但它只能显示在最前面一列无法放到中间列也无法做“整行样式”的联动。所以要用自定义 CheckBox 覆盖到需要的列。可以这样写一个复用方法private void AddCheckBox(int itemIndex, int subItemIndex, bool isChecked) { CheckBox chk new CheckBox(); chk.Checked isChecked; chk.AutoSize false; chk.Width 24; chk.Height 24; chk.Tag itemIndex; chk.CheckedChanged Chk_CheckedChanged; _host.AddControl(itemIndex, subItemIndex, chk); } private void Chk_CheckedChanged(object sender, EventArgs e) { CheckBox chk (CheckBox)sender; int itemIndex (int)chk.Tag; // 同步到数据源 }AutoSize false这一点要记牢。默认 CheckBox 会自动撑到内容大小在表格里容易看起来参差不齐。手动设置 24x24 或者按行高来设置会整齐很多。如果你想让 CheckBox 垂直居中可以在刷新函数里额外加一个 offset或者用Top加(rowHeight - checkboxHeight) / 2微调。3.3 ComboBox行内下拉状态切换ComboBox 和按钮、勾选框不一样它需要一个下拉列表数据源。如果每个 ListViewItem 对应不同的可选状态那要给每个 ComboBox 单独设置DataSource或Items。我的做法是封装一个方法private void AddStatusComboBox(int itemIndex, int subItemIndex, string[] options, string selectedValue) { ComboBox cmb new ComboBox(); cmb.DropDownStyle ComboBoxStyle.DropDownList; cmb.Items.AddRange(options); cmb.SelectedItem selectedValue; cmb.Tag itemIndex; cmb.SelectedIndexChanged Cmb_SelectedIndexChanged; // 宽度比列宽略小一点留出下拉箭头空间 cmb.Width 80; _host.AddControl(itemIndex, subItemIndex, cmb); }这里有个易错点SelectedIndexChanged在代码里设置SelectedItem时也会触发如果这时候数据源还没准备好会出现空引用。我的解决办法是先用一个bool _initializing标记位控件完成初始化后再挂事件。或者把SelectedItem selectedValue这行放在SelectedIndexChanged ...之前事件晚点挂就不会误触发。ComboBox 还有一个 DropDown 展开时被 ListView 盖住的问题因为 ListView 里其他区域是系统自绘的可能遮挡下拉列表的弹出部分。我一般用cmb.BringToFront()解决。但如果 ComboBox 到了窗体边缘Windows 自带下拉列表也会有裁剪这不是我们能完全控制的只能尽量保证行不在最后一行或者给 ListView 留出额外底部空间。3.4 TextBox 与 DateTimePicker、ProgressBar行内编辑文本时可以放一个 TextBox。思路和上面完全一样但要注意 Enter 键确认、Esc 键取消这些交互。我的习惯是在 TextBox 的KeyDown事件里判断Enter把当前文本写回 ListView 的 SubItem.Text然后隐藏控件按Esc则直接还原旧文本。这种“编辑器”行为比一直把 TextBox 摆在行里体验更好也省资源。DateTimePicker 在工控系统里用于排程、日期录入用法类似。但 DateTimePicker 控件高度通常接近 25 像素如果 ListView 行高不足会截断最好在添加前用ItemHeight或手动设置ListView的SmallImageList来撑高行高listView1.SmallImageList new ImageList { ImageSize new Size(1, 24) };ProgressBar 通常用来显示采集进度、任务进度。它不能直接作为一个“可编辑控件”但很适合做状态展示。一行一个进度条关键是不需要太多交互只要定时刷新位置和值。值得注意的是 ProgressBar 的Style如果设置成Continuous在进度值更新时会有比较明显的闪烁可以用System.Windows.Forms.ProgressBar默认样式问题不大。性能上千万不要给每一行都创建一个实时刷新的 ProgressBar建议只给当前可见的少量行创建滚出视图就移除或隐藏否则滚动会明显卡顿。3.5 统一的事件注册与位置刷新技巧在实际编码中我给每个自定义控件写一个工厂方法统一由工厂注册事件、添加控制器。这样做的好处是事件处理逻辑集中不会分散在各个页面的按钮 Click 里后续加“禁用权限”之类的逻辑只改一处。核心套路是private Control CreateControl(ControlType type, object data) { switch (type) { case ControlType.Button: return CreateButton(data); case ControlType.CheckBox: return CreateCheckBox(data); case ControlType.ComboBox: return CreateComboBox(data); case ControlType.TextBox: return CreateTextBox(data); default: return new Label(); } }然后把创建出来的控件统一传给_host.AddControl(itemIndex, subItemIndex, control)。放到真实项目里我还会监听ListView.ItemSelectionChanged在用户点击某一行时判断点击位置是否在控件范围内如果在控件范围内就不让 ListView 改变选中项避免行高亮闪一下干扰操作。判断方式可以用 ListView 的HitTest或者直接看鼠标点击的坐标是否落在某个已挂载控件的Bounds内。4. 常见问题与排查技巧实录4.1 控件不显示或位置跑偏如果你按照上面的思路写完控件不显示第一个要查的是SubItem是否存在。很多列表只给ListViewItem.Text赋值没有SubItems.Add这时候第 1 列、第 2 列根本不存在LVM_GETSUBITEMRECT拿到的矩形自然不可用。所以添加控件前一定要保证对应子项已经创建哪怕是空字符串ListViewItem item new ListViewItem(设备A); item.SubItems.Add(192.168.1.10); item.SubItems.Add(运行中); item.SubItems.Add(); // 这一列用来放按钮 listView1.Items.Add(item);位置跑偏多数是因为在ListView还没有完成布局时就调用了AddControl。解决办法是在listView1.HandleCreated之后再添加或者调用RefreshPositions()延迟一次。还有一个不起眼但很坑的问题如果窗体有缩放比例比如 125% 或 150% DPIWinForms 的坐标需要做 DPI 换算。你的程序如果支持高 DPI最好在Program.cs里设置Application.SetHighDpiMode(HighDpiMode.SystemAware)让 ListView 和子控件使用同一套坐标系否则控件会整体偏移几个像素。4.2 滚动时控件满天飞这是真实项目里最常见的 bug列表不动时一切正常一滚动按钮还停在原来的行位置看起来像“满天飞”。原因就是滚动消息触发时没有刷新控件位置。虽然我前面提供了WndProc拦截方案但如果你用的是只监听Scroll事件的普通 ListView会发现鼠标按住滚动条拖动时事件触发并不连续等松手才更新体验很差。所以一定要用自定义EmbeddedControlsListView重写WndProc。另外一个细节是当用户用键盘上下键移动当前选中行时ListView 可能只触发SelectedIndexChanged而不触发滚动事件但视觉上列表已经滚动了。所以ItemSelectionChanged里也应该调用RefreshPositions()。这类边界问题只能靠“多刷几次”兜底不要指望某个事件能覆盖所有情况。4.3 点击控件却触发了 ListView 行选择这个问题隐蔽但很容易碰到。当你点击行内 Button 的瞬间因为 Button 是 ListView 的子控件MouseDown事件会先落到 Button 上但 ListView 本身也可能收到鼠标消息导致整行变成选中状态。高亮闪烁一下是小事严重的是如果ItemSelectionChanged里写了“选中新行时刷新数据”的逻辑触发顺序会乱。我用的方案是重写EmbeddedControlsListView的WndProc在收到WM_LBUTTONDOWN时判断当前鼠标位置是否命中某个已挂载控件如果命中就调用base.WndProc但不想让 ListView 处理选中。实际操作里更简单的做法是在 Button 的MouseDown事件里设置this.ActiveControl button然后调用listView1.SelectedItems.Clear()并return让 ListView 不继续处理。不过这个方案依赖事件顺序我建议直接写一个命中判断protected override void WndProc(ref Message m) { if (m.Msg 0x0201) // WM_LBUTTONDOWN { Point pt PointToClient(Cursor.Position); foreach (Control c in Controls) { if (c.Visible c.Bounds.Contains(pt)) { // 让子控件处理不交给 ListView 处理选中 return; } } } base.WndProc(ref m); }注意这个处理是针对“非交互区域”如果点的是 ListView 空白处还是应该交还给 ListView 处理。千万不要直接拦截所有WM_LBUTTONDOWN否则列表连最基本的选中和滚动都废了。4.4 界面闪烁和性能问题界面闪烁主要来自两个地方一是 ListView 自身重绘时没有双缓冲二是子控件在位置刷新时频繁SetBounds导致重绘。前者可以在一开始就开启双缓冲public EmbeddedControlsListView() { SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true); }对于后者我采取的是“控件池”策略不反复创建新控件而是先创建一批控件放池子里需要显示时从池子取出并设置坐标滚出屏幕后Visible false而不是Dispose()。这样即使有 1000 条数据实际上只创建当前可见行附近的十几个控件性能好很多。代码大致是private StackButton _buttonPool new StackButton(); private Button GetButtonFromPool() { if (_buttonPool.Count 0) { return _buttonPool.Pop(); } return new Button(); } private void ReleaseButton(Button btn) { btn.Visible false; btn.Parent null; _buttonPool.Push(btn); }4.5 焦点和键盘操作混乱嵌入的 TextBox 或 ComboBox 在获得焦点时ListView 的键盘事件可能被抢占导致按上下方向键不是移动行而是切换 ComboBox 选项。处理方式是在控件的KeyDown事件里捕获方向键如果当前控件需要保留则执行对应业务逻辑并SuppressKeyPress true如果想恢复 ListView 的导航可以主动把焦点移回 ListView。这个没有统一模板完全看交互需求。我的默认建议是ComboBox 的下拉状态下上下键用于切换选项失去焦点后上下键用于移动行。这样做符合用户直觉。还有一个坑ListView.Controls.Add(control)后如果control设了Anchor或者Dock它会跟随 ListView 的尺寸变化但这可能不是你想要的。嵌入的控件定位全部靠SetBounds手动算不要用Anchor否则在某些尺寸调整场景下会出现双重位移。5. 一些使用体会和扩展建议做了这么多项目我的个人体会是ListView 嵌入自定义控件这件事技术上不复杂难的是把“边界情况”处理干净。滚动、选中、DPI、性能每个问题单独看都不大但合在一起就会让人烦躁。所以真正落到项目里不要每个页面都重写一套而是把ListControlHost和EmbeddedControlsListView封装成公共类放进项目工具箱后续所有人直接调用。如果时间允许我建议你把“自绘 真实控件”两者结合正常显示时用 OwnerDraw 绘制出控件外观只有用户点击或者进入编辑态时才显示真实控件。这样列表本身可以承载几千上万条数据不卡交互也完整。缺点是代码量会多不少适合对性能要求极高的场景。如果只是几百条数据的中小型界面真实控件嵌入完全够用。另外题目里也提到了 Qt 有“listview 单元格可编辑”的方案原理是用 Delegate 绘制编辑器其实和 WinForms 的“自绘 编辑控件”思路类似。如果你以后从 WinForms 切到 WPF或者切到 Qt会发现思路完全可以平移先定位单元格矩形再决定绘制内容或弹出编辑器。只不过 C# WinForms 里没有现成的 Delegate 机制所以我们只能自己写宿主类一旦写好了后面再遇到类似需求就是复制粘贴的事。最后再分享一个小技巧调试这类功能时不要一上来就嵌控件。先用一个临时的方法在DrawSubItem里把你拿到的子项矩形画成红色边框滚动几下确认边框一直紧贴行和列再往里放控件。这样能提前排除 80% 的位置问题省得你对着错位的按钮调半天还不知道是矩形算错了还是事件没触发。本文还有配套的精品资源点击获取