简介:面向MFC界面开发者的CListCtrl深度定制资料,聚焦滚动条、表头与列表项三重重绘,适合需要为类似资源管理器的列表界面打造个性化视觉风格的场景。代码按三步展开:先继承CScrollBar并重写OnPaint绘制滚动条轨道与滑块,再通过自定义CHeaderCtrl实现表头字体与配色,最后借LVS_OWNERDRAWFIXED样式与OnDrawItem重载控制每个列表项的绘制,并给出WM_HSCROLL、WM_VSCROLL、HDM_LAYOUT等消息的处理思路。压缩包共42个文件,以bmp位图资源(14个)、h头文件(9个)和cpp源码(7个)为主体,辅以txt说明与工程配置,体积仅87KB。内含可运行的SkinList_demo示例工程,边看代码边对照试验,能直观感受重绘前后差异。已有326人学习浏览,适合具备MFC基础、正准备提升控件定制能力的开发者作为实战参考。
1. 重绘CListCtrl的scrollbar、headerctrl和items:一次把列表控件三件套改造到能看的工程方案
接手一个用MFC写的老项目,最头疼的往往是那些默认控件的“原生脸”。CListCtrl这种列表控件,Windows 7时代还算顺眼,放到Windows 10/11的深色主题产品里,系统滚动条灰得发亮、表头白底黑字、选中项蓝得刺眼,整个界面像打了补丁。要把CListCtrl彻底重绘,绕不开三块:scrollbar、headerctrl、items。很多人的做法是只给items上色,结果滚动条和表头照样辣眼睛,半吊子还不如不改。
这篇笔记把我实际落地过的一套方案讲清楚:三个部件分别走什么绘制机制、每个部件怎么动手、参数怎么调、哪些地方容易翻车。适合正在维护MFC老工程、被Win10/11视觉风格逼着改UI的C++工程师,也适合刚接触自绘控件、想知道从哪下手的初学者。方案核心是让列表控件三个部件的绘制权全部收回到你手里,而不是去跟系统主题搏斗。
2. 先定框架:自绘CListCtrl的滚动条、表头和列表项,都有各自的绘制机制
2.1 三件套对应的绘制入口:ScrollBar、HeaderCtrl、NM_CUSTOMDRAW
重绘之前,先把CListCtrl内部结构摸清楚。列表控件不是一个简简单单的窗口,它至少包含三样东西:一个拥有客户区的主体窗口、一个CHeaderCtrl表头子控件、以及由系统负责绘制的内容区域。滚动条情况特殊一点,当列表使用标准系统滚动条时,它画在非客户区,没有独立窗口句柄;只有启用FlatSB或者自己替换滚动条,才有真正意义上的“窗口”可以操作。
三件套的绘制机制差别很大,不能用一套代码通吃:
| 部件 | 本质 | 可用的自绘入口 |
|---|---|---|
| scrollbar | 非客户区或独立子窗口 | FlatSB颜色接口 / 自绘CScrollBar替换 |
| headerctrl | CListCtrl的子控件 | HDS_OWNERDRAWN + DrawItem |
| items | CListCtrl主体自行渲染 | NM_CUSTOMDRAW通知反射 |
理解这个表格,你就明白了为什么很多人卡住:items用的是Custom Draw机制,headerctrl用的是Owner Draw机制,scrollbar又是另一套。三套机制各自独立,依赖的消息、回调函数、返回码都不同。所谓“重绘CListCtrl”,本质是同时驱动这三套机制,让它们在一棵列表里风格统一。
2.2 一个边界:重绘items不是重写OnPaint
有个常见误区——为了自绘列表项,直接重写CListCtrl的OnPaint或OnEraseBkgnd,把客户的绘制逻辑全塞进去。这个思路在简单场景下能跑,但列表一旦涉及整行选中、聚焦矩形、热追踪、状态图标,系统内部已经做了很多状态管理,OnPaint里很难拿到每行每列的真实状态,越写越像在跟黑匣子搏斗。
Windows列表控件提供了一套相对友好的扩展机制:NM_CUSTOMDRAW。它会按绘制阶段把消息反射给控件本身,你在每个阶段决定“系统画还是自己画”。这套机制好处在于,你不必接管整个绘制流程,只要在合适时机截住某一层的绘制,改完再放行。缺点是阶段码比较多,新手一上来容易被CDDS_PREPAINT、CDDS_ITEMPREPAINT、CDDS_SUBITEMPREPAINT这种名词绕晕。但只要把阶段顺序理清,这套机制比OnPaint可控得多。
2.3 派生类骨架:一个CMyListCtrl把三部分串起来
实际操作中,我一般从CListCtrl派生一个类,把滚动条、表头、列表项三个子对象都封装进去,这样DDX_Control绑定后所有自绘逻辑就自动生效。类的声明大概长这样:
// MyListCtrl.h class CMyListCtrl : public CListCtrl { public: DECLARE_DYNAMIC(CMyListCtrl) protected: CMyScrollBar m_wndScrollBar; // 自绘滚动条(替代系统滚动条) CMyHeaderCtrl m_wndHeader; // 自绘表头 public: void ApplyTheme(const ListTheme& theme); // 统一设置三个部件颜色 protected: afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct); afx_msg void OnNMCustomDraw(NMHDR* pNMHDR, LRESULT* pResult); DECLARE_MESSAGE_MAP() };这个骨架的关键点在于:滚动条用成员对象持有,而不是每次用时再找句柄;表头也要子类化;items通过消息映射里的ON_NOTIFY_REFLECT(NM_CUSTOMDRAW, OnNMCustomDraw)反射进这个类。后续所有颜色、字体、尺寸配置,统一走ApplyTheme,避免三个部件各写各的导致风格漂移。
3. 重绘scrollbar:替换成自绘滚动条是最稳妥的落地路径
3.1 为什么直接画系统滚动条是个伪命题
CListCtrl默认的系统滚动条不是独立控件窗口,它绘制在非客户区,没有HWND,也没有WM_PAINT可以重写。有人用WM_NCPAINT去画,但非客户区绘制要自己处理边框、系统按钮、滚动条状态,代码极其脆弱,主题一换就对不齐。
常见的快捷做法是用FlatSB API给滚动条换色,比如调用FlatSB_EnableScrollBar之后,用FlatSB_SetScrollProp设置track和thumb的颜色。但这里有个大坑:当代应用程序清单里几乎都启用了ComCtl32 v6视觉样式,FlatSB的颜色设置在视觉样式激活时会被系统主题覆盖,设置了个寂寞。所以我在项目里不把FlatSB当终点,只当临时救急手段。
3.2 稳妥路径:隐藏系统滚动条,用自绘CScrollBar替代
我最终采用的方案是:关掉CListCtrl的系统滚动条,在列表客户区右侧放一个自绘的CScrollBar子窗口,自己维护它的SCROLLINFO,并在滚动时把位置同步回列表。这样做的好处是滚动条完全受你控制,颜色、宽度、圆角、箭头全部自己画,不再受视觉样式干扰。
先在CMyListCtrl的OnCreate或PreSubclassWindow里禁用系统滚动条:
int CMyListCtrl::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CListCtrl::OnCreate(lpCreateStruct) == -1) return -1; // 关掉系统滚动条,避免和自绘滚动条重叠 ShowScrollBar(SB_BOTH, FALSE); // 创建自绘滚动条子窗口,贴在右侧客户区 m_wndScrollBar.Create(WS_CHILD | WS_VISIBLE | SBS_VERT, CRect(0, 0, 14, 100), this, 1001); UpdateScrollBarInfo(); return 0; }这里有两个细节:ShowScrollBar(SB_BOTH, FALSE)只是隐藏,不是销毁,所以列表的内部滚动逻辑仍存在,但我们不再依赖它的视觉呈现;自绘滚动条宽度我习惯用14px,因为正好是默认系统垂直滚动条的宽度,视觉上替换时不会有明显的跳动。如果你做更窄的滚动条,记得同时调整其矩形位置。
3.3 自绘滚动条的绘制:track、thumb、箭头三步走
CMyScrollBar的绘制集中在OnPaint里。我先画track背景,再根据SCROLLINFO计算thumb位置和高度,最后画上下箭头按钮。这里最核心的是thumb高度计算,直接关系拖动手感:
void CMyScrollBar::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(&rc); // 1. 画track背景 dc.FillSolidRect(rc, m_clrTrack); // 2. 取滚动信息 SCROLLINFO si = { sizeof(si), SIF_ALL }; GetScrollInfo(&si); // 3. 计算thumb高度 // nPage是当前可见的“单位数”,nMax-nMin+1是总范围 double dPage = (double)si.nPage; double dRange = (double)(si.nMax - si.nMin + 1); double ratio = dRange > 0 ? dPage / dRange : 1.0; if (ratio > 1.0) ratio = 1.0; // 内容不足一屏时不要算成负数 int nThumbH = max(24, (int)(rc.Height() * ratio)); // 4. 计算thumb位置 int nScrollable = si.nMax - si.nMin - si.nPage + 1; double dPos = nScrollable > 0 ? (double)(si.nPos - si.nMin) / nScrollable : 0.0; int nThumbY = rc.top + (int)((rc.Height() - nThumbH) * dPos); // 5. 画thumb CRect rcThumb(rc.left, nThumbY, rc.right, nThumbY + nThumbH); dc.FillSolidRect(rcThumb, m_clrThumb); }这个OnPaint里有三个参数值得你细调。第一,thumb最小高度24px,小于这个值鼠标很难精准命中;第二,nPage不能直接用像素行数,要用SCROLLINFO里的逻辑单位,通常是行数或项数;第三,ratio大于1时说明内容不满一屏,此时要强制ratio为1,否则thumb会超出窗口底部。很多自绘滚动条翻车,都是这三处边界没处理干净。
3.4 滚动位置同步:别让列表和滚动条各走各的
自绘滚动条画出来之后,还差一步联动。CListCtrl有自己的滚动逻辑,需要把UI方向和实际位置对齐。做法是给CMyScrollBar加一个WM_VSCROLL回调,在用户拖动thumb时把SCROLLINFO位置发给父窗口,CMyListCtrl收到后再真正滚动列表内容。
void CMyScrollBar::OnVScroll(UINT nSBCode, UINT nPos, CScrollBar* pScrollBar) { // 把用户操作转发给列表控件,让它执行实际的滚动 GetParent()->SendMessage(WM_VSCROLL, MAKEWPARAM(nSBCode, nPos), (LPARAM)GetSafeHwnd()); } void CMyListCtrl::UpdateScrollBarInfo() { SCROLLINFO si = { sizeof(si), SIF_ALL }; GetScrollInfo(SB_VERT, &si); m_wndScrollBar.SetScrollInfo(&si); m_wndScrollBar.Invalidate(); }这里最隐蔽的坑是:当用户拖动thumb时,CListCtrl内部会更新自己的SCROLLINFO,但自绘滚动条不会自动刷新。所以列表滚动发生后,必须调用一次UpdateScrollBarInfo,把最新的SCROLLINFO推给滚动条。我一般会把几个触发点都挂上:OnVScroll处理完后、OnMouseWheel处理完后、SetItemCount之后。漏掉任何一处,滚动条thumb都会停在原地不动,表现得像个僵尸——这是自绘滚动条最常见的“玄学故障”,其实只是忘了同步。
4. 重绘headerctrl:GetHeaderCtrl拿到句柄后,DrawItem才真正接管
4.1 子类化表头:句柄获取的正确姿势
表头是CListCtrl的子控件,有独立HWND,所以处理它最直接的方式是子类化。在CMyListCtrl初始化时,通过GetHeaderCtrl拿到指针,再把它转交给CMyHeaderCtrl子类化:
void CMyListCtrl::PreSubclassWindow() { CListCtrl::PreSubclassWindow(); // 获取表头子控件句柄 HWND hHeader = NULL; CHeaderCtrl* pHeader = GetHeaderCtrl(); if (pHeader && ::IsWindow(pHeader->GetSafeHwnd())) hHeader = pHeader->GetSafeHwnd(); else hHeader = ::GetDlgItem(m_hWnd, 0); // 兼容某些创建顺序 // 子类化表头,必须在列表显示前完成 if (hHeader && !m_wndHeader.GetSafeHwnd()) m_wndHeader.SubclassWindow(hHeader); }用GetHeaderCtrl之前先判空,这个习惯很重要。CListCtrl在早期初始化阶段表头可能尚未创建完整,GetHeaderCtrl返回空指针是常态。保底方案是GetDlgItem(0),这是列表表头固定的控件ID。子类化之后,表头的绘制消息就会先经过CMyHeaderCtrl,不再直接走系统默认逻辑。
4.2 开启Owner Draw:ModifyStyle是关键一步
拿到句柄还不够,必须给表头窗口加上HDS_OWNERDRAWN风格,它才会在每次绘制时回调DrawItem。在CMyHeaderCtrl的PreSubclassWindow里做这件事最合适:
void CMyHeaderCtrl::PreSubclassWindow() { CHeaderCtrl::PreSubclassWindow(); // 开启自绘,同时去掉系统排序箭头的默认绘制 ModifyStyle(0, HDS_OWNERDRAWN); // 表头高度设为固定的24px,配合深色背景 SetItemSize(24); }SetItemSize控制的是表头总高度,配合HDS_FIXEDWIDTH风格会更稳定,否则用户拖动列宽时表头高度可能跟着变。设成24px是深色UI里比较常见的基准值,如果你界面里控件密度高,22px更紧凑;如果字号大、有图标,28px更安全。
4.3 DrawItem实现:先画背景,再画分隔线,最后画文字和排序箭头
CHeaderCtrl的自绘走的是DrawItem,收到的是LPDRAWITEMSTRUCT。绘制顺序建议固定为:背景、分隔线、文本、排序箭头。这个顺序不是随意的——先覆盖整块区域,才不会残留上一步绘制的痕迹;排序箭头最后画,是为了让它永远在文本上层。
void CMyHeaderCtrl::DrawItem(LPDRAWITEMSTRUCT lpDIS) { CDC* pDC = CDC::FromHandle(lpDIS->hDC); CRect rcItem(lpDIS->rcItem); int nCol = (int)lpDIS->itemID; // 1. 背景:整列铺底色 pDC->FillSolidRect(rcItem, m_clrHeaderBack); // 2. 右侧分隔线:1px,左侧也画一条保持边界清晰 pDC->FillSolidRect(rcItem.right - 1, rcItem.top, 1, rcItem.Height(), m_clrHeaderBorder); // 3. 排序箭头:有排序状态才绘制 TCHAR szText[128] = {0}; HDITEM hdi = {0}; hdi.mask = HDI_TEXT | HDI_FORMAT; hdi.pszText = szText; hdi.cchTextMax = 127; GetItem(nCol, &hdi); CRect rcText = rcItem; rcText.left += 6; if (hdi.fmt & HDF_SORTUP) PaintArrow(pDC, rcText, true); else if (hdi.fmt & HDF_SORTDOWN) PaintArrow(pDC, rcText, false); // 4. 文本:靠左居中,避免被排序箭头覆盖 rcText.right -= 16; pDC->SetBkMode(TRANSPARENT); pDC->SetTextColor(m_clrHeaderText); pDC->DrawText(szText, &rcText, DT_LEFT | DT_VCENTER | DT_SINGLELINE); }这个函数的几个参数决定表头观感:文本左边距6px,是为了让第一列文字不贴边;右侧留16px,因为右侧要留给排序箭头。如果你项目里排序列很多,还要把排序箭头的绘制独立成PaintArrow函数,用三角路径填充,而不是画字符,否则在不同字体下箭头位置会漂移。
4.4 排序箭头状态同步:点击列头后要主动刷新
HDS_OWNERDRAWN有一个连带问题:系统在列头点击排序时,只是修改了内部排序状态,并不会自动触发完整重绘。所以很多时候你会发现箭头只有一半、或者点完不出现,得鼠标移开再回来才正常。原因是DrawItem里读取的HDF_SORTUP/HDF_SORTDOWN状态没在第一时间同步到绘制层。
解决办法是拦截表头的HDN_ITEMCLICK通知,在父窗口里收到该消息后强制Invalidate表头区域:
// CMyListCtrl的消息映射中 ON_NOTIFY(HDN_ITEMCLICKA, 0, &CMyListCtrl::OnHeaderItemClick) ON_NOTIFY(HDN_ITEMCLICKW, 0, &CMyListCtrl::OnHeaderItemClick) void CMyListCtrl::OnHeaderItemClick(NMHDR* pNMHDR, LRESULT* pResult) { m_wndHeader.Invalidate(); *pResult = 0; }这里有个容易漏掉的小坑:HDN_ITEMCLICK分ANSI和Unicode两个版本,消息映射里两个都要写,否则Unicode编译下点击列头不触发。反正我每次写表头点击通知都会顺手把这两个宏一起放上去,省得后期忘了再补,效果立竿见影。
5. 重绘items:用NM_CUSTOMDRAW三阶段搞定整行高亮和焦点框
5.1 Custom Draw的消息反射:注册时机和条件
items的自绘走NM_CUSTOMDRAW,它是由列表控件发出、最终反射到CMyListCtrl的通知消息。在CMyListCtrl的消息映射里注册:
BEGIN_MESSAGE_MAP(CMyListCtrl, CListCtrl) ON_NOTIFY_REFLECT(NM_CUSTOMDRAW, &CMyListCtrl::OnNMCustomDraw) END_MESSAGE_MAP()ON_NOTIFY_REFLECT是MFC里非常关键的一个宏,它表示消息不是发给父窗口,而是先反射给控件自身处理。用这个宏,CMyListCtrl就能直接接管自己的绘制,而不需要在对话框里写一堆通知处理。这也是把三件套封装到一个类里的好处:对话框只管绑定CMyListCtrl,所有自绘逻辑都隐形地生效了。
5.2 绘制阶段解码:PREPAINT、ITEMPREPAINT、SUBITEMPREPAINT
Custom Draw的难点是阶段码。你收到的dwDrawStage决定当前该画什么,常见的组合如下:
| 阶段码 | 含义 | 适合做的事 |
|---|---|---|
| CDDS_PREPAINT | 列表即将开始绘制 | 告诉系统需要哪些阶段的通知 |
| CDDS_ITEMPREPAINT | 某一行即将绘制 | 设置整行文字色、背景色 |
| CDDS_SUBITEMPREPAINT | 某个单元格即将绘制 | 按列设置不同颜色 |
| CDDS_ITEMPOSTPAINT | 某一行绘制完毕 | 画焦点矩形、自定义边框 |
处理思路是:在PREPAINT阶段请求ITEMPREPAINT和SUBITEMPREPAINT通知,这样每一行、每一个单元格都会经过你的手;然后再具体在行阶段或单元格阶段做定制。
5.3 整行选中背景:设置clrTextBk比手动填充更可靠
很多新手追求“自己FillSolidRect整行”,但系统在SubItem阶段还会叠加默认背景,导致你画的高亮被盖掉一半。更稳的做法是直接告诉Custom Draw机制这一行的文字色和背景色,让系统用你的颜色去画。
void CMyListCtrl::OnNMCustomDraw(NMHDR* pNMHDR, LRESULT* pResult) { LPNMLVCUSTOMDRAW lpLVCD = (LPNMLVCUSTOMDRAW)pNMHDR; DWORD dwStage = lpLVCD->nmcd.dwDrawStage; if (dwStage == CDDS_PREPAINT) { // 请求行和单元格两级通知 *pResult = CDRF_NOTIFYITEMDRAW | CDRF_NOTIFYSUBITEMDRAW; return; } if (dwStage & CDDS_ITEMPREPAINT) { // 整行状态:选中行、普通行、禁用行自行区分 BOOL bSelected = (lpLVCD->nmcd.uItemState & CDIS_SELECTED); if (bSelected) { lpLVCD->clrText = m_clrItemSelText; lpLVCD->clrTextBk = m_clrItemSelBkg; } else { lpLVCD->clrText = m_clrItemText; lpLVCD->clrTextBk = m_clrItemBkg; } *pResult = CDRF_NOTIFYSUBITEMDRAW; // 继续让单元格阶段通知 return; } if (dwStage & CDDS_SUBITEMPREPAINT) { // 需要按列特殊处理的逻辑在这里写 *pResult = CDRF_DODEFAULT; return; } *pResult = CDRF_DODEFAULT; }这里有一个容易被忽略的彩蛋:clrTextBk影响的是文字底色,虽然看起来像整行背景,但实际上系统只会在文字所在区域填充。如果你设置了LVS_EX_FULLROWSELECT,Windows会帮你把整行拉通;如果没设置,背景色就只能盖住文字区域,视觉上会一条一条的。所以记得给列表加上整行选中扩展样式:
SetExtendedStyle(GetExtendedStyle() | LVS_EX_FULLROWSELECT | LVS_EX_DOUBLEBUFFER);5.4 ITEMPREPAINT返回值的连锁反应
新手最容易栽在返回值上。ITEMPREPAINT如果不返回CDRF_NOTIFYSUBITEMDRAW,后面的单元格阶段根本不会触发,按列定制就全部失效。反过来,如果你返回CDRF_DODEFAULT,系统又会忽略你已经修改的clrText和clrTextBk,等于白改。正确的做法是:行阶段修改属性后返回CDRF_NOTIFYSUBITEMDRAW,让系统带着你的属性继续画后续单元格。
另外,CDRF_NOTIFYPOSTPAINT这个返回码也有用途。它告诉系统在整行绘制完成后再次通知你,适合在行末尾补画边框、焦点框。我通常会在需要自绘分隔线的场景里用ITEMPOSTPAINT,在行底画一条1px的分隔线,比依赖系统的网格线更可控。
5.5 键盘焦点矩形:不画就会在选中态里消失
列表支持键盘操控时,焦点项和选中项是不同状态。选中项高亮很容易处理,但焦点虚线矩形经常被忽略——字符在选中背景下被吞掉,键盘导航的用户完全看不到光标在哪。解决方案是开启CDRF_NOTIFYITEMPOSTPAINT,在行绘制结束后用DrawFocusRect补一个矩形:
if (dwStage & CDDS_ITEMPOSTPAINT) { int nItem = (int)lpLVCD->nmcd.dwItemSpec; if (GetFocus() == GetSafeHwnd() && (lpLVCD->nmcd.uItemState & CDIS_FOCUS)) { CRect rcItem; if (GetItemRect(nItem, &rcItem, LVIR_BOUNDS)) { CDC* pDC = CDC::FromHandle(lpLVCD->nmcd.hdc); pDC->DrawFocusRect(&rcItem); } } }注意条件里加了GetFocus() == GetSafeHwnd(),目的是只有列表自身拥有焦点时才画焦点框,否则列表不在焦点态却画了虚线框,界面会显得脏。这个细节也是后来被测试反复报bug才补上的——有个阶段列表失焦了,焦点框还挂在最后一项上,看着就像卡死了一样。
6. 避坑:自绘ListCtrl三件套最容易翻车的六个地方
6.1 第一行文字变成蓝底白字,自绘选中色没生效
现象:列表第一行或者正在编辑的单元格,文字背景突然变回系统默认蓝,和自绘的其他行颜色不一致。原因:同时设置了LVS_EX_FULLROWSELECT,但NM_CUSTOMDRAW的行阶段没有正确返回CDRF_NOTIFYSUBITEMDRAW,导致subitem阶段用系统默认颜色又画了一遍。解决办法:确认ITEMPREPAINT分支严格返回CDRF_NOTIFYSUBITEMDRAW;检查是否把行背景填充逻辑写进了SUBITEMPREPAINT,导致重复覆盖。
6.2 FlatSB颜色设置后毫无反应,滚动条还是系统灰
现象:调用了FlatSB_SetScrollProp设置track和thumb颜色,程序运行起来滚动条没有一丝变化。原因:目标exe启用了ComCtl32 v6视觉样式,FlatSB的颜色能力在主题接管后直接失效,这不是代码写错,是机制被剥夺了。解决办法:如果你一定要用FlatSB,可以在exe的manifest里关掉视觉样式,但代价是整个程序都退回经典样式,一般不接受。更稳的对策就是用本章开头讲的自绘CScrollBar替换方案,彻底绕开主题。
6.3 表头排序箭头出现残影,点击后不刷新
现象:点击列头排序后,箭头没有立即出现,或者箭头还停留在旧列上,鼠标在表头上晃一下才恢复正常。原因:HDS_OWNERDRAWN开启后,系统不再自动重绘画排序箭头,而HDN_ITEMCLICK通知又没有触发表头重绘。解决办法:在CMyListCtrl里同时处理HDN_ITEMCLICKA和HDN_ITEMCLICKW两个消息,并在其中调用m_wndHeader.Invalidate(),强制表头立即重画。
6.4 子类化表头后退出对话框时崩溃
现象:程序关闭对话框时,在CMyListCtrl析构或清理阶段偶发崩溃,调用栈指向CMyHeaderCtrl内部。原因:CHeaderCtrl是CListCtrl的子窗口,析构顺序可能晚于CMyListCtrl,如果子类对象m_wndHeader先被销毁,窗口仍然指向一个失效的WndProc。解决办法:在CMyListCtrl的OnNcDestroy或接管WM_DESTROY时,先调用m_wndHeader.UnsubclassWindow()和m_wndScrollBar.UnsubclassWindow(),解除子类化再走默认析构。这个后悔药我在多个项目里都用上了,稳定性提升明显。
6.5 自绘滚动条thumb比例在DPI缩放下错位
现象:系统DPI从100%切到150%,自绘滚动条thumb变得过短或过长,拖动摇杆时位置和内容不匹配。原因:自绘滚动条计算thumb高度时用了像素高度,但SCROLLINFO里的nPage、nMax是ListView内部逻辑单位,在DPI缩放时两者比例失配。解决办法:用GetDpiForWindow或GetSystemMetrics拿到当前DPI系数,将逻辑单位乘以缩放后再用于thumb高度;或者把所有相关尺寸都设为按DPI动态计算,不要在构造时写成死值。
6.6 开启LVS_EX_DOUBLEBUFFER后自绘行背景闪烁
现象:加了LVS_EX_DOUBLEBUFFER消除默认闪烁,但自绘的items背景在滚动时反而闪得更厉害。原因:双缓冲和NM_CUSTOMDRAW的时机产生冲突,你的背景绘制可能发生在内存画布上,随后又被系统以未更新画布覆盖了部分区域。解决办法:改用CMyListCtrl整体双缓冲,在OnEraseBkgnd里返回TRUE,并把NM_CUSTOMDRAW里的绘制目标从hdc改成一张持久位图;或者干脆去掉LVS_EX_DOUBLEBUFFER,只保留NM_CUSTOMDRAW的按需绘制,通常已经足够顺滑。
7. 进阶:把三件套的颜色收进一个Theme对象,实现一键换肤
三件套各自能自绘之后,最麻烦的其实是维护一致性。改一处深色,滚动条、表头、列表项三处都要跟着改,否则界面就会“半浅半深”。我的做法是把所有颜色和尺寸收进一个Theme结构体,CMyListCtrl内部分发给三个子对象,对话框只需一次调用即可整体换肤。
struct ListTheme { COLORREF clrItemText; COLORREF clrItemSelText; COLORREF clrItemBkg; COLORREF clrItemSelBkg; COLORREF clrHeaderText; COLORREF clrHeaderBkg; COLORREF clrHeaderBorder; COLORREF clrScrollTrack; COLORREF clrScrollThumb; COLORREF clrScrollThumbHover; int nHeaderHeight; int nScrollBarWidth; }; void CMyListCtrl::ApplyTheme(const ListTheme& theme) { m_theme = theme; // items:直接作为成员变量供Custom Draw使用 // header:分发给表头子类 m_wndHeader.SetColors(theme.clrHeaderText, theme.clrHeaderBkg, theme.clrHeaderBorder); m_wndHeader.SetItemSize(theme.nHeaderHeight); // scrollbar:分发给自绘滚动条子类 m_wndScrollBar.SetColors(theme.clrScrollTrack, theme.clrScrollThumb); m_wndScrollBar.SetWidth(theme.nScrollBarWidth); // 全部触发重绘 Invalidate(); m_wndHeader.Invalidate(); m_wndScrollBar.Invalidate(); }这个设计的验证方式很简单:切到深色主题后,依次检查三个部件的底色是否一致、分隔线是否清晰、滚动条在悬停和按下状态下的反馈是否正常。切换主题后如果发现某个部件颜色没跟上,不需要逐行查绘制代码,先看Theme对象里对应字段有没有传入,这是最快定位手段。
回想我之前做深色版列表,第一版只改了items文字颜色,表头白底还露在外面,被产品截图挂在墙上当反面教材。后来把三件套统一收进Theme对象,每次只动参数、不碰绘制逻辑,后面所有颜色改动都稳定多了。希望你重绘自己的CListCtrl时,少走那段别扭路,一次把scrollbar、headerctrl、items三件套做到位,希望帮到你。
本文还有配套的精品资源,点击获取