简介一份面向MFC界面开发者的CListCtrl自绘实现代码包解决默认滚动条、表头和列表项样式生硬的问题。资源包含自定义滚动条类、自定义表头类以及基于Owner-Draw的列表项绘制逻辑涉及CScrollBar/CHeaderCtrl继承、OnPaint重绘、WM_HSCROLL/WM_VSCROLL及HDM_LAYOUT等消息处理。压缩包共42个文件以h/cpp源码为主另有14个bmp位图资源、2个txt说明以及示例工程SkinList_demo整体仅87KB。已有325人学习适合需要提升MFC列表界面美观度的初中级开发者参考。通过阅读rm.txt和示例工程可快速掌握自绘控件衔接与消息分工并直接复用或改造到自己的项目中。 做MFC界面开发的朋友应该都有过这种经历项目做到一半产品经理指着你提交的版本说“这个CListCtrl太素了表头要有质感、选中行要变成蓝色而且失焦不能变灰、滚动条也要跟整体风格统一”。一开始你可能觉得简单调几个颜色就行真正动手才发现系统默认绘制机制比想象中顽固得多。表头HeaderCtrl、列表项Items、滚动条Scrollbar这三块各有各的绘制路径HeaderCtrl和Items还好说滚动条自绘更是能把人绕晕。这篇文章把我折腾过的一整套CListCtrl重绘代码拆开讲清楚从HeaderCtrl到Items再到Scrollbar每个部分怎么接管、拦截哪些消息、处理哪些状态以及那些网上很少说透的坑一次性写明白。适合正在做MFC界面美化、被CListCtrl各种默认行为折磨、又不想引入第三方皮肤库的朋友参考。1. 重绘前的思路先搞清楚要动哪几个部分1.1 三条自绘路线怎么选CListCtrl的自绘业内常见有三条路线第一条是Custom DrawNM_CUSTOMDRAW它适合列表项和整行的定制也是大多数人第一次接触CListCtrl自绘时用的方式第二条是Owner DrawLVS_OWNERDRAWFIXED给列表项完全自绘的能力灵活但工作量大还要自己处理所有绘制细节非必要不推荐第三条是对子窗口做子类化比如把CHeaderCtrl拦截下来自己画或者拦截ToolTip、滚动条之类的系统组件。实际工程里这三条路线往往是组合使用的。就拿我这个项目来说HeaderCtrl我用的OwnerDraw路线给表头设置HDS_OWNERDRAWFIXED样式后重载DrawItem因为表头的视觉结构比较固定背景、文字、排序箭头、分隔线四样东西自己画反而比跟系统默认绘制做斗争更可控。Items用的Custom Draw消息处理链清晰能做交替行色、选中高亮、图标混合这些常见效果而且不用像Owner Draw那样自己处理键盘导航和编辑状态。Scrollbar这条路最特殊后面单独用一整章讲因为它根本不是普通意义上的“子窗口”没法直接子类化。1.2 三个目标的技术特点与坑点概览动手之前先把三个部分的特点和主要难点列个表方便你对照自己的需求判断优先级。实际项目里这三个部分不是必须同时改很多时候只改Items就能让列表颜值提升一大截。绘制目标推荐方案关键消息/接口主要难点HeaderCtrl子类化 OwnerDrawHDS_OWNERDRAWFIXED、DrawItem排序箭头状态、分隔线像素ItemsNM_CUSTOMDRAWCDDS_PREPAINT/ITEMPREPAINT失焦变灰、选中态覆盖ScrollbarWM_NCPAINT或替换控件WM_NCPAINT、GetScrollInfo非客户区交互、滑块坐标计算这个表是我整理项目笔记时画的也是我后来做同类需求时最先翻出来看的东西。你可以把滚动条那行单独圈出来它是三个部分里唯一需要同时处理绘制和交互的地方也是最容易在“画出来了但拖不动”这个诡异状态上卡住的部分。2. 表头HeaderCtrl重绘从默认样式到完全自绘2.1 子类化CHeaderCtrl并启用OwnerDraw要接手表头绘制不能直接在CListCtrl里发消息得先拿到CHeaderCtrl窗口并子类化它。最常见的写法是在CMyListCtrl的PreSubclassWindow里做手脚。我建议把CMyHeaderCtrl单独声明成一个类别把所有逻辑堆在CListCtrl里否则后期维护会很难受。// MyListCtrl.h class CMyHeaderCtrl : public CHeaderCtrl { public: virtual void PreSubclassWindow() override; protected: virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) override; }; class CMyListCtrl : public CListCtrl { public: virtual void PreSubclassWindow() override; protected: CMyHeaderCtrl m_header; }; // MyListCtrl.cpp void CMyListCtrl::PreSubclassWindow() { CListCtrl::PreSubclassWindow(); CHeaderCtrl* pHeader GetHeaderCtrl(); if (pHeader ! NULL) { m_header.SubclassWindow(pHeader-GetSafeHwnd()); } } void CMyHeaderCtrl::PreSubclassWindow() { CHeaderCtrl::PreSubclassWindow(); // 关键启用 OwnerDraw 样式否则 DrawItem 不会被调用 ModifyStyle(0, HDS_OWNERDRAWFIXED); }这里有个细节值得注意PreSubclassWindow是在窗口创建时调用的理论上此时GetHeaderCtrl已经返回了有效的CHeaderCtrl指针但实际运行中还是会遇到拿到NULL的情况。比如在对话框模板里创建的ListCtrl如果模板的visible状态或者创建顺序有微妙差异表头可能还没初始化完。所以我习惯加一层空指针判断宁可什么都不做也不能让程序崩掉。如果你用的是Create动态创建建议在OnCreate里而不是PreSubclassWindow里做子类化。启用HDS_OWNERDRAWFIXED之后DrawItem会被系统调用HDC就是表头客户区这时候就算完全进入自绘模式了。2.2 绘制排序箭头和分隔线的细节在DrawItem里必须取HDITEM注意mask要包含HDI_TEXT和HDI_FORMAT两样。HDI_FORMAT里除了列对齐方式还带有HDF_SORTUP和HDF_SORTDOWN标志这是排序箭头状态的唯一来源。很多新手不知道这一点以为箭头需要自己维护一个变量来记排序状态结果列表排序一变化箭头状态就错乱。void CMyHeaderCtrl::DrawItem(LPDRAWITEMSTRUCT lpDIS) { CDC* pDC CDC::FromHandle(lpDIS-hDC); if (!pDC) return; CRect rc(lpDIS-rcItem); CRect rcText rc; rcText.left 8; rcText.right - 16; // 右侧预留箭头空间 // 背景 pDC-FillSolidRect(rc, RGB(52, 55, 58)); // 底色分割线 CPen pen(PS_SOLID, 1, RGB(70, 72, 75)); CPen* pOldPen pDC-SelectObject(pen); pDC-MoveTo(rc.left, rc.bottom - 1); pDC-LineTo(rc.right, rc.bottom - 1); pDC-SelectObject(pOldPen); // 获取列文本与格式 TCHAR szText[128] {0}; HDITEM hdi {0}; hdi.mask HDI_TEXT | HDI_FORMAT; hdi.pszText szText; hdi.cchTextMax 127; GetItem(lpDIS-itemID, hdi); // 文字 pDC-SetBkMode(TRANSPARENT); pDC-SetTextColor(RGB(230, 230, 230)); pDC-DrawText(szText, rcText, DT_LEFT | DT_VCENTER | DT_SINGLELINE); // 排序箭头 if (hdi.fmt HDF_SORTUP) { DrawSortArrow(pDC, rc, TRUE); } else if (hdi.fmt HDF_SORTDOWN) { DrawSortArrow(pDC, rc, FALSE); } }绘制箭头时有个最容易踩的坑文字和箭头抢空间。如果列宽比较小文字会把箭头位置盖住。我的做法是rcText.right - 16给箭头留出固定的16像素空间箭头画在列右侧10像素附近。另一个坑是分隔线位置画在rcItem.right - 1还是rcItem.right效果差别很大画到right上会被下一列的背景盖住看起来像是分隔线断续。按1像素内缩才能在视觉上形成连续分隔效果。箭头三角形本身用Polygon画就行注意坐标要跟文字垂直居中对齐。这个方案在XP到Win10的实测中表现稳定但有一点必须提醒如果系统开启了视觉风格manifest里带comctl32 v6默认分隔线的颜色和宽度会跟你画的对不上视觉风格下CHeaderCtrl自身还有一层渐变背景需要你完全覆盖所有像素否则会有毛边。3. 列表项Items重绘选中态与焦点态才是重点3.1 NM_CUSTOMDRAW的完整处理链Items部分的核心是NM_CUSTOMDRAW消息。在CMyListCtrl里直接用ON_NOTIFY_REFLECT反射消息消息经ListCtrl自己处理一遍再传给父窗口这样代码可以封装在控件类内部外部调用方不用感知。BEGIN_MESSAGE_MAP(CMyListCtrl, CListCtrl) ON_NOTIFY_REFLECT(NM_CUSTOMDRAW, CMyListCtrl::OnNMCustomDraw) END_MESSAGE_MAP() void CMyListCtrl::OnNMCustomDraw(NMHDR* pNMHDR, LRESULT* pResult) { NMLVCUSTOMDRAW* pLVCD (NMLVCUSTOMDRAW*)pNMHDR; DWORD dwStage pLVCD-nmcd.dwDrawStage; *pResult CDRF_DODEFAULT; if (dwStage CDDS_PREPAINT) { *pResult CDRF_NOTIFYITEMDRAW; return; } if (dwStage CDDS_ITEMPREPAINT) { *pResult CDRF_NOTIFYSUBITEMDRAW; return; } if (dwStage CDDS_SUBITEMPREPAINT) { // 在这里按列设置文字颜色、背景色 } }这个处理链的顺序是PREPAINT整控件准备绘制→ ITEMPREPAINT每一行准备绘制→ SUBITEMPREPAINT每行每一列准备绘制。每一层返回不同的CDRF标志决定下一层是否会收到通知。如果你只做整行颜色统一走到ITEMPREPAINT就够了直接在pLVCD-clrText和pLVCD-clrTextBk里赋值返回CDRF_NEWFONT如果需要不同列不同颜色才必须继续通知到SUBITEMPREPAINT。注意一点CDDS_PREPAINT里如果返回CDRF_SKIPDEFAULT整个列表都会变成空白这是新手最容易犯的错误。3.2 失焦不变灰的两种实现思路先解释为什么CListCtrl失焦后选中项会变灰。系统默认在失去焦点时会把选中项画成灰色视觉风格下是浅灰蓝而Custom Draw的uItemState里的CDIS_SELECTED标志在失焦时会被系统清掉所以你在Custom Draw里判断CDIS_SELECTED会发现失灵选中项突然不生效了。思路一是绕开CDIS_SELECTED直接查询项状态。LVIS_SELECTED这个状态位在失焦后依然保留选中状态本身不会因为失焦而清除只是视觉上被系统强制画成灰。所以你可以直接调用GetItemState去查不看uItemState里那个不可靠的标志。但只改clrTextBk还不够系统对选中行背景有特殊处理视觉风格下是一层渐变蓝失焦时这层渐变会变成灰色盖过你设置的背景色。更彻底的做法是在ITEMPREPAINT阶段自己FillSolidRect整行背景然后返回CDRF_NEWFONT让系统继续画文字但文字颜色自己控制。if (dwStage CDDS_ITEMPREPAINT) { int iItem (int)pLVCD-nmcd.dwItemSpec; CDC* pDC CDC::FromHandle(pLVCD-nmcd.hdc); CRect rcItem; GetItemRect(iItem, rcItem, LVIR_BOUNDS); if (GetItemState(iItem, LVIS_SELECTED) LVIS_SELECTED) { // 无论焦点在不在都画成同一种选中蓝 pDC-FillSolidRect(rcItem, RGB(0, 120, 215)); pLVCD-clrText RGB(255, 255, 255); pLVCD-clrTextBk RGB(0, 120, 215); } else { pDC-FillSolidRect(rcItem, RGB(255, 255, 255)); pLVCD-clrText RGB(30, 30, 30); pLVCD-clrTextBk RGB(255, 255, 255); } *pResult CDRF_NEWFONT; return; }这段代码最核心的一句就是GetItemState(iItem, LVIS_SELECTED) LVIS_SELECTED它不关心焦点状态只关心这一行是否处于“选中”这个逻辑状态。背景已经被你填成固定的亮蓝色失焦时系统也没有机会再把它画灰了。我自己实测下来这个方案在Win7到Win10、XP加经典主题下都稳定。思路二是拦截WM_KILLFOCUS和WM_SETFOCUS收到消息后强制RedrawWindow在绘制期间临时把焦点状态“固定”成有焦点。这个方法在理论上也行但实际容易被模态对话框、菜单弹出、视图切换打断而且焦点消息和绘制消息之间有时序问题会出现偶发的“闪一下灰再变蓝”的鬼畜效果。我不推荐除非你遇到了连GetItemState都不能用的特殊场景。3.3 取消所有选择行与选中行颜色控制“取消所有选择行”是网上问得很多的功能其实LVM_SETITEMSTATE一条消息就能解决。关键在于用-1作为item索引表示“所有项”。很多人第一次写会把state参数传错导致一行都没取消掉。void DeselectAll(CListCtrl list) { LVITEM lvItem {0}; lvItem.stateMask LVIS_SELECTED | LVIS_FOCUSED; lvItem.state 0; list.SendMessage(LVM_SETITEMSTATE, (WPARAM)-1, (LPARAM)lvItem); }注意stateMask必须同时包含LVIS_SELECTED和LVIS_FOCUSEDstate清零。如果你只清LVIS_SELECTED而保留了LVIS_FOCUSED焦点行会留下一个虚框看起来像是没有完全取消选择。这条代码在需要全选/全不选切换的场景里非常实用。选中行颜色控制方面别把颜色值硬编码在绘制函数里。我习惯在CMyListCtrl里维护两个成员变量m_crSelectionBack和m_crSelectionText在OnSysColorChange里根据系统主题动态更新这样换肤或者系统高对比度模式下选中行颜色不会显得突兀。另外一个容易被忽略的点是选中行文本里如果有图标DrawIcon的透明区域跟FillSolidRect的背景色要协调否则图标边缘会出现难看的白框。4. 滚动条Scrollbar重绘最硬核也最容易被问爆的地方4.1 为什么滚动条不能像表头那样直接子类化很多人一想到重绘滚动条第一反应是跟表头一样子类化结果发现GetScrollBarCtrl(SB_VERT)对CListCtrl返回是NULL。原因是CListCtrl的滚动条不是独立窗口而是ListView窗口非客户区的一部分你根本没有一个独立的HWND可以去Subclass。这是滚动条重绘第一道坎也是网上资料总是讲一半的原因。正因为如此业内形成了两条主流路线。路线A是拦截WM_NCPAINT在非客户区自己绘制滚动条同时处理WM_NCHITTEST、WM_NCLBUTTONDOWN等命中消息这是真正意义上的滚动条重绘但交互逻辑全得自己写路线B是去掉标准滚动条样式在ListCtrl右侧放一个自绘的CScrollBar子类控件通过消息同步滚动位置。工程上路线B更可控很多开源皮肤库都在用调试也直观。4.2 路线AWM_NCPAINT自绘非客户区采用路线A的话核心思路是先调用Default()让系统把边框等非客户区画好然后在滚动条区域上叠画。数据来源是GetScrollInfo滑块位置和高度必须自己算。void CMyListCtrl::OnNcPaint() { Default(); // 先让系统画默认非客户区 CWindowDC dc(this); CRect rcClient; GetClientRect(rcClient); ClientToScreen(rcClient); // 滚动条区域 CRect rcScroll; rcScroll.left rcClient.right - GetSystemMetrics(SM_CXVSCROLL); rcScroll.top rcClient.top; rcScroll.bottom rcClient.bottom; rcScroll.right rcClient.right; SCROLLINFO si; ZeroMemory(si, sizeof(si)); si.cbSize sizeof(si); si.fMask SIF_ALL; GetScrollInfo(SB_VERT, si); // 滑块高度 int nTrackH rcScroll.Height(); int nThumbH (int)(nTrackH * (double)si.nPage / (si.nMax 1)); nThumbH max(nThumbH, 20); int nRange max(1, nTrackH - nThumbH); int nThumbPos (si.nPos - si.nMin) * nRange / max(1, si.nMax - si.nPage 1); // 画轨道与滑块 dc.FillSolidRect(rcScroll, RGB(240, 240, 240)); CRect rcThumb(rcScroll.left, rcScroll.top nThumbPos, rcScroll.right, rcScroll.top nThumbPos nThumbH); dc.FillSolidRect(rcThumb, RGB(150, 150, 150)); }这段代码有几个计算细节必须注意ListView的滚动范围不是0到nMax而是0到nMax - nPage 1所以滑块位置的分母要用si.nMax - si.nPage 1否则滚到底滑块也到不了底部。滑块高度用比例计算后还要做最小高度限制否则内容很多时滑块会变成一条肉眼几乎看不见的细线。系统没有提供直接获取非客户区DPI本文还有配套的精品资源点击获取