做MFC控件美化的时候,最容易被网上老代码带偏的坑,就是自绘CListCtrl时照搬CListBox那套Owner Draw流程。最近我就在CListCtrl派生类里写了ON_WM_MEASUREITEM_REFLECT,也重写了DrawItem(LPDRAWITEMSTRUCT lpMeasureItemStruct),样式加上了LVS_OWNERDRAWFIXED,可是断点怎么打都不进,消息就是没反应。网上搜了一圈,同款问题不少,答案却五花八门:有人说要检查样式,有人说要处理反射顺序,还有人直接劝你弃用Owner Draw转投NM_CUSTOMDRAW。我把整条链路从样式到消息再到反射彻底排查了一遍,才搞清楚CListCtrl的自绘机制和CListBox根本是两码事。这篇文章就把我的排查思路、原理理解、以及最终可复现的自绘方案全部写出来,给还在这条路上折腾的人一个明确方向。
1. 问题现场:自绘CListCtrl,反射和DrawItem全没反应
1.1 最初按CListBox思路写出的代码
我最初的处理方式,完完全全是按照自绘CListBox的套路来的。先在CListCtrl的派生类头文件里声明两个重写函数:
// CMyListCtrl.h class CMyListCtrl : public CListCtrl { public: virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct); virtual void MeasureItem(LPMEASUREITEMSTRUCT lpMeasureItemStruct); };然后在源文件里挂上消息映射:
// CMyListCtrl.cpp BEGIN_MESSAGE_MAP(CMyListCtrl, CListCtrl) ON_WM_MEASUREITEM_REFLECT() ON_WM_DRAWITEM_REFLECT() END_MESSAGE_MAP() void CMyListCtrl::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { // 在这里绘制每一行 } void CMyListCtrl::MeasureItem(LPMEASUREITEMSTRUCT lpMeasureItemStruct) { // 在这里设置行高 }再在对话框初始化或者PreSubclassWindow里加上Owner Draw样式:
void CMyListCtrl::PreSubclassWindow() { CListCtrl::PreSubclassWindow(); SetWindowLong(GetSafeHwnd(), GWL_STYLE, GetWindowLong(GetSafeHwnd(), GWL_STYLE) | LVS_OWNERDRAWFIXED); }代码写完,编译没问题,运行也没报错——但列表长得和之前一模一样,DrawItem和MeasureItem里的断点从头到尾没触发过。这个现象很典型:不是语法错误,也不是调用错误,而是整个自绘机制根本就没走这条路。
1.2 三个典型症状
我总结下当时遇到的三个表现,如果你也全中,那基本可以确定是同一个原因:
- 断点不进入DrawItem和MeasureItem,无论怎么刷新、滚动、点击行,一个断点都不触发。
- 控件行为完全正常,列表能显示、能选中、能滚动,说明不是控件损坏,也不是消息被异常吃掉。
- 单独把LVS_OWNERDRAWFIXED加上后又去掉,列表外观没有任何变化,说明这个样式对CListCtrl的实际绘制路径影响极小。
这种状态最让人恼火——一切正常,但你要的扩展点就是不执行。对比一下自绘CListBox时,只要设了LBS_OWNERDRAWFIXED,DrawItem立刻被疯狂调用;而CListCtrl设了LVS_OWNERDRAWFIXED,却像什么都没发生一样。
1.3 网上常见的误导性建议
就这个问题,我见过太多不靠谱的回复。有人说LVS_OWNERDRAWFIXED必须在Create时通过dwStyle传入,运行时再改无效——我试了,依然不触发。有人说ON_WM_MEASUREITEM_REFLECT这类反射消息只能在控件的消息映射里放在特定位置——这纯粹是臆测,MFC的消息映射宏跟顺序没有这种强绑定关系。还有人说要重写WindowProc去拿原始消息——这种思路本身没错,但它道出了一个真相:指望CListCtrl像CListBox那样自动把绘制消息反射回派生类,是行不通的。
2. 三路排查:从样式、消息、反射逐层定位根因
2.1 第一路:验证控件样式是否真的生效
我第一步做的是确认LVS_OWNERDRAWFIXED到底有没有设置成功。在PreSubclassWindow里加断点,查看GetWindowLong的返回值:
DWORD dwOldStyle = GetWindowLong(GetSafeHwnd(), GWL_STYLE); DWORD dwNewStyle = dwOldStyle | LVS_OWNERDRAWFIXED; SetWindowLong(GetSafeHwnd(), GWL_STYLE, dwNewStyle); DWORD dwVerify = GetWindowLong(GetSafeHwnd(), GWL_STYLE); // dwVerify & LVS_OWNERDRAWFIXED 确实不为0实测下来,样式确实设置成功了。问题不在样式这一层。
然后我顺手检查了视图模式。CListCtrl只有在Report视图下,LVS_OWNERDRAWFIXED才被文档认为是“可能有效”的,Icon、SmallIcon、List视图下基本不参与Owner Draw这套逻辑。我的控件用的是LVS_REPORT,视图没问题。
2.2 第二路:确认WM_DRAWITEM是否发给了父窗口
样式有了,接下来要确认消息本身有没有发出。最直接的办法是在父窗口(比如CDialog派生类)里重写OnDrawItem和OnMeasureItem,看这两条消息到底有没有到达父窗口:
// 父窗口消息映射 BEGIN_MESSAGE_MAP(CMyDialog, CDialogEx) ON_WM_DRAWITEM() ON_WM_MEASUREITEM() END_MESSAGE_MAP() void CMyDialog::OnDrawItem(int nIDCtl, LPDRAWITEMSTRUCT lpDrawItemStruct) { // 断点在这里 CDialogEx::OnDrawItem(nIDCtl, lpDrawItemStruct); } void CMyDialog::OnMeasureItem(int nIDCtl, LPMEASUREITEMSTRUCT lpMeasureItemStruct) { // 断点在这里 CDialogEx::OnMeasureItem(nIDCtl, lpMeasureItemStruct); }结果很有意思:在默认视觉主题下,WM_MEASUREITEM根本没有发送到父窗口,WM_DRAWITEM同样没有任何动静。整个Owner Draw消息链路,对CListCtrl来说就像不存在一样。
为了进一步确认,我临时禁用了视觉主题,调用SetWindowTheme(GetSafeHwnd(), L"", L"")关闭主题对窗口的接管,再跑一次。这时WM_MEASUREITEM和WM_DRAWITEM开始出现了。这基本验证了一个方向:在现代公共控件库(comctl32 v6)以及视觉主题开启的情况下,CListCtrl的系统绘制是走主题引擎内部路径的,LVS_OWNERDRAWFIXED并不能保证触发WM_DRAWITEM。网上那些老教程能跑通,往往是在XP时代或者关闭主题的环境下。
2.3 第三路:理解MFC反射为什么会失效
如果你恰好关闭了主题,WM_DRAWITEM确实发到了父窗口,但派生类的ON_WM_DRAWITEM_REFLECT依然是“看运气”触发。因为MFC的反射机制是这样的:
- 控件发送WM_DRAWITEM给父窗口。
- 父窗口的CWnd::OnDrawItem收到消息后,通过lpDrawItemStruct->hwndItem找到发送消息的控件句柄。
- 如果这个句柄对应一个由MFC管理且已Subclass的CWnd对象,MFC会向该控件再发送一条OCM_DRAWITEM(即WM_DRAWITEM + OCM__BASE偏移)。
- 控件的消息映射里的ON_WM_DRAWITEM_REFLECT,真正处理的其实是OCM_DRAWITEM,而不是WM_DRAWITEM。
CListBox这套走得通,是因为它老老实实发WM_DRAWITEM给父窗口,父窗口能通过hwndItem反射回控件自身。而CListCtrl在主题模式下根本不发WM_DRAWITEM,反射自然无从谈起。至于WM_MEASUREITEM,情况更微妙。CListCtrl即使发了WM_MEASUREITEM,lParam指向的MEASUREITEMSTRUCT里的CtlID字段也不一定是控件的资源ID,父窗口的CWnd::OnMeasureItem想通过CtlID去GetDlgItem查找控件,经常找不到,反射也就静默失败了。
2.4 排查结论:别在这条路上硬刚
走完这三路,结论已经很清楚了。CListCtrl的自绘优先使用的不是Owner Draw,而是Custom Draw。NM_CUSTOMDRAW通知是它在绘制过程中主动发给父窗口的,也天然支持MFC的ON_NOTIFY_REFLECT反射。与其研究怎么让LVS_OWNERDRAWFIXED和WM_DRAWITEM在CListCtrl上“复活”,不如直接切换到它真正支持的自绘通道。
3. 原理剖析:CListCtrl的自绘和CListBox根本不是一回事
3.1 Owner Draw的本质与适用控件
Owner Draw这套机制,核心是两条消息:WM_MEASUREITEM负责告诉系统“每个条目的尺寸是多少”,WM_DRAWITEM负责“每个条目怎么画”。操作系统把这两件事交给控件所有者(通常是父窗口)去处理。
这套机制在CButton、CComboBox、CListBox、CMenu上都很好用。原因是这些控件的条目结构简单,绘制需求统一,系统可以很干脆地说“这条item我不画了,有本事你画”。而CListCtrl不行,它本身是一个复合控件,有列头、有行、有单元格,还有图标、状态、排序箭头等大量内部元素。如果在Report视图下把整行绘制权完全交给外部,系统内部的命中测试、焦点矩形、键盘导航、编辑框定位等一大堆逻辑都会受影响。所以微软在设计list view时,把详细的绘制过程做成了“分阶段回调”,也就是NM_CUSTOMDRAW。
3.2 CListCtrl真正的自绘通道:NM_CUSTOMDRAW
NM_CUSTOMDRAW的机制就像一个层层递进的流水线。控件在绘制的不同阶段,都会发一条WM_NOTIFY给父窗口,父窗口根据返回值决定下一步怎么做。大概分这几层:
- CDDS_PREPAINT:控件准备开始绘制,此时你可以决定是否要关注条目级绘制。
- CDDS_ITEMPREPAINT:某个条目(行)准备开始绘制。
- CDDS_SUBITEM | CDDS_ITEMPREPAINT:某个单元格准备开始绘制。
- 后置阶段:CDDS_ITEMPOSTPAINT、CDDS_SUBITEM | CDDS_ITEMPOSTPAINT等。
每一层的返回值控制后续流程。比如CDDS_PREPAINT返回CDRF_NOTIFYITEMDRAW,后续才会收到ITEMPREPAINT;ITEMPREPAINT返回CDRF_NOTIFYSUBITEMDRAW,后续才会收到每个单元格的绘制通知。这套机制最大的优点是不强制你画全部内容,你可以只改颜色,甚至只改某个单元格,其余交给系统默认绘制。
3.3 行高为什么也不该用MeasureItem
很多人在CListBox里用MeasureItem设置条目动态高度,到了CListCtrl发现没反应。原因在于CListCtrl在Report视图下的行高是“全局一致”的,它根本没有为每个条目单独测量高度的概念。行高的正确设置方式是LVM_SETITEMHEIGHT消息,对应的MFC封装是:
// 设置行高为28像素 m_list.SetItemHeight(0, 28);顺带一提,WPARAM在这里通常传0,LPARAM传像素值。这个设置是立即生效的,不需要LVS_OWNERDRAWFIXED,也不需要WM_MEASUREITEM。CListCtrl的局限也在这:你没法做到第1行高30、第2行高50这种ListBox才能实现的动态高度。
3.4 一张表看懂ListBox和ListCtrl的差异
| 对比点 | CListBox | CListCtrl(Report视图) |
|---|---|---|
| 自绘核心消息 | WM_MEASUREITEM / WM_DRAWITEM | NM_CUSTOMDRAW |
| 自绘相关样式 | LBS_OWNERDRAWFIXED / VARIABLE | LVS_OWNERDRAWFIXED(受主题影响大) |
| 行高设置 | MeasureItem里返回itemHeight | LVM_SETITEMHEIGHT |
| 单元格级自绘 | 不支持,DrawItem自己算位置 | CDDS_SUBITEM阶段天然支持 |
| 视觉主题兼容性 | 较好 | Owner Draw差,Custom Draw好 |
| 绘制工作量 | 必须画整项 | 可只改颜色,也可全自绘 |
| MFC反射支持 | ON_WM_DRAWITEM_REFLECT稳定 | ON_NOTIFY_REFLECT(NM_CUSTOMDRAW)稳定 |
这个表基本就是我这几天踩坑后的浓缩版。以后凡是涉及CListCtrl美化,直接跳过左上角那一列。
4. 绕坑正道:用NM_CUSTOMDRAW自绘CListCtrl的完整方案
4.1 派生类中的消息映射与基本骨架
推荐方案是在CListCtrl派生类里直接处理NM_CUSTOMDRAW,这样绘制逻辑可以完全封装在控件类内部,对话框不用介入。消息映射写法如下:
// CMyListCtrl.h class CMyListCtrl : public CListCtrl { protected: afx_msg void OnNmCustomDraw(NMHDR* pNMHDR, LRESULT* pResult); DECLARE_MESSAGE_MAP() };// CMyListCtrl.cpp BEGIN_MESSAGE_MAP(CMyListCtrl, CListCtrl) ON_NOTIFY_REFLECT(NM_CUSTOMDRAW, &CMyListCtrl::OnNmCustomDraw) END_MESSAGE_MAP() void CMyListCtrl::OnNmCustomDraw(NMHDR* pNMHDR, LRESULT* pResult) { NMLVCUSTOMDRAW* pLVCD = reinterpret_cast<NMLVCUSTOMDRAW*>(pNMHDR); DWORD dwDrawStage = pLVCD->nmcd.dwDrawStage; // 先给默认值 *pResult = CDRF_DODEFAULT; if (dwDrawStage == CDDS_PREPAINT) { // 控件即将绘制,告诉控件我需要逐条item绘制通知 *pResult = CDRF_NOTIFYITEMDRAW; return; } if (dwDrawStage == CDDS_ITEMPREPAINT) { // 某一行即将绘制,这里可以对整行做颜色或字体调整 // 如果需要进一步细化到单元格,则请求subitem通知 *pResult = CDRF_NOTIFYSUBITEMDRAW; return; } if (dwDrawStage == (CDDS_SUBITEM | CDDS_ITEMPREPAINT)) { // 某个单元格即将绘制 // 在这里做具体列的样式控制 return; } }注意消息映射用的是ON_NOTIFY_REFLECT,不是ON_NOTIFY。这个反射宏会把父窗口收到的WM_NOTIFY再转回控件自身,和之前失效的ON_WM_DRAWITEM_REFLECT完全是两条路径。实践下来,只要控件是MFC派生类且正确Subclass了,NM_CUSTOMDRAW的反射非常稳定。
4.2 实现斑马纹:按行设置背景色
最常见的需求是隔行变色。在CDDS_ITEMPREPAINT阶段,通过NMLVCUSTOMDRAW的clrText和clrTextBk字段,可以控制整行的文字和背景颜色:
if (dwDrawStage == CDDS_ITEMPREPAINT) { int nItem = static_cast<int>(pLVCD->nmcd.dwItemSpec); if (nItem % 2 == 0) { pLVCD->clrText = RGB(32, 32, 32); pLVCD->clrTextBk = RGB(244, 246, 248); } else { pLVCD->clrText = RGB(32, 32, 32); pLVCD->clrTextBk = RGB(255, 255, 255); } *pResult = CDRF_NOTIFYSUBITEMDRAW; return; }这种写法只向系统提交颜色修改,文字内容、图标、选中态仍然由系统绘制,效率和观感都很好。如果你发现修改后文字和背景的颜色没有完全生效,多半是扩展样式里没开LVS_EX_DOUBLEBUFFER或者列没有背景填充,可以给控件加上LVS_EX_DOUBLEBUFFER试试。
4.3 按单元格绘制:给特定列加状态色
有时候需要给某一列单独上色,比如状态列红色表示异常,这就要在CDDS_SUBITEM | CDDS_ITEMPREPAINT阶段处理。先获取当前单元格的行和列:
if (dwDrawStage == (CDDS_SUBITEM | CDDS_ITEMPREPAINT)) { int nItem = static_cast<int>(pLVCD->nmcd.dwItemSpec); int nSubItem = pLVCD->iSubItem; if (nSubItem == 1) // 假设第2列是状态列 { CString strStatus = GetItemText(nItem, nSubItem); if (strStatus == _T("异常")) { pLVCD->clrText = RGB(255, 255, 255); pLVCD->clrTextBk = RGB(220, 80, 70); } else if (strStatus == _T("正常")) { pLVCD->clrText = RGB(32, 32, 32); pLVCD->clrTextBk = RGB(90, 200, 120); } } return; }与整行不同,单元格阶段可以拿到iSubItem,所以控制粒度更细。返回CDRF_DODEFAULT之后,系统会按最新设置的颜色继续绘制默认文本,这样你不需要自己DrawText,也不会破坏列的图标和排序箭头。
4.4 全自绘单元格:自己控制绘制内容
颜色控制满足不了所有场景,比如想在某个单元格里画一个小进度条,或者画一个圆形状态灯。这时候就需要真正的“接管绘制”,用CDRF_SKIPDEFAULT告诉系统这个单元格你别画了,我自己来:
if (dwDrawStage == (CDDS_SUBITEM | CDDS_ITEMPREPAINT)) { int nItem = static_cast<int>(pLVCD->nmcd.dwItemSpec); int nSubItem = pLVCD->iSubItem; if (nSubItem == 2) // 某列自绘 { CDC* pDC = CDC::FromHandle(pLVCD->nmcd.hdc); CRect rcItem; GetSubItemRect(nItem, nSubItem, LVIR_BOUNDS, rcItem); // 画背景 pDC->FillSolidRect(rcItem, RGB(255, 255, 255)); // 画一个简单的进度条 CString strProgress = GetItemText(nItem, nSubItem); int nProgress = _ttoi(strProgress); CRect rcBar = rcItem; rcBar.DeflateRect(4, 6, 4, 6); pDC->FillSolidRect(rcBar, RGB(230, 230, 230)); if (nProgress > 0) { CRect rcFill = rcBar; rcFill.right = rcBar.left + (rcBar.Width() * nProgress) / 100; pDC->FillSolidRect(rcFill, RGB(0, 160, 233)); } // 画进度文字 pDC->SetBkMode(TRANSPARENT); pDC->DrawText(strProgress, rcItem, DT_CENTER | DT_VCENTER | DT_SINGLELINE); *pResult = CDRF_SKIPDEFAULT; return; } }这里有个容易踩的坑:CDRF_SKIPDEFAULT是“完全不画”,包括背景、文字、焦点框都不画,所以你必须自己处理完整。如果只画了背景和文字但忘了边框,视觉上会少东西。另外,GetSubItemRect用之前先确认控件设置了LVS_EX_FULLROWSELECT或者至少是Report视图,否则第0列的矩形会覆盖整行宽度,导致绘制错位。
4.5 配套:行高、整行选中与防闪烁
在对话框初始化时,建议统一做好这些设置:
m_list.SetExtendedStyle( LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER); // 行高28像素 ListView_SetItemHeight(m_list.GetSafeHwnd(), 0, 28); // 加列和设置列宽 m_list.InsertColumn(0, _T("名称"), LVCFMT_LEFT, 120); m_list.InsertColumn(1, _T("状态"), LVCFMT_LEFT, 80); m_list.InsertColumn(2, _T("进度"), LVCFMT_LEFT, 100);LVS_EX_DOUBLEBUFFER非常关键。CListCtrl在Custom Draw过程中频繁触发刷新,没有双缓冲的话,滚动时会出现明显闪烁。LVS_EX_FULLROWSELECT则让点击任意列都能选中整行,对自绘观感影响很大。
4.6 Custom Draw返回值速查
| 返回值 | 含义 | 使用场景 |
|---|---|---|
| CDRF_DODEFAULT | 按系统默认方式继续 | 只修改了颜色或不需要特殊处理 |
| CDRF_SKIPDEFAULT | 不再执行默认绘制 | 完全自绘单元格或整行 |
| CDRF_NOTIFYITEMDRAW | 要求后续发送条目级通知 | 在CDDS_PREPAINT返回 |
| CDRF_NOTIFYSUBITEMDRAW | 要求后续发送单元格级通知 | 在CDDS_ITEMPREPAINT返回 |
| CDRF_NOTIFYPOSTPAINT | 条目绘制完成后发送通知 | 需要画选中框、高亮线等 |
| CDRF_NEWFONT | 已改变字体,系统需要重新计算 | 在ITEMPREPAINT中SelectObject字体后返回 |
记住一个口诀:PREPAINT要ITEMPREPAINT就返回NOTIFYITEMDRAW,ITEMPREPAINT要单元格就返回NOTIFYSUBITEMDRAW,真不想让系统画就返回SKIPDEFAULT,只是改色就返回DODEFAULT。
5. 非要用DrawItem:父窗口Owner Draw的抢救与局限
5.1 在父窗口直接处理WM_DRAWITEM
如果你接手的老代码已经把绘制逻辑写在DrawItem里,暂时改不动,那也不是完全没救。一个可行的办法是放弃在CListCtrl派生类里放DrawItem,改到父窗口里处理。在对话框或视图类的消息映射加:
BEGIN_MESSAGE_MAP(CMyDialog, CDialogEx) ON_WM_DRAWITEM() END_MESSAGE_MAP() void CMyDialog::OnDrawItem(int nIDCtl, LPDRAWITEMSTRUCT lpDrawItemStruct) { if (nIDCtl == IDC_MYLIST) { // 从LPDRAWITEMSTRUCT中拿到绘制区域和item数据 CDC* pDC = CDC::FromHandle(lpDrawItemStruct->hDC); CRect rcItem(lpDrawItemStruct->rcItem); // 画背景 pDC->FillSolidRect(rcItem, RGB(255, 255, 255)); // 画文字 pDC->SetBkMode(TRANSPARENT); pDC->DrawText(_T("自定义内容"), rcItem, DT_LEFT | DT_VCENTER | DT_SINGLELINE); return; } CDialogEx::OnDrawItem(nIDCtl, lpDrawItemStruct); }但这里有个前提,前面已经强调过:WM_DRAWITEM对CListCtrl而言,在开启视觉主题的现代系统上经常不发送。所以这个方案能用,但不一定在任何环境都能触发。如果你只是改颜色、改行高,这个方案远不如Custom Draw可靠。
5.2 让反射生效的冷门条件
真要在派生类里让ON_WM_DRAWITEM_REFLECT跑起来,你需要满足相当多条件。我总结过,至少包括:
- 控件必须是MFC管理窗口,并且经过了SubclassWindow或DDX_Control绑定,不是临时包装的HWND。
- 视图模式必须是LVS_REPORT,同时设置了LVS_OWNERDRAWFIXED。
- 当前系统不能使用WinXP之后的视觉主题接管绘制路径,必要时调用SetWindowTheme关闭主题。
- 父窗口收到WM_DRAWITEM后,CWnd::OnDrawItem才能正确按hwndItem反射回控件;如果某层逻辑提前消费了消息,反射就断掉。
- 你需要在派生类中同时保留ON_WM_DRAWITEM_REFLECT,且不能和父窗口的ON_WM_DRAWITEM冲突。
这些条件凑齐的难度,比你直接迁移到NM_CUSTOMDRAW高得多。如果你不是被历史代码锁死,我强烈建议不要在反射这里浪费时间。
5.3 为什么不推荐这条路
一句话总结:CListCtrl的Owner Draw路径是历史遗留设计,在现代系统上处于“时灵时不灵”的状态。你花一晚上让它跑起来,换一台用户机器、换一个Windows版本,可能又失效了。Custom Draw则是list view官方推荐的自绘方式,从XP到Win11一路兼容。两条路投入产出比完全不对等。
6. 常见问题速查与避坑实录
6.1 问题排查速查表
| 症状 | 可能原因 | 应对方案 |
|---|---|---|
| ON_WM_MEASUREITEM_REFLECT不触发 | 系统未发WM_MEASUREITEM,或者CtlID无法定位到CListCtrl | 行高改用SetItemHeight,不依赖MeasureItem |
| DrawItem不触发 | 主题模式不发送WM_DRAWITEM,或反射链路中断 | 改用NM_CUSTOMDRAW,或在父窗口处理WM_DRAWITEM并注意环境限制 |
| NM_CUSTOMDRAW不触发 | 没用ON_NOTIFY_REFLECT,或消息被父窗口OnNotify拦截 | 确认宏是ON_NOTIFY_REFLECT,检查父窗口OnNotify是否提前返回 |
| 修改clrTextBk不生效 | 没有设置LVS_EX_DOUBLEBUFFER,或列背景被系统覆盖 | 加上LVS_EX_DOUBLEBUFFER扩展样式 |
| 自绘单元格后文字错位 | GetSubItemRect获取的矩形和实际绘制有偏移 | 区分第0列和后续列,使用LVIR_BOUNDS,并配合LVS_EX_FULLROWSELECT |
| 滚动时闪烁 | 自绘逻辑频繁触发重绘,没有双缓冲 | 启用LVS_EX_DOUBLEBUFFER,减少双缓冲DC之外的自绘面积 |
6.2 调试自绘的实用技巧
排查自绘问题,我习惯在OnNmCustomDraw入口先打一个条件断点,条件是dwDrawStage == CDDS_PREPAINT。只要能在这里停下来,说明通知链路是通的,后续阶段只是返回值的问题。如果PREPAINT都进不来,那就查控件是否是MFC子类、是否正常Subclass、消息映射是否真的挂上了。
另外可以用Spy++或者SetWindowLong配合GetWindowLong观察父窗口收到的WM_NOTIFY通知码,确认NM_CUSTOMDRAW确实被发送。很多时候“不触发”不是系统没发,而是你的派生类根本没被正确的消息映射处理,或者你同时拦了父窗口的OnNotify并且提前return了。
6.3 一个容易忽略的坑:第0列的特殊性
在Report视图下,第0列(也就是最左列)和其它列有一个明显区别:它承载着整行的选择和展开状态。当控件没有设置LVS_EX_FULLROWSELECT时,第0列的宽度会延伸到整个客户区,后面的列只是在上面“覆盖”绘制。这种情况下你在CDDS_SUBITEM阶段获取第0列的GetSubItemRect,经常得到一个宽得离谱的矩形。
解决方法是:要么设置LVS_EX_FULLROWSELECT让行选择贯穿所有列,要么在自绘时对第0列单独做矩形裁剪。我建议直接开启LVS_EX_FULLROWSELECT,不仅视觉统一,自绘逻辑也简单很多。
6.4 关于Custom Draw的字体修改
在CDDS_ITEMPREPAINT阶段,你可以选择一个新的字体,让整行文字换字体。关键是要在选中字体后返回CDRF_NEWFONT,系统才会重新度量并采用你的字体:
if (dwDrawStage == CDDS_ITEMPREPAINT) { CDC* pDC = CDC::FromHandle(pLVCD->nmcd.hdc); CFont* pOldFont = pDC->SelectObject(&m_FontBold); // 注意:SelectObject后不需要立即恢复,系统绘制完会自动处理 *pResult = CDRF_NOTIFYSUBITEMDRAW | CDRF_NEWFONT; return; }不返回CDRF_NEWFONT的常见现象是:你看代码里SelectObject了,绘制结果却还是旧字体。这个返回值专门就是告诉控件“字体变了,按新字体重新排版”。类似的,如果你在某些阶段修改了HDC的映射模式或者画刷,也要考虑返回对应的标志,否则系统默认绘制可能不认你的新设置。
6.5 和虚拟列表LVS_OWNERDATA共存的注意事项
如果你用了虚拟列表(LVS_OWNERDATA),自绘时注意一点:NM_CUSTOMDRAW照常工作,你可以给任意行和单元格加颜色,但不要试图在DrawItem里获取不存在的文本。虚拟列表模式下,系统不保存文本数据,GetItemText返回的往往是空字符串或者是你通过LVN_GETDISPINFO动态提供的内容。我在自绘状态列时就吃过这个亏,直接get文本得到空串,最后改成在LVN_GETDISPINFO里先准备一份缓存,再在Custom Draw阶段读取缓存,问题才解决。
我的体会是,自绘CListCtrl这件事,只要方向对了,半小时就能出效果;方向错了,能折腾好几天。希望这篇文章能让你少走这段弯路。最后再分享一个小技巧:调试Custom Draw时,把dwDrawStage的所有阶段都用OutputDebugString打印一遍,你会看到系统绘制ListView时其实经过了非常多的阶段回调,理解每个阶段的意图之后,画什么都能做到游刃有余。