☰
MFC CListCtrl全自绘指南:滚动条、表头与列表项统一美化
2026/10/21 14:04:17 网站建设 项目流程

简介:面向MFC界面开发者,这份代码包演示了如何重绘CListCtrl的滚动条、表头和列表项,解决默认控件样式单一、难以匹配应用主题的问题。资源包含完整示例工程与说明文档,覆盖自定义滚动条绘制、表头样式定制以及OwnerDraw列表项自绘等关键技术点,可直接参考或整合进现有项目。

压缩包共42个文件,以bmp位图资源、h头文件、cpp实现类和txt说明为主,另有dsp/dsw工程文件、rc资源脚本及示例对话框源码,整体仅87KB,下载查看都很方便。作者封装了SkinListCtrl、SkinHeaderCtrl、SkinHorizontalScrollbar、SkinVerticleScrollbar等类,配合皮肤位图可快速实现统一视觉风格;rm.txt和ReadMe.txt则补充了实现思路与使用说明,降低二次开发门槛。

资源已有325人学习浏览,适合具备一定MFC基础、希望提升列表控件外观的中级开发者。 做MFC界面开发的朋友应该都有过这种体验,系统自带的CListCtrl样式总停留在Win95时代。滚动条是老式方块,表头扁平没有立体感,选中项一旦失焦就变成灰扑扑一片,和整套界面的深色风格怎么都搭不上。我前阵子做一个桌面客户端,客户明确要求列表控件整体风格统一,于是把重绘CListCtrl的三个核心部分——scrollbar、headerctrl、items——从头到尾完整走了一遍。这篇就专门聊聊这套重绘方案的选型、代码实现和踩坑记录。

文章适合两类人看:一是老MFC项目需要做界面美化,想给CListCtrl换肤的朋友;二是被“选中后蓝色、失焦后变灰”这类经典问题困扰的开发者。全文不涉及复杂框架,核心就是NM_CUSTOMDRAW、CHeaderCtrl子类化、WM_NCPAINT这三板斧。

1. 重绘前先想清楚:整体方案怎么选

1.1 默认样式的问题到底在哪

CListCtrl本质上是一个复合控件,它由三套独立绘制机制拼在一起:列表项本身走控件的自绘通知,表头是内部的一个SysHeader32子窗口,滚动条则是系统绘制的非客户区元素。这三部分各自为政,也是为什么很多新手想重绘却无从下手的原因——你用NM_CUSTOMDRAW搞定了item,表头和滚动条还是老样子,整体风格依然不统一。

我刚开始做深色主题时也犯过这个错,只处理了列表项的颜色,结果表头白花花一片,滚动条还是系统默认的浅灰色,整个界面看起来像拼接出来的。后来才意识到,重绘CListCtrl的正确姿势是“三线作战”:items走CustomDraw通知,header用子类化窗口,scrollbar则要处理非客户区绘制。

1.2 自绘的三条路线对比

先说结论,这三部分各自的推荐方案完全不同:

  • Items:监听NM_CUSTOMDRAW通知,在OnNMCustomdraw里自己画背景和文字。这是MFC官方推荐的自绘方式,性能和灵活性平衡最好,千万不要用OwnerDraw(LVS_OWNERDRAWFIXED),那意味着所有绘制全包,连双击、焦点框、快捷键高亮都要自己处理,工作量翻倍。
  • HeaderCtrl:派生CHeaderCtrl类,重写DrawItem,再通过CListCtrl::GetHeaderCtrl()拿到指针后SubclassDlgItem。也可以用HDN_ITEMDRAW通知实现,但那个通知在XP和Win7下的行为有不小差异,子类化更可控。
  • Scrollbar:最省事的是FlatSB(平面滚动条)API,想完全定制外观就得处理WM_NCPAINT消息,先让系统画完,再覆盖绘制滚动条区域。

我最终采用的组合是:CustomDraw处理item + 派生CHeaderCtrl处理表头 + WM_NCPAINT处理滚动条。下面按这三个方向逐个拆解。

2. 重绘Scrollbar:外观定制的两种可行打法

