☰
C# mono ListView 内嵌按钮事件失效的完整解决方案与自绘实现
2026/10/14 16:15:39 网站建设 项目流程

简介:这份资源面向使用 C# Mono 进行桌面或移动端开发的程序员,聚焦 ListView 控件中嵌入按钮并绑定点击事件的实现方式,适合已掌握基础控件用法、希望进一步定制交互列表的开发者参考。压缩包共 86 个文件,约 9.63MB,包含 cs 源码、class 编译文件、java 与 axml 布局、dll 程序集、xml 配置及 mdb 数据库等,覆盖 Windows 窗体与 Android 两套工程结构,另有 apk 与 dex 便于直接运行验证。资源围绕 ListView 的初始化、列头设置、Item 子控件添加、按钮事件处理及数据绑定等环节展开,并延伸至 Android 平台下 Adapter 填充与 getView 中处理按钮事件的差异,可帮助读者理解跨平台列表交互的实现思路与常见坑点。目前已有 197 人学习下载,适合作为控件事件与自定义视图的实践参考。

1. 从一次跨平台翻车说起:C# mono ListView 带按钮事件到底能解决什么

去年帮一个做工业巡检的团队调一个跨平台工具,界面用 C# WinForms 写的,在 Windows 上跑得好好的,一搬到 Linux 下的 mono 环境,ListView 里嵌的按钮全成了摆设——点下去没反应,日志里连事件都没触发。这不是个例,mono 对 WinForms 的兼容层在控件事件路由上和原生 .NET Framework 有肉眼可见的差异,尤其是 ListView 这种复合控件里再塞 Button 子控件的场景。这份资源就是围绕这个痛点来的:它给了一套在 mono 下让 ListView 内嵌按钮事件正常响应的完整实例,包含控件布局、事件绑定、坐标换算和消息循环处理。适合谁?正在用 C# 做跨平台桌面工具、被 mono 的 WinForms 兼容性折磨过、或者准备把现有 WinForms 项目往 Linux 上迁的开发者。如果你只在 Windows 上跑,这份资源的价值会打折扣;但只要涉及 mono,它就是能省你两三天排查时间的参考实现。

2. 拆开看结构:ListView 嵌按钮在 mono 下的实现骨架

2.1 为什么不能直接往 ListView 里塞 Button

WinForms 的 ListView 本质上是一个原生控件的托管封装,它的子项渲染走的是 owner-draw 或者系统原生绘制路径。在 Windows 上,你可以通过ListView.Controls.Add()把 Button 加进去,虽然官方文档不推荐,但实际能跑。mono 的实现里,ListView 的控件容器行为被简化了,Controls集合虽然存在,但子控件的消息循环没有完整挂到 ListView 的窗口句柄上。结果就是按钮画出来了,鼠标点击的 WM_LBUTTONDOWN 消息被 ListView 自己吞掉,传不到 Button 的 WndProc。

常见做法是绕开这个限制:不用真正的 Button 控件,而是用 ListView 的 DrawItem 或 DrawSubItem 事件自己画一个按钮样式的区域,然后在 ListView 的 MouseClick 里根据坐标判断点到了哪个“按钮”。这样做的好处是事件源头统一在 ListView 上,mono 的消息处理路径不会断。代价是你得自己处理按钮的按下、悬停、禁用三种状态的绘制。

我一般会先确认项目是否真的需要“按钮”这个视觉元素。如果只是要一个可点击的图标或文字,用 ListView 自带的 CheckBox 或者 StateImage 也能凑合。但工业场景里操作员习惯看到明确的按钮边框和按压反馈,所以自绘方案更稳妥。

2.2 自绘按钮的核心参数与坐标换算

自绘方案的关键在于把 ListView 的客户区坐标换算成具体某一行的某一列。ListView 提供了GetItemAt(x, y)方法,但它返回的是 ListViewItem,不直接告诉你点到了哪个子列。你需要再调HitTest方法,它返回一个ListViewHitTestInfo对象,里面包含Item、SubItem和Location。

下面这段代码是初始化 ListView 并开启自绘的典型写法:

