简介:面向需要在VC++程序中实现复选下拉框的开发者,基于VS2008 SP1环境编写,核心为CheckComboBox.h与CheckComboBox.cpp两个文件,提供了可直接使用的CCheckComboBox组件。针对作者在模态对话框使用中遇到的多次进入后无法正常选择的棘手问题,资源给出了改用非模态子对话框的解决方案,帮助读者避开这一陷阱。压缩包共22个文件,除核心源码外,还包含6个cpp源文件、8个头文件以及工程配置、资源文件、说明文档等,整体仅136KB,结构清晰,便于直接集成或参考。资源附带的完整示例工程演示了在对话框中使用该组件的具体流程,可帮助理解消息映射与界面刷新逻辑。目前已有949人学习使用,适合正在处理复杂界面交互的VC++开发者,尤其是遇到下拉复选功能异常时可作排错参考。通过阅读示例工程与源码,可快速掌握复选下拉框的正确用法及其在对话框编程中的注意事项,提升开发效率。
1. 项目概述与核心需求
做MFC桌面开发的兄弟,十有八九会遇到这种需求:界面上有个下拉框,里面列了十几个选项,用户需要勾选其中好几个,然后程序再根据勾选结果干活。你用默认的CComboBox拉下来,一次只能选一项,根本顶不住;你用一堆CheckBox控件平铺在界面上,十几个又占地方又难看;你用ListBox加多选模式,界面风格又和下拉框不搭。这就是我这次要聊的——在VC++里实现一个自带复选框的下拉框,我习惯把它叫做CCheckComboBox,本质就是对CComboBox做二次扩展,让下拉列表里的每一项前面都有一个CheckBox。
这个控件能解决什么问题?很直接:让“多选”这个操作在视觉上和操作上都保持下拉框的轻量感,不额外占据对话框空间,同时用户看到勾选框就知道可以多选,交互心智零成本。它适合谁来参考?如果你正在用VC++ 6.0到Visual Studio 2017甚至更新的版本做MFC开发,碰到过类似的“下拉多选”需求,或者你想研究一下CComboBox的Owner Draw机制到底怎么玩,这篇文章的完整实现思路和踩坑记录都能直接用上。
先交代一下实现路径:我们不引入任何第三方库,就是基于MFC的CComboBox派生一个自定义类,重写几个关键虚函数和处理几个通知消息,然后把它当普通下拉框一样拖到对话框上用。核心原理说穿了就两件事:第一,让下拉框进入自绘模式(Owner Draw),这样每一项的内容由我们自己画;第二,在每一项前面根据该项的选中状态画一个CheckBox图案,点击的时候切换状态并刷新显示。听起来不复杂,但真正落地的时候,有几个坑不踩一遍根本想不到。
2. 方案分析与界面设计思路
2.1 为什么选择继承CComboBox而不是从零写控件
有人可能会问,直接拿CListBox加LBS_MULTIPLECHECK风格,再嵌到一个下拉框外形里不就行了?这事我试过,实际做出来之后交互很别扭。CListBox自带的复选列表风格(LBS_MULTIPLECHECK)画出来的勾选框样式比较老气,而且它不归CComboBox管,你得自己处理下拉弹出、收起、失去焦点时的行为,等于同时维护两个控件窗口的联动状态。还有一版我试过用CComboBox的下拉列表直接SetDroppedWidth然后自绘,但那个方案对行高的控制很死,勾选框坐标也容易算歪。最后稳定下来的方案还是从CComboBox派生,利用它自带的DropDown列表框架,配合Owner Draw机制逐项自绘。这样下拉框的弹出、滚动、选中高亮、键盘操作这些底层行为都是CComboBox自带的,我们只负责画内容和处理点击,工作量小一半还不容易出Bug。
这里的核心机制叫Owner Draw,也就是自绘模式。CComboBox创建的时候带上CBS_OWNERDRAWFIXED风格,系统就不再画每一行的默认文本了,而是通过WM_DRAWITEM消息回调通知我们说“这一项需要你来画”。对应到MFC里,就是重写虚函数DrawItem。同时还有一个MeasureItem,用来告诉系统每一行的行高是多少,这直接决定了下拉列表的行距。DrawItem和MeasureItem这两个虚函数,就是整个复选下拉框的基石。
在自绘模式下,CComboBox的文本显示区(平时没拉下来时显示当前文字的那个编辑框或静态区域)也会走DrawItem。这意味着你要自己画“收缩状态”下的显示内容。我的做法是,平时把已勾选的项的文字用逗号拼起来显示,如果一项都没勾,就显示一个占位符,比如“请选择...”。这个逻辑写在一个私有的UpdateText成员函数里,每次勾选状态发生变化时调用。
2.2 数据结构设计与状态存储
复选状态需要存储。CComboBox有一个非常方便的配套机制:每一项都可以通过SetItemData绑定一个DWORD_PTR类型的附加数据,用GetItemData取出来。这个附加数据原本常用的用途是存放指针或者索引值,我这里直接拿它当布尔状态用,0代表未勾选,1代表已勾选。为什么不用一个额外的BOOL数组?因为CComboBox的下拉项是可以被动态增删的,如果维护一个独立数组,AddString、DeleteString、InsertString、ResetContent这些函数全部都要同步维护数组,哪一次忘了就出隐蔽Bug。用SetItemData绑定,增删项的时候状态跟着项走,自动同步,省心。
当然,如果你需要存储“半选”状态或者更复杂的业务标识,一个DWORD_PTR不够用,那就应该改成存储一个堆指针,指向你自己的结构体。这里先按下不表,我们基础版本用0/1就够。
代码骨架长这样,头文件里类的声明:
// CCheckComboBox.h #pragma once class CCheckComboBox : public CComboBox { DECLARE_DYNAMIC(CCheckComboBox) public: CCheckComboBox(); virtual ~CCheckComboBox(); void SetCheck(int nIndex, BOOL bChecked); BOOL GetCheck(int nIndex) const; void SelectAll(BOOL bCheckAll); CString GetCheckedItemsText() const; protected: afx_msg void OnDropdown(); afx_msg void OnCloseup(); afx_msg LRESULT OnSelEndOK(WPARAM wParam, LPARAM lParam); afx_msg LRESULT OnSelChange(WPARAM wParam, LPARAM lParam); virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct); virtual void MeasureItem(LPMEASUREITEMSTRUCT lpMeasureItemStruct); BOOL PreTranslateMessage(MSG* pMsg); DECLARE_MESSAGE_MAP() private: CString m_strPlaceholder; BOOL m_bDropped; void UpdateText(); void ToggleCheck(int nIndex); };这里有几个值得聊的设计点。第一,我用m_bDropped标记下拉是否展开。为什么要这个变量?因为DrawItem在下拉展开和收缩两种状态下都会被调用,画法不同,而DrawItem的参数里没有直接告诉你当前是不是展开状态,所以要在OnDropdown(下拉框展开通知)和OnCloseup(收起通知)里手动维护。第二,OnSelEndOK和OnSelChange为什么用消息映射而不是普通虚函数?因为我们要拦截的是CBN_SELENDOK和CBN_SELCHANGE这两个通知消息,它们在用户在下拉列表里选中某一项时触发,父窗口会把它们以WM_COMMAND的形式发给CComboBox自己,MFC通过消息映射表能够反射处理。简单说,消息反射机制让控件自己处理自己的子控件通知,父对话框不用插一手。第三,PreTranslateMessage用来拦截鼠标和键盘消息,后面讲坑的时候会细说。
3. 核心流程与关键步骤实现
3.1 创建控件与样式设定
关键的样式设定必须在CreateWindow时一步到位。比如你要让下拉框自绘,就要有CBS_OWNERDRAWFIXED;要让下拉列表是列表框而不是编辑框风格,就不要CBS_DROPDOWN而是CBS_DROPDOWNLIST。CBS_DROPDOWNLIST意味着用户不能手动输入文字,只能点开下拉选择,这对我们的复选场景是合适的,因为用户不需要输入任意文本。
在对话框模板里动态创建的话,这样写:
// 在对话框初始化时动态创建复选下拉框 CRect rcCombo(10, 10, 250, 30); m_checkCombo.Create( WS_CHILD | WS_VISIBLE | WS_TABSTOP | CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS, rcCombo, this, IDC_CHECK_COMBO);注意CBS_HASSTRINGS。这个风格告诉系统,下拉项的数据以字符串为主,这样GetLBText等函数才能正常工作,而且系统内部会管理字符串内存。MeasureItem和DrawItem中需要的行高、文本输出都会依赖这个风格,不能省。
如果你打算直接在对话框资源编辑器里放置一个Combo Box控件,再在类向导里给控件关联变量并手动改成CCheckComboBox类型,也可以,但必须记得在对话框的OnInitDialog里给控件动态加上CBS_OWNERDRAWFIXED风格。因为对话框模板里预创建的控件是没有这个风格的,缺了它DrawItem就不会被调用。补风格代码如下:
// 如果控件是在资源模板中定义的,需要在这里补上自绘风格 m_checkCombo.ModifyStyle(0, CBS_OWNERDRAWFIXED);3.2 MeasureItem:行高的精确控制
MeasureItem这个函数的作用是告诉系统每一行占多高。这里有个细节很多人忽略:返回的行高是下拉列表弹出来时每一项的高度,但DrawItem里绘画区域的高度也是它。如果行高给太小,勾选框和文字挤在一起;给太大,十几项的下拉列表会变得很高很难看。
我的经验值是:用当前字体信息动态计算行高,文字高度的基础上加6到8像素的上下留白,这样勾选框能宽松地放进去。
void CCheckComboBox::MeasureItem(LPMEASUREITEMSTRUCT lpMeasureItemStruct) { // 根据字体动态计算项高度 TEXTMETRIC tm = { 0 }; HDC hDC = GetDC(); CFont* pFont = GetFont(); CFont* pOldFont = (CFont*)hDC->SelectObject(pFont); GetTextMetrics(hDC, &tm); hDC->SelectObject(pOldFont); ReleaseDC(hDC); // 行高 = 字符高度 + 上下各4像素留白 + 1像素底边线 lpMeasureItemStruct->itemHeight = tm.tmHeight + 9; }这套写法在DPI变化的环境中表现相对稳定,因为字体信息是动态查的。在高DPI显示器(125%、150%缩放)下,系统字体会变大,行高跟着变,不会出现文字被截断的问题。
3.3 DrawItem:绘制勾选框、高亮和文本
DrawItem是整份代码的灵魂。在这个函数里,你要处理三件事:背景绘制、勾选框绘制、文本绘制。先说背景。当下拉列表里鼠标停留在一项上时,系统会设置ODS_SELECTED标志,这时候需要自己画高亮背景色,通常用COLOR_HIGHLIGHT。但要注意,如果你整行都填充了高亮色,再画一个勾选框在上面,勾选框的区域就会显得很怪——所以我处理的方式是:先整行填充背景高亮,再画勾选框的白色方形区域,最后在方形区域中心画一个对勾符号。
勾选框的绘制不要自己去画那些复杂的3D边框效果,直接调用DrawFrameControl函数,用DFC_BUTTON | DFBS_CHECKBOX参数,系统会以当前主题样式画出带圆角的、符合Windows风格的勾选框。选中状态则在DrawFrameControl后再覆盖一个DFCS_CHECKED状态标志。这是最简单也最不容易画歪的办法,比用Rectangle+Line手画稳定得多。
核心绘制代码如下:
void CCheckComboBox::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC* pDC = CDC::FromHandle(lpDrawItemStruct->hDC); CRect rcItem(lpDrawItemStruct->rcItem); int nIndex = lpDrawItemStruct->itemID; // 1. 画背景色(选中高亮优先) UINT nState = lpDrawItemStruct->itemState; COLORREF clrBk, clrText; if (nState & ODS_SELECTED) { clrBk = GetSysColor(COLOR_HIGHLIGHT); clrText = GetSysColor(COLOR_HIGHLIGHTTEXT); } else { clrBk = GetSysColor(COLOR_WINDOW); clrText = GetSysColor(COLOR_WINDOWTEXT); } pDC->FillSolidRect(rcItem, clrBk); // 2. 计算勾选区域(左侧留8像素边距,宽度等于行高) CRect rcCheck = rcItem; rcCheck.left += 8; rcCheck.right = rcCheck.left + rcItem.Height() - 8; rcCheck.top += 4; rcCheck.bottom -= 4; // 3. 用系统API绘制勾选框 BOOL bChecked = GetCheck(nIndex); UINT uCheckState = DFCS_BUTTONCHECK; if (bChecked) uCheckState |= DFCS_CHECKED; ::DrawFrameControl(pDC->GetSafeHdc(), &rcCheck, DFC_BUTTON, uCheckState); // 4. 绘制文字区域 CRect rcText = rcItem; rcText.left = rcCheck.right + 6; rcText.right -= 4; CString strText; GetLBText(nIndex, strText); pDC->SetBkMode(TRANSPARENT); pDC->SetTextColor(clrText); pDC->DrawText(strText, rcText, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); }两个绘制细节值得单独说。第一个细节是rcItem在列表展开时是客户区整行,但在收缩状态时可能只有一部分宽度——如果勾选框算出来的rcCheck超出了rcItem的可视范围,DrawFrameControl会自动裁剪,没有关系。第二个细节是绘制文字时一定要用DT_VCENTER | DT_SINGLELINE的组合,不要用DT_END_ELLIPSIS以外的额外标志,否则文字在部分字体下会出现像素级的垂直偏移。还有一点,文字底色我设置了TRANSPARENT,因为背景已经自己填充过了,再让DrawText去填充底色会闪一下,而且会和选中高亮色之间出现一条缝。
3.4 鼠标交互与通知消息处理
光画出来还不够,用户点一下勾选框,状态要能切换。这里有个特别隐蔽的坑:鼠标点击下拉列表某一行的时候,系统会默认发出一个选中的通知,紧接着下拉框就会收起。如果我只处理CBN_SELCHANGE消息去切换勾选状态,用户会发现点一下,勾选框变了,但下拉框也关了,想勾第二项还得重新打开,体验很差。
解决这个问题的标准做法是:在下拉框展开期间拦截CBN_SELENDOK消息,并且在这个消息处理里不让它关闭下拉列表。具体来说,MFC里可以在OnSelEndOK里直接返回一个非零值,但这个返回值并不能阻止关闭。真正的做法是在OnDropdown之后给自己的控件发送一个CB_SHOWDROPDOWN消息把它重新强制弹开——这招虽然暴力,但是有效,很多老项目里都这么干。还有一种做法是重写PreTranslateMessage,在鼠标按下时判断点击位置是否落在某一行上,如果落在该项的勾选区或者整行,就调用ToggleCheck切换状态,然后返回TRUE吞掉这条鼠标消息,让它不进入默认的下拉收起流程。
我自己的实现选了第二种,因为它不打断系统原本的窗口关闭时机,逻辑更干净。参考代码如下:
BOOL CCheckComboBox::PreTranslateMessage(MSG* pMsg) { // 动态创建的下拉列表是弹出的,需要单独判断 if (pMsg->message == WM_LBUTTONDOWN && m_bDropped) { CWnd* pDropList = GetDroppedControl(); if (pDropList != NULL && pDropList->GetSafeHwnd() == pMsg->hwnd) { // 把客户区坐标换算成列表项索引 CPoint pt(GET_X_LPARAM(pMsg->lParam), GET_Y_LPARAM(pMsg->lParam)); pDropList->ScreenToClient(&pt); // 注意:需要判断按钮点击是否落在勾选区域或整行,这里简化成整行切换 int nIndex = pDropList->HitTest(pt); if (nIndex >= 0 && nIndex < GetCount()) { ToggleCheck(nIndex); UpdateText(); // 通知父窗口状态变化 GetParent()->SendMessage(WM_COMMAND, MAKEWPARAM(GetDlgCtrlID(), CBN_CHECKCHANGE), (LPARAM)GetSafeHwnd()); return TRUE; // 吞掉消息,不让列表收起 } } } return CComboBox::PreTranslateMessage(pMsg); }注意这里GetDroppedControl()的用法。CComboBox内部的下拉列表框是一个独立的控件窗口,它收到的鼠标消息hwnd和CComboBox自身的hwnd不是同一个。所以拦截鼠标消息必须判断pMsg->hwnd是否是下拉列表框的句柄,坐标转换也得分清楚。
ToggleCheck里边做的是取反当前项的存取数据,然后强制触发重绘:
void CCheckComboBox::ToggleCheck(int nIndex) { if (nIndex < 0 || nIndex >= GetCount()) return; BOOL bChecked = GetCheck(nIndex); SetCheck(nIndex, !bChecked); Invalidate(); }OnDropdown和OnCloseup用来维护m_bDropped状态:
void CCheckComboBox::OnDropdown() { m_bDropped = TRUE; CComboBox::OnDropdown(); } void CCheckComboBox::OnCloseup() { m_bDropped = FALSE; CComboBox::OnCloseup(); }3.5 收缩状态文本更新
前面提到过,收缩状态下要显示“已选中的项名称”拼起来的文本。这个文本不能直接SetWindowText,因为CComboBox在CBS_DROPDOWNLIST风格下,文本是通过SetCurSel控制的。如果我们重写了DrawItem,那么SetWindowText的效果也会被自绘覆盖掉,所以干脆也是用自己的成员变量保存一个显示字符串,再调用Invalidate触发重绘,绘制时直接输出这个字符串。这样最简单。
void CCheckComboBox::UpdateText() { CString strText = GetCheckedItemsText(); if (strText.IsEmpty()) { // 没有勾选任何项时显示占位符 m_strPlaceholderText = _T("请选择..."); SetWindowText(m_strPlaceholderText); } else { m_strPlaceholderText.Empty(); SetWindowText(strText); } Invalidate(); }GetCheckedItemsText就是把所有选项遍历一遍,勾了的取文字拼起来,用逗号分隔。如果选中项太多,显示区域有限,绘制代码里已经加了DT_END_ELLIPSIS,文本超出部分会显示省略号,不会把控件撑爆。
这里有一个性能上的小建议:如果选项数量非常少(比如不到10个),每次UpdateText时全量遍历没有任何问题;如果选项有成百上千个,就不要用这种全量拼接的方式了,建议维护一个“已勾选索引集合”的成员变量,用CArray或者std::vector来存,增删时同步维护,UpdateText只遍历这个集合,效率会高得多。
4. 对话框集成与事件通知机制
4.1 在对话框中放置控件并关联变量
动态创建的方案前面已经给了完整代码,但大多数MFC项目还是倾向于在资源编辑器里拖一个Combo Box摆好位置。这种情况下,除了在OnInitDialog里补上CBS_OWNERDRAWFIXED风格之外,还需要把控件的变量类型改成CCheckComboBox。具体操作是:用类向导给控件添加变量,变量类型填CCheckComboBox,然后在对话框的头文件里确保包含“CCheckComboBox.h”。如果你用的Visual Studio版本比较老,可能类向导的下拉列表里没有这个类,那就直接在头文件里手动声明:
// CMyDialog.h #include "CCheckComboBox.h" class CMyDialog : public CDialogEx { ... protected: CCheckComboBox m_ctrlCheckCombo; ... };然后在DoDataExchange里加一句DDX_Control:
void CMyDialog::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Control(pDX, IDC_CHECK_COMBO, m_ctrlCheckCombo); }这样在OnInitDialog里就能直接给控件加项了:
BOOL CMyDialog::OnInitDialog() { CDialogEx::OnInitDialog(); // 补自绘风格(模板创建的控件需要) m_ctrlCheckCombo.ModifyStyle(0, CBS_OWNERDRAWFIXED); // 添加选项 m_ctrlCheckCombo.AddString(_T("篮球")); m_ctrlCheckCombo.AddString(_T("足球")); m_ctrlCheckCombo.AddString(_T("羽毛球")); m_ctrlCheckCombo.AddString(_T("乒乓球")); // 默认勾选第一项 m_ctrlCheckCombo.SetCheck(0, TRUE); m_ctrlCheckCombo.UpdateText(); return TRUE; }4.2 向父窗口发送自定义通知消息
很多时候,对话框需要知道用户在下拉框里勾选了哪一项,以便联动刷新其他界面元素。MFC的控件通知一般通过WM_COMMAND消息发送,对于标准控件有CBN_SELCHANGE、CBN_EDITCHANGE这些。但我们的复选交互是自定义的,没有现成的通知码,所以我在ToggleCheck之后给父窗口发了一个自定义通知,用CBN_CHECKCHANGE。这个通知码要用一个不冲突的值,通常可以在WM_USER上面做文章,或者直接用0x1000以下没被占用的通知码。这里有个坑:通知码的取值空间是被系统定义的CBN_系列占了一部分的,取值范围是0x0300到0x030F附近,自定义时建议取一个大的,比如0x1000,然后定义成:
#define CBN_CHECKCHANGE 0x1000父窗口里如果用的是ON_CONTROL_REFLECT,可以在子控件类内部自己处理;如果想让对话框响应,就在对话框的消息映射里加:
ON_CONTROL(CBN_CHECKCHANGE, IDC_CHECK_COMBO, &CMyDialog::OnCheckComboChange)这样在OnCheckComboChange里可以安全地遍历所有项,把勾选状态读出来,做后续的业务逻辑。不要试图在PreTranslateMessage里直接调用对话框的业务函数,那会让控件类和对话框耦合很重,后面维护起来难受。
4.3 键盘操作支持
鼠标操作搞定之后,千万别把键盘用户忘了。下拉框展开时,用户可能用上下方向键移动高亮项,然后按空格键做勾选切换。这个交互我现在已经用得很顺手了,一定要在PreTranslateMessage里加对VK_SPACE的处理。逻辑很简单:当下拉框展开时,当前高亮项由GetCurSel()拿到,如果是合法的索引,就ToggleCheck它,然后返回TRUE。
if (pMsg->message == WM_KEYDOWN && m_bDropped) { if (pMsg->wParam == VK_SPACE) { int nCur = GetCurSel(); if (nCur >= 0) { ToggleCheck(nCur); UpdateText(); return TRUE; } } }加上这个之后,整个控件用键盘操作起来就和普通下拉框一样顺手,空格勾选,回车或Esc收起,Tab切换焦点,无障碍访问的键盘路径就通了。
5. 常见问题与排查技巧实录
5.1 下拉列表点击无效,勾选框不响应
这个问题主要是鼠标消息被父窗口截胡了。你要在PreTranslateMessage里拦截WM_LBUTTONDOWN,但前提是GetDroppedControl()拿到的句柄要和pMsg->hwnd匹配。我自己调试时遇到过的经典场景是:把判断写成了if (pMsg->hwnd == GetSafeHwnd()),结果点的永远是下拉列表窗口,永远进不了分支。排查办法其实很简单,在PreTranslateMessage开头加一个TRACE(_T("hwnd: 0x%X, drop: 0x%X\n"), pMsg->hwnd, GetDroppedControl()->GetSafeHwnd());,跑一次就知道该怎么办了。
另外要确认自己确实加了CBS_OWNERDRAWFIXED风格。没有这个风格,DrawItem根本不执行,勾选框压根不会画出来,这种情况下点哪里都没反应。
5.2 勾选框画出来但特别小,或者文字对不齐
行高和字体有关。MeasureItem里如果用固定的像素值(比如写死24),在125%缩放的屏幕上就会出问题,文字底部会被截断。解决办法就是我前面说的,在MeasureItem里动态获取当前字体的TEXTMETRIC,以此为基础计算行高。这个做法在DPI变化时能自适应,不用额外去处理WM_DPICHANGED。还要留意,下拉框和对话框的字体如果设置了不同大小,MeasureItem拿到的GetFont返回的是CComboBox自身的字体,不是父窗口的。如果两者字体不一致,行高会按错的字体来算。建议在OnInitDialog里统一给控件SetFont(GetFont())。
5.3 下拉框点击后立刻收起,无法连续勾选
这是复选下拉框最经典的问题,本质原因是CComboBox的默认交互逻辑是“点一项就选定并关闭”。我在3.4节里给的方案是吞掉鼠标消息。但如果你在项目里不方便重写PreTranslateMessage,还有一个备选方案:在OnCloseup消息里判断本次是否发生了勾选变化,如果变化了,重新弹开下拉列表。这个方案有一个副作用:下拉框在收起时会有一次肉眼可见的闪烁和跳动。相比之下我还是推荐PreTranslateMessage的做法,它从根源上阻止了鼠标消息触发默认的“选中并关闭”流程,动画上更平滑。
这里再强调一句,吞掉鼠标消息后,高亮项依然会跟着鼠标移动,因为高亮是由系统对WM_MOUSEMOVE的处理完成的,我们拦截的只是WM_LBUTTONDOWN,不会影响视觉反馈。
5.4 下拉框收起后显示的文本不更新
如果你发现UpdateText里调用了SetWindowText,但界面上显示还是老样子,先检查是不是自绘模式下的收缩状态绘制里输出了硬编码的旧文本。我在早期版本里犯过这个错:在DrawItem函数里当nIndex == -1时,我直接输出变量m_strSelectedText,但UpdateText更新的是另一个成员变量,两边没同步。排查起来很浪费时间的。建议把“当前显示文本”统一收敛成一个成员变量,所有会修改它的地方都走同一个函数,不要多个地方各改各的。
5.5 常见问题速查表
| 现象 | 直接原因 | 排查与修复 |
|---|---|---|
| 下拉项全部空白,不显示文字 | 缺少CBS_HASSTRINGS风格 | 创建时加上CBS_HASSTRINGS,或ModifyStyle动态添加 |
| DrawItem不被调用 | 控件没有CBS_OWNERDRAWFIXED风格 | 动态创建时带上该风格,模板创建的在OnInitDialog里ModifyStyle |
| 勾选框画出来但不是Windows主题样式 | 使用了自己画的矩形加对勾 | 改用DrawFrameControl(DFC_BUTTON / DFBS_CHECKBOX) |
| 点击列表项后下拉框立刻关闭 | 默认选中交互被触发 | 在PreTranslateMessage拦截WM_LBUTTONDOWN并吞掉 |
| 键盘空格无法切换勾选 | 没处理WM_KEYDOWN的空格键 | 在下拉展开状态下拦截VK_SPACE并调用ToggleCheck |
| 文字被截断 | 行高不足或字体不一致 | 在MeasureItem里动态获取TEXTMETRIC并计算行高 |
| 收缩后显示文本不及时更新 | 更新文本逻辑没有统一入口 | 用UpdateText统一处理,并在勾选变化后调用 |
| 父窗口收不到变化通知 | 没发送自定义通知码 | 勾选状态变化后SendMessage(WM_COMMAND, MAKEWPARAM(ID, CBN_CHECKCHANGE), ...) |
6. 扩展思路与实际使用体会
这个CCheckComboBox基础版做完之后,如果你愿意,还可以往几个方向继续打磨。比如支持“全选”和“清空”的上下文菜单,右键点击下拉框弹出一个菜单,提供全选/全不选,这在选项特别多的业务场景里很好用。再比如支持只读模式,某些情况下不允许用户改勾选状态,只需要展示,可以在DrawItem里根据只读标志把勾选框画成灰色禁用状态。还可以支持在收缩状态下显示“已选3项”而不是把每项都拼出来,这种显示策略适合选项名称特别长的场景。
我用了好几年这个控件,最大的体会是:自绘控件并不是什么高深的技术,核心就是理解DrawItem、MeasureItem、消息反射、窗口句柄归属这几套机制,剩下的事情就是反复调试细节。尤其是窗口句柄的归属问题,CComboBox本身是一个复合控件,父窗口和内部弹出的下拉列表框是两个不同的HWND,很多诡异的Bug都是因为混淆了这两个窗口导致的。调试这类问题最有效的办法就是TRACE输出各个句柄,一目了然。
如果你要把这个控件实际用在自己的项目里,我最后再给你一个建议:一定要在项目一开始就把DPI感知打开,并且用动态字体计算行高,否则后面屏幕适配会让你改到怀疑人生。另外,如果你用的是Visual Studio 2017或更高版本,记得在项目属性里把字符集设成“使用Unicode字符集”,所有字符串都用_T宏包一下,这样代码在VC++ 6.0的老工程和现代工程之间移植都不需要大改。复选下拉框这个需求很常见,把这个控件做扎实了,你的工具箱里又多了一件称手的武器。
本文还有配套的精品资源,点击获取