ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

MFC自绘CListCtrl:彻底解决选中蓝与失焦灰问题

MFC自绘CListCtrl:彻底解决选中蓝与失焦灰问题 简介一套针对MFC CListCtrl控件的自绘实现源码重点展示Item区域与网格线的自定义绘制效果接近论坛网格布局适合希望摆脱系统默认外观、提升列表交互质感的C/MFC开发者学习参考。资源共22个文件以h头文件与cpp源文件为主配合rc资源脚本、ico图标和bmp位图可完整还原工程结构压缩包约49KB轻量且便于直接阅读核心绘图逻辑。目前已有749人学习下载。代码覆盖自绘控件的主流技术路径包括重载绘制消息、使用CDC完成字体与边框输出、处理LVS_OWNERDRAWFIXED样式以及通过通知消息同步绘制列头同时提供了ListView与HeaderCtrl的联动方案便于进一步扩展排序箭头、动态行高等功能。对正在钻研自绘控件或准备项目界面定制的程序员而言这套源码能帮助理清绘制流程、避开常见兼容性陷阱是一份实用的入门与进阶示例。1. 为什么要自绘CListCtrl默认控件的“蓝”与“灰”是真难看1.1 “选中蓝”和“失焦灰”两个老大难做MFC界面开发的十有八九都被CListCtrl的默认效果折磨过。新建一个列表跑起来看选中行永远是系统写死的高亮蓝窗口一旦失去焦点那抹蓝色立刻变成一片灰。项目稍微有点UI追求这个表现基本就过不了关。我翻了一下后台搜索词“mfc clistctrl 选中的后蓝色”“丢去焦点变灰 如何失去焦点不变灰”这类问题出现的频率非常高。说明大家卡住的地方其实很一致不是不会用CListCtrl而是不知道怎么把那个“系统默认状态”彻底盖过去。热词里还有一个很典型的场景——列表失焦后选中行还是灰一块放在仪表盘、数据看板这类界面上观感一下子就掉档次了。这次分享的“完美自绘制CListCtrl控件2[MFC]例子源码”就是我基于第一版例子重构后的完整方案。核心解决三件事选中行的颜色完全由自己控制窗口失焦点后选中态保持高亮不灰化行高、隔行变色、单元格颜色字体等扩展能力全部开放出来。适合正在做MFC上位机、工具软件、数据管理界面的朋友直接抄作业。1.2 自绘路线选型OwnerDraw 与 CustomDraw 怎么选自绘CListCtrl大家最常听到的两个词就是 OwnerDraw 和 CustomDraw。我在第一版例子里用了OwnerDraw重写DrawItem把每一行每一列全部自己画效果确实可以做到很细但代价是坑多到怀疑人生滚动条位置要自己算编辑框定位要自己管焦点矩形要自己补表格线画不齐是常态排序时列数据错位更是家常便饭。所以第二版我换了一条路核心用NM_CUSTOMDRAW通知来做也就是CustomDraw方案。它和OwnerDraw最大的区别在于系统仍然负责控件的布局、滚动、编辑、命中测试这些底层行为我们只是在绘制流程中插入自己的颜色、字体、背景设置。简单说一个是你全包一个是你只负责“涂色”后者显然稳妥得多。我整理了一个两种方案的对比表方便你根据项目情况做判断对比项OwnerDrawDrawItemCustomDrawNM_CUSTOMDRAW绘制粒度整行整体绘制可细化到每个单元格交互行为需要自己维护系统自动保留实现成本高重写逻辑多低只改绘制参数出坑风险高滚动、编辑、排序都容易出问题低系统默认行为仍在适用场景完全自定义外观的复杂报表换肤、配色、状态高亮等大多数UI需求实测下来如果你不是要做那种完全脱离Windows风格的炫酷列表CustomDraw完全够用而且稳定性好很多。2. NM_CUSTOMDRAW到底怎么用自绘的核心流程拆解2.1 绘制流水线的几个关键阶段NM_CUSTOMDRAW是一条贯穿绘制流程的通知消息Windows在控件绘制的各个阶段都会发一次。我们要做的就是根据发送时机决定“这层皮我要不要改”。在控件自己子类里面处理第一步是消息反射声明// MyListCtrl.h class CMyListCtrl : public CListCtrl { protected: afx_msg void OnCustomDraw(NMHDR* pNMHDR, LRESULT* pResult); DECLARE_MESSAGE_MAP() }; // MyListCtrl.cpp BEGIN_MESSAGE_MAP(CMyListCtrl, CListCtrl) ON_NOTIFY_REFLECT(NM_CUSTOMDRAW, CMyListCtrl::OnCustomDraw) END_MESSAGE_MAP()如果是把这个消息放在对话框里处理则对应的是ON_NOTIFY(NM_CUSTOMDRAW, IDC_LIST, CMyDialog::OnCustomDraw)。子类反射的好处是代码内聚后续任何对话框拖进来都能直接用。绘制阶段由NMLVCUSTOMDRAW结构体里的dwDrawStage字段标识。最常用的四个阶段是CDDS_PREPAINT整条绘制流程开始返回值决定后面是否继续通知CDDS_ITEMPREPAINT准备绘制某一行CDDS_SUBITEMPREPAINT准备绘制某一列单元格CDDS_ITEMPOSTPAINT某一行绘制结束适合补画分隔线、图标等额外内容对应的返回值也有讲究CDRF_NOTIFYITEMDRAW是告诉系统“列表项绘制前再来叫我”CDRF_NOTIFYSUBITEMDRAW是告诉系统“每个单元格绘制前再来叫我”。简单理解这两个返回值就是一路“开绿灯”让通知可以逐级下沉。2.2 行级绘制与单元格级绘制的取舍初学者最容易犯的错是直接在CDDS_ITEMPREPAINT里面就把所有列的字体、背景全改了结果发现只有第一列生效其他列还是老样子。原因是NM_CUSTOMDRAW的通知粒度是逐级的如果不在PREPAINT阶段返回CDRF_NOTIFYITEMDRAW后面的行通知压根不会进来如果不在ITEMPREPAINT阶段返回CDRF_NOTIFYSUBITEMDRAW单元格级别的通知也不会触发。但这里有一个性能权衡的问题。SUBITEM阶段的触发频率是“行数×列数”一个100行、8列的列表一次重绘就要进来800次。如果你在绘制函数里做字符串格式化、动态创建画刷、加载字体界面卡顿几乎是必然。我的经验是整行统一背景、隔行变色、选中高亮这类操作尽量在ITEMPREPAINT阶段完成通过设置NMLVCUSTOMDRAW中的clrTextBk和clrText字段影响整行颜色。只有不同列需要不同文字颜色、字体或者需要画进度条、状态图标时才进入SUBITEM阶段逐列处理。能少走一级就少走一级这是自绘列表流畅运行的大前提。3. 实操封装一个直接能用的自绘列表基类3.1 先用配置结构体把“皮肤”定义好既然目标是复用就不能把颜色和字体写死在绘制函数里。我第一版代码被同事吐槽过“换个颜色要找半天”所以第二版干脆在前面加了一个配置结构体把常用外观参数全收进去。// MyListCtrl.h struct ListStyleConfig { COLORREF clrText; // 正常文字颜色 COLORREF clrTextBk; // 正常背景色 COLORREF clrSelectedText; // 选中文字颜色 COLORREF clrSelectedBk; // 选中背景色有焦点时 COLORREF clrInactiveSelText; // 失焦时选中文字颜色 COLORREF clrInactiveSelBk; // 失焦时选中背景色 COLORREF clrAlternateBk; // 隔行背景色 int nRowHeight; // 行高 BOOL bAlternateRow; // 是否隔行变色 BOOL bKeepSelWhenInactive; // 失焦后是否保持高亮 };这个结构体干的事就是把“列表皮肤”具象化。你在对话框初始化时调用一行SetStyleConfig后续换肤只需要改结构体内容再调用Invalidate刷新不需要动绘制函数。3.2 核心绘制函数的完整实现下面是OnCustomDraw的实现也是整个自绘逻辑的核心。我在代码里加了详细注释方便你对照理解每个阶段的走向void CMyListCtrl::OnCustomDraw(NMHDR* pNMHDR, LRESULT* pResult) { NMLVCUSTOMDRAW* pLVCD (NMLVCUSTOMDRAW*)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 (int)pLVCD-nmcd.dwItemSpec; int nCol pLVCD-iSubItem; // 隔行变色偶数行用默认色奇数行用交替色 if (m_styleCfg.bAlternateRow (nRow % 2) 1) { pLVCD-clrTextBk m_styleCfg.clrAlternateBk; } // 选中状态处理 if (pLVCD-nmcd.uItemState CDIS_SELECTED) { // 根据是否有焦点决定用哪组颜色 BOOL bFocus (GetFocus() this) || (GetParent() GetParent()-GetFocus() GetParent()); if (bFocus || !m_styleCfg.bKeepSelWhenInactive) { pLVCD-clrTextBk m_styleCfg.clrSelectedBk; pLVCD-clrText m_styleCfg.clrSelectedText; } else { pLVCD-clrTextBk m_styleCfg.clrInactiveSelBk; pLVCD-clrText m_styleCfg.clrInactiveSelText; } } else { pLVCD-clrText m_styleCfg.clrText; } *pResult CDRF_DODEFAULT; } break; default: break; } }这里最关键的一行是pLVCD-clrTextBk m_styleCfg.clrSelectedBk。很多人在NM_CUSTOMDRAW里用GetDC再画矩形费劲不说还容易出现背景闪一下再被系统覆盖的问题。直接改NMLVCUSTOMDRAW结构体里的颜色字段系统在画文字前会自动用这个颜色填充单元格背景这才是CustomDraw“准”自绘的正确姿势。关于“失焦是否保持高亮”的处理我再多说一句。需求一般分两种一种是数据展示型界面列表失焦后依然要醒目地显示选中行另一种是交互编辑型界面失焦后选中行弱化方便用户把视觉焦点转移到其他控件。bKeepSelWhenInactive这个开关就是为这两种场景准备的。3.3 行高、隔行变色、整行选中的配套设置自绘列表还有一个高频需求是调行高。CListCtrl本身没有SetRowHeight接口但有一个经典技巧通过设置Small Image List的高度来撑起行高。// 在控件初始化时调用例如OnInitDialog m_imgList.Create(1, 32, ILC_COLOR, 1, 0); // 1像素宽32像素高 SetImageList(m_imgList, LVSIL_SMALL); // 行高被撑到32像素注意这个CImageList对象必须声明为控件类的成员变量绝对不能是局部变量。局部变量在函数结束后析构会把内部的HIMAGELIST句柄释放掉控件持有一个悬空句柄轻则行高恢复默认重则直接崩溃。整行选中则用扩展样式SetExtendedStyle(GetExtendedStyle() | LVS_EX_FULLROWSELECT | LVS_EX_DOUBLEBUFFER);LVS_EX_FULLROWSELECT让点击任意一列都能选中整行LVS_EX_DOUBLEBUFFER开启双缓冲能明显减少重绘时的闪烁。这两个扩展样式和自绘配合非常默契建议默认就开。4. 常见问题与排查技巧实录4.1 高频问题速查表我在做这个例子源码的过程中把网友问得最多的问题和排查思路整理成了表格。你在自绘过程中遇到类似现象可以直接对照处理。现象大概率原因解决办法选中行颜色没变还是系统蓝通知没有进SUBITEM分支检查PREPAINT是否返回CDRF_NOTIFYITEMDRAWITEMPREPAINT是否返回CDRF_NOTIFYSUBITEMDRAW只有第一列变色其他列不变在ITEMPREPAINT里设置了clrTextBk单元格颜色要在SUBITEM阶段设置窗口失焦后选中行变灰没有处理焦点状态判断用GetFocus结合bKeepSelWhenInactive逻辑决定颜色列表闪烁严重未开双缓冲或绘制函数太重开启LVS_EX_DOUBLEBUFFER避免在绘制里创建GDI对象设置了行高但文字被裁切ImageList句柄释放或行高数值不合法将CImageList设为成员变量行高建议不小于20像素滚动后单元格颜色错位绘制逻辑依赖行号但数据视图已偏移使用NM_CUSTOMDRAW时不要基于行号缓存状态每次实时判断另外还有两个容易被忽略的细节。第一自绘只是改了颜色和绘制参数并不会改变选择行为所以“取消所有选择行”这种需求仍然用SetItemState(-1, 0, LVIS_SELECTED)就能实现。第二如果你用了RedrawItems局部刷新它同样会触发NM_CUSTOMDRAW不用担心只刷新部分行时颜色就不对。4.2 闪烁与刷新深坑的规避经验闪烁问题几乎是自绘列表绕不开的一道坎。很多人一遇到闪烁就上来双缓冲其实要先看闪烁是整块闪还是局部闪。整块闪通常是背景擦除和重绘顺序造成的处理方式是在子类里拦截WM_ERASEBKGND并直接返回TRUE让列表不要反复清空背景BOOL CMyListCtrl::OnEraseBkgnd(CDC* pDC) { return TRUE; // 背景交给NM_CUSTOMDRAW里的clrTextBk处理 }局部闪则普遍是绘制函数里做了耗时操作比如每次进来都创建CFont、CBrush或者调用GetDC刷屏。正确做法是把字体、画刷这些GDI对象声明为成员变量初始化时创建一次NM_CUSTOMDRAW里只负责选入和恢复。我自己踩过最深的一个坑是在SUBITEM阶段调用了GetItemText来取文本并做字符串拼接用来判断单元格颜色。100行×8列的表格一次重绘就是800次字符串操作界面掉帧掉到没法看。后来把所有判断逻辑改成基于数据索引查询速度立刻回到流畅档位。自绘函数里一切重复性的重量级操作都要预先算好。5. 进阶表头自绘与列表项里的“装饰列”5.1 把表头也纳入绘制范围列表主体搞定之后你会发现表头还是系统默认的灰色渐变样式和自绘的列表体搭在一起很突兀。表头自绘和列表体不一样它的通知走的是HDN_ITEMDRAW或者直接给Header Control设置OwnerDraw风格。我自己用的是比较省事的做法在对话框资源里给CListCtrl一个子类化窗口然后子类里处理HDN_ITEMDRAW反射。绘制阶段同样有CDDS_PREPAINT、CDDS_ITEMPREPAINT这些区分通过NMCUSTOMDRAW结构体拿到表头项然后设置HDC的字体、背景色、文字颜色即可。需要注意表头自绘时文字的对齐和排序箭头位置需要自己算。我的经验是先调用DrawText画出文字再保留右侧空间给排序箭头避免文字和箭头重叠。这里比较花时间的是排序箭头的绘制如果不想自己画三角形也可以借用系统的DrawFrameControl带DFCS_SORTDOWN或DFCS_SORTUP标志来生成。5.2 扩展成可配置的通用列表控件把自绘能力做成通用基类之后我习惯再加一层“单元格绘制回调”机制。也就是在配置结构体里增加一个回调函数指针或者std::function按列号注册不同的绘制行为。举个例子第1列画状态灯红绿圆点第2列画进度条其他列正常显示文字。SUBITEM阶段拿到nCol之后先查回调表命中就进入自定义画法没命中就走默认文字绘制。这样列表控件从“能改颜色”升级成“每种列都有自己的表达方式”后续做MFC上位机里的设备状态表、数据监控面板基本拿来就能用。我在第二版例子里专门留了这个扩展点也写了一个“状态列进度列”的sample方便把整个绘制流程串起来看。实际效果是列表既能保持Windows原生的滚动、编辑、排序能力又能在视觉上完全融入项目的整体皮肤风格。最后说一个我后来才想明白的体会自绘列表做得好不好不在于把某一行画得多花哨而在于你知道系统在哪个阶段给了你插手的机会并且只在最合适的时机做最少的干预。调试NM_CUSTOMDRAW的时候我的习惯是在OnCustomDraw入口断点交替观察dwDrawStage和uItemState的变化基本跑一遍就能看清整条绘制链路。只要把这条链路摸透了CListCtrl的“完美自绘”其实也就那么回事。本文还有配套的精品资源点击获取
返回列表