基于CWnd自绘CListCtrl控件:实现带自定义滚动条的列表

发布时间:2026/10/12 10:25:24
基于CWnd自绘CListCtrl控件:实现带自定义滚动条的列表 1. 为什么是“自绘”而不是直接用CListCtrl做MFC界面开发的老哥们应该都有过跟CListCtrl较劲的经历。标准CListCtrl用起来确实快插入列、添加行、设图标、接LVN_ITEMCHANGED消息一套流程很熟。但真要到生产环境就会发现它就是个“看起来能定制、实际上处处给你挖坑”的控件。先说痛点。第一是样式太死板列头字体、行高、高亮颜色、网格线、hove效果你都得去响应NM_CUSTOMDRAW或者干脆自己画Header。NM_CUSTOMDRAW能解决一部分问题但它是按“阶段”回调的涉及子项绘制时你要在CDDS_ITEM和CDDS_ITEMPOST之间反复切换逻辑一旦复杂就特别容易乱。第二是数据量问题几万行时CListCtrl默认的InsertItem会卡到怀疑人生即使用虚拟列表LVS_OWNERDATA也只是把数据获取回调变成你来提供绘制细节还是受限于系统控件内部实现想做到“每行自定义背景、进度条、按钮”这种业务型列表CListCtrl的OwnerDraw又不够灵活很多绘制状态系统内部已经决定了。第三是滚动条标准ListCtrl的滚动条是Windows原生的那根窄条样式和你的界面主题大概率不搭你想在列表右侧放一个圆角滑块、宽一点、带渐变根本改不了。所以就有了本文这个方案基于CWnd完全自绘一个仿CListCtrl的控件。标题里写着“基于CWnd自绘CListCtrl控件且带滚动条”说白了就是不用系统控件自己从零写一个列表窗口自己存放数据、自己在WM_PAINT里画每一行每一列、自己处理鼠标滚轮和点击、自己画右侧滚动条。这样做的好处是“像素级别可控”坏处是工作量大、细节巨多。本文会把我在实际项目里做的这类控件的关键设计、绘制机制、滚动条交互、坑都拆出来适合看过MFC消息映射、基本GDI绘图、又想摆脱系统控件限制的开发者。2. 先定架构再谈绘制控件类的整体设计自绘列表不是说上来就写OnPaint先把类的骨架和数据结构理清楚后面才不会乱。2.1 从CWnd派生并注册窗口类MFC里自定义控件最简单的方式是直接从CWnd派生然后调用Create创建窗口。这里有个关键点需要先注册一个窗口类指定窗口过程。虽然MFC内部会做Subclass的封装但自绘控件建议用AfxRegisterWndClass注册一个自己的类名设置CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS并指定背景刷子为NULL光标为加载的箭头光标。注册的代码一般放在静态函数或者类的InitInstance里用一个静态成员函数RegisterWndClass。注册窗口类还有一个隐藏作用保证双击消息能被窗口接收。如果不加CS_DBLCLKSOnLButtonDblClk消息是收不到的。很多自绘列表需要双击编辑单元格这步必须做。创建控件的时候样式要设置成WS_CHILD | WS_VISIBLE | WS_TABSTOP滚动条不要用系统默认的WS_VSCROLL和WS_HSCROLL因为我们后面要自绘滚动条如果客户端带上了系统滚动条客户区尺寸会被占掉一块自绘滚动条反而会和系统滚动条叠加。我这里的做法是完全不要系统滚动条在客户区右侧留一条固定宽度的滚动条区域比如14像素完全自己画。2.2 数据模型与视图分离自绘列表的优势是你自己决定行数据的存储方式。我们需要两个核心结构列结构包含每列的宽度、对齐方式、标题文本、列标识ID。行数据建议用一个结构体包一层内部可以挂着真实数据指针也可以存一组字符串具体看业务。类内部通常维护这几样std::vectorCOLUMN_INFO m_vecColumnsstd::vectorROW_DATA* m_vecRows或者干脆std::vectorstd::vectorCString m_vecDataint m_nScrollPosY——垂直滚动偏移量int m_nScrollPosX——横向滚动偏移量如果支持横向滚动int m_nCurSel——当前选中行int m_nHoverRow——鼠标悬停行用于高亮这里我特别建议用“逻辑行索引 滚动偏移”来定位。绘制时从索引为m_nScrollPosY的行开始画一直画到客户区底部这样可以非常自然地处理大数据量。如果数据量确实巨大比如百万行甚至可以考虑延迟加载行数据不过绝大部分项目到不了这个量级。2.3 列头、行高和客户区坐标换算自绘列表里面最绕的是坐标换算。控件有四个区域列头一般在顶部高度30像素左右、客户区画数据行的地方、右侧滚动条区域、水平滚动条区域可选。绘制和命中测试都要换算到逻辑区。例如客户区数据区高度是rcClient.Height() - m_nHeaderHeight那么可见行数为nVisibleRows dataHeight / m_nRowHeight 1。而滚动条滑块的大小就根据“可见行数 / 总行数”来算这个比例是滚动条绘制的核心。命中测试鼠标点击时首先要判断是否点击在列头区域如果在则执行列头点击排序逻辑否则计算row m_nScrollPosY (y - m_nHeaderHeight) / m_nRowHeight然后遍历列宽判断x落在哪一列。这个过程和CListCtrl内部做的事情实际上是一样的。3. 核心绘制OnPaint里的一笔一画自绘控件的精华全在绘制函数里。绘制时一定要用内存DC做双缓冲否则刷新频繁时闪烁到怀疑人生。3.1 双缓冲绘制框架OnPaint的逻辑一般这样用CPaintDC获取窗口DC然后创建内存兼容DC和位图大小等于客户区大小。把内存DC用背景色填充后依次绘制列头、数据行、选中高亮边框、滚动条、边缘阴影最后一次性BitBlt到窗口DC。这么做可以保证每次WM_PAINT操作的是内存位图不会出现绘制过程中窗口内容被部分擦除又部分重绘的明显闪烁。MFC里有一个技巧把绘制函数设计为void Draw(CDC* pDC, CRect rcClient)这样既可以被OnPaint调也可以被OnPrint调将来做打印功能、导出预览图都能复用。建议直接这样设计。3.2 数据行的逐行绘制画数据行是最核心的部分。我习惯的步骤是void CMyListCtrl::DrawData(CDC* pDC, CRect rcData) { CRect rcRow; rcRow.top rcData.top; rcRow.bottom rcRow.top m_nRowHeight; int nFirst m_nScrollPosY; int nVisibleCount rcData.Height() / m_nRowHeight 1; for (int i 0; i nVisibleCount; i) { int nIndex nFirst i; if (nIndex (int)m_vecRows.size()) break; // 确定行背景色 COLORREF clrBg (nIndex m_nCurSel) ? m_clrSelectedBk : (i 1) ? m_clrOddBk : m_clrEvenBk; pDC-FillSolidRect(rcRow, clrBg); // 画每一列的文字 int nLeft rcRow.left - m_nScrollPosX; for (size_t c 0; c m_vecColumns.size(); c) { CRect rcCell(nLeft, rcRow.top, nLeft m_vecColumns[c].nWidth, rcRow.bottom); // 裁剪 pDC-SetClipRect(rcCell, RGN_AND); DrawCellText(pDC, nIndex, c, rcCell); pDC-SelectClipRgn(NULL); // 恢复 nLeft m_vecColumns[c].nWidth; } // 画网格线 pDC-Draw3dRect(rcRow, m_clrGrid, m_clrGrid); rcRow.top m_nRowHeight; rcRow.bottom m_nRowHeight; } }这里有个常见的坑FillSolidRect使用的是窗口DC如果你之前设置过裁剪区域恢复后填充颜色才不会波及客户区外。另外文字颜色要区分普通状态、选中状态、悬停状态。选中行的文字颜色通常用白色悬停行用浅蓝色背景但文字颜色不变。3.3 列头和列宽调整列头建议也自己画这样能实现统一风格。画列头的时候每个列头矩形要缓存起来用于点击判断。列头文本用DrawText居中显示底部画一条深色分隔线。如果支持拖拽调整列宽还需要在列头分割线上处理光标形状和WM_LBUTTONDOWN/WM_MOUSEMOVE消息检测到鼠标移动到列头边界时SetCursor(IDC_SIZEWE)按下时记录列索引和起始位置拖动时更新列宽并Invalidate。这个功能我强烈建议保留因为业务列的宽度经常要由用户调整。3.4 自绘滚动条的绘制标题里专门点出了“且带滚动条”说明滚动条不是系统默认的。自绘滚动条分垂直和水平两种垂直滚动条纵向条区域位于客户区最右侧水平滚动条位于底部如果支持横向滚动。拿垂直滚动条来说需要先确定滚动条滑块的位置和大小。计算方法滚动条区域rcScrollBar通常高度就是客户区高度宽度固定14像素。滑块长度与总内容高度成比例thumbHeight max( minThumbHeight, rcScrollBar.Height() * visibleRows / totalRows )。滑块位置thumbPos rcScrollBar.top (rcScrollBar.Height() - thumbHeight) * m_nScrollPosY / (totalRows - visibleRows 1)。这里要注意总行数小于等于可见行数时滚动条滑块应该是整根填充或隐藏。我通常把滚动条绘制做成一个单独函数包括上下两个箭头、凹陷的滚动槽、滑块本体。滑块颜色可以用两年渐变色模拟比如从浅灰到中灰边缘画一圈阴影特别像新版列表。绘制代码示意void CMyListCtrl::DrawVScrollBar(CDC* pDC, CRect rcBar) { // 画槽 pDC-FillSolidRect(rcBar, m_clrScrollBarBk); // 上下箭头 DrawScrollArrow(pDC, rcBar.top, true); DrawScrollArrow(pDC, rcBar.bottom - SCROLL_ARROW_SIZE, false); // 滑块区域 CRect rcThumb; CalcVThumbRect(rcBar, rcThumb); pDC-FillSolidRect(rcThumb, m_clrThumb); // 用高光和阴影让滑块立体 }自绘滚动条的最大好处是可以完全控制滑块尺寸。为了让用户看出“可滚动”滑块最短不要低于20像素否则太小没法拖。整个滚动条区域的宽度我建议不要小于12像素太窄了误操作率高太宽了占客户区。这个尺寸在DPI变化时还要按比例缩放后面单独讲。4. 滚动交互滚轮、拖条、键盘一个都不能少绘制只是控件的一半另外一半是交互。自绘滚动条的好处是我们自己控制所有消息坏处也是所有消息都要自己处理。4.1 鼠标滚轮处理WM_MOUSEWHEEL的消息处理是所有滚动控件的核心。标准实现是用zDelta累积然后移动m_nScrollPosY。最简单的方式是每滚一格鼠标滚动3行滚轮消息带GET_WHEEL_DELTA_WPARAM(wParam)默认值是120表示一格。你可以这样void CMyListCtrl::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { int nStep (zDelta 0) ? -3 : 3; // 正方向向上滚行数减少 ScrollBy(nStep); CWnd::OnMouseWheel(nFlags, zDelta, pt); }ScrollBy函数要处理边界m_nScrollPosY小于0时取0大于maxScrollPos时取maxScrollPosmaxScrollPos totalRows - visibleRows 1但如果是自绘控件用逻辑行数做偏移量maxScrollPos (int)m_vecRows.size() - 1。这里有个细节假如数据行数正好倍数关系最后一段显示不满一行滚动到底部时应该允许最后一行顶到顶部或底部为了避免底部留大片空白我一般用“行偏移”而不是“像素偏移”这样始终保证整行移动。如果需要像素级平滑滚动就要用双缓冲并且每次移动像素但计算复杂度高一些对老代码来说行偏移已经够用了。滚轮方向要注意用户往上滚内容向下移动相当于行号减少很多入门者容易把反向搞反。4.2 拖动滚动条滑块滑块拖动是整个控件中比较难写对的一部分。核心思路是在WM_LBUTTONDOWN中判断点击位置是否命中滑块如果命中则进入“拖拽模式”记录两个值m_bDragging TRUE以及鼠标按下时的屏幕点与滑块顶部的偏移量m_nDragOffset。然后在WM_MOUSEMOVE里把屏幕点转换为客户区坐标计算新的滑块顶部位置再反向计算目标m_nScrollPosYint nThumbY pt.y - m_nDragOffset; int nAvailHeight rcBar.Height() - nThumbHeight; m_nScrollPosY (nThumbY - rcBar.top) * maxScrollPos / nAvailHeight;这里注意计算时要用整数除法并避免nAvailHeight为0。如果滚动条槽被箭头占掉区域的话还要先把箭头区域高度去掉。拖拽过程中要不间断刷新滚动条和列表内容但不要每一像素都Invalidate整个窗口可以只Invalidate列表数据区和滚动条区域的并集性能会好很多。WM_LBUTTONUP时释放捕捉并结束拖拽。设置SetCapture/ReleaseCapture很重要因为在拖拽过程中鼠标可能移动到控件外面如果没捕捉鼠标WM_MOUSEMOVE就收不到滑块会“卡死”。4.3 点击滚动条槽和箭头点击滚动条的上下区域滑块之外通常实现为“翻一页”。用户点击滑块上方的槽滚动条向上翻一屏点击下方向下翻一屏。或者翻几行看产品需求。我个人习惯点击槽滚动“整页”点击箭头滚动“一行”。箭头区域比较小判断时给一点容错比如整个箭头矩形稍微放大2像素减少点击难度。4.4 键盘导航和Tab焦点自绘列表如果只支持鼠标在键盘用户面前就不好用了。要支持方向键上下移动选中行PageUp/PageDown翻页Home/End跳到首尾。还要在获得焦点时绘制一个焦点矩形一般用DrawFocusRect不然用户看不出当前焦点在哪里。让控件支持WS_TABSTOP并且在WM_SETFOCUS/WM_KILLFOCUS中Invalidate重绘焦点状态。键盘操作的核心函数可以这样写void CMyListCtrl::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { switch (nChar) { case VK_DOWN: SetCurSel(m_nCurSel 1); break; case VK_UP: SetCurSel(m_nCurSel - 1); break; case VK_NEXT: SetCurSel(m_nCurSel m_nVisibleRows); break; case VK_RETURN: GetParent()-SendMessage(WM_COMMAND, MAKEWPARAM(GetDlgCtrlID(), LBN_DBLCLK), (LPARAM)m_hWnd); break; // ... } CWnd::OnKeyDown(nChar, nRepCnt, nFlags); }SetCurSel要自动把选中的行滚动到可见区域方法是if newSel m_nScrollPosY令m_nScrollPosY newSel | if newSel m_nScrollPosY visibleRows令m_nScrollPosY newSel - visibleRows 1。另外一个实用细节列表控件通常会发出通知给父窗口比如点击、双击、选中变化。我一般在鼠标或键盘操作后向父窗口发送WM_NOTIFY消息用NM_LISTVIEW结构体包含当前选中行和列这样外部就像使用CListCtrl一样接LVN_ITEMCHANGED通知了。这是自绘控件能否融入现有MFC对话框工程的关键点千万别漏了。5. 进阶细节悬停、双击、列宽拖拽和DPI适配5.1 悬停高亮与光标反馈鼠标移动时不断计算当前位于哪一行如果和上次的m_nHoverRow不同就更新并Invalidate数据区域。为了避免移动1像素就重绘整表可以把新旧行的矩形区域记录下来只Invalidate这两行。没什么复杂度但体验提升明显。鼠标移动到滚动条滑块上方时光标应该变成标准的箭头但如果是数据区上方一般是IDC_ARROW。如果需要点击列排序列头区域的排序箭头光标可以用IDC_HAND。这些细节都会影响控件的“专业感”。5.2 双击单元格处理如果业务需求是双击某行打开编辑器或弹窗在处理WM_LBUTTONDBLCLK时先判断是否在数据区然后计算出行索引和列索引。热区域判断要复用命中测试函数。命中测试我会写一个统一的函数int CMyListCtrl::HitTest(CPoint pt) const { // 返回 -1 表示不在数据区大于等于0则为行索引 }有了命中测试双击、右键菜单、单击选中都能共用避免三个地方各写一遍坐标换算。5.3 列宽拖拽与列存储列宽信息建议持久化比如保存到注册表或配置文件中下次启动恢复。很多老程序没有这个功能用户每次都要重新调列宽特别烦。在OnNcDestroy里遍历列宽并保存启动时读取也就是几十行代码的事。拖拽列宽时要把分隔线的光标指示器做得明显一点最好在列头边缘画一条高亮的垂直重线。按下鼠标时要判断是否在列边缘而不是列内部否则和单击列头排序冲突。我习惯用4像素的判断区鼠标x坐标与列左边距或右边距小于4视为边缘。5.4 DPI缩放适配MFC在DPI缩放方面比较传统如果你的控件是硬编码像素值在高DPI下会显得又小又模糊。建议在控件创建后从资源线程拿到当前的DPI缩放系数GetDeviceCaps(pDC, LOGPIXELSX)/96.0f把行高、列头高度、滚动条宽度、字体大小都乘以这个系数。字体不要用DEFAULT_GUI_FONT最好用CreateFontIndirect创建一个等比缩放的字体。还有就是窗口创建时可能被系统整体缩放WM_DPICHANGED要捕获并重新计算。不过如果是老MFC工程非Per-Monitor V2 DPI大部分时候只需要在首次创建时缩放一次即可不能过度处理。我建议至少保证1920*1080的100%缩放和4K 150%缩放都能正常显示这基本够用了。6. 常见问题与排查技巧实录操作中总会遇到一些“看起来正常却跑偏”的情况这里列几个我踩过的坑。6.1 控件背景疯狂闪烁闪烁的根源是每次OnPaint先FillSolidRect背景又填充行背景两步之间窗口被系统看到中间态。解决办法只有一个全量双缓冲。使用内存DC绘制所有元素后再BitBlt。如果还是闪检查是不是OnEraseBkgnd里返回了TRUE而没有填背景或者填背景后又调用了CListCtrl基类。自绘控件直接让OnEraseBkgnd返回TRUE即可什么都不干背景全部由OnPaint覆盖。6.2 滚动条滑块位置算错滑块跳来跳去多半是计算公式里把可见行数除错特别是可见行数大于总行数时maxScrollPos算出负数分母变成0。记得在Calc函数开头加保护int nTotal (int)m_vecRows.size(); int nVisible rcData.Height() / m_nRowHeight; if (nTotal nVisible) { m_nScrollPosY 0; return; }然后maxScrollPos nTotal - nVisible。所有用到maxScrollPos的地方都要以它为基准不要单独再算。6.3 鼠标滚轮滚动后滚动条没变化检查是否在OnMouseWheel里调用了CWnd::OnMouseWheel这会把消息继续传给父窗口如果父窗口也有滚动逻辑可能导致双向滚动。自绘控件处理完滚轮后直接接受消息不要调用基类。另外如果控件没有WS_VSCROLL样式有些MFC默认滚动逻辑可能失效但我们是自绘滚动条完全独立。6.4 双击列表只能双击行的一部分有效这通常是命中测试只计算了客户区数据行没有考虑当数据行不满时底部空白区域或者列头高度没减掉。检查HitTest里是否用rcData区域判断而不是整个rcClient。另外WM_LBUTTONDBLCLK时第一次单击会被WM_LBUTTONDOWN吃掉从而多触发一次选中这个行为可以接受但如果要去重可以在OnLButtonDown里判断IsDoubleClick标志不过意义不大。写自绘控件的一点个人体会这类控件写起来确实是持久仗。第一次实现我花了两天左右后来不断打磨又花了一周。核心价值不在代码行数而在于把WM_PAINT、坐标换算、滚动逻辑、命中测试这些基础功练扎实了。现在项目里遇到任何复杂的列表展示我第一反应都不是去CListCtrl上贴NM_CUSTOMDRAW而是直接评估是不是该用这篇文章的套路自绘一个。毕竟CListCtrl是微软几十年前给你的默认选项但业务只有你自己清楚。实际经验里还有个要点不要把自绘控件里的数据操作逻辑和绘制逻辑耦合得太紧比如尽量不要直接操作m_vecRows里面的指针而是提供一批类似SetItemText、GetItemData的公共接口。这样将来控件还可以复用到别的对话框甚至抽成独立的MFC控件工程。滚动条处理留一个开关m_bShowScrollBar TRUE/FALSE在不需要滚动条的场合直接关闭业务代码的改动量会非常小。如果你打算在正式项目里使用这种控件建议从小范围场景开始试水比如先替换一个只有几十行的只读列表把绘制、滚轮、选中跑通再慢慢扩展到虚拟大数据、编辑单元格、拖拽列宽等复杂功能。踩过几次坑之后再回头看你会发现自绘控件的思路其实非常统一无非是“数据—坐标—绘制—消息”四个环节每个环节都拆分清楚一切都在掌控之中。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询