// 初始化 ListView,开启 owner-draw listView1.View = View.Details; listView1.OwnerDraw = true; listView1.FullRowSelect = true; listView1.HeaderStyle = ColumnHeaderStyle.Nonclickable; // 添加列,最后一列留给“操作按钮” listView1.Columns.Add("设备编号", 120); listView1.Columns.Add("状态", 80); listView1.Columns.Add("操作", 100); // 绑定 DrawColumnHeader 和 DrawSubItem 事件 listView1.DrawColumnHeader += (s, e) => { e.DrawDefault = true; // 表头用系统默认绘制 }; listView1.DrawSubItem += (s, e) => { // 只对最后一列做自绘按钮 if (e.ColumnIndex == 2) { // 绘制按钮背景 Rectangle btnRect = new Rectangle(e.Bounds.X + 4, e.Bounds.Y + 2, e.Bounds.Width - 8, e.Bounds.Height - 4); ControlPaint.DrawButton(e.Graphics, btnRect, ButtonState.Normal); // 绘制按钮文字 TextRenderer.DrawText(e.Graphics, "执行", e.Font, btnRect, Color.Black, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); } else { e.DrawDefault = true; // 其他列默认绘制 } };

逻辑说明:OwnerDraw = true是前提,否则 DrawSubItem 不会触发。e.ColumnIndex == 2限定了只有第三列走自绘逻辑,其他列交给系统。ControlPaint.DrawButton是 .NET 自带的按钮边框绘制方法,mono 也实现了它,画出来的效果和原生按钮接近。TextRenderer.DrawText比Graphics.DrawString在 mono 下更稳定,尤其是中文字体渲染。

参数方面,e.Bounds是当前子项的矩形区域,单位是像素。btnRect往里缩了 4 像素和 2 像素,是为了让按钮不贴边,视觉上更舒服。这个偏移量可以根据你的行高调整,行高 25 像素时用 4/2 比较合适,行高 40 像素时可以改成 6/4。

2.3 点击事件的坐标判断与分发

自绘只是画出了按钮的样子,真正让按钮“活”起来的是 MouseClick 事件里的坐标判断。你不能直接绑 Button.Click,因为根本没有 Button 控件。需要在 ListView 的 MouseClick 里做命中测试:

