
简介面向 MFC 开发者的 CListCtrl 界面定制资源包聚焦列表控件滚动条、表头与列表项的自绘实现。资源适合需要美化列表界面、提升控件交互体验的 C/MFC 桌面程序开发者内容覆盖自定义滚动条类设计、水平与垂直滚动消息处理、表头样式重绘、OwnerDraw 列表项绘制等核心环节。压缩包共42个文件大小约87KB以 h 头文件与 cpp 源文件为主辅以 bmp 图片资源、工程配置文件及说明文档目录结构便于对照示例工程查看完整代码组织包内含 SkinList_demo 示例工程、实现说明及可参考文本可直观了解控件重绘的类结构、消息流和关键绘制流程。已有325人学习浏览通过这套代码读者能够掌握 MFC 自绘列表控件的一般方法并在此基础上扩展出符合自身产品风格的高质量列表界面。 前阵子做MFC工控客户端界面美化这件事被提到了日程上。产品经理丢过来一句话列表能不能别长得这么原生翻译一下就是CListCtrl的三件套——滚动条scrollbar、表头headerctrl、列表项items——都得重画。网上关于这块的代码零零散散有人贴一段NM_CUSTOMDRAW有人贴一个自绘滚动条类真要把三样东西拼到同一个列表里还是得自己踩一遍坑。这篇文章就把我实际重绘这三块的思路、代码和翻车记录整理出来顺带把选中后蓝色、失去焦点变灰、怎么取消所有选中行这几个高频问题一起解决掉。1. 先搞清楚CListCtrl的绘制权到底在哪很多人上来就找OnDraw但CListCtrl这控件比较特殊它不是一个窗口自己画完所有东西。它内部至少有三个绘制主体列表项区域由ListView自身负责表头是独立的SysHeader32窗口垂直滚动条和水平滚动条属于非客户区绘制。这三者走的是完全不同的事件通道想一次搞定必须分开处理。我建议动手前先想清楚你要哪种程度的自绘因为这条路其实分三档档位做法适合场景轻量设置颜色、字体、LVS_EX_*扩展样式只想改文字颜色、选中色、网格线中间NM_CUSTOMDRAW 自定义绘制需要按列/按行/按状态画不同效果重量完全接管表头、表项、滚动条的绘制和命中测试深色皮肤、完全自定义UI标题里说的重绘scrollbar, headerctrl和items基本就是第三档。但我的建议是不要把三件套一次性全上。如果你只是想让列表融入深色主题先做表头和表项的自绘滚动条放到最后因为它是最容易把项目拖进泥潭的部分。后面的章节我按从易到难的顺序讲这样你就算只做一半也能先拿到能用的成果。2. 表头重绘从NM_CUSTOMDRAW下刀表头是整个列表里最好处理的一块。它虽然是个独立窗口但系统给了NM_CUSTOMDRAW这条公开通道你不需要子类化HeaderCtrl也能插一脚。核心思路是在CDDS_ITEMPREPAINT阶段接管绘制然后返回CDRF_SKIPDEFAULT告诉系统这格我自己画你别动手。代码骨架大概长这样void CMyListCtrl::OnHeaderCustomDraw(NMCUSTOMDRAW* pNMCD, LRESULT* pResult) { if (pNMCD-dwDrawStage CDDS_PREPAINT) { *pResult CDRF_NOTIFYITEMDRAW; return; } if (pNMCD-dwDrawStage CDDS_ITEMPREPAINT) { CDC* pDC CDC::FromHandle(pNMCD-hdc); CRect rcItem(pNMCD-rc); // 1. 画底色 pDC-FillSolidRect(rcItem, RGB(38, 42, 50)); // 2. 画表头文字注意留出排序箭头的空间 TCHAR szText[256] { 0 }; // 通过 pNMCD-dwItemSpec 找到列索引再用 GetColumn 取列标题 CString strHeader GetHeaderText((int)pNMCD-dwItemSpec); rcItem.right - 18; pDC-DrawText(strHeader, rcItem, DT_SINGLELINE | DT_CENTER | DT_VCENTER); // 3. 如果这一列是当前排序列再画排序箭头 if ((int)pNMCD-dwItemSpec m_nSortCol) { DrawSortArrow(pDC, rcItem.right 4, rcItem.top, rcItem.Height(), m_bSortAscending); } // 4. 右侧画一条细分隔线 CRect rcDivider(rcItem.right 16, rcItem.top 6, rcItem.right 17, rcItem.bottom - 6); pDC-FillSolidRect(rcDivider, RGB(55, 60, 70)); *pResult CDRF_SKIPDEFAULT; } }这里的重点有三个返回CDRF_SKIPDEFAULT之后整格内容都由你负责包括背景、文字、图标。漏画任何东西都会很难看。表头的排序箭头没有现成API能画得自己用Polygon画三角。别用字符▲/▼不同字体下渲染高度不一致用绘制代码最稳。表头的列宽拖拽分割线是通过系统自己的命中测试完成的。只要你在CDDS_ITEMPREPAINT阶段返回不拦截鼠标消息拖拽功能依然能用这一点比滚动条友好太多。另外提一句如果表头背景色很深文字颜色也最好跟着变。直接在OnHeaderCustomDraw里画文字不要依赖SetTextColor那种全局属性因为排序状态变化后系统重绘可能会覆盖你的设置。3. 表项重绘选中蓝、失焦灰和取消全选都是这里的事表项的自绘接口同样是NM_CUSTOMDRAW但处理逻辑比表头复杂因为你要面对行状态选中、悬浮、焦点和子项subitem两个维度。最基础的处理长这样void CMyListCtrl::OnListCustomDraw(NMLVCUSTOMDRAW* pLVCD, LRESULT* pResult) { switch (pLVCD-nmcd.dwDrawStage) { case CDDS_PREPAINT: *pResult CDRF_NOTIFYITEMDRAW; break; case CDDS_ITEMPREPAINT: { BOOL bSelected (pLVCD-nmcd.uItemState CDIS_SELECTED) ! 0; if (bSelected) { DrawSelectedRow(pLVCD); // 自己画整行背景 *pResult CDRF_SKIPDEFAULT; } else { pLVCD-clrTextBk RGB(255, 255, 255); pLVCD-clrText RGB(40, 40, 40); *pResult CDRF_NOTIFYSUBITEMDRAW; } break; } default: *pResult CDRF_DODEFAULT; break; } }3.1 失焦后选中行变灰的根源如果你在默认绘制下设置clrTextBk会发现一个奇怪现象列表有焦点时选中行是蓝色的窗口一切到后台选中行就变成了系统灰。这不是你的代码被覆盖而是ListView的默认行为——它会根据窗口是否有焦点来选择选中态配色。想做到失去焦点也不变灰最可靠的办法就是上面代码里的思路一旦检测到该行处于CDIS_SELECTED状态就不要让系统去画选中背景而是自己在DrawSelectedRow里统一使用同一种颜色。这样不管窗口有没有焦点看到的都是你自己定义的高亮色。我当时用的高亮是RGB(0, 120, 215)配合白色文字焦点切换前后几乎看不出变化。DrawSelectedRow里除了FillSolidRect铺底色别忘了自己画文字。文字可以用ListView_GetItemText取出来再用DrawText画。图标、CheckBox如果存在也要同步处理。这就是完全自绘的代价——你已经不接受系统的默认渲染了。3.2 一键取消所有选中行取消所有选择行这个需求在数据刷新、弹窗关闭后非常常见。最直观的写法是循环遍历实际上也建议这么做void CMyListCtrl::ClearSelection() { SetRedraw(FALSE); for (int i 0; i GetItemCount(); i) { SetItemState(i, 0, LVIS_SELECTED); } SetRedraw(TRUE); Invalidate(); }有人图省事想传SetItemState(-1, 0, LVIS_SELECTED)一把梭但LVM_SETITEMSTATE对iItem为-1的行为在文档里写得不明确不同系统版本表现并不一致。稳妥起见数据量不大的时候用循环几百行也不会卡。数据量上万的话你可以用虚拟列表LVS_OWNERDATA配合LVIS_SELECTED状态统一清零但那就牵扯到另一个话题了。3.3 选中行悬浮态和聚焦框自绘之后很多人会忘记处理MouseHover下的高亮导致整个列表看起来死沉沉的。要在CDDS_ITEMPREPAINT里判断悬浮态需要先给ListView设置LVS_EX_TRACKSELECT扩展样式然后检查CDIS_HOT状态。我建议悬浮态颜色比普通背景稍微亮一点点就行不要做得太过否则大列表扫一眼全是高亮视觉很累。还有那个细虚线的聚焦框focus rect在自绘模式下通常不会自动出现。深色皮肤下它本来也不好看我一般直接不画。如果你的UI规范要求键盘操作可见焦点那就必须在CDDS_FOCUS阶段自己画虚线或边框。4. 滚动条重绘真正绕不开的硬骨头滚动条是这三块里最麻烦的因为Windows根本没有给普通滚动条提供自绘消息。ListView的滚动条并不是一个可控的CScrollBar子窗口它基本属于系统非客户区的绘制产物。想让它变成皮肤风格常见路线有两条我把两条都给你摆出来。4.1 路线一子控件滚动条移花接木适合中小型项目、时间紧的情况。思路是禁用ListView自带的滚动条然后在客户区右侧叠放一个自绘CScrollBar子窗口让它负责滚动逻辑。大致步骤创建ListView后用ModifyStyle(WS_VSCROLL, 0)隐藏系统滚动条或者用ShowScrollBar(SB_VERT, FALSE)。在ListView右侧创建子类化的CMyScrollBar通过WM_DRAWITEM或自绘OnPaint画成你的皮肤。建立同步关系ListView数据变化时从ListView的GetScrollInfo(SB_VERT, si)拿滚动信息同步到滚动条滚动条滚动时用SendMessage(LVM_SCROLL, 0, nDelta)让ListView滚动到指定位置。核心同步代码大概这样void CMyListCtrl::SyncScrollBar() { SCROLLINFO si { sizeof(si) }; si.fMask SIF_ALL; GetScrollInfo(SB_VERT, si); m_skinScrollBar.SetScrollInfo(si, TRUE); m_skinScrollBar.ShowWindow(si.nMax 0 ? SW_SHOW : SW_HIDE); }子控件的自绘滚动条有一个额外好处鼠标命中和拖动逻辑都在你自己的窗口里调试起来直观。代价是水平滚动条、滚动条置顶层级、边框重叠这些细节都要自己处理尤其是“列表只露出左边一块右侧让给滚动条”这一步容易出现过界或遮挡文字的问题。4.2 路线二接管WM_NCPAINT硬画非客户区如果你追求“滚动条和列表严丝合缝”就得走这条硬核路线。CListCtrl在滚动时垂直滚动条区域会交由系统在非客户区绘制。你要做的是子类化CListCtrl在WM_NCPAINT里用GetScrollInfo拿到滚动条数据把系统画出来的默认滚动条覆盖掉。这里的核心算法是滚动条thumb的矩形计算CRect rcScroll rcWindow; rcScroll.left rcWindow.right - nScrollBarWidth; SCROLLINFO si { sizeof(si) }; si.fMask SIF_ALL; GetScrollInfo(SB_VERT, si); if (si.nMax 0) { int nTrackHeight rcScroll.Height(); int nThumbHeight max(20, nTrackHeight * si.nPage / max(1, si.nMax si.nPage)); int nMaxThumbPos nTrackHeight - nThumbHeight; int nThumbPos 0; if (si.nMax si.nPage) { nThumbPos nMaxThumbPos * si.nPos / max(1, si.nMax - si.nPage); } rcScroll.top nThumbPos; rcScroll.bottom rcScroll.top nThumbHeight; }算出矩形后自己FillRoundRect画thumb再用Polygon画上下箭头。麻烦的不只是画而是这一整套要重写WM_NCHITTEST、WM_NCLBUTTONDOWN/MOVE/UP、WM_NCMOUSEMOVE、WM_NCMOUSELEAVE。也就是说连鼠标点在滚动条上、点在上箭头、点在thumb上这些命中判断都得自己模拟。完整实现没有一千行代码下不来。就我个人经验除非是做那种全局统一的UI框架、以后每个控件都要复用否则不建议硬啃这条线。很多时候移花接木方案已经足够而且可控性更高。4.3 谁都能踩的DPI坑自绘滚动条有个隐蔽问题不要直接写死宽度16这种数值。Windows的滚动条宽度来自系统指标在125%、150%缩放下得用GetSystemMetrics(SM_CXVSCROLL)。如果是PerMonitorV2 DPI感知的程序还得用GetSystemMetricsForDpi(SM_CXVSCROLL, dpi)否则高分屏上滚动条要么细成一条线要么被拉伸糊掉。我在4K屏上就翻过这个车当时代码里写死了14像素结果在150%缩放下整个滚动条窄得没法点。5. 实测翻车点双缓冲、重绘顺序和隐藏雷区代码写出来能编译只是第一步真正跑起来才能看到一堆奇怪现象。我把自己踩过的几个大坑列在这里按出现概率排序。现象原因处理列表滚动时闪烁自绘未做双缓冲使用LVS_EX_DOUBLEBUFFER扩展样式背景不断重擦白边WM_ERASEBKGND默认擦除返回TRUE并自己画背景Table的列宽盖到滚动条子控件滚动条占用客户区预留宽度或调整列宽度高分屏下滚动条比例不对DPI缩放未处理用GetSystemMetricsForDpi选中行颜色不稳定系统选中色覆盖选中行必须CDRF_SKIPDEFAULT自己画5.1 双缓冲不是选项是默认项传统做法里如果你自己绘制了太多内容主线程又频繁Invalidate闪是必然的。给ListView加LVS_EX_DOUBLEBUFFER扩展样式之后列表区域自身的闪烁问题基本治愈。不过要注意如果你在WM_NCPAINT里自绘滚动条双缓冲只对客户区有效滚动条区域还需要自己用内存DC画好再BitBlt上去。5.2 表头、表项、滚动条的重绘顺序很多人以为自绘三件套里顺序不重要。实际绘制顺序是先画表项区域再画表头最后画滚动条。因为表头窗口是独立子窗口它天然会覆盖在表项上方滚动条如果走NC路线就永远在最高层。如果你用了移花接木的子控件方案还要注意不要让边缘黑线露出我在滚动条和列表之间留了1像素的分隔线视觉上舒服很多。5.3 别动WM_ERASEBKGND以外的“小脑筋”有人为了让自绘列表彻底不闪把WM_ERASEBKGND返回TRUE结果列表控件周围的背景色也变成一块黑。这是两个层次的事客户区背景擦除和列表项的绘制区域不是一回事。正确做法是WM_ERASEBKGND返回TRUE的同时在CDDS_PREPAINT阶段调用FillSolidRect把整个列表客户区背景统一刷成你的底色然后让后续item绘制基于这块底色叠加。最后再分享一个我自己的小习惯自绘代码不要散落在CListCtrl的OnNotify一大坨里。我是拆成三个方法——OnHeaderDraw、OnItemDraw、OnScrollDraw——每个方法只管自己的区域。因为后面排bug时你最需要的就是我只改一处其他地方不会动的信心。MFC本来就不擅长UI美化CListCtrl这一套做完你会比做业务功能时更能理解Windows控件到底是怎么一层层画出来的。本文还有配套的精品资源点击获取