news 2026/9/7 10:10:59

C# WinForms ListView自绘按钮列跨平台实现与命中测试详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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选中事件控制旁边按钮状态代码量小,逻辑直观操作关联性差,列表刷新易丢选中
方案B:ListView嵌入子控件在ListView的SubItem上动态添加真实Button控件点击体验最接近原生按钮滚动位置计算繁琐,Mono下容易错位,性能差
方案C:OwnerDraw自绘按钮列自绘列表项时画一个按钮样式矩形,用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 会触发DrawColumnHeaderDrawSubItem两个核心事件。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 Dictionary<int, string> _buttonTexts = new Dictionary<int, string>(); private int _pressedRow = -1; private int _hoverRow = -1; public event EventHandler<ButtonListEventArgs> 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; } } }

这里有一个很重要的设计:事件参数里同时传了RowIndexItem。拿到ListViewItem后,可以直接通过item.Tag取到绑定的实体对象,这样按钮事件就能精确关联到具体设备实例,不会错位。

3.2 在窗体里绑定数据与事件

控件封装好之后,在窗体里的使用就非常简单了。先初始化列和数据:

listView1.Columns.Add("设备名称", 180); listView1.Columns.Add("当前状态", 120); listView1.Columns.Add("操作", 100); List<DeviceInfo> 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 与子项列边界并不完全一致,尤其在设置了FullRowSelectGridLines的情况下,边框线的高度和宽度会把坐标带偏。正确做法是用item.SubItems[col].Bounds,这个属性返回的是当前子项在客户区坐标的实际边界,跟绘制时用的区域完全一致。

第二个坑是滚动条。当列表滚动后,子项的 Bounds 仍然返回的是“当前屏幕可见区域内的坐标”,所以只要你的绘制和命中测试都基于SubItem.Bounds计算,滚动后依然能正确对应。如果你在代码里缓存了某一时刻的矩形坐标,滚动后没有清缓存,就一定会错位。解决办法是“即用即算”,不要缓存按钮矩形。

第三个坑是 HitTest 的使用。HitTest返回的SubItem在点击空白处可能为 null,所以必须先判断info.Iteminfo.SubItem都不为 null,再做包含判断。否则一旦点击行尾空白,直接 IndexOf 空对象会抛异常。

4.3 多线程刷新与性能优化

上位机环境里,设备状态数据通常来自后台线程,比如 Socket 接收、扫码枪触发、PLC 轮询。这些数据刷新界面时有一个原则:不要直接在非 UI 线程里操作 ListView 的 Items 集合,必须通过InvokeBeginInvoke封送到 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+ 对象有托管包装,但频繁创建释放还是会有开销。如果列表非常大,可以把常用的SolidBrushPen缓存成控件字段,避免每次绘制都 new 一遍。这个优化在我之前的 1000 行数据列表上效果比较明显。

4.4 常见问题速查表

现象可能原因解决办法
按钮列显示为空白OwnerDraw 未开启,或 DrawSubItem 里没处理按钮列分支检查OwnerDraw = true,确认列索引判断正确
点击按钮无反应MouseDown 里没做按钮矩形包含判断GetButtonRectangle的返回矩形做 Contains 判断
点击 A 行触发 B 行用了Item.Bounds或缓存了固定坐标改用SubItem.Bounds实时计算
按钮文字在 Linux 下被截断字体渲染差异改用控件 Font,按钮 padding 加大
列表刷新时按钮文字不变业务状态更新后没同步_buttonTexts同步调用SetButtonTextInvalidate
滚动条后点击错位缓存了旧的按钮矩形即用即算,不在字段里缓存矩形

写在最后

这套“ListView 自绘按钮列”的思路,本质上就是把一个假按钮画出来,再用命中测试补上交互,一旦理解了这个套路,就不只是能做一个按钮列。你在项目里可能还会遇到“ListView 行内加进度条”“行内加状态圆点”之类的需求,其实都是同一个思路:OwnerDraw 自由绘制 + 坐标反查事件。把这些封装成自定义控件后,团队其他人用起来也省心,不用每次重复造轮子。

如果你也在做类似的 C# 上位机项目,我建议不要上来就堆功能,先把自绘和命中的基础代码吃透,后面换什么场景都不慌。这套代码在 Windows 和 Linux Mono 环境下我都已经跑稳了,你直接复制改一下列索引和业务回调就能用,有问题欢迎在评论区一起讨论。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 10:10:54

AMD与AI巨头认股权证交易背后的技术合作与算力方案评估

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 10:08:51

SpringBoot+微信小程序+AI大模型智能校园导航系统毕设项目详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华