2.1 零代码速成方案:FlatSB

如果项目对滚动条要求不高,只是想去掉老式3D凸起效果,FlatSB是最快的路。CListCtrl创建之后调用InitializeFlatScrollBar(GetSafeHwnd()),然后通过FlatSB_SetScrollProp设置滚动条颜色、滑块大小等属性,十几行代码就能让滚动条从“复古3D”变成“平面现代风”。

这个方案的优点是简单、稳定,不涉及任何自绘代码,缺点同样明显:FlatSB是Win2000时代的东西,样式固定在扁平风格上,颜色配置自由度也很有限,你没法画圆角滑块,也没法加悬停高亮效果。如果项目要求跟整体UI高度一致,FlatSB只能当过渡方案。

2.2 进阶方案:WM_NCPAINT覆盖绘制

要完全控制滚动条外观,核心思路是:在WM_NCPAINT里先调用默认处理,让系统把边框和滚动条画好,然后用GetWindowDC拿到非客户区DC,把滚动条区域重新覆盖绘制一遍。这里的难点在于滑块位置必须和系统内部状态保持一致,否则会出现“外观滑块位置”和“实际点击热区”对不上的问题。

我的做法是绘制前用GetScrollInfo拿到SIF_ALL信息,根据nPos、nPage、nMax自己计算滑块的高度和位置,再画轨道、滑块和上下箭头按钮。这样无论用户怎么拖拽滚动条,下次重绘时外观都会跟内部状态同步。核心代码大概长这样:

void CMyListCtrl::OnNcPaint() { CListCtrl::OnNcPaint(); // 先让系统画好边框 CWindowDC dc(this); CRect rcWin, rcScroll; GetWindowRect(&rcWin); int cxVScroll = GetSystemMetrics(SM_CXVSCROLL); rcScroll = CRect(rcWin.right - cxVScroll, rcWin.top, rcWin.right, rcWin.bottom); dc.Draw3dRect(rcScroll, RGB(30,30,30), RGB(30,30,30)); // 轨道背景 SCROLLINFO si = {0}; si.cbSize = sizeof(si); si.fMask = SIF_ALL; GetScrollInfo(SB_VERT, &si); int nTrackH = rcScroll.Height() - 2 * cxVScroll; // 去掉上下箭头的高度 int nThumbH = max(20, nTrackH * si.nPage / si.nMax); int nThumbY = rcScroll.top + cxVScroll + (nTrackH - nThumbH) * si.nPos / max(1, si.nMax - si.nPage); CRect rcThumb(rcScroll.left + 2, nThumbY, rcScroll.right - 2, nThumbY + nThumbH); dc.FillSolidRect(rcThumb, RGB(80, 80, 80)); dc.FillSolidRect(rcScroll.left + 1, rcScroll.top, cxVScroll - 2, cxVScroll, RGB(60, 60, 60)); // 上箭头 dc.FillSolidRect(rcScroll.left + 1, rcScroll.bottom - cxVScroll, cxVScroll - 2, cxVScroll, RGB(60, 60, 60)); // 下箭头 }

2.3 滚动条自绘的三个注意点

第一,DC必须用GetWindowDC,不能用GetDC。GetDC拿到的客户区DC根本画不到非客户区,一开始我用错了,折腾半天滚动条就是不变样。

第二,绘制时要保证窗口边框不被覆盖。我习惯在绘制前用GetWindowRect拿到完整窗口矩形,然后把绘制区域裁剪到滚动条那一竖条,避免把系统画好的edge边线涂掉。

第三,刷新时机。滚动条位置变化后,WM_NCPAINT不一定会自动触发,需要在WM_VSCROLL、WM_HSCROLL以及滚轮消息里手动调用SetWindowPos(NULL, 0,0,0,0, SWP_NOMOVE|SWP_NOSIZE|SWP_NOZORDER|SWP_FRAMECHANGED)来强制重绘非客户区。

3. 重绘HeaderCtrl:从子类化开始

3.1 为什么推荐派生CHeaderCtrl而不是只处理HDN_ITEMDRAW

网上很多方案是在父窗口里响应HDN_ITEMDRAW,用NMHDR里的NMCUSTOMDRAW信息画表头。这个方案在Win7上问题不大,但在XP的某些版本里,这个通知的触发时机不稳定,偶尔会出现表头画一半、排序箭头不刷新之类的情况。

我推荐的方式是派生CHeaderCtrl,重写DrawItem,然后把这个子类窗口装到CListCtrl上。这样表头的绘制逻辑完全收拢在自己的类里,方便复用也方便调试。

3.2 表头自绘核心代码

先声明一个CMyHeaderCtrl,在DrawItem里自行处理背景、分割线、文字和排序箭头:

class CMyHeaderCtrl : public CHeaderCtrl { protected: virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct); }; void CMyHeaderCtrl::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC* pDC = CDC::FromHandle(lpDrawItemStruct->hDC); CRect rcItem(lpDrawItemStruct->rcItem); int nIndex = lpDrawItemStruct->itemID; // 背景 pDC->FillSolidRect(rcItem, RGB(45, 45, 48)); // 右侧分割线 CRect rcDiv(rcItem.right - 1, rcItem.top, rcItem.right, rcItem.bottom); pDC->FillSolidRect(rcDiv, RGB(30, 30, 30)); // 获取表头文字 TCHAR szText[256] = {0}; HDITEM hdi = {0}; hdi.mask = HDI_TEXT; hdi.pszText = szText; hdi.cchTextMax = 255; GetItem(nIndex, &hdi); // 文字区域留出排序箭头空间 CRect rcText = rcItem; rcText.left += 6; rcText.right -= 20; pDC->SetBkMode(TRANSPARENT); pDC->SetTextColor(RGB(235, 235, 235)); pDC->DrawText(szText, &rcText, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); // 绘制排序箭头 HDITEM hdiArrow = {0}; hdiArrow.mask = HDI_FORMAT; GetItem(nIndex, &hdiArrow); if (hdiArrow.fmt & HDF_SORTUP) { DrawSortArrow(pDC, rcItem, TRUE); } else if (hdiArrow.fmt & HDF_SORTDOWN) { DrawSortArrow(pDC, rcItem, FALSE); } }

