简介:Qt开发者若想让QListView摆脱默认文本图标样式,可参考这套自定义Item实现方案。资源以Qt模型/视图框架为基础,聚焦重写QStyledItemDelegate代理类,通过paint与sizeHint定制绘制逻辑,配合自定义数据类和界面布局,最终呈现类似网易云PC客户端的卡片化列表效果。压缩包共18个文件,主要有cpp/h源码、ui界面、qss样式、qrc资源及多张演示图片,整体仅37KB,轻量易读。目前已有2137人学习下载。示例中包含完整注释代码和动画、交互状态处理思路,可帮助理解复杂Item绘制、hover点击反馈、数据绑定与布局管理,并注意避免过度绘制带来的性能问题,适合有一定Qt基础、希望提升列表交互质感的开发者参考。 第一次接触 ListView 自定义 Item,多半是产品经理甩过来一张效果图,列表里要带小图标、多行文本、进度条、按钮,甚至还得有圆角背景。默认的 ListView 一行一条文本、一个图标根本顶不住,这时候你就知道“自定义 Item”这个技能点有多值钱了。
不管你做的是 WinForms、WPF 还是移动端,核心思路其实是打通的:列表控件只负责容器,Item 长什么样、怎么交互,全部由你的自定义逻辑接管。这篇文章我会以 WinForms 中最为经典的 ListView 为例,从需求拆解、方案选型、核心绘制、到性能优化和坑位排查,一条线走完整,让你看完就能抄作业,也能自己扩展出想要的效果。
1. ListView自定义Item到底自定义了什么
1.1 默认Item的局限
很多人把 ListView 当成一个“能显示列表的控件”直接用,但一遇到稍微复杂一点的业务场景就开始别扭。比如一个订单列表,每个订单项要有订单号、商品名、金额、状态标签,最后还要一个“查看详情”按钮。你用默认的 ListView 怎么做?只能拼字符串塞进 SubItems,数据是能显示了,但排版丑陋、状态不醒目、按钮更是想都不用想。
这就是默认 Item 的极限:它本质上只有固定的文本和图标节点,没有“组合控件”的容器能力,也没有局部绘制、局部命中的能力。你写的每一行、每一个图标、每一处点击区域,都需要自定义代码去实现。
1.2 三种自定义路线的选择
既然要自定义 Item,我们首先得选择路线。常见的方案有三种:
- 继承重绘(OwnerDraw):设置 ListView 的 OwnerDraw 属性为 True,自己接管 DrawItem、DrawSubItem、DrawColumnHeader 事件。这是纯 GDI+ 绘制,性能好、控制最细。
- 嵌入真实控件:在 Item 位置动态放置 Label、Button 等 WinForms 控件,跟随滚动条同步移动。优点是开发快,缺点是不适合大量数据,控件数量一多就卡顿、闪烁。
- 模板化(自定义控件外层套容器):这不是 WinForms 原生方案,通常是 WPF/Flutter/移动端里 DataTemplate 的做法,把 Item 做成独立组件。
如果是 WinForms 项目,我这几年做下来的结论是:普通几千行以内的列表,OwnerDraw 就够了;超过一万行建议考虑虚拟模式 + OwnerDraw;万一你真得在一屏里塞七八十个真实控件,那还不如换方案,别硬凹控件嵌入。
2. 动手之前:先拆需求再定设计
2.1 数据模型先行
很多人一上来就画界面,我建议反着来。先建立数据模型,再让界面去“翻译”这个模型。举个例子,假设我们要做一个 ToDo 列表,每项包含:标题、截止日期、优先级、完成状态。
定义一个类:
public class TodoItem { public string Title { get; set; } public DateTime DueDate { get; set; } public int Priority { get; set; } // 1低 2中 3高 public bool IsDone { get; set; } }然后把这个模型列表转换成 ListViewItem 集合,放进 ListView。自定义 Item 最核心的原则就是:ListViewItem 是 UI 层的东西,业务数据别直接塞在里面,至少要有一层转换映射。这样后续要换控件、加统计、做序列化都轻松。
2.2 布局与交互清单
在写绘制代码之前,先把“长什么样、能点什么”列成清单。比如我这个 ToDo 列表,每个 Item 的高度约 50 像素,左侧一个用于勾选完成状态的圆形框,中间两行文本(标题 + 截止日期 + 优先级文字),右下角一个“删除”文字按钮。
交互上:点击圆形框切换完成状态,点击删除按钮移除该项,点击其他区域触发行选中。这个清单一列出来,绘制逻辑就清楚了。这也是为什么我建议先列清单再写代码——没有清单,你很容易在 DrawSubItem 里画着画着就开始迷茫。
3. OwnerDraw方式逐个像素绘制你的Item
3.1 准备一个自定义ListView
首先在窗体上放一个 ListView,设置好基本属性:
this.listView1.View = View.Details; this.listView1.OwnerDraw = true; this.listView1.FullRowSelect = true; this.listView1.GridLines = false; this.listView1.MultiSelect = false; this.listView1.HideSelection = false; this.listView1.BorderStyle = BorderStyle.None;加三列,列宽按需求来,实际列文字可以不显示,因为所有列内容都由我们自己绘制,列头也可以完全自定义:
this.listView1.Columns.Add("colCheck", ""); this.listView1.Columns.Add("colMain", ""); this.listView1.Columns.Add("colDelete", "");3.2 绘制列表项的核心代码
接下来,接管三个事件。先看 DrawItem 和 DrawSubItem,这是整个自定义 Item 最核心的部分。
private void listView1_DrawItem(object sender, DrawListViewItemEventArgs e) { // 绘制背景 using (Brush bgBrush = new SolidBrush(GetItemBackgroundColor(e.Item))) { e.Graphics.FillRectangle(bgBrush, e.Bounds); } // 绘制选中边框 if ((e.Item.Selected) && !e.Item.Tag.ToString().Equals("header", StringComparison.OrdinalIgnoreCase)) { using (Pen pen = new Pen(Color.FromArgb(59, 130, 246))) { e.Graphics.DrawRectangle(pen, e.Bounds.X, e.Bounds.Y, e.Bounds.Width - 1, e.Bounds.Height - 1); } } // 绘制左侧圆形状态图标 if (e.Item.Tag is TodoItem todo) { Rectangle circleRect = new Rectangle(e.Bounds.X + 12, e.Bounds.Y + (e.Bounds.Height - 18) / 2, 18, 18); using (var brush = new SolidBrush(todo.IsDone ? Color.FromArgb(34, 197, 94) : Color.White)) { e.Graphics.FillEllipse(brush, circleRect); e.Graphics.DrawEllipse(Pens.Gray, circleRect); } if (todo.IsDone) { // 打勾 using (var pen = new Pen(Color.White, 2)) { e.Graphics.DrawLine(pen, circleRect.X + 4, circleRect.Y + 9, circleRect.X + 8, circleRect.Y + 13); e.Graphics.DrawLine(pen, circleRect.X + 8, circleRect.Y + 13, circleRect.X + 14, circleRect.Y + 5); } } } }再处理 DrawSubItem,这里有个关键点:DrawSubItem 会为每一列都触发一次,而且它会在 DrawItem 之后触发,所以如果处理不好,文字就会被覆盖两次。我的做法是,只在主列画文字,其他列什么都不画,或者画列对应的专属信息。
private void listView1_DrawSubItem(object sender, DrawListViewSubItemEventArgs e) { if (e.ColumnIndex < 0 || e.Item == null) return; if (e.ColumnIndex == 1 && e.Item.Tag is TodoItem todo) { // 标题 TextRenderer.DrawText(e.Graphics, todo.Title, titleFont, new Rectangle(e.Bounds.X + 40, e.Bounds.Y + 4, e.Bounds.Width - 60, 20), todo.IsDone ? Color.Gray : Color.FromArgb(17, 24, 39), TextFormatFlags.Left | TextFormatFlags.VerticalCenter); // 副标题:截止日期和优先级 string sub = todo.DueDate.ToString("yyyy-MM-dd") + " · " + GetPriorityText(todo.Priority); TextRenderer.DrawText(e.Graphics, sub, subFont, new Rectangle(e.Bounds.X + 40, e.Bounds.Y + 25, e.Bounds.Width - 60, 18), Color.Gray, TextFormatFlags.Left | TextFormatFlags.VerticalCenter); } }注意,这里e.Graphics绘制文本时我优先使用TextRenderer.DrawText,因为它用的是当前系统的 GDI 字体渲染,文字清晰度比Graphics.DrawString高,在 WinForms 列表里尤其明显。之前有一版用 DrawString,同样字号按比例缩放后总是发虚,换 TextRenderer 问题就没了。
3.3 绘制表头和表头细节
表头也是可以完全定制的。虽然例子中列头文字不重要,但如果你需要样式统一,自定义表头会让整体效果提升一个档次:
private void listView1_DrawColumnHeader(object sender, DrawListViewColumnHeaderEventArgs e) { using (var brush = new SolidBrush(Color.FromArgb(249, 250, 251))) { e.Graphics.FillRectangle(brush, e.Bounds); } using (var pen = new Pen(Color.FromArgb(229, 231, 235))) { e.Graphics.DrawLine(pen, e.Bounds.Right - 1, e.Bounds.Top, e.Bounds.Right - 1, e.Bounds.Bottom); } TextRenderer.DrawText(e.Graphics, "任务列表", headFont, new Rectangle(e.Bounds.X + 8, e.Bounds.Y, e.Bounds.Width - 16, e.Bounds.Height), Color.FromArgb(107, 114, 128), TextFormatFlags.VerticalCenter | TextFormatFlags.Left); }到这一步,一个能看、能选、能显示多行信息的自定义 Item 就初步成型了。但!这只是一张皮,真正的坑在后头。
4. 那些不写进文档的坑:行高、闪烁、点击与性能
4.1 行高如何调整
WinForms 的 ListView 没有现成的 RowHeight 属性,这是个让很多人头疼的点。间接做法是给 ListView 设置一个 SmallImageList,并把 ImageList 里的 ImageSize 高度设成你需要的行高,然后在每个 ListViewItem 上挂一个 Index。
ImageList imgList = new ImageList(); imgList.ImageSize = new Size(1, 50); // 高度50,宽度1即可 imgList.Images.Add(new Bitmap(1, 50)); listView1.SmallImageList = imgList; for (int i = 0; i < data.Count; i++) { ListViewItem item = new ListViewItem(); item.ImageIndex = 0; item.Tag = data[i]; listView1.Items.Add(item); }这个方法是我早年从某个论坛帖子学来的,官方文档里根本没提,但实测有效。OwnerDraw 绘制时,Item 的 Bounds.Height 就会变成 50,你所有绘制坐标都以这个为基准。注意:这个技巧必须在绑定数据前就设置好 ImageList,否则行高不会生效。
4.2 双缓冲防闪烁
自定义绘制最怕闪烁。OwnerDraw 模式下你每滚动一次,ListView 就要重绘所有可见行,如果绘制代码不够快,或者控件没有开双缓冲,那画面就会像霓虹灯一样闪个没完。
解决方案分两步。第一是给 ListView 开启双缓冲:
typeof(Control).GetProperty("DoubleBuffered", BindingFlags.Instance | BindingFlags.NonPublic) .SetValue(listView1, true, null);第二是在绘制事件里尽量少创建对象。比如上面代码里我直接用using包裹画刷和画笔,看似标准,其实在频繁滚动时会产生大量短生命周期对象,给 GC 增加压力。更极致的做法是把常用的SolidBrush、Pen预先定义成字段,绘制时直接拿来用:
private readonly SolidBrush _doneBrush = new SolidBrush(Color.FromArgb(34, 197, 94)); private readonly Pen _selectedPen = new Pen(Color.FromArgb(59, 130, 246), 1.5f);这样性能会明显改善。特别是当数据量上千、一屏可见 30 行左右时,两种写法的卡顿感差异非常明显。
4.3 事件判定与命中测试
OwnerDraw 绘制出来后,点击区域也是要自己算的。因为 ListView 默认只响应行选中,你要想“点到圆形框切状态”“点到右下角删任务”,就得在 MouseDown 或 MouseClick 里自己做碰撞检测。
private void listView1_MouseClick(object sender, MouseEventArgs e) { ListViewItem item = listView1.GetItemAt(e.X, e.Y); if (item == null || !(item.Tag is TodoItem todo)) return; Rectangle circleRect = new Rectangle(item.Bounds.X + 12, item.Bounds.Y + (item.Bounds.Height - 18) / 2, 18, 18); if (circleRect.Contains(e.Location)) { todo.IsDone = !todo.IsDone; item.SubItems.Clear(); listView1.Refresh(); return; } Rectangle deleteRect = new Rectangle(item.Bounds.Right - 60, item.Bounds.Y + (item.Bounds.Height - 20) / 2, 50, 20); if (deleteRect.Contains(e.Location)) { listView1.Items.Remove(item); return; } }必须注意:GetItemAt拿到的坐标是相对 ListView 客户区的,而绘制时的e.Bounds也是客户区坐标,两边是一致的,直接用就行。但如果你的 ListView 嵌在 Panel 里或者有滚动偏移,就要考虑AutoScrollPosition的影响。我建议所有自定义绘制的控件都先确保没有外层滚动容器,否则坐标变换很容易出错。
还有一个小细节:MouseClick 事件比 MouseDown 晚,能拿到完整的点击状态。如果你要做拖拽或者按下时的视觉反馈,建议用 MouseDown,但要注意记得在 MouseUp 时清理状态,不然按下不动就会一直保持高亮。
4.4 超过1000行请用虚拟模式
数据量很小时,手动往 ListView 塞几千个 ListViewItem 问题不大。但如果业务上可能有几万行甚至更多,物理创建大量 ListViewItem 会让内存和初始化时间急剧膨胀,滚动也不流畅。
这时候就该上 VirtualMode:
listView1.VirtualMode = true; listView1.RetrieveVirtualItem += ListView1_RetrieveVirtualItem; listView1.VirtualListSize = data.Count; private void ListView1_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e) { TodoItem todo = data[e.ItemIndex]; ListViewItem item = new ListViewItem(); item.Tag = todo; item.ImageIndex = 0; // 其他列信息按需设置 e.Item = item; }虚拟模式下 ListView 不会保存所有 Item 对象,而是在需要显示某一行时临时调用 RetrieveVirtualItem 让你提供数据。配合 OwnerDraw,性能和内存占用都能控制在很健康的状态。需要注意的是,虚拟模式下你不能直接访问listView1.Items[i]来改数据,必须修改业务数据源然后调用listView1.Invalidate()触发重绘。
5. 常见问题与排查技巧实录
实战中我踩过不少坑,列一个速查表,照这个排查基本能解决 80% 的问题:
| 问题现象 | 常见原因 | 解决办法 |
|---|---|---|
| 行高设置不生效 | 没有设置 SmallImageList,或 ImageList 在绑定前未配置 | 先创建 ImageList 并设置 ImageSize,再添加 Item |
| 选中行背景花屏/重影 | DrawItem 和 DrawSubItem 各自绘制背景,叠加导致 | 只在 DrawItem 里画背景,DrawSubItem 只画文本 |
| 文字模糊 | 用了 Graphics.DrawString | 改用 TextRenderer.DrawText |
| 滚动时闪烁 | 控件未双缓冲,或绘制频繁创建画刷/画笔 | 反射开启双缓冲,预创建常用 GDI 对象 |
| 点击按钮没反应 | 命中测试坐标没有考虑行内偏移 | 检查 e.Location 是否在目标矩形内,打印调试坐标 |
| 文本被截断/重叠 | 列宽设置不正确,绘制区域过小 | 用 Graphics.MeasureString 测量后再画,或设置自动列宽 |
| 虚拟模式滚动到新行报错 | RetrieveVirtualItem 返回了未设置完整列数据的 Item | 每次构造 Item 时确保 Tag、ImageIndex、SubItems 正确 |
| 表头高度不对 | DrawColumnHeader 绘制内容超出默认高度 | 自定义表头绘制时按实际字体高度计算,或重设 ColumnHeader.Height |
| 选中行显示不出来 | HideSelection 为 True 时失焦选中行不可见 | 设置 HideSelection = False |
| 鼠标点击区域与绘制区域不一致 | DPI 缩放导致坐标偏移 | 在配置了 DPI 感知的进程中测试,必要时用 PerMonitorV2 模式 |
这里重点说一下 DPI 缩放的问题。如果你开发机是 100% 缩放,用户那边是 125% 或 150%,OwnerDraw 里的坐标计算会全部按物理像素走,但 ListView 的 Bounds 可能又是缩放后的值,两者一混,点击区域就偏了。我现在的做法是整个项目启用 PerMonitorV2 DPI 感知,并且在绘制里全部使用缩放后的坐标。碰上高分屏问题,先确认是不是 DPI 导致,而不是急着调代码。
还有一个经验之谈,自定义 Item 的调试不要靠眼睛盯,最好加一个中间的辅助方法,可以快速把鼠标当前位置、目标 Item 的 Bounds、各按钮区域都打印到输出窗口。我之前调试命中检测时开着一堆临时 MessageBox 差点把自己烦死,后来改成输出日志,两分钟就定位到了问题。
写在最后
折腾完这一整套 ListView 自定义 Item 的方案,我个人最大的体会是:列表类控件的自定义,重点不在控件的 API,而在于你想清楚两件事——数据长什么样,交互有哪些区域。一旦这两点清晰了,剩下的绘制和命中检测就是体力活。
最后再分享一个小技巧:如果你只是想让某一行有特殊背景色,不需要完全从零写绘制,可以在 DrawItem 里用 e.Item.BackColor 配合 e.Item.UseItemStyleForSubItems 控制整行颜色,能省一大半代码。而当你真的要复杂布局时,再回到这篇文章的思路来。ListView 自定义 Item 这条路不难,但每一步都有小坑,按步骤来,多打印日志,总能搞定。
本文还有配套的精品资源,点击获取