MFC CListCtrl自绘实战:重绘滚动条、列头与列表项

发布时间:2026/9/1 2:12:22
MFC CListCtrl自绘实战:重绘滚动条、列头与列表项 简介这是一份面向 MFC 界面开发者的 CListCtrl 深度定制资料包重点解决列表控件滚动条、表头与列表项的自绘难题适合需要提升 UI 质感的中高级 Win32 开发者参考。压缩包内含 42 个文件其中 14 个 BMP 位图为自绘样式素材9 个 H 头文件与 7 个 CPP 源文件构成了完整的 Skinnable 控件实现另有工程文件、说明文档等整体仅 87KB。资源结构清晰自绘代码与演示工程分离便于对照学习。已有 325 人学习下载。文件目录包含 SkinListCtrl、SkinHeaderCtrl、SkinHorizontalScrollbar、SkinVerticleScrollbar 等核心类并附带一个名为 SkinList_demo 的可用示例工程。通过阅读代码和运行演示开发者可以快速掌握用自定义滚动条类替换默认滚动条、用 OwnerDraw 样式重绘列表项、以及派生 CHeaderCtrl 定制表头样式的完整套路进而直接移植到自己的项目当中省去从零实现的调试时间。 MFC里CListCtrl大概是很多人又爱又恨的控件。爱的是它功能完整列头、滚动条、勾选、排序一套齐全恨的是它长得实在太“系统”——默认的灰底小滑块、古板的列头样式、选中项一失焦就变灰跟现代客户端的UI风格格格不入。这篇文章就围绕“重绘CListCtrl的scrollbar、headerctrl和items”这条主线把我实际落地过的一套自绘方案完整拆开讲清楚。适合正在做MFC界面美化的朋友尤其是那种要在老框架里做现代化改造、又不想换DUI/Qt重写的Windows客户端项目。这套方案的价值很直接不替换控件、不动消息机制只通过窗口消息和自绘通知把三个视觉部分接管过来。你改完拿到的还是标准CListCtrl增删改查逻辑完全不用动但外观可以和系统的旧样式彻底脱钩。我踩过的坑也一块记在里面能帮你少走不少弯路。1. 项目概述与重绘思路1.1 为什么要动CListCtrl的“脸面”先说一个现实问题CListCtrl的外观由三个独立部分组成而系统的默认绘制是按老式主题走的。你要做一个深色主题列表或者只是想让选中色变成品牌蓝、让滑块变扁平系统那套绘制根本不会配合你——它永远是淡灰色背景、蓝色渐变滑块、浅灰列头。很多人第一反应是换控件库但MFC项目换UI库意味着大量重写风险极高。另一个常见操作是给CListCtrl设置视觉主题比如SetWindowTheme(LExplorer)或SetVisualTheme这能改善一点外观但离“自定义”还差很远。真正可控的做法就是自绘自己接管绘制逻辑画什么颜色、什么样式的滑块和列头全由代码决定。还有一类需求是行为层面的比如项目标题里带出的“clistctrl取消所有选择行”“丢去焦点变灰”这类问题。这些问题表面上是“选中状态的颜色问题”本质上是自绘通知里对选中状态的判断方式不对。后面我会专门讲。1.2 三类重绘目标的边界划分与方案选型CListCtrl的三个自绘目标实现路径完全不同必须先分清楚重绘目标所在区域推荐实现方式Scrollbar非客户区处理 WM_NCPAINT在非客户区DC上叠加绘制HeaderCtrl客户区顶部的子控件派生 CHeaderCtrl重写 DrawItem 虚函数Items客户区主体处理 NM_CUSTOMDRAW 通知修改绘制颜色或完全自绘这里有一个容易踩坑的认知误区滚动条不在客户区普通 OnPaint 根本碰不到它必须在 WM_NCPAINT 里处理Header 是独立的子窗口不能直接靠 NM_CUSTOMDRAW 管因为列表项的通知根本不会发给它Items 则走唯一标准入口 NM_CUSTOMDRAW。搞清楚这三块的归属后面的代码才不会写错地方。方案选型上我的核心原则是能用通知解决的不派生新类必须派生的派生后也尽量复用原消息流程。这样改动能限制在最小范围内万一将来要回退也容易。2. 重绘滚动条从非客户区入手2.1 WM_NCPAINT滚动条重绘的入口滚动条在非客户区Windows对非客户区的绘制入口就是WM_NCPAINT。在CListCtrl派生类里重写OnNcPaint先调Default()让系统把窗户纸画一遍再拿CWindowDC叠加上去这是最简单也最稳妥的“叠加式自绘”路径。BEGIN_MESSAGE_MAP(CCustomListCtrl, CListCtrl) ON_WM_NCPAINT() ON_WM_ERASEBKGND() ON_NOTIFY_REFLECT(NM_CUSTOMDRAW, CCustomListCtrl::OnListCustomDraw) ON_NOTIFY_REFLECT(HDN_DRAWITEM, CCustomListCtrl::OnHeaderDraw) END_MESSAGE_MAP()注意CWindowDC的坐标系原点是窗口客户区左上角而我们要画的是滚动条区域所以窗口矩形需要做一次平移void CCustomListCtrl::OnNcPaint() { Default(); if (!(GetStyle() WS_VSCROLL)) return; CWindowDC dc(this); CRect rcWin; GetWindowRect(rcWin); rcWin.OffsetRect(-rcWin.left, -rcWin.top); int cxScroll GetSystemMetrics(SM_CXVSCROLL); CRect rcScroll rcWin; rcScroll.left rcScroll.right - cxScroll; ... }为什么这里判断WS_VSCROLL因为CListCtrl滚动条默认是自动弹出的当列表项不足以填满可视区域时WS_VSCROLL会消失。你不判断就画就会在本来没有滚动条的地方画出一个假的滚动条视觉上很怪异。2.2 滑块位置与尺寸的精确计算画滑块前必须知道滑块的位置和高度这依赖SCROLLINFO数据SCROLLINFO si { sizeof(si) }; si.fMask SIF_ALL; GetScrollInfo(SB_VERT, si); if (si.nMax 0) return; int nTrackH rcScroll.Height(); int nThumbH max(24, (int)((double)nTrackH * si.nPage / (si.nMax si.nPage))); int nRange max(1, si.nMax - (int)si.nPage 1); int nThumbY rcScroll.top (int)((double)si.nPos / nRange * (nTrackH - nThumbH)); CRect rcThumb(rcScroll.left, nThumbY, rcScroll.right, nThumbY nThumbH); dc.FillSolidRect(rcThumb, RGB(150, 150, 150));这个计算有一点值得解释nRange是最大滚动位置等于nMax - nPage 1。滑块高度按“可见页占总内容的比例”来算比如内容总高是1000像素可视页只占100像素那滑块大约就只有滑槽的1/10高。但实际比例太小会导致滑块几乎看不见所以用max(24, ...)兜底这是Windows本身的做法。滑块Y坐标则是“当前位置占总可滚动范围的比例”乘以“可用滑道高度”。注意可用滑道高度是nTrackH - nThumbH因为滑块走到最底部时是滑块的底部挨着滑槽底部不是顶部。2.3 叠加式自绘与完整自绘的选择我上面用的是“先Default再覆盖”的方案。好处是上下箭头的悬停、点击反馈还是系统在画滑块之外的状态同步也由系统维护代码量小。缺点是滑块区域不会区分悬停和按下因为滑块的背景色是统一盖掉的系统的hot状态被覆盖没了。如果你的UI要求滑块也有hover效果那就得走完整自绘不调Default()自己画背景、上下箭头、滑槽、滑块并自行检测鼠标位置和按下状态。完整自绘涉及的代码量会翻倍需要处理WM_NCMOUSEMOVE、WM_NCLBUTTONDOWN等一系列非客户区鼠标消息才能保证拖动和点击行为不被破坏。我一般建议先用叠加式跑通视觉确认业务稳定后再考虑完整自绘。这个区域还有一个容易被忽略的点DPI缩放。GetSystemMetrics(SM_CXVSCROLL)在系统缩放125%或150%时拿到的可能不是实际的物理像素宽度导致滚动条宽度和系统画出来的不一样。后面第5章专门讲。3. 重绘列头派生CHeaderCtrl接管绘制3.1 为什么HDN_DRAWITEM不是首选CListCtrl的列头是它内部创建的一个CHeaderCtrl子窗口本质上是独立控件。自绘列头有两条路拦截HDN_DRAWITEM通知或者派生CHeaderCtrl重写DrawItem。我先把话说在前面HDN_DRAWITEM这条路看起来诱人实际很别扭。这个通知虽然叫DRAWITEM但它的lParam只指向一个NMHDR结构里面只有hwndFrom、idFrom、code三个字段连HDC都不给你。你要自绘还得自己GetDC拿到整个控件窗口的DC再反过来推算哪个列需要重画处理不好就会闪烁而且列头的动态状态排序箭头、按下反馈很不好维护。标准做法是派生 CHeaderCtrl重写DrawItem虚函数。系统在HDS_OWNERDRAWFIXED样式下会调用它并把精确的DRAWITEMSTRUCT传进来里面有hDC、rcItem、itemID、itemState信息完整得多。3.2 CMyHeaderCtrl::DrawItem的完整实现先声明派生类和样式设置class CCustomHeaderCtrl : public CHeaderCtrl { public: void SetSortArrow(int nCol, BOOL bUp) { m_nSortCol nCol; m_bSortUp bUp; Invalidate(FALSE); } protected: virtual void DrawItem(LPDRAWITEMSTRUCT lpDIS); private: int m_nSortCol -1; BOOL m_bSortUp FALSE; };然后把自绘版本和列表控件绑定。我是在CCustomListCtrl::PreSubclassWindow里做的保证列表控件一完成子类化就把列头接管过来void CCustomListCtrl::PreSubclassWindow() { CListCtrl::PreSubclassWindow(); CHeaderCtrl* pHeader GetHeaderCtrl(); if (pHeader m_headerCtrl.GetSafeHwnd() NULL) { m_headerCtrl.SubclassWindow(pHeader-GetSafeHwnd()); m_headerCtrl.ModifyStyle(0, HDS_OWNERDRAWFIXED); } }下面的DrawItem就是绘制主体。背景、分隔线、文字、排序箭头都在这一步画完void CCustomHeaderCtrl::DrawItem(LPDRAWITEMSTRUCT lpDIS) { CDC* pDC CDC::FromHandle(lpDIS-hDC); CRect rcItem(lpDIS-rcItem); int iIndex lpDIS-itemID; BOOL bPressed (lpDIS-itemState ODS_SELECTED) ! 0; // 1. 背景 pDC-FillSolidRect(rcItem, bPressed ? RGB(210, 210, 210) : RGB(240, 240, 240)); // 2. 右侧和下侧分隔线 CPen pen(PS_SOLID, 1, RGB(220, 220, 220)); CPen* pOldPen pDC-SelectObject(pen); pDC-MoveTo(rcItem.right - 1, rcItem.top); pDC-LineTo(rcItem.right - 1, rcItem.bottom); pDC-MoveTo(rcItem.left, rcItem.bottom - 1); pDC-LineTo(rcItem.right - 1, rcItem.bottom - 1); pDC-SelectObject(pOldPen); // 3. 列标题文本 HDITEM hd { 0 }; hd.mask HDI_TEXT | HDI_FORMAT; TCHAR szText[128] { 0 }; hd.pszText szText; hd.cchTextMax 128; GetItem(iIndex, hd); CRect rcText rcItem; rcText.DeflateRect(8, 0, 8, 0); pDC-SetBkMode(TRANSPARENT); pDC-SetTextColor(RGB(60, 60, 60)); pDC-DrawText(szText, rcText, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); // 4. 排序箭头 if (iIndex m_nSortCol) { CPoint ptCenter(rcItem.CenterPoint().x, rcItem.CenterPoint().y); if (m_bSortUp) { CPoint pts[3] { CPoint(ptCenter.x - 5, ptCenter.y 3), CPoint(ptCenter.x 5, ptCenter.y 3), CPoint(ptCenter.x, ptCenter.y - 5) }; CBrush brush(RGB(80, 80, 80)); CBrush* pOldBr pDC-SelectObject(brush); pDC-Polygon(pts, 3); pDC-SelectObject(pOldBr); } else { CPoint pts[3] { CPoint(ptCenter.x - 5, ptCenter.y - 3), CPoint(ptCenter.x 5, ptCenter.y - 3), CPoint(ptCenter.x, ptCenter.y 5) }; CBrush brush(RGB(80, 80, 80)); CBrush* pOldBr pDC-SelectObject(brush); pDC-Polygon(pts, 3); pDC-SelectObject(pOldBr); } } }排序箭头这里用的是三个顶点画等边三角形短小精悍。列头按下状态那行我直接用了ODS_SELECTED这是系统在鼠标按下列头时会设置的标志这样画出来的效果和用户预期一致。3.3 排序箭头与按下状态的绘制有一个细节值得单独说DrawItem 里的itemState只在按下瞬间有效鼠标松开后这个状态就没有了。而排序箭头的显示则需要你在排序逻辑里显式调SetSortArrow并重画。我习惯在HDN_ITEMCLICK和LVN_COLUMNCLICK的处理函数里同时更新箭头位置。如果你接的是CListCtrl的LVM_SORTITEMS流程记得箭头要画在正在排序列的标题上这点很多初写自绘列头的人会漏掉。漏掉的后果是列表数据按某列排了序但列头上没有任何视觉提示用户根本不知道当前按哪列排的。4. 重绘列表项NM_CUSTOMDRAW与选中态控制4.1 从PREPAINT到SUBITEM的绘制链路列表项的自绘入口是NM_CUSTOMDRAW这是ListView在绘制每个阶段都会发送的通知。绘制的调用链是先发CDDS_PREPAINT你在里面返回CDRF_NOTIFYITEMDRAW表示“每一行绘制前我都想知道”然后每一行进入时发CDDS_ITEMPREPAINT你返回CDRF_NOTIFYSUBITEMDRAW表示“这一行的每个子项绘制前我也想知道”最后每个单元格进入前发CDDS_ITEMPREPAINT | CDDS_SUBITEM。理解这个链条特别重要因为很多“选中项颜色不对”的问题就出在阶段判断错了。比如你在CDDS_ITEMPREPAINT阶段设置了clrTextBk这个颜色会作用于整行如果只在CDDS_ITEMPREPAINT | CDDS_SUBITEM阶段设置就只影响对应单元格。Report视图下我推荐在CDDS_ITEMPREPAINT阶段设置整行背景色再在子项阶段按列调整文字颜色这样选中行是一条完整的高亮带不会出现只有第一列有颜色的碎片感。void CCustomListCtrl::OnListCustomDraw(NMHDR* pNMHDR, LRESULT* pResult) { NMLVCUSTOMDRAW* pLVCD reinterpret_castNMLVCUSTOMDRAW*(pNMHDR); switch (pLVCD-nmcd.dwDrawStage) { case CDDS_PREPAINT: *pResult CDRF_NOTIFYITEMDRAW; return; case CDDS_ITEMPREPAINT: { int iItem static_castint(pLVCD-nmcd.dwItemSpec); if (GetItemState(iItem, LVIS_SELECTED) LVIS_SELECTED) { pLVCD-clrTextBk m_clrSelectedBk; pLVCD-clrText m_clrSelectedText; } *pResult CDRF_NOTIFYSUBITEMDRAW; return; } case CDDS_ITEMPREPAINT | CDDS_SUBITEM: { int iItem static_castint(pLVCD-nmcd.dwItemSpec); int iSub pLVCD-iSubItem; if (GetItemState(iItem, LVIS_SELECTED) LVIS_SELECTED) { pLVCD-clrTextBk m_clrSelectedBk; pLVCD-clrText m_clrSelectedText; } *pResult CDRF_DODEFAULT; return; } default: *pResult CDRF_DODEFAULT; return; } }4.2 失焦变灰的根源与解决现在说项目标题里那个最经典的问题“丢去焦点变灰”。你把焦点切到别的窗口或别的控件后CListCtrl的选中行会变成浅灰色这几乎是每个人都会撞上的第一道坎。根源不在于系统在失焦后做了什么额外操作而在于NM_CUSTOMDRAW在失焦后发送时nmcd.uItemState里不再包含CDIS_SELECTED标志。如果你按uItemState CDIS_SELECTED来判断是否高亮那失焦后自然就“变灰”了因为系统每一帧都在告诉你“这行没选中”。解决方式非常直接判断选中状态别用uItemState自己去查控件的真实状态也就是我上面代码里的GetItemState(iItem, LVIS_SELECTED)。这是控件自己维护的选中集合和焦点无关失焦后依然返回正在选中的项。改成这个判断后选中行颜色在任何焦点状态下都保持一致视觉上就完全没有“变灰”的过程了。这个技巧也解释了为什么很多人贴了各种SetItemState、OnKillFocus的代码去“修复”变灰问题都修不好——问题根本不在焦点消息而是判断来源错了。4.3 取消选择行的快捷做法再看热词里的“clistctrl取消所有选择行”。需求场景很常见列表失去焦点或被刷新时要清掉所有选中项、移除那条高亮蓝。这里可以直接用系统宏一行搞定void CCustomListCtrl::SelectNone() { ListView_SetItemState(m_hWnd, -1, 0, LVIS_SELECTED | LVIS_FOCUSED); }ListView_SetItemState的第一个参数传-1表示“所有项”第三个参数传0表示清除的状态掩码第四个参数指定要清除哪些状态位。这里把LVIS_SELECTED和LVIS_FOCUSED一起清掉既能去掉选中高亮也能去掉焦点虚线框。如果你不想用宏循环SetItemState(i, 0, LVIS_SELECTED)也可以但行为上有个细微差别循环写法会逐项发送通知、逐项触发重绘数据量大时效率低宏传-1给系统后系统会批量处理性能好很多。列表行数超过几千时建议用宏而不是循环。5. 常见问题与闪屏排查实录5.1 自绘后疯狂闪烁怎么办自绘最常见的反馈就是“我画完以后列表闪得不行”。我排查这类问题的顺序很固定。第一步先看背景擦除。WM_ERASEBKGND默认会把整个客户区擦成白色然后OnCustomDraw再逐个格子绘制一擦一画就会闪。解决方案是在 CListCtrl 派生类里重写OnEraseBkgnd直接返回TRUE取消擦除BOOL CCustomListCtrl::OnEraseBkgnd(CDC* pDC) { return TRUE; }但注意取消擦除后背景本身也要有人画。也就是说你要保证列表背景色在OnCustomDraw里被正确填充否则列表区域会变成花屏。通常我会在处理CDDS_PREPAINT时用背景色填充整个列表客户区或者配合LVS_EX_DOUBLEBUFFER扩展样式让系统自己处理。第二步才是看自己的绘制有没有重复刷屏。滚动条那边随手写FillSolidRect覆盖没问题但如果有渐变效果建议用内存DC画好再BitBlt上屏避免每次OnNcPaint都做昂贵的光栅操作。第三步确认消息频率。NM_CUSTOMDRAW是高频消息列表滚动、鼠标移动、插入删除都会触发。不要在NM_CUSTOMDRAW里做耗时操作比如获取窗口DC、创建GDI对象再释放这些都要提前在成员变量里缓存好。曾经有人直接在绘制函数里GetDC再ReleaseDC结果内存DC反复创建导致UI卡顿这是低级但真实的坑。5.2 消息顺序引起的绘制错乱另一类问题是“画出来总觉得哪里不对”排序箭头画了但列头不刷新滚动条滑块位置对不上Header列头文字跑偏。先说排序箭头。HDN_ITEMCLICK只是通知你有列被点击系统并不会自动重绘整个Header。你必须在点击事件里调SetSortArrow再显式Invalidate(FALSE)否则箭头就一直停留在上一次的位置。再说滚动条滑块位置。WM_NCPAINT的触发时机是滚动条需要重画的时候但如果你在别的代码里调SetScrollInfo修改滚动范围系统不会立刻触发WM_NCPAINT。正确的做法是每次SetScrollInfo后手动SetWindowPos(NULL, o, o, o, o, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED)把非客户区强制重绘一下。最后说一个奇怪的“列头残留”。如果DrawItem里不画背景仅画文本那么调整列宽时旧的文字会残留在列头区域。这是HDS_OWNERDRAWFIXED的典型坑因为系统认为列头是由你全权绘制的它不再负责擦除任何局部区域。所以DrawItem的第一件事永远是FillSolidRect或FillRect填满整个rcItem确保旧内容被完全覆盖。5.3 DPI缩放与系统metrics的区别这个问题比较隐蔽但在现在的高分屏环境里几乎必踩。系统缩放为125%或150%时GetSystemMetrics(SM_CXVSCROLL)返回的滚动条宽度是逻辑像素还是物理像素取决于程序是否声明DPI感知。没有声明DPI感知的MFC程序系统会对所有GDI调用做虚拟化GetSystemMetrics拿到的通常还是96DPI对应的值但实际滚动条宽度已经按缩放比例画出。这时候你画出来的滑块宽度会偏小和系统滚动条对不上看起来就是“滑块细了一号”。处理方式有两种一种是程序启动时调SetProcessDPIAware()让所有GDI都按真实DPI走这种方式简单粗暴但需要全程序都对DPI做好适配另一种是局部处理在OnNcPaint里用GetDpiForWindow(GetSafeHwnd())拿到实际DPI再换算相应的滚动条宽度UINT dpi GetDpiForWindow(GetSafeHwnd()); int cxScroll MulDiv(GetSystemMetrics(SM_CXVSCROLL), dpi, 96);这样即便系统缩放比例变了滚动条宽度也能和系统画出来的保持一致。需要说明的是这个函数在Win10 1607以上的系统才可用老系统需要自己缓存DPI值。考虑到本文还有配套的精品资源点击获取