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_DBLCLKS,OnLButtonDblClk消息是收不到的。很多自绘列表需要双击编辑单元格,这步必须做。
创建控件的时候,样式要设置成WS_CHILD | WS_VISIBLE | WS_TABSTOP,滚动条不要用系统默认的WS_VSCROLL和WS_HSCROLL,因为我们后面要自绘滚动条,如果客户端带上了系统滚动条,客户区尺寸会被占掉一块,自绘滚动条反而会和系统滚动条叠加。我这里的做法是完全不要系统滚动条,在客户区右侧留一条固定宽度的滚动条区域,比如14像素,完全自己画。
2.2 数据模型与视图分离
自绘列表的优势是你自己决定行数据的存储方式。我们需要两个核心结构:
列结构,包含每列的宽度、对齐方式、标题文本、列标识ID。行数据,建议用一个结构体包一层,内部可以挂着真实数据指针,也可以存一组字符串,具体看业务。
类内部通常维护这几样:
std::vector<COLUMN_INFO> m_vecColumnsstd::vector<ROW_DATA*> m_vecRows,或者干脆std::vector<std::vector<CString>> 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时取maxScrollPos,maxScrollPos = totalRows - visibleRows + 1,但如果是自绘控件用逻辑行数做偏移量,maxScrollPos = (int)m_vecRows.size() - 1。这里有个细节:假如数据行数正好倍数关系,最后一段显示不满一行,滚动到底部时应该允许最后一行顶到顶部或底部,为了避免底部留大片空白,我一般用“行偏移”而不是“像素偏移”,这样始终保证整行移动。如果需要像素级平滑滚动,就要用双缓冲并且每次移动像素,但计算复杂度高一些,对老代码来说行偏移已经够用了。
滚轮方向要注意:用户往上滚,内容向下移动,相当于行号减少,很多入门者容易把反向搞反。
4.2 拖动滚动条滑块
滑块拖动是整个控件中比较难写对的一部分。核心思路是:在WM_LBUTTONDOWN中判断点击位置是否命中滑块,如果命中则进入“拖拽模式”,记录两个值:m_bDragging = TRUE,以及鼠标按下时的屏幕点与滑块顶部的偏移量m_nDragOffset。
然后在WM_MOUSEMOVE里,把屏幕点转换为客户区坐标,计算新的滑块顶部位置,再反向计算目标m_nScrollPosY:
int 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,在不需要滚动条的场合直接关闭,业务代码的改动量会非常小。
如果你打算在正式项目里使用这种控件,建议从小范围场景开始试水,比如先替换一个只有几十行的只读列表,把绘制、滚轮、选中跑通,再慢慢扩展到虚拟大数据、编辑单元格、拖拽列宽等复杂功能。踩过几次坑之后再回头看,你会发现自绘控件的思路其实非常统一:无非是“数据—坐标—绘制—消息”四个环节,每个环节都拆分清楚,一切都在掌控之中。