然后在CListCtrl初始化时,把默认的表头替换成这个子类:

CMyHeaderCtrl* m_pHeader = new CMyHeaderCtrl(); m_pHeader->SubclassDlgItem(0, this); // 这一步是关键,0表示表头控件的ID

注意SubclassDlgItem的第一个参数传0是可行的,因为CListCtrl内部表头ID固定为0。如果直接调用GetHeaderCtrl()拿到指针再SubclassWindow,在某些MFC版本里也可能出问题,这个坑我后面会细说。

3.3 排序箭头和按下状态的坑

画排序箭头时,我一开始用DrawFrameControl(DFCS_ADOWN)来画,结果箭头大小和系统自带的差异很大,在深色背景下边缘还有毛刺。后来改成自己画三角形,代码反而更干净:

void CMyHeaderCtrl::DrawSortArrow(CDC* pDC, CRect rcHeader, BOOL bUp) { CPoint ptCenter; ptCenter.x = rcHeader.right - 12; ptCenter.y = rcHeader.CenterPoint().y; POINT ptArrow[3]; if (bUp) { ptArrow[0] = CPoint(ptCenter.x, ptCenter.y - 4); ptArrow[1] = CPoint(ptCenter.x - 5, ptCenter.y + 3); ptArrow[2] = CPoint(ptCenter.x + 5, ptCenter.y + 3); } else { ptArrow[0] = CPoint(ptCenter.x, ptCenter.y + 4); ptArrow[1] = CPoint(ptCenter.x - 5, ptCenter.y - 3); ptArrow[2] = CPoint(ptCenter.x + 5, ptCenter.y - 3); } CBrush br(RGB(200, 200, 200)); pDC->SelectObject(&br); pDC->SelectStockObject(NULL_PEN); pDC->Polygon(ptArrow, 3); }

