MFC自绘CListCtrl完美实现:失焦保持高亮、斑马纹与自定义行高

发布时间:2026/9/3 18:05:51
MFC自绘CListCtrl完美实现:失焦保持高亮、斑马纹与自定义行高 简介这是一份面向MFC开发者的自绘控件参考源码重点演示如何通过重载OnDrawItem与OnDrawColumnHeader实现CListCtrl及CHeaderCtrl的完全自定义绘制包括网格线、列表项样式与列头外观并处理非Item区域的背景绘制适合希望摆脱系统默认外观、构建类似CSDN论坛网格布局界面的中级Windows程序员。资源包共22个文件压缩后仅49KB包含7个头文件与6个C源文件核心实现集中在SkinListCtrl.cpp、SkinHeaderCtrl.cpp等代码中同时提供2个位图资源、图标、资源脚本及Visual Studio工程文件可直接编译运行观察效果。代码中涉及使用CDC类进行绘图、响应LVN_DRAWITEM与LVN_HEADERDRAWITEM通知消息、设置LVS_OWNERDRAWFIXED风格等关键环节并包含自绘控件常用的性能优化思路便于读者迁移到其他自定义控件中。资源已有749人学习对于想要深入理解MFC控件自绘、界面美化和交互优化的开发者来说是一份轻量而实用的学习材料。 做过MFC界面开发的朋友应该都有这种体验CListCtrl作为最常用的列表控件默认外观实在谈不上好看。灰白底色、系统字体、选中后一旦窗口失去焦点就变成浅灰色用户经常看不出到底选中了哪一行跟现代软件界面的观感完全是两个时代。这次要拆解的“完美自绘制CListCtrl控件2[MFC]例子源码”核心就是把CListCtrl的绘制逻辑整个接管过来让列表在失焦时依然保持选中行的高亮状态同时支持斑马纹、自定义行高、单元格文字颜色和背景色灵活切换。整个过程不依赖任何第三方皮肤库用的全是MFC自身的能力重点就落在NM_CUSTOMDRAW消息的理解和一套稳定可跑的源码上。我这次把源码的完整思路、实现细节和踩过的坑一次性讲清楚适合正在做界面开发的MFC初学者也适合想在老项目里快速提升控件观感的人参考。1. 整体设计与思路拆解1.1 为什么默认CListCtrl满足不了实际需求先说说为什么要想办法自绘。默认的CListCtrl在Report模式下有几个非常“膈应”的问题第一选中行的高亮色完全依赖系统主题窗口失去焦点后高亮会自动变成灰色用户经常看不出来当前到底选中了哪一列第二行高受系统字体限制想做成报表那种紧凑或者宽松的视觉效果非常费劲第三默认背景色是系统白或灰想跟深色UI主题或自定义皮肤融合几乎不可能。很多项目做到最后其他控件都换肤成功了卡在列表控件上就是因为没有从根本上接管它的绘制。这个源码选择了一条很务实的路线通过NM_CUSTOMDRAW通知在系统绘制流程的关键节点上插入自己的绘制逻辑。它的好处是既保留了CListCtrl原有的交互行为比如点击选中、滚动、键盘导航又能在渲染层面做到完全自定义不需要重写整个控件。1.2 两条自绘路线CUSTOMDRAW还是Owner Draw自绘CListCtrl在MFC里主要有两种路线一种是利用NM_CUSTOMDRAW通知消息在系统绘制流程的各个阶段插入自己的绘制代码另一种是设置LVS_OWNERDRAWFIXED样式完全接管每一项的绘制。对比下来CUSTOMDRAW是明显更推荐的做法。原因很简单Owner Draw是全量接管连默认的排序箭头、拖拽插入标记、子项编辑框显示这些行为都要自己处理工作量非常大。而CUSTOMDRAW保留了系统绘制的大部分能力只在PREPAINT、ITEMPREPAINT、SUBITEMPREPAINT这些关键节点上做“局部介入”既能改颜色、改背景、改字体又不用从头画每一个像素。这两种方案在项目里的取舍简单说就是“画全部”和“改部分”的区别。1.3 项目结构与核心类这个例子的主干是一个从CListCtrl派生的自定义类核心成员包括列表样式控制设置LVS_OWNERDRAWFIXED等扩展风格颜色映射表记录每一行或每一列的文字色、背景色绘制状态标志区分正常、选中、悬停、失焦等状态自定义字体和画刷避免频繁创建GDI对象提升绘制效率。这种设计把绘制相关的逻辑全部封装在控件内部外部使用时只需要调用类似SetRowColor、SetRowHeight这样的接口不需要关心绘制细节。2. 核心细节解析与实操要点2.1 NM_CUSTOMDRAW的分支处理逻辑NM_CUSTOMDRAW的处理是整个自绘控件的灵魂。每次绘制时系统会按照绘制阶段向父窗口发送通知MFC里通过OnNMCustomdraw处理。常见的分支包括CDDS_PREPAINT列表开始绘制决定后续是否需要接收条目级别的通知CDDS_ITEMPREPAINT每个条目标题绘制前触发可以返回CDRF_NOTIFYSUBITEMDRAW请求子项通知CDDS_SUBITEMPREPAINT每个子项绘制前触发在这里设置文字颜色、背景色、字体等。理解了这套阶段划分就不会在“改行色不生效”或“改列色不生效”这类问题上卡太久。核心思路是需要改什么粒度就在对应的阶段返回对应的通知标志。比如要按列着色就必须在ITEMPREPAINT阶段返回CDRF_NOTIFYSUBITEMDRAW否则SUBITEM通知根本不会触发。2.2 绘制矩形与状态的获取在自绘过程中最常用的一个结构体是NMLVCUSTOMDRAW它里面既有通用的NMHDR结构还有nmcd包含hdc、rc等绘制上下文以及clrText、clrTextBk、iSubItem这些子项专用的颜色成员。很多人一开始会忽略nmcd.rc直接用GetSubItemRect去取矩形但CUSTOMDRAW通知里其实已经帮我们把当前绘制项的矩形算好了。直接用nmcd.rc省一次API调用而且对于表头、整行绘制场景语义更准确。绘制文本时要手动调用DrawText或者TextOut并且要处理文本对齐方式。CListCtrl列头设置的LVCFMT_CENTER、LVCFMT_RIGHT等格式不会自动生效必须自己读出来再传给DrawText的uFormat参数。这个细节漏掉自绘之后所有文字都会挤到左边看起来非常别扭。2.3 焦点丢失时保持高亮的关键点这个例子标题里“完美”两个字最能体现价值的地方就是失焦后选中行依然保持高亮。系统默认行为是当控件所在窗口失去激活状态时选中行会被绘制成灰色调。要改变这个行为不外乎两条路在ITEMPREPAINT或SUBITEMPREPAINT阶段根据选中状态主动设置clrTextBk和clrText完全不依赖系统默认的“失焦灰”绘制重写OnPaint或者拦截WM_PAINT消息但这意味着连系统默认绘制都要重新实现复杂度太高。实际用下来第一种方式最干净。只要控件的Extended Style里设置了LVS_EX_FULLROWSELECT在绘制子项时检查LVIS_SELECTED状态然后把背景色设置成自定义的高亮色系统默认的失焦变灰根本没有机会执行。3. 实操过程与核心环节实现3.1 派生控件类与消息映射第一步是在VS里创建一个MFC对话框或SDI工程然后添加一个继承自CListCtrl的类比如CMyListCtrl。类声明的基本框架如下// MyListCtrl.h #pragma once #include afxtempl.h class CMyListCtrl : public CListCtrl { public: CMyListCtrl(); virtual ~CMyListCtrl(); // 对外接口设置行高、颜色、字体 void SetRowHeight(int nHeight); void SetRowColor(int nRow, COLORREF clrText, COLORREF clrBk); void SetSelectedColor(COLORREF clrText, COLORREF clrBk); void SetGridLine(bool bShow); protected: afx_msg void OnNMCustomdraw(NMHDR *pNMHDR, LRESULT *pResult); DECLARE_MESSAGE_MAP() private: int m_nRowHeight; COLORREF m_clrSelectedText; COLORREF m_clrSelectedBk; BOOL m_bShowGrid; CMapint, int, COLORREF, COLORREF m_mapRowTextColor; CMapint, int, COLORREF, COLORREF m_mapRowBkColor; };消息映射怎么加在cpp文件里写BEGIN_MESSAGE_MAP(CMyListCtrl, CListCtrl) ON_NOTIFY_REFLECT(NM_CUSTOMDRAW, CMyListCtrl::OnNMCustomdraw) END_MESSAGE_MAP()注意这里用的是ON_NOTIFY_REFLECT而不是ON_NOTIFY。因为NM_CUSTOMDRAW是发给父窗口的通知如果直接把处理函数放在父窗口对话框类里会破坏控件的封装性。用消息反射把通知“反弹”回控件自身处理后续谁用它谁就自带绘制能力不用每个对话框里再写一遍。3.2 逐步实现核心绘制函数接下来看OnNMCustomdraw的具体实现。这段代码是整个控件的核心逻辑我用注释把每个阶段的作用标清楚void CMyListCtrl::OnNMCustomdraw(NMHDR *pNMHDR, LRESULT *pResult) { NMLVCUSTOMDRAW* pLVCD reinterpret_castNMLVCUSTOMDRAW*(pNMHDR); *pResult CDRF_DODEFAULT; switch (pLVCD-nmcd.dwDrawStage) { case CDDS_PREPAINT: // 列表开始绘制请求条目级通知 *pResult CDRF_NOTIFYITEMDRAW; break; case CDDS_ITEMPREPAINT: { // 条目开始绘制请求子条目级通知 *pResult CDRF_NOTIFYSUBITEMDRAW; break; } case CDDS_SUBITEMPREPAINT: { int nRow static_castint(pLVCD-nmcd.dwItemSpec); int nCol pLVCD-iSubItem; // 先恢复默认色避免上次绘制残留 pLVCD-clrText RGB(0, 0, 0); pLVCD-clrTextBk RGB(255, 255, 255); // 选中行始终使用自定义高亮色不随焦点变化 if ((pLVCD-nmcd.uItemState CDIS_SELECTED) || (GetItemState(nRow, LVIS_SELECTED) LVIS_SELECTED)) { pLVCD-clrText m_clrSelectedText; pLVCD-clrTextBk m_clrSelectedBk; } else { // 按行设置过的背景色 COLORREF clrBk; if (m_mapRowBkColor.Lookup(nRow, clrBk)) pLVCD-clrTextBk clrBk; COLORREF clrText; if (m_mapRowTextColor.Lookup(nRow, clrText)) pLVCD-clrText clrText; // 斑马纹偶数行给一个浅灰底 if (!m_bShowGrid (nRow % 2 1)) pLVCD-clrTextBk RGB(245, 245, 245); } // 绘制网格线 if (m_bShowGrid) { CRect rc; GetSubItemRect(nRow, nCol, LVIR_BOUNDS, rc); pLVCD-clrTextBk pLVCD-clrTextBk; // 背景色不变 // 用自定义函数画边框 DrawGridLine(pLVCD-nmcd.hdc, rc); } *pResult CDRF_NEWFONT; break; } default: break; } }要特别提醒一个坑很多人在SUBITEMPREPAINT里设置了clrTextBk但实际显示出来背景色没变。原因是CListCtrl默认的Extended Style没有开启LVS_EX_GRIDLINES或LVS_EX_DOUBLEBUFFER而text背景色只有在设置了LVS_EX_SUBITEMIMAGES或某些特殊样式下才会被重绘。最稳妥的做法是在控件初始化时调用SetExtendedStyle把LVS_EX_FULLROWSELECT、LVS_EX_DOUBLEBUFFER都打开void CMyListCtrl::InitControl() { SetExtendedStyle(GetExtendedStyle() | LVS_EX_FULLROWSELECT | LVS_EX_DOUBLEBUFFER | LVS_EX_GRIDLINES); }LVS_EX_DOUBLEBUFFER特别重要。自绘之后如果有闪烁十有八九是没开双缓冲。3.3 附加的交互优化行高也是自绘场景里被问得很多的问题。CListCtrl本身没有直接设置行高的接口通常的做法是给控件设置一个高度较大的ImageList用图片高度撑起行高void CMyListCtrl::SetRowHeight(int nHeight) { // 创建一张nHeight高度的位图然后设置到LVSIL_SMALL CBitmap bmp; bmp.CreateCompatibleBitmap(GetDC(), 1, nHeight); m_ImageList.Create(1, nHeight, ILC_COLOR, 1, 1); m_ImageList.Add(bmp, RGB(0, 0, 0)); SetImageList(m_ImageList, LVSIL_SMALL); }这段代码用了ImageList里的透明占位图来抬高行高效果立竿见影。而且不需要额外加载资源运行期动态创建就行。注意ImageList成员变量必须保存在类成员中不能是局部变量否则控件保存的是野指针。另外如果希望去掉焦点虚线框可以在PreSubclassWindow里调用ModifyStyle(0, LVS_SHOWSELALWAYS)或者干脆在自绘时返回CDRF_SKIPDEFAULT跳过系统默认的选中矩形绘制。实际项目中我倾向于保留焦点框因为键盘用户依赖它判断当前条目。3.4 数据填充与颜色映射控件封装好之后外部使用就非常简单了。比如在对话框初始化里填充数据并给指定行设置颜色m_List.SetExtendedStyle(...); // 前面说过 m_List.InsertColumn(0, _T(序号), LVCFMT_CENTER, 60); m_List.InsertColumn(1, _T(名称), LVCFMT_LEFT, 180); m_List.InsertColumn(2, _T(状态), LVCFMT_CENTER, 90); for (int i 0; i 10; i) { CString strID; strID.Format(_T(%d), i 1); int nItem m_List.InsertItem(i, strID); m_List.SetItemText(nItem, 1, _T(项目条目) strID); m_List.SetItemText(nItem, 2, (i % 2 0) ? _T(运行中) : _T(停止)); if (i 3 || i 7) m_List.SetRowColor(i, RGB(0, 0, 200), RGB(255, 255, 220)); } m_List.SetSelectedColor(RGB(255, 255, 255), RGB(0, 120, 215)); m_List.SetRowHeight(28); m_List.SetGridLine(TRUE);到这里外部使用体验已经接近一个成熟的商业控件了调用方不关心内部绘制细节只要设置颜色、行高、交互方式就行。这也是自绘控件源码值得学习的最大价值——把复杂逻辑封装成简单接口。4. 常见问题与排查技巧实录4.1 选中行失焦变灰的根治方法这是这个例子被搜索最多的问题mfc clistctrl选中的后蓝色丢失焦点变灰如何失去焦点不变灰按照上面3.2节的代码选中行高亮色全部走自定义分支不再依赖系统的主题绘制失焦之后就不应该变灰了。如果实测还是变灰首先要检查有没有漏掉LVS_SHOWSELALWAYS样式。这个样式的作用是即使控件没有焦点也始终显示选中状态。必须在PreSubclassWindow或初始化时加上ModifyStyle(0, LVS_SHOWSELALWAYS);还有一个容易忽略的点如果用了CListCtrl的整行选中LVS_EX_FULLROWSELECT在SUBITEMPREPAINT里判uItemState不一定准确不同系统版本表现不一致。更稳妥的做法是用GetItemState主动查询选中状态也就是代码里写的第二个条件。4.2 如何彻底取消所有选中行热搜词里有“clistctrl取消所有选择行”这是自绘场景里常见的交互需求比如点击空白区域取消选中。实现方式很直接void CMyListCtrl::ClearAllSelection() { int nCount GetItemCount(); for (int i 0; i nCount; i) { SetItemState(i, 0, LVIS_SELECTED); } }如果列表项很多逐条设置会有效率问题这时候可以用SendMessage配合LVM_SETITEMSTATE批量操作但一般几千条以内上面这个循环足够快。需要注意的是清空选中后要主动Invalidate否则界面不会立刻刷新。4.3 自绘不生效的几种原因很多朋友把代码抄回去发现颜色没有任何变化排查看下来基本都是下面几个原因没有在消息映射里使用ON_NOTIFY_REFLECT而是把处理写在了父窗口里且父窗口没正确处理使用控件变量时DDX_Control绑定的窗口还没创建导致ModifyStyle调用在子类化之前执行DrawText绘制时没有传入DT_END_ELLIPSIS等格式导致文字超出单元格后没有省略号子项绘制阶段没有返CDRF_NOTIFYSUBITEMDRAW颜色只作用于整行不是每个单元格。排查思路很固定先在CDDS_PREPAINT里设置一个醒目的红色背景如果连红色都不显示说明通知链路有问题如果红色能显示但文字颜色不对说明是DrawText参数或者clrText设置的问题。4.4 大数据量下的性能优化自绘最怕的就是数据量大了以后滚动卡顿。几万行数据每次滚动都触发大量绘制通知如果每个item绘制时都创建新的画刷、字体性能会直线下降。优化的经验有几点字体、画刷、画笔全部做成成员变量初始化一次绘制时直接SelectObject尽量减少GetSubItemRect调用能用nmcd.rc就尽量用如果不需要逐行着色可以考虑在CDDS_PREPAINT里设置整个列表的背景色减少子项重绘次数开启LVS_EX_DOUBLEBUFFER是最基本的否则界面闪烁到怀疑人生。实测下来用这套优化方案单列表塞5万行数据滚动依然流畅。关键是别在绘制循环里做耗时操作一次都没有。我在实际项目里用这套自绘CListCtrl替换掉原来的系统默认控件后最大的感受还不是“变好看了”而是交互状态的确定性变高了。以前用户总是问“我到底选了哪一行”现在失焦、切窗口、弹对话框选中行的颜色始终稳定再也不用靠猜。最后再分享一个小技巧自绘方案定下来之后尽量把颜色值集中到一个配置文件或公共函数里管理比如一行SetSelectedColor(RGB(0,120,215))后续调主题、换配色只需改一个地方整个项目所有列表控件同步生效维护成本能降一个量级。本文还有配套的精品资源点击获取