MFC CListCtrl重绘实战:滚动条、表头与列表项自绘指南
简介面向MFC界面开发者的CListCtrl重绘资源包聚焦滚动条、表头和列表项三类元素的个性化定制适合需要提升列表控件视觉效果、摆脱系统默认样式的Win32/MFC项目。资源共42个文件其中14个位图用于界面皮肤9个头文件与7个cpp文件构成核心实现类还包含工程文件、资源脚本、说明文本等整体压缩后仅87KB轻量易用。已有325人学习浏览适合中高级MFC开发者参考。资源内含SkinList_demo示例工程以及SkinListCtrl、SkinHeaderCtrl、SkinHorizontalScrollbar等完整类实现通过继承CScrollBar/CHeaderCtrl并重绘OnPaint配合WM_HSCROLL/WM_VSCROLL消息处理完成滚动条外观与交互定制列表项部分采用OwnerDraw方式可控制每项的颜色、图标和文字效果。随附说明文档详细解释实现思路与集成方法可直接嵌入既有项目快速打造个性化列表界面。 做MFC界面开发这么多年重绘CListCtrl算是我每次都躲不开的第一道坎。系统默认的列表控件放在那些高度定制化的客户端里简直就像在西装里面穿了条牛仔裤——违和感拉满。更别提那个“选中行后再点到别的控件背景色就变成灰扑扑”的经典毛病不少刚接触自绘的朋友第一个问题就是怎么让选了却失焦的行保持蓝色这篇文章就从实际工程出发把重绘CListCtrl的滚动条、表头HeaderCtrl和列表项Items这件事拆开揉碎。不光给出能直接抄的代码还会说清楚每个选择背后的考虑以及在MFC这种“半老不新”的框架里做自绘时最容易撞上的几个坑。适合正在改造列表外观、准备做自己的定制控件、或者被“失焦变灰”折腾过的朋友看。1. 为什么默认CListCtrl外观让人想重绘1.1 默认风格的几个痛点系统默认的CListCtrl在WinXP、Win7、Win10和Win11上呈现完全不同的样式。问题是你的产品界面一旦确定了视觉风格就不可能跟着操作系统走。默认控件在报告模式下表头是一层塑料感的渐变渐变滚动条是最朴素的系统矩形选中行则是“深蓝色失焦变浅灰”的状态。对于追求统一视觉的客户端程序来说这三种元素无法与我们自己设计的自定义控件共存。还有一个很现实的问题默认CListCtrl的表头不支持设置高度不支持设置字体颜色滚动条的宽度完全由系统决定选中行的背景颜色也不是一个简单属性可以直接改的。更麻烦的是在“聚焦”和“失焦”之间系统会强制切换颜色这导致很多人在选中后一点其它按钮列表里蓝条就变成了灰条视觉上像是被取消了选中状态用户会产生误判。1.2 重绘方案的选型对比遇到这类问题很多人第一时间想到的是用“双缓冲”、“绘制到内存DC”之类的技巧但CListCtrl的自绘核心根本不在这里。真正的核心问题有两个滚动条属于非客户区需要子类化处理WM_NCPAINT或者直接使用自绘滚动条库表头和列表项属于客户区可以用NM_CUSTOMDRAW通知来接管。方案上主要有两条路一是直接子类化CListCtrl在内部处理所有绘制逻辑这个做法可控性最高但代码量也最大二是使用MFC的反射机制和Virtual CListCtrl扩展把细节隐藏在基类里。我们这里以“子类化CListCtrl 处理NM_CUSTOMDRAW”为主这也是目前社区里最通用、最灵活的路线。2. 滚动条重绘先解决最显眼的“违和感”2.1 滚动条重绘的两种主流路线很多人以为在CListCtrl里重绘滚动条只需要处理WM_NCPAINT然后画一个矩形就行。实际上CListCtrl呈现出来的滚动条是一个非常复杂的组合体包含垂直和水平滚动条、上下箭头按钮、滑块、页面边框而且它们都在同一个非客户区。直接画一次并不足够覆盖所有交互状态按下、禁用、悬停都会触发系统擦除重绘。比较常见的两种做法中一种是“子类化滚动条并取代默认滚动条”通过SIF_ALL或者是发送WM_NCCALCSIZE控制滚动条的显示尺寸然后完全用自己创建的CScrollBar子类覆盖上去。另一种是“修改系统滚动条肤色”在Vista以后系统本身支持CSS的滚动条自定义但MFC中我们没法直接用CSS所以只能处理WM_NCPAINT自己画。实际工程中我更推荐使用后者“重绘非客户区”的方式因为代码稳定性更高不会因为窗口创建顺序或布局变化而错位。具体方法是在子类化CListCtrl中拦截WM_NCPAINT然后用GetWindowDC获取窗口DC用ExcludeClipRect排除客户区后直接绘制滚动条轨道、滑块和箭头。2.2 使用自绘滚动条类的实现要点下面这段代码示意了在CListCtrl子类中通过处理WM_NCPAINT来完成自定义滚动条绘制的基本思路void CSkinListCtrl::OnNcPaint() { CWnd::OnNcPaint(); // 先让系统绘制非客户区包括边框 // 获取窗口DC后我们需要手动绘制滚动条区域 CWindowDC dc(this); CRect rcWnd; GetWindowRect(rcWnd); // 计算垂直滚动条区域窗口右侧一个固定宽度 CRect rcVScroll rcWnd; rcVScroll.left rcVScroll.right - m_nScrollWidth; // 因为坐标系是屏幕需要转换回来 ScreenToClient(rcVScroll); // 这里用自定义颜色绘制轨道 dc.FillSolidRect(rcVScroll, RGB(240,240,240)); // 滑块位置需要根据当前滚动条位置来计算 SCROLLINFO si { sizeof(SCROLLINFO) }; GetScrollInfo(SB_VERT, si); int nThumbHeight /* 根据页大小和范围计算滑块高度 */; CRect rcThumb /* 根据si.nPos计算滑块矩形 */; dc.FillSolidRect(rcThumb, RGB(180,180,180)); }上述代码只是一个框架真实应用中还需要处理几个重要细节。第一你必须把滚动条的“系统绘制”彻底禁掉否则系统会在WM_NCPAINT之前或之后重画一遍我们的自定义内容造成闪烁。通常的做法是覆写WM_NCCALCSIZE并返回特定值或者在WM_NCPAINT函数里先将整个非客户区绘制为背景色再绘制滚动条。第二滑块高度计算公式建议为int nPage si.nPage; int nMax si.nMax; // 注意nMax包含nPage int nRange nMax - nPage; nThumbHeight (nPage * rcTrackHeight) / (nMax 1); nThumbPos (si.nPos * (rcTrackHeight - nThumbHeight)) / (nRange 0 ? nRange : 1);不然滑块会变得不合比例拖动时会有严重的错位感。2.3 为何不推荐直接用“经典滚动条样式”有些朋友偷懒直接给窗口加上一句SetWindowLongPtr(this-m_hWnd, GWL_STYLE, GetWindowLongPtr(this-m_hWnd, GWL_STYLE) | WS_BORDER | WS_VSCROLL);或者简单地通过ModifyStyle带上“WS_VSCROLL | WS_HSCROLL”但这样只能保证滚动条出现无法控制它的粗细、颜色和交互反馈。如果只想要“细滚动条”甚至在Win11上系统会自动加上圆角悬浮样式那就不能和在Win7上保持一致。这也是我坚持做WM_NCPAINT自绘的根本原因——不同操作系统下界面像素级一致是客户端软件的底线。3. 表头重绘从响应通知到自定义绘制3.1 拦截HDN通知还是NM_CUSTOMDRAWCListCtrl在报告模式下自带一个Header Control可以通过GetHeaderCtrl()拿到子窗口句柄。要重绘表头有两种主流姿势一种是在父窗口CListCtrl所在对话框中处理HDN_ITEMDRAW通知另一种是得到CHeaderCtrl的CWnd指针后子类化它在子类里处理WM_PAINT或者NM_CUSTOMDRAW。比较两路HDN_ITEMDRAW的优势是能拿到每个列的Item信息而且不用自己维护窗口消息但项目多时重绘控制稍微麻烦一些。NM_CUSTOMDRAW则更直接它允许我们在表头绘制过程中针对每个item返回CDRF_NEWFONT或CDRF_SKIPDEFAULT实现完全自绘代码结构也更紧凑。我推荐的做法是子类化CHeaderCtrl然后在NM_CUSTOMDRAW处理函数中自绘表头背景、文字和边框。这样做的好处是在CListCtrl子类内部处理所有绘制外部调用方不需要关心表头内部逻辑且方便内存DC提前绘制防止刷新时闪烁。3.2 实现自绘表头绘制背景与热跟踪效果下面是一个典型的重绘表头代码段void CSkinHeaderCtrl::OnNMCustomDraw(NMHDR *pNMHDR, LRESULT *pResult) { NMCUSTOMDRAW* pNMCustomDraw (NMCUSTOMDRAW*)pNMHDR; *pResult CDRF_DODEFAULT; if (pNMCustomDraw-dwDrawStage CDDS_PREPAINT) { *pResult CDRF_NOTIFYITEMDRAW; return; } if (pNMCustomDraw-dwDrawStage CDDS_ITEMPREPAINT) { // 获取当前所绘制的表头柱索引 int nIndex (int)pNMCustomDraw-dwItemSpec; CRect rcItem pNMCustomDraw-rc; CDC* pDC CDC::FromHandle(pNMCustomDraw-hdc); if (!pDC) return; // 背景 pDC-FillSolidRect(rcItem, RGB(250,250,250)); // 模拟分割线 pDC-Draw3dRect(rcItem, RGB(200,200,200), RGB(120,120,120)); // 绘制文字 TCHAR szText[256] {0}; HDITEM hd {0}; hd.mask HDI_TEXT | HDI_FORMAT; hd.pszText szText; hd.cchTextMax _countof(szText) - 1; GetItem(nIndex, hd); CRect rcText rcItem; rcText.left 6; pDC-SetTextColor(RGB(80,80,80)); pDC-SetBkMode(TRANSPARENT); pDC-DrawText(szText, rcText, DT_LEFT | DT_VCENTER | DT_SINGLELINE); // 如果是当前按下或悬停状态绘制一个高亮背景 if (nIndex m_nHoverIndex) { pDC-FillSolidRect(rcItem, RGB(230,245,255)); } *pResult CDRF_SKIPDEFAULT; // 让系统不再绘制默认内容 } }在实现过程中我踩过一个坑Header控件的文字默认会有一个系统设置的偏移如果直接这么绘制文字会跟排序箭头以及列间距冲突。需要额外检查HDI_FORMAT中是否有HDF_SORTUP或HDF_SORTDOWN标志并在绘制文字之前把箭头空间单独预留出来。否则重绘之后所有排序列的箭头不是莫名其妙消失就是文字被箭头遮挡。3.3 如何让表头高度可自定义表头高度的自定义也是重绘的诉求之一。CListCtrl默认表头高度由系统根据字体决定并不提供公共的属性接口。重绘表头后可以通过发送HDM_SETITEM消息来实际改变表头高度或者在子类化后的CHeaderCtrl中重写Layout函数。常用做法是重写CHeaderCtrl的OnLayoutvoid CSkinHeaderCtrl::OnLayout(HDHDR *phdhdr, LRESULT *pResult) { // CHeaderCtrl的重置布局时机 DefWindowProc(HDM_LAYOUT, 0, (LPARAM)phdhdr); // 修改每项的高度为你期望的像素值 if (phdhdr-rgrc[0].bottom - phdhdr-rgrc[0].top m_nHeaderHeight) { phdhdr-rgrc[0].bottom phdr-rgrc[0].top m_nHeaderHeight; } *pResult TRUE; }这样做的好处是CListCtrl整体窗口布局也会随之调整表头和子项显示区域的分界不再僵硬。4. 列表项重绘状态处理是灵魂4.1 Owner-Draw与NM_CUSTOMDRAW我怎么选CListCtrl中“Items”的重绘有两个层面一种是整行每行作为一个自绘单元另一种是每个子项每个单元格独立绘制。对于“选中行蓝色、失焦变灰”这种状态处理最好的方式是使用NM_CUSTOMDRAW通知因为它提供了更细粒度的绘制阶段控制。我建议在CListCtrl子类里面响应NM_CUSTOMDRAW。下面是核心处理逻辑框架void CSkinListCtrl::OnNMCustomdraw(NMHDR *pNMHDR, LRESULT *pResult) { NMLVCUSTOMDRAW* pLVCD reinterpret_castNMLVCUSTOMDRAW*(pNMHDR); *pResult CDRF_DODEFAULT; if (CDDS_PREPAINT pLVCD-nmcd.dwDrawStage) { *pResult CDRF_NOTIFYITEMDRAW; return; } if (CDDS_ITEMPREPAINT pLVCD-nmcd.dwDrawStage) { *pResult CDRF_NOTIFYSUBITEMDRAW; return; } if ((CDDS_SUBITEM | CDDS_ITEMPREPAINT) pLVCD-nmcd.dwDrawStage) { // 拿到当前行和列 int nRow static_castint(pLVCD-nmcd.dwItemSpec); int nCol pLVCD-iSubItem; CDC* pDC CDC::FromHandle(pLVCD-nmcd.hdc); if (!pDC) return; // 判断行状态 BOOL bSelected (GetItemState(nRow, LVIS_SELECTED) LVIS_SELECTED) ! 0; BOOL bFocused (GetFocus() this); CRect rcSub; GetSubItemRect(nRow, nCol, LVIR_BOUNDS, rcSub); // 背景色失焦时仍然用我们自己的高亮色而不是系统灰 COLORREF clrBk RGB(255,255,255); if (bSelected) { clrBk bFocused ? RGB(51,153,255) : RGB(51,153,255); // 都保持同一种蓝 } pDC-FillSolidRect(rcSub, clrBk); // 文字颜色 pDC-SetTextColor(bSelected ? RGB(255,255,255) : RGB(51,51,51)); // 绘制文本 CString strText GetItemText(nRow, nCol); pDC-SetBkMode(TRANSPARENT); pDC-DrawText(strText, rcSub CRect(4,0,0,0), DT_LEFT | DT_VCENTER | DT_SINGLELINE); *pResult CDRF_SKIPDEFAULT; } }代码中我特意在“bFocused ? …”两处都用了同一种蓝色RGB(51,153,255)这样不管列表控件有没有焦点选中行都会保持蓝色从根本上解决“失焦变灰”问题。4.2 绘制不同状态悬停、选中、禁用除了选中和失焦状态之外列表项自绘还应该关注鼠标悬停状态。用户界面里悬停反馈在光标移动到行上时应该出现浅色背景离开时恢复。这个可以通过检测鼠标位置与行矩形是否相交来实现不需要额外处理消息。我通常会在NM_CUSTOMDRAW中根据当前鼠标位置来点亮单行悬停CPoint ptCursor; GetCursorPos(ptCursor); ScreenToClient(ptCursor); for (int i 0; i GetItemCount(); i) { CRect rcItem; GetItemRect(i, rcItem, LVIR_BOUNDS); if (rcItem.PtInRect(ptCursor)) { if (m_nHoverRow ! i) { m_nHoverRow i; Invalidate(); } break; } }但这种方法需要小心防止在每次移动时刷新整个列表造成闪烁。更好的办法是保存上一行悬停状态只有悬停行变化时才局部刷新那一行和旧行的区域。4.3 取消所有选择行的实现有朋友会问“clistctrl取消所有选择行”其实这句一般在两个场景中出现一个是业务逻辑上需要“不得有任何行处于选中状态”另一个是程序初始化时清除上一次残留的选中状态。最简单的实现方式是SetItemState(-1, 0, LVIS_SELECTED);注意这里第三个参数的掩码如果只传LVIS_SELECTED就会只清除选中状态而保留其它状态。如果还想让焦点框同时消失可以再加一个SetItemState(-1, 0, LVIS_SELECTED | LVIS_FOCUSED);这种清空操作在重绘中并不会造成额外问题因为自绘时读取到的bSelected自然为FALSE。某些场景下希望在点击空白区域时清空选中行则需要在子类中处理WM_LBUTTONDOWN检查点击位置是否超出了所有行的范围后执行上面的清除代码。5. 失焦不变灰的实战解法5.1 问题根源系统默认的“失焦颜色”在CListCtrl中一旦控件失去焦点系统就会用GetSysColor(COLOR_BTNFACE)或者系统主题中的“inactive selection”颜色来替换选中行背景。很多自绘尝试无效根本原因是没有真正接管列表项绘制或者只是在自定义绘制中修改了文本颜色但仍把背景绘制交给了系统。系统会以默认矩形背景覆盖颜色导致选择状态的颜色控制失败。采用NM_CUSTOMDRAW并返回CDRF_SKIPDEFAULT之后我们就完全接管了背景和文字的绘制。这样一来系统的“失焦变灰”逻辑就被绕开了。代码中一个比较容易被忽略也容易出问题的点在于“判断是否聚焦”时要考虑到CListCtrl虽然不是一个窗口焦点但它的父窗口可能依然有焦点比如在子对话框中列表框本身不接收焦点你可能希望它始终处于选中高亮状态因此最好把判断条件放宽BOOL bListHasFocus (GetFocus() this || GetFocus()-GetParent() this);如果GetFocus()返回的是我们嵌入的编辑框或下拉框也要视为有焦点。5.2 自定义绘制中固定高亮色的方案另一种更简单粗暴的方案是不为“有无聚焦”设定两套颜色而是始终用一套颜色绘制选中行。在很多仪表盘、监控类客户端的列表里选中行在全局视觉上需要一直保持醒目。所以在我的CSkinListCtrl里直接定义了一个成员变量m_clrSelection默认情况下是蓝绿色并不会随焦点丢失而改变。void CSkinListCtrl::SetSelectionColor(COLORREF clr) { m_clrSelection clr; Invalidate(FALSE); }然后在绘制时只用这个颜色不再依赖焦点判断。这种做法虽然牺牲了“失焦后降低高亮”的系统视觉习惯但换来了视觉一致性和用户认知的稳定。我们在项目中实测用户反馈很好不再因为焦点切换而误以为“还没选中”。5.3 结合热词“clistctrl选中的后蓝色”的注意事项热搜词里还有“mfc clistctrl 选中的后蓝色”这个其实是很多用户想知道“如何让选中后显示蓝色背景”的问题。如果在不重绘的情况下只需要把CListCtrl的风格加上LVS_SHOWSELALWAYS就可以让失去焦点时依然显示选中项高亮但颜色仍然是系统指定的灰蓝。如果想要纯正的蓝色只能采用自绘。所以这两种方法的取舍要视需求而定不想自绘只希望“失焦不变灰”使用LVS_SHOWSELALWAYS样式简单但颜色偏灰不同系统效果不完全一致。希望颜色完全可控使用NM_CUSTOMDRAW自绘这一点在上面已经给出了代码路径。我自己的项目里通常两种会结合使用先加LVS_SHOWSELALWAYS保证在未被自绘覆盖的区域也不会出现“灰到看不见”的尴尬然后通过自绘把选中背景统一成自己定义的高亮蓝。6. 性能与稳定性重绘过程中踩过的坑6.1 避免在CustomDraw中做重量级操作NM_CUSTOMDRAW会在鼠标移动、滚动、焦点切换时被大量触发。如果在这个处理函数里去GetItemText拼接字符串、创建新的CFont、加载位图很容易导致列表滚动时CPU占用飙升、界面卡顿。我的经验是把常用的字体、画刷、位图预先创建为成员变量或缓存表CustomDraw中只使用它们不做任何创建或释放操作。比如// 在OnInit或构造函数中创建画刷 m_brSelection.CreateSolidBrush(RGB(51,153,255)); m_brNormal.CreateSolidBrush(RGB(255,255,255)); m_fontText.CreateFont(-12, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, ANTIALIASED_QUALITY, FIXED_PITCH, _T(Microsoft YaHei));在绘制时直接选入绘制完毕后再选回原字体避免每行重复创建。6.2 双击/拖动时重绘闪屏自绘列表项后闪屏通常来自“绘制背景绘制文字分开两步”。例如先FillSolidRect填充背景再用DrawText画文字如果列表项尺寸较大并且刷新频率高就会出现白色闪烁。解决办法是使用内存DC。把整个“行”的绘制先画到内存位图中再一次BitBlt到目标DC。对列表项来说如果仅仅绘制一行一次内存绘制开销并不大但在多列的情况下要注意一次性把当前子项及其背景绘制完整减少多次Invalidate。我习惯在绘制分组时为一整行建立一个内存DC然后针对每个子项切换绘制区域这样整行刷新时闪烁很小。6.3 与CListCtrl固有行为冲突的坑最后说说几个与系统行为冲突的坑。自绘后你会发现自己手动绘制的排序箭头和系统排序状态不同步因为CHeaderCtrl的重绘不会感知到HDN_ITEMCLICK消息。必须自己处理HDN_ITEMCLICK并更新绘制状态。其次CListCtrl自绘后默认的grid line网格线也会消失如果还需要网格线效果建议在NM_CUSTOMDRAW中自己画每行的分隔线。还有更隐蔽的一个问题自绘后选中行的背景覆盖了整行但如果你按下键盘上下键焦点框focus rect还是会被系统绘制出来。此时你应该在CDDS_PREPAINT阶段返回CDRF_NOTIFYITEMDRAW然后在绘制环节把焦点虚线框取消掉或者调用HideCaret类似的方式处理。否则会出现“自绘的高亮和系统虚线框重叠”的丑陋效果。解决方式是在绘制末尾调用pDC-DrawFocusRect(rcSub);或者干脆不调用让焦点框不再显示。6.4 实测建议滚动条和列表项联动自绘滚动条和列表项之间其实存在联动。当滚动条被拖动时列表客户区会不断重绘而WM_NCPAINT如果不会同步触发滚动条的滑块位置就会略微滞后于内容。这属于正常的Windows刷新机制但如果你在自绘滚动条时依赖GetScrollInfo(si.nPos)来定位滑块你会发现它直到拖动结束才会被更新。正确的做法是在CListCtrl子类中响应WM_VSCROLL、WM_HSCROLL、WM_MOUSEWHEEL之后主动调用SetScrollPos(SB_VERT, si.nPos); Invalidate(FALSE); SendMessage(WM_NCPAINT, 0, 0);其中SendMessage(WM_NCPAINT)会强制非客户区重绘让滚动条滑块与列表内容同步这也是我在自绘过程中最常被人忽略的一个细节。写在最后一点个人经验重绘CListCtrl并不是一项可以依赖“抄一段代码就完事”的工作它需要你把许多细节装进脑子里滚动条的非客户区绘制、表头的热跟踪、列表项的焦点判断每一样都藏着“系统默认行为”的阴影。真正稳定高效的代码是在不断适配不同系统、多种字体和遇到各种用户操作习惯后逐步打磨出来的。如果你现在正准备改造一个列表控件我的建议是从“列表项自绘”开始先解决视觉上最明显的选中状态问题再逐步处理表头和滚动条。每完成一层就实际运行一段时间观察滚动、拖选、键盘操作是否正常。等你把这三层都接管了你得到的就不仅仅是一个“不变灰的列表”而是一个完全由你掌控的UI组件。另外关于“取消所有选择行”和“失去焦点不变灰”这两个高频问题其实绝大多数情况下都可以通过一行样式或者一个自绘分支解决并不需要把整个控件重写。看清问题本质再下手比盲目套用别人的封装类要可靠得多。这些经验希望能帮你少踩几个坑。本文还有配套的精品资源点击获取