另一个坑是表头按下时的凹陷效果。自绘之后系统不会自动画按下状态,如果你在DrawItem里没有根据lpDrawItemStruct->itemState判断ODS_SELECTED,用户点击表头时是没有任何视觉反馈的,看起来很“愣”。我后来在DrawItem开头加了一段:

if (lpDrawItemStruct->itemState & ODS_SELECTED) { pDC->Draw3dRect(rcItem, RGB(20, 20, 22), RGB(60, 60, 62)); }

这样按下去表头会变暗一圈,交互反馈就出来了。

4. 重绘Items:失焦不变灰是最好的练手场景

4.1 NM_CUSTOMDRAW绘制清单

列表项重绘是这三部分里代码量最大、也最容易出效果的。先确认CListCtrl设置了LVS_REPORT风格,然后给控件添加NM_CUSTOMDRAW的消息映射,在响应函数里按阶段处理:

void CMyListCtrl::OnNMCustomdraw(NMHDR* pNMHDR, LRESULT* pResult) { NMLVCUSTOMDRAW* pLVCD = reinterpret_cast<NMLVCUSTOMDRAW*>(pNMHDR); *pResult = CDRF_DODEFAULT; if (pLVCD->nmcd.dwDrawStage == CDDS_PREPAINT) { *pResult = CDRF_NOTIFYITEMDRAW; return; } if (pLVCD->nmcd.dwDrawStage == CDDS_ITEMPREPAINT) { // 进入单个item绘制 *pResult = CDRF_NOTIFYSUBITEMDRAW; return; } if (pLVCD->nmcd.dwDrawStage == (CDDS_ITEMPREPAINT | CDDS_SUBITEM)) { DrawSubItem(pLVCD); *pResult = CDRF_SKIPDEFAULT; } }

其实更常见的做法是只在CDDS_ITEMPREPAINT里处理行背景色,子项文字还是交给系统画。但如果需要完全控制颜色和字体,就需要走到子项级别,也就是CDDS_SUBITEM。

在DrawSubItem里,我做了这几件事:先根据行号判断是否选中,填充背景色;再根据列宽计算文字绘制区域;然后绘制图标、勾选框和文字。

4.2 失焦仍保持选中蓝色的正确写法

这里就是热词里问得最多的“丢去焦点变灰,如何失去焦点不变灰”的核心。系统默认行为是:列表控件失去焦点后,选中项会从高亮蓝色变成灰色。要打破这个行为,唯一可靠的方式就是在自绘中完全接管选中项的绘制,不让系统去画背景和文字。

关键点在DrawSubItem里的逻辑:

BOOL bSelected = (pLVCD->nmcd.uItemState & CDIS_SELECTED); if (bSelected) { // 不管焦点状态,永远用我们自己的蓝色 pDC->FillSolidRect(rcCell, RGB(38, 79, 120)); } else { if (nRow % 2) { pDC->FillSolidRect(rcCell, RGB(50, 50, 52)); } else { pDC->FillSolidRect(rcCell, RGB(42, 42, 44)); } }

这里有个细节特别容易踩坑:在CDDS_SUBITEM阶段,系统传入的uItemState有时并不包含CDIS_SELECTED,需要自己调用GetItemState(row, LVIS_SELECTED)来判断。我最初就是直接相信了传进来的uItemState,结果选中状态时灵时不灵,后来强制改用GetItemState判断,问题就消失了。

文字颜色同理,不关心控件是否有焦点:

if (bSelected) { pDC->SetTextColor(RGB(255, 255, 255)); } else { pDC->SetTextColor(RGB(220, 220, 220)); }

这么处理之后,这个列表无论有没有焦点,选中行永远保持深蓝色底、白字。配合去掉CListCtrl默认的焦点虚线框(在PreSubclassWindow里用ModifyStyle去除LVS_SHOWSELALWAYS,或者干脆不设置它),视觉效果非常稳定。

4.3 双缓冲防闪烁

自绘列表项很容易出现拖动滚动条时整列闪烁的问题。我建议在CListCtrl初始化时加上这行:

SetExtendedStyle(GetExtendedStyle() | LVS_EX_DOUBLEBUFFER | LVS_EX_GRIDLINES);

LVS_EX_DOUBLEBUFFER能解决大部分滚动闪烁。但注意:XP系统上这个扩展样式在部分老版本comctl32里无效,如果你还需要兼容XP,建议检查系统版本,或者在WM_ERASEBKGND里直接返回TRUE,并用内存DC手动双缓冲。我的做法是自绘代码不依赖系统缓存,而是自己在DrawSubItem里用内存DC绘制,先画到临时位图,再一次性BitBlt到目标DC。代价是代码多几行,但滚动流畅度提升非常明显。

5. 常见问题与排查技巧实录

5.1 自绘后复选框消失

如果列表启用了LVS_EX_CHECKBOXES,在自绘Item时一定要自己画勾选框,否则系统默认绘制被CDRF_SKIPDEFAULT跳过之后,勾选框就没了。

解决办法是在CDDS_SUBITEM阶段判断当前列是否是第一列,然后调用DrawFrameControl画复选框:

BOOL bChecked = GetCheck(nRow); UINT uState = DFCS_BUTTONCHECK | (bChecked ? DFCS_CHECKED : 0); pDC->DrawFrameControl(rcCheck, DFC_BUTTON, uState);

5.2 Report模式子项文字重叠

自绘子项时,很多人拿到一个整行矩形就画文字,结果第一列文字和后面几列叠在一起。正确做法是用GetColumnWidth循环累加得到每一列的起始X坐标,然后按列宽裁剪矩形:

int x = 0; for (int col = 0; col < nColCount; col++) { int w = GetColumnWidth(col); CRect rcCol(x, rcRow.top, x + w, rcRow.bottom); // 用rcCol绘制该列 x += w; }

这里要注意列与列之间有一条gridline,如果启用了网格线,绘制前要把矩形右侧缩进1像素,否则文字会和网格线重叠。

5.3 取消所有选择行与选中焦点风格

“clistctrl取消所有选择行”本身很简单,一条代码搞定:

SetItemState(-1, 0, LVIS_SELECTED);

但如果你的自绘代码逻辑写得不好,取消选择后画面上可能还残留高亮背景,这是因为你在绘制时没有强制重绘整个客户区。正确做法是在取消选择后调用Invalidate(FALSE),强制所有行重新走一遍CustomDraw。

5.4 滚动条重绘后滑块拖不动

这是WM_NCPAINT自绘滚动条最常见的问题,原因是覆盖绘制时改了滑块外观,但系统内部仍然用原来的滚动条信息处理点击热区,只要外观滑块位置和内部状态不一致,就会出现“画出来的滑块在这里,但点上去没反应”。

我的排查经验是:不要自己维护滑块位置,绘制时每次都用GetScrollInfo现算。这样即使系统内部状态变化了,下次绘制也能跟上。如果发现拖拽后滑块跳来跳去,多半是WM_VSCROLL里没有触发非客户区重绘,补上SetWindowPos强制刷新即可。

另外,还有一个容易忽视的细节:当列表总行数很少、滚动条变为禁用状态时,滚动条的轨道和滑块要用弱化颜色绘制,否则禁用状态下滚动条依然高亮,视觉上是错的。在WM_NCPAINT里用GetScrollInfo判断nPage >= nMax时,直接画一个置灰轨道,就能规避这个问题。

结尾

这套重绘方案我前后花了一周时间才完全跑通,回头看不难,但三个部分各自的坑确实不少。个人最深的体会是:做CListCtrl自绘,一定要先界定边界——哪些绘制交给系统、哪些自己接管,然后严格计算自绘区域,避免和系统绘制打架。特别是滚动条这种非客户区元素,能少碰就少碰,必须碰的时候,保证每次绘制都从实时状态读取,别自己维护位置缓存。

另外一个小技巧:所有自绘代码里用到的颜色值最好集中定义成常量或配置项,后续换主题时只需要改一处。我后来把深色配色抽成了一个结构体,滚动条、表头、item三处引用同一套色值,换肤方便很多。如果你也想给CListCtrl做统一风格化,建议从Items自绘入手练手,再逐步扩展表头和滚动条,这条路走起来最顺。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询