C# WinForms ListView自绘按钮列跨平台实现与命中测试详解
简介面向C# Mono开发者的ListView带按钮事件完整实例覆盖Windows窗体与Android移动端两类应用场景解决数据列表展示与行内按钮交互的常见需求。实例项目演示了从ListView初始化、多列标题设置、Item子控件添加到Click事件绑定的完整编码过程在Android部分则展示了基于Adapter填充列表项、在getView中处理按钮点按的典型写法可帮助读者建立跨平台控件编程的通用思路。压缩包共86个文件、约9.63MB主要包含C#/Java源代码、class/dll/dex等编译产物、APK安装包、AXML与XML配置、Visual Studio工程文件以及少量PNG图像资源结构分明便于直接编译或对照学习。已有196人学习适合正在练习C# Mono控件编程的初中级开发者尤其是需要同时兼顾桌面端和移动端列表交互的读者可直接参考其中的事件绑定与适配器写法。 做了这么多年上位机开发C# 和 WinForms 这套组合我一直当主力用。最近在做一个跨平台工控项目时要在 Mono 环境下做一个带按钮事件的 ListView网上搜了一圈例子不少但能直接跑起来、又不带坑的并不多。正好借这个机会把 ListView 自绘按钮列、点击命中测试、跨平台注意点这些内容整理成一篇完整的实例分享给同样被这个问题卡住的朋友。老规矩先说我遇到的问题背景现场上位机需要展示一组设备状态列表列表最后要有一列“操作”按钮点击后触发对应的启停或重启命令。Windows 下用 DataGridView 加按钮列其实很方便但项目最终要跑在 Linux 工控机上用的是 Mono 环境DataGridView 在跨平台渲染上不如 ListView 稳。这就逼着我在 ListView 上自绘按钮并自己处理按钮的点击事件。今天这篇就围绕这个完整实现来展开。1. 场景与方案选型1.1 这个需求是从哪来的工控上位机里“设备列表 操作按钮”是非常标配的组合。比如你同时监控十几台 PLC 或者扫码枪界面左侧或主区域是一张实时刷新的设备状态表最后一列需要放“启动”“停止”“重启”这类操作入口。传统做法是放几个不大不小的 Button 控件在窗体右侧选中 ListView 一行后再点击按钮操作。这种做法逻辑简单但有两个痛点一是操作关联性弱操作者得先确认“选中的行是不是我要操作的设备”现场一忙容易误操作二是列表刷新时选中状态会被冲掉操作按钮和数据行的对应关系容易错位。所以更合适的交互是每一行数据的最后一列就有一个“操作按钮”点击哪个按钮就操作哪一行所见即所得。在 WinForms 里ListView 默认没有按钮列这就需要我们通过 OwnerDraw 自绘一个按钮区域再通过鼠标事件去命中测试手动触发对应的业务回调。1.2 为什么在 Mono 环境下还坚持用 ListView可能有人会问既然这么麻烦为什么不用 DataGridView我的判断是这个项目最终运行环境是国产化 Linux 工控机安装的是 Mono Runtime。在实际测试中DataGridView 虽然也能跑但跨平台的绘制兼容性、滚动性能表现都比 ListView 差一些。特别是列表项数量达到几百行、数据按 500ms 间隔滚动刷新时DataGridView 在 Linux 下刷屏闪烁更明显字体渲染和行高表现也不够一致。相比之下ListView 底层是 WinForms 里实现比较成熟的控件OwnerDraw 模式在 Windows/.NET Framework 和 Mono/Linux 下行为一致性更好绘制灵活性也足够高。我们只需要把按钮区域当成一个“假按钮”画出来自己做命中测试就行。这套方案我在两个平台都验证过代码不做任何预处理可以直接跨平台编译运行。1.3 实现带按钮 ListView 的三条路线对比我梳理了一下实现“ListView 带按钮事件”常见有三条路方案思路优点缺点方案A选中行外部Button不新增列靠ListView选中事件控制旁边按钮状态代码量小逻辑直观操作关联性差列表刷新易丢选中方案BListView嵌入子控件在ListView的SubItem上动态添加真实Button控件点击体验最接近原生按钮滚动位置计算繁琐Mono下容易错位性能差方案COwnerDraw自绘按钮列自绘列表项时画一个按钮样式矩形用MouseDown做命中测试跨平台稳定性能好视觉可控需要自己处理绘制细节和点击区域计算方案A适合“操作不频繁、行数少、不容易误操作”的场景方案B我实测在滚动时子控件位置经常延迟更新Mono 下尤其容易闪烁不推荐方案C虽然前期代码多一点但一旦封装成自定义控件后面所有页面都能复用长期收益最高。这次我选择的就是方案C。2. 核心细节解析与实操要点2.1 先把 ListView 的底子打牢列与基本属性在动笔写代码之前先把 ListView 的基础配置理清楚。因为按钮列的自绘是围绕着 ListView 的列结构展开的列宽、子项对齐方式、行高都会直接影响按钮的绘制位置。首先设置 ListView 的基础属性listView.View View.Details; listView.FullRowSelect true; listView.GridLines true; listView.MultiSelect false; listView.HideSelection false; listView.OwnerDraw true; listView.DoubleBuffered true; // 注意这个属性默认是 internal需要反射设置几个关键点逐个说View.Details是列表带列头的基础模式按钮列要放在 Details 模式下才有意义。FullRowSelect true保证点击不只是在子项上而是整行选中交互体验更好。HideSelection false保证列表失去焦点时选中行仍然高亮现场操作时不容易看错行。OwnerDraw true是自绘的开关开启后 ListView 的绘制就完全交给我们的代码。DoubleBuffered能尽量减少刷新闪烁。但注意ListView 的 DoubleBuffered 属性是受保护的 internal 属性外部无法直接设置需要用反射强开后面我会给完整代码。另外列头的按钮列宽度要留足。按常规按钮的视觉尺寸按钮列建议宽度不低于 80 像素否则文字和点击区域都会太局促我一般直接定 100。2.2 OwnerDraw 自绘按钮列的绘制逻辑开启 OwnerDraw 后ListView 会触发DrawColumnHeader和DrawSubItem两个核心事件。DrawColumnHeader负责列头的绘制一般我们要调用e.DrawDefault true沿用系统默认样式DrawSubItem负责每个单元格的绘制这是我们动手的重头戏。按钮列绘制的核心思路是判断当前绘制的是不是按钮列比如第3列ColumnIndex 2如果是就调用 GDI 绘制一个带边框、带背景色的矩形然后在矩形中间绘制按钮文字。其他列保持默认绘制。绘制时还要考虑两个细节第一按钮不要铺满整个单元格。如果按钮占满整格行列之间没有视觉间距看起来会很挤。我的做法是在矩形四周各留 3~4 像素的边距让按钮在格子内部居中显示。第二按钮要区分“正常态”和“按下态”。鼠标按下后按钮绘制成反色或深色背景弹起后恢复这样才有真实的按钮反馈感。这个状态可以在 MouseDown 时记录按下的行索引MouseUp 时清除并通过Invalidate()触发重绘。2.3 命中测试怎么让“假按钮”响应真点击自绘按钮只是“画”出来的不是真正的控件所以不会自动响应点击事件。那怎么判断用户点到了哪个按钮思路是在 ListView 的MouseDown事件里先通过HitTest方法拿到鼠标点击位置的 ListViewItem 和子项再拿点击坐标和按钮列子项的边界矩形做包含判断。ListViewHitTestInfo info listView.HitTest(e.X, e.Y); if (info.Item ! null info.SubItem ! null) { int colIndex info.SubItem null ? 0 : info.Item.SubItems.IndexOf(info.SubItem); if (colIndex buttonColumnIndex) { Rectangle btnRect GetButtonRectangle(info.Item, buttonColumnIndex); if (btnRect.Contains(e.X, e.Y)) { // 触发按钮事件传入当前行的数据源对象 OnButtonClick(info.Item); } } }这里的GetButtonRectangle需要根据当前子项的实际绘制区域计算按钮内缩后的矩形保证“画的按钮在哪点的区域就在哪”。不要用固定的偏移量因为 Linux 和 Windows 下字体渲染差异可能导致行高变化固定偏移很容易错位。3. 完整实现与代码落地3.1 自定义 ListView 控件的完整代码为了方便复用我把这一套逻辑封装成了一个自定义控件ButtonListView继承自 ListView。完整代码如下using System; using System.Collections.Generic; using System.Drawing; using System.Reflection; using System.Windows.Forms; namespace IndustrialControls { public class ButtonListView : ListView { public int ButtonColumnIndex { get; set; } 2; public Color ButtonBackColor { get; set; } Color.FromArgb(52, 152, 219); public Color ButtonHoverBackColor { get; set; } Color.FromArgb(41, 128, 185); public Color ButtonTextColor { get; set; } Color.White; private readonly Dictionaryint, string _buttonTexts new Dictionaryint, string(); private int _pressedRow -1; private int _hoverRow -1; public event EventHandlerButtonListEventArgs ButtonClick; public ButtonListView() { this.View View.Details; this.FullRowSelect true; this.GridLines true; this.MultiSelect false; this.HideSelection false; this.OwnerDraw true; SetDoubleBuffered(); this.DrawColumnHeader OnDrawColumnHeader; this.DrawSubItem OnDrawSubItem; this.MouseDown OnMouseDown; this.MouseMove OnMouseMove; this.MouseLeave OnMouseLeave; this.MouseUp OnMouseUp; } private void SetDoubleBuffered() { typeof(ListView).InvokeMember(DoubleBuffered, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.SetProperty, null, this, new object[] { true }); } public void SetButtonText(int rowIndex, string text) { _buttonTexts[rowIndex] text; } private void OnDrawColumnHeader(object sender, DrawListViewColumnHeaderEventArgs e) { e.DrawDefault true; } private void OnDrawSubItem(object sender, DrawListViewSubItemEventArgs e) { if (e.ColumnIndex ! ButtonColumnIndex) { e.DrawDefault true; return; } Rectangle btnRect GetButtonRectangle(e.Item, e.ColumnIndex); bool isPressed _pressedRow e.ItemIndex; bool isHover _hoverRow e.ItemIndex; using (SolidBrush bgBrush new SolidBrush(isPressed ? ButtonHoverBackColor : (isHover ? ButtonHoverBackColor : ButtonBackColor))) using (Pen borderPen new Pen(Color.FromArgb(41, 128, 185))) using (SolidBrush textBrush new SolidBrush(ButtonTextColor)) using (StringFormat sf new StringFormat { Alignment StringAlignment.Center, LineAlignment StringAlignment.Center }) { e.Graphics.FillRectangle(bgBrush, btnRect); e.Graphics.DrawRectangle(borderPen, btnRect); string text _buttonTexts.ContainsKey(e.ItemIndex) ? _buttonTexts[e.ItemIndex] : 操作; e.Graphics.DrawString(text, this.Font, textBrush, btnRect, sf); } } private Rectangle GetButtonRectangle(ListViewItem item, int columnIndex) { Rectangle subItemRect item.SubItems[columnIndex].Bounds; const int padding 4; return new Rectangle( subItemRect.Left padding, subItemRect.Top padding, subItemRect.Width - padding * 2, subItemRect.Height - padding * 2); } private void OnMouseDown(object sender, MouseEventArgs e) { ListViewHitTestInfo info this.HitTest(e.X, e.Y); if (info.Item ! null info.SubItem ! null) { int colIndex info.Item.SubItems.IndexOf(info.SubItem); if (colIndex ButtonColumnIndex) { Rectangle btnRect GetButtonRectangle(info.Item, colIndex); if (btnRect.Contains(e.X, e.Y)) { _pressedRow info.Item.Index; Invalidate(btnRect); } } } } private void OnMouseUp(object sender, MouseEventArgs e) { if (_pressedRow 0) { ListViewHitTestInfo info this.HitTest(e.X, e.Y); if (info.Item ! null info.SubItem ! null) { int colIndex info.Item.SubItems.IndexOf(info.SubItem); if (colIndex ButtonColumnIndex) { Rectangle btnRect GetButtonRectangle(info.Item, colIndex); if (btnRect.Contains(e.X, e.Y)) { ButtonClick?.Invoke(this, new ButtonListEventArgs(info.Item.Index, info.Item)); } } } _pressedRow -1; Invalidate(); } } private void OnMouseMove(object sender, MouseEventArgs e) { ListViewHitTestInfo info this.HitTest(e.X, e.Y); int hoverRow -1; if (info.Item ! null info.SubItem ! null) { int colIndex info.Item.SubItems.IndexOf(info.SubItem); if (colIndex ButtonColumnIndex) { Rectangle btnRect GetButtonRectangle(info.Item, colIndex); if (btnRect.Contains(e.X, e.Y)) { hoverRow info.Item.Index; } } } if (_hoverRow ! hoverRow) { _hoverRow hoverRow; Invalidate(); } } private void OnMouseLeave(object sender, EventArgs e) { if (_hoverRow 0) { _hoverRow -1; Invalidate(); } } } public class ButtonListEventArgs : EventArgs { public int RowIndex { get; } public ListViewItem Item { get; } public ButtonListEventArgs(int rowIndex, ListViewItem item) { RowIndex rowIndex; Item item; } } }这里有一个很重要的设计事件参数里同时传了RowIndex和Item。拿到ListViewItem后可以直接通过item.Tag取到绑定的实体对象这样按钮事件就能精确关联到具体设备实例不会错位。3.2 在窗体里绑定数据与事件控件封装好之后在窗体里的使用就非常简单了。先初始化列和数据listView1.Columns.Add(设备名称, 180); listView1.Columns.Add(当前状态, 120); listView1.Columns.Add(操作, 100); ListDeviceInfo devices GetDevices(); // 模拟获取设备列表 foreach (var dev in devices) { ListViewItem item new ListViewItem(dev.Name); item.SubItems.Add(dev.Status); item.Tag dev; // 关键把实体对象放到 Tag 里 listView1.Items.Add(item); listView1.SetButtonText(listView1.Items.Count - 1, dev.IsRunning ? 停止 : 启动); } listView1.ButtonClick (s, e) { DeviceInfo dev e.Item.Tag as DeviceInfo; if (dev null) return; ButtonListView lv s as ButtonListView; bool newState !dev.IsRunning; dev.IsRunning newState; lv.SetButtonText(e.RowIndex, newState ? 停止 : 启动); e.Item.SubItems[1].Text newState ? 运行中 : 已停止; // 这里再写真实的启停命令比如向设备发送TCP/Socket指令 };登记按钮文字时我用了SetButtonText因为DrawSubItem绘制时没有直接拿到按钮文字源的属性用一个字典缓存每行的按钮文案绘制时按行号取这样灵活度更高。业务状态切换后记得同步更新字典和界面文字否则绘制时还是旧文案。3.3 扩展到更多按钮列和业务场景如果同一行需要两个甚至更多按钮比如“启动”“重启”同时存在不用新写一套逻辑稍微扩展一下就行。可以把按钮列做成“多按钮”模式把按钮矩形在子项里横向切成几段每段对应一个按钮然后给每段定义不同的回调和悬停状态。核心仍然是“绘制时按区域分块画点击时按坐标分块判断”。我自己在另一个项目里就做过类似的三按钮列表第一列按钮“启动”第二列按钮“停止”第三列按钮“配置”整体代码结构不变只是把按钮矩形切分成几个子矩形。这种做法在实际使用中很顺手尤其是设备现场操作员习惯点哪行就操作哪行误操作率比“选中行 外部按钮”低很多。而且因为点击逻辑里带了 Tag 实体后续要记录操作日志、二次确认弹窗也都很容易接。4. 常见问题与排查技巧实录4.1 Mono 跨平台下最容易踩的坑这套代码在 Windows .NET Framework 上跑通只是第一步真正要验证的是 Mono 下 Linux 工控机的表现。我实测遇到比较典型的问题有三个第一个是文字字体渲染差异。Linux 下 Mono 默认字体渲染和 Windows 不完全一致同样指定 9pt 的字号Linux 下按钮文字可能被裁切。解决方法是绘制文字时不要写死 font size而是跟随控件的 Font同时在按钮文字区域四周多留 2~3 像素的 padding。第二个是双缓冲反射设置。我在代码里直接通过反射设置DoubleBuffered这个方法在 .NET Framework 和 Mono 下都能跑但如果某些精简的 Mono 发行版约束了反射权限可能抛异常。稳妥做法是把反射包在 try-catch 里即便失败也只是刷新略微闪烁不影响功能。第三个是放大字体或高 DPI 屏幕下坐标错位。如果操作员在系统里把显示缩放调到了 125% 或 150%HitTest 拿到的坐标和绘制坐标可能出现偏差。我的做法是在Main函数里显式设置应用程序的高 DPI 感知[STAThread] static void Main() { if (Environment.OSVersion.Version.Major 6) { SetProcessDPIAware(); } Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); } [DllImport(user32.dll)] private static extern bool SetProcessDPIAware();这样在 Windows 上调试时行为统一Linux 上本身没有系统级缩放问题所以不会受这个影响。4.2 点击位置偏移与滚动错位排查自绘按钮最烦的就是“点击不灵敏”“点了 A 行却触发 B 行”。这类问题九成出在坐标计算上。我先说最容易踩的坑千万不要用ListViewItem.Bounds配合固定偏移量计算按钮位置。因为在 Details 模式下每一项的 Bounds 与子项列边界并不完全一致尤其在设置了FullRowSelect、GridLines的情况下边框线的高度和宽度会把坐标带偏。正确做法是用item.SubItems[col].Bounds这个属性返回的是当前子项在客户区坐标的实际边界跟绘制时用的区域完全一致。第二个坑是滚动条。当列表滚动后子项的 Bounds 仍然返回的是“当前屏幕可见区域内的坐标”所以只要你的绘制和命中测试都基于SubItem.Bounds计算滚动后依然能正确对应。如果你在代码里缓存了某一时刻的矩形坐标滚动后没有清缓存就一定会错位。解决办法是“即用即算”不要缓存按钮矩形。第三个坑是 HitTest 的使用。HitTest返回的SubItem在点击空白处可能为 null所以必须先判断info.Item和info.SubItem都不为 null再做包含判断。否则一旦点击行尾空白直接 IndexOf 空对象会抛异常。4.3 多线程刷新与性能优化上位机环境里设备状态数据通常来自后台线程比如 Socket 接收、扫码枪触发、PLC 轮询。这些数据刷新界面时有一个原则不要直接在非 UI 线程里操作 ListView 的 Items 集合必须通过Invoke或BeginInvoke封送到 UI 线程再更新。private void UpdateDeviceStatus(DeviceInfo dev) { if (listView1.InvokeRequired) { listView1.BeginInvoke(new Action(() UpdateDeviceStatus(dev))); return; } // 此处安全更新 ListView }性能方面如果数据量较大超过 500 行每次刷新时不要Items.Clear()后重新 Add这样会导致界面闪烁和滚动位置丢失。更好的做法是新增时用Add更新时直接改对应ListViewItem.SubItems的 Text 和Tag只在需要插入或删除时改集合。实测下来刷新频率 1Hz、200 行数据时CPU 占用几乎忽略不计。按钮列绘制这块OnDrawSubItem每次触发都会重新创建笔刷和画刷。虽然 GDI 对象有托管包装但频繁创建释放还是会有开销。如果列表非常大可以把常用的SolidBrush和Pen缓存成控件字段避免每次绘制都 new 一遍。这个优化在我之前的 1000 行数据列表上效果比较明显。4.4 常见问题速查表现象可能原因解决办法按钮列显示为空白OwnerDraw 未开启或 DrawSubItem 里没处理按钮列分支检查OwnerDraw true确认列索引判断正确点击按钮无反应MouseDown 里没做按钮矩形包含判断用GetButtonRectangle的返回矩形做 Contains 判断点击 A 行触发 B 行用了Item.Bounds或缓存了固定坐标改用SubItem.Bounds实时计算按钮文字在 Linux 下被截断字体渲染差异改用控件 Font按钮 padding 加大列表刷新时按钮文字不变业务状态更新后没同步_buttonTexts同步调用SetButtonText并Invalidate滚动条后点击错位缓存了旧的按钮矩形即用即算不在字段里缓存矩形写在最后这套“ListView 自绘按钮列”的思路本质上就是把一个假按钮画出来再用命中测试补上交互一旦理解了这个套路就不只是能做一个按钮列。你在项目里可能还会遇到“ListView 行内加进度条”“行内加状态圆点”之类的需求其实都是同一个思路OwnerDraw 自由绘制 坐标反查事件。把这些封装成自定义控件后团队其他人用起来也省心不用每次重复造轮子。如果你也在做类似的 C# 上位机项目我建议不要上来就堆功能先把自绘和命中的基础代码吃透后面换什么场景都不慌。这套代码在 Windows 和 Linux Mono 环境下我都已经跑稳了你直接复制改一下列索引和业务回调就能用有问题欢迎在评论区一起讨论。本文还有配套的精品资源点击获取