十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WinForms ListView自定义Item完全指南:从OwnerDraw到性能优化

WinForms ListView自定义Item完全指南:从OwnerDraw到性能优化 简介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方式逐个像素绘制你的Item3.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 会让内存和初始化时间急剧膨胀滚动也不流畅。这时候就该上 VirtualModelistView1.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 这条路不难但每一步都有小坑按步骤来多打印日志总能搞定。本文还有配套的精品资源点击获取
返回列表