listView1.MouseClick += (s, e) => { // 只处理左键 if (e.Button != MouseButtons.Left) return; ListViewHitTestInfo hit = listView1.HitTest(e.Location); if (hit.Item == null || hit.SubItem == null) return; // 判断是否点在最后一列 int colIndex = hit.Item.SubItems.IndexOf(hit.SubItem); if (colIndex != 2) return; // 进一步判断是否落在按钮矩形内(去掉边距) Rectangle subRect = hit.SubItem.Bounds; Rectangle btnRect = new Rectangle(subRect.X + 4, subRect.Y + 2, subRect.Width - 8, subRect.Height - 4); if (!btnRect.Contains(e.Location)) return; // 执行按钮对应的业务逻辑 string deviceId = hit.Item.SubItems[0].Text; ExecuteAction(deviceId); };

逻辑说明:HitTest返回的SubItem是当前坐标所在的子项,SubItems.IndexOf拿到列索引。hit.SubItem.Bounds给出该子项的矩形区域,再用和绘制时相同的偏移量算出按钮矩形,最后用Contains判断点击是否落在按钮内。这样即使用户点在了子项边缘但没点到按钮上,也不会误触发。

参数注意:e.Location是相对于 ListView 客户区的坐标,HitTest接受的也是客户区坐标,两者一致,不需要额外换算。但如果你把 ListView 放在了一个滚动的 Panel 里,就得先把 Panel 的滚动偏移加回去,否则坐标会偏。这是 mono 下另一个容易翻车的点,后面避坑章节会细说。

3. 从零复现:mono 环境搭建与实例跑通步骤

3.1 mono 运行时的安装与版本选择

在 Linux 上跑 WinForms 程序,mono 是绕不开的运行时。但 mono 的版本选择有讲究:太老的版本(比如 4.x)对 WinForms 的支持残缺,ListView 的 OwnerDraw 事件可能不触发;太新的版本(6.x 以后)虽然兼容性好一些,但部分发行版的包管理里没有预编译包,得自己编译。我一般推荐用 mono 6.8 或 6.12 这两个稳定分支,它们在 Ubuntu 20.04 和 Debian 11 上都有官方源。

安装步骤(以 Ubuntu 为例):

# 添加 mono 官方源 sudo apt install gnupg ca-certificates sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF echo "deb https://download.mono-project.com/repo/ubuntu stable-focal main" | sudo tee /etc/apt/sources.list.d/mono-official-stable.list sudo apt update # 安装完整运行时和 WinForms 支持 sudo apt install mono-complete mono-winforms

逻辑说明:mono-complete包含编译器、运行时和基础库,mono-winforms是 WinForms 的兼容层,必须单独装,否则System.Windows.Forms.dll找不到。apt-key那步如果报错,可以换成wget -qO - https://download.mono-project.com/repo/xamarin.gpg | sudo apt-key add -。

装完后用mono --version确认版本,输出里应该能看到Mono JIT compiler version 6.12.x。如果版本低于 6.8,建议先升级,否则后面的 OwnerDraw 事件可能不响应。

3.2 编译与运行:从 .cs 到可执行文件

mono 下编译 C# WinForms 项目用mcs或csc都行,mcs是 mono 自带的编译器,对 WinForms 的引用路径处理得更顺。假设你的源码文件叫ListViewButtonDemo.cs,编译命令如下:

# 编译,引用 WinForms 和 Drawing 程序集 mcs -r:System.Windows.Forms.dll -r:System.Drawing.dll -out:ListViewButtonDemo.exe ListViewButtonDemo.cs # 运行 mono ListViewButtonDemo.exe

逻辑说明:-r指定引用的程序集,WinForms 项目至少需要这两个。-out指定输出文件名,不加的话默认是ListViewButtonDemo.exe。运行的时候如果报System.DllNotFoundException: libgdiplus,说明系统缺 GDI+ 的本地库,用sudo apt install libgdiplus补上。

参数注意:如果你的项目有多个 .cs 文件,把所有文件名列在-out后面即可,或者用-recurse:*.cs递归编译。但 WinForms 项目通常还有.resx资源文件,mcs对资源文件的处理需要额外加-resource:参数,这个在纯代码实例里用不到,但如果你从 Visual Studio 迁移项目,就得注意。

3.3 验证按钮事件是否真正触发

跑起来之后,怎么确认按钮事件在 mono 下真的触发了?最直接的办法是在ExecuteAction里写日志:

private void ExecuteAction(string deviceId) { // 输出到控制台,mono 下 Console.WriteLine 默认可见 Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] 按钮触发,设备编号:{deviceId}"); // 同时写文件,方便没有终端时排查 File.AppendAllText("action.log", $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} - {deviceId}{Environment.NewLine}"); }

逻辑说明:Console.WriteLine在 mono 下默认输出到启动终端,如果你用mono xxx.exe &放到后台,输出会丢,所以同时写文件更保险。File.AppendAllText每次追加一行,不会覆盖历史记录。

验证步骤:启动程序,点击 ListView 里任意一行的“执行”按钮,观察终端是否打印日志,同时检查action.log文件是否新增记录。如果终端没输出但文件有,说明事件触发了,只是终端被重定向了;如果两者都没有,说明 MouseClick 没进到按钮判断逻辑里,需要回头检查HitTest的返回值。

4. 避坑与排查:mono 下 ListView 按钮事件的五个血泪坑

4.1 坑一:OwnerDraw 开了但 DrawSubItem 不触发

现象:设置了OwnerDraw = true,也绑了DrawSubItem事件,但界面上的按钮区域是空白,事件里的断点(如果你用 mono 调试器)根本不进。

原因:mono 的 ListView 在View = View.Details模式下,DrawSubItem的触发依赖于DrawItem是否也被处理。如果只绑了DrawSubItem而没绑DrawItem,mono 会认为你没有接管绘制,直接走默认路径。

解决:同时绑定DrawItem事件,在里面设置e.DrawDefault = true,把绘制权交给DrawSubItem。代码示例:

listView1.DrawItem += (s, e) => { e.DrawDefault = true; // 让 DrawSubItem 接管 };

4.2 坑二:HitTest 返回的 SubItem 为 null

现象:点击按钮区域,hit.SubItem是 null,导致IndexOf抛异常或者直接 return,按钮没反应。

原因:mono 的HitTest在 ListView 没有获得焦点时,可能返回不完整的命中信息。尤其是程序启动后第一次点击,焦点还在其他控件上,ListView 的命中测试会失效。

解决:在 MouseClick 开头先调listView1.Focus(),确保 ListView 拿到焦点后再做 HitTest。另外,如果 ListView 的FullRowSelect = false,点击行内空白区域时 SubItem 也可能为 null,所以建议保持FullRowSelect = true。

4.3 坑三:坐标偏移导致按钮点不准

现象:按钮画在子项中间,但点击时必须偏左或偏上几个像素才能触发,点按钮正中间反而没反应。

原因:mono 下e.Bounds返回的坐标是相对于 ListView 客户区的,但如果你把 ListView 放在了一个带滚动条的 Panel 里,e.Location是相对于 Panel 的,两者坐标系不一致。

解决:在 MouseClick 里先把e.Location换算成 ListView 的客户区坐标:

Point clientPoint = listView1.PointToClient(listView1.PointToScreen(e.Location));

然后再用clientPoint去做 HitTest 和 Contains 判断。这个换算在 Windows 上通常不需要,因为消息直接发给 ListView,但 mono 下事件路由有时会经过父容器,加上这行能避免大部分坐标偏移问题。

4.4 坑四:中文字体在按钮上显示为方块

现象:按钮文字“执行”在 Windows 上正常,在 mono 下变成两个方块或乱码。

原因:mono 默认的字体映射表里没有包含中文字体,TextRenderer.DrawText找不到对应字形时就画方块。

解决:在程序启动时显式设置默认字体,或者在 DrawSubItem 里指定一个系统里确定存在的中文字体:

Font btnFont = new Font("WenQuanYi Micro Hei", 9f); // 文泉驿微米黑,Linux 常见中文字体 TextRenderer.DrawText(e.Graphics, "执行", btnFont, btnRect, Color.Black, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);

如果系统里没有文泉驿,可以用fc-list :lang=zh查一下已安装的中文字体,换成对应的字体名。注意字体大小要和 ListView 的行高匹配,9f 在 25 像素行高下比较合适。

4.5 坑五:按钮点击后界面卡死或无响应

现象:点击按钮后,业务逻辑执行时间较长(比如读写串口或网络请求),界面直接卡住,按钮状态也不刷新。

原因:WinForms 的事件处理默认在 UI 线程上同步执行,mono 下 UI 线程的消息泵比 Windows 更脆弱,长时间阻塞会导致整个窗口无响应。

解决:把耗时操作放到后台线程,用BeginInvoke回到 UI 线程更新界面:

private void ExecuteAction(string deviceId) { Task.Run(() => { // 耗时操作在后台线程执行 string result = DoLongRunningWork(deviceId); // 回到 UI 线程更新界面 listView1.BeginInvoke(new Action(() => { // 更新按钮文字或状态 Console.WriteLine($"完成:{result}"); })); }); }

注意:mono 下Task.Run的调度器行为和 .NET 略有差异,如果发现后台任务不执行,可以换成ThreadPool.QueueUserWorkItem,兼容性更好。

5. 进阶技巧:把自绘按钮封装成可复用的 ListView 扩展

5.1 封装思路与接口设计

上面那套自绘加坐标判断的逻辑,如果每个 ListView 都复制一遍,维护起来很痛苦。我一般会把它封装成一个扩展类,通过附加属性的方式挂到 ListView 上。核心接口设计成三个部分:按钮列的索引、按钮文字的委托、点击回调的委托。

public class ListViewButtonExtension { // 按钮所在列索引 public int ButtonColumnIndex { get; set; } = -1; // 按钮文字生成器,根据行数据返回显示文字 public Func<ListViewItem, string> ButtonTextProvider { get; set; } // 点击回调,传入被点击的 ListViewItem public Action<ListViewItem> ButtonClickHandler { get; set; } public void Attach(ListView lv) { lv.OwnerDraw = true; lv.DrawItem += (s, e) => e.DrawDefault = true; lv.DrawSubItem += (s, e) => DrawButton(lv, e); lv.MouseClick += (s, e) => HandleClick(lv, e); } private void DrawButton(ListView lv, DrawListViewSubItemEventArgs e) { if (e.ColumnIndex != ButtonColumnIndex) { e.DrawDefault = true; return; } Rectangle btnRect = GetButtonRect(e.Bounds); ControlPaint.DrawButton(e.Graphics, btnRect, ButtonState.Normal); string text = ButtonTextProvider?.Invoke(e.Item) ?? "操作"; using (Font f = new Font("WenQuanYi Micro Hei", 9f)) { TextRenderer.DrawText(e.Graphics, text, f, btnRect, Color.Black, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); } } private void HandleClick(ListView lv, MouseEventArgs e) { if (e.Button != MouseButtons.Left) return; lv.Focus(); Point pt = lv.PointToClient(lv.PointToScreen(e.Location)); ListViewHitTestInfo hit = lv.HitTest(pt); if (hit.Item == null || hit.SubItem == null) return; if (hit.Item.SubItems.IndexOf(hit.SubItem) != ButtonColumnIndex) return; Rectangle btnRect = GetButtonRect(hit.SubItem.Bounds); if (!btnRect.Contains(pt)) return; ButtonClickHandler?.Invoke(hit.Item); } private Rectangle GetButtonRect(Rectangle subBounds) { return new Rectangle(subBounds.X + 4, subBounds.Y + 2, subBounds.Width - 8, subBounds.Height - 4); } }

逻辑说明:Attach方法把三个事件一次性绑好,调用方只需要设置ButtonColumnIndex、ButtonTextProvider和ButtonClickHandler三个属性。ButtonTextProvider用委托而不是固定字符串,是为了支持不同行显示不同文字,比如“启动”和“停止”根据设备状态切换。GetButtonRect把偏移量计算抽出来,绘制和点击判断共用同一个方法,避免两边偏移不一致导致点不准。

参数注意:ButtonColumnIndex默认 -1,表示不启用。调用方必须显式设置,否则DrawSubItem里会走e.DrawDefault = true,等于没启用扩展。ButtonTextProvider如果返回 null,代码里用?? "操作"兜底,防止按钮上空白。

5.2 在多个 ListView 上复用的实际效果

封装好之后,在窗体代码里初始化就变成三行:

var ext = new ListViewButtonExtension { ButtonColumnIndex = 2, ButtonTextProvider = item => item.SubItems[1].Text == "运行中" ? "停止" : "启动", ButtonClickHandler = item => ToggleDevice(item.SubItems[0].Text) }; ext.Attach(listView1);

逻辑说明:ButtonTextProvider根据第二列的状态文字动态返回按钮文字,这样同一个按钮在不同行显示不同操作,操作员一眼就能看出该点哪个。ButtonClickHandler拿到设备编号后调ToggleDevice做业务处理。

验证方法:在ToggleDevice里加日志,然后跑起来点击不同行的按钮,确认日志里的设备编号和按钮文字都对得上。再测试一下快速连续点击同一按钮,看会不会重复触发——如果会,需要在ToggleDevice里加一个正在处理的标记,或者把按钮临时置灰。

5.3 一个容易忽略的细节:按钮状态的视觉反馈

自绘按钮默认只有 Normal 状态,用户点下去没有按压感,体验上会让人觉得“没点上”。我一般会在 MouseDown 和 MouseUp 里记录当前按下的行,然后在 DrawSubItem 里根据这个状态画ButtonState.Pushed。

private int pressedItemIndex = -1; // 在 Attach 里追加 lv.MouseDown += (s, e) => { var hit = lv.HitTest(e.Location); if (hit.Item != null && hit.Item.SubItems.IndexOf(hit.SubItem) == ButtonColumnIndex) { pressedItemIndex = hit.Item.Index; lv.Invalidate(hit.Item.Bounds); // 只重绘当前行 } }; lv.MouseUp += (s, e) => { if (pressedItemIndex >= 0) { lv.Invalidate(lv.Items[pressedItemIndex].Bounds); pressedItemIndex = -1; } };

然后在DrawButton里判断e.Item.Index == pressedItemIndex,是的话用ButtonState.Pushed代替ButtonState.Normal。lv.Invalidate(hit.Item.Bounds)只重绘当前行,比lv.Refresh()全量重绘效率高,mono 下尤其明显,全量刷新在数据量大时会有闪烁。

从那以后我每次在 mono 下做 ListView 自绘按钮,都会先把按压状态和坐标换算这两步写进扩展类里,再开始写业务逻辑。看起来多花了十分钟,但省掉的是后面反复调点击偏移和用户反馈“按钮没反应”的来回折腾。希望帮到你。

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

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

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

立即咨询