简介:这是一份使用C++与MFC类库编写的带下拉箭头工具栏按钮示例程序,面向需要自定义界面控件的Windows桌面开发者,尤其适合MFC初学者对照学习。工程共包含30个文件,以头文件、C++源文件和资源脚本为主,另附图标、位图以及编译好的可执行文件,压缩包整体仅65KB,体量小巧但结构清晰。当前已有122人学习下载。通过阅读源码,可以掌握扩展CButton类、重写消息映射、自定义绘制下拉箭头以及关联CMenu弹出菜单的关键思路,工程还配有Visual C++项目文件和调试记录,便于直接打开研究或迁移到自己的项目中。实现上覆盖了控件状态管理、资源文件定义与菜单响应等完整环节,配合文档视图框架代码,适合在此基础上改造成其他带下拉菜单的工具栏按钮。
1. 把 DropArrayTB_standl1r_Vc_ 看明白:MFC 工具栏下拉按钮的完整范本
老牌 C++/MFC 工程师打开这套 DropArrayTB 源码,第一眼就能认出它是 VC6 时代的东西:.dsp/.dsw 工程文件、.ncb/.clw 缓存、StdAfx 预编译头,全都在。项目标题里的 standl1r 是 standalone 的简写,Vc 指 Visual C++,合起来就是一套独立可编译的 MFC 下拉箭头按钮实现,模仿的是 IE 工具栏那种「主按钮 + 右侧小箭头」的交互——点箭头弹菜单,点主体执行默认动作。这套代码对两类人特别值钱:手里还压着老 MFC 界面要维护的,以及想学自定义控件、但不想从零啃 WTL 的。它能直接告诉你按钮消息怎么拐弯、箭头区域怎么画、菜单怎么挂上去,全是能落地的细节。
2. 工程结构与消息映射:为什么点击按钮能弹出一张菜单
2.1 三十多个文件先挑核心:DropArrayTB.cpp 才是主战场
解压 DropArrayTB.rar 之后,src 目录下躺着一堆文件,新手容易看花眼。按我的习惯,拿到任何 MFC 源码包,第一件事是拿文件清单对照项目正文,把「编译必需」和「IDE 缓存垃圾」分开。VC6 的工程不像现代 CMake 那样目录结构一目了然,很多文件是 IDE 自动生成的,删了反而清爽。
| 文件 | 角色 | 是否手工维护 |
|---|---|---|
| DropArrayTB.cpp / DropArrayTB.h | 核心控件类,下拉按钮的主体实现 | 是,重点读 |
| MainFrm.cpp / MainFrm.h | 主框架窗口,负责创建按钮实例 | 是 |
| View.cpp / View.h | 视图类,展示按钮宿主环境 | 是 |
| Doc.cpp / Doc.h | 文档类,MFC Doc/View 骨架 | 自动生成 |
| DropArrayTB.rc / resource.h | 资源脚本与 ID 定义,菜单、图标都在这里 | 是 |
| Toolbar.bmp | 工具栏位图资源 | 是 |
| StdAfx.cpp / StdAfx.h | 预编译头 | 自动生成 |
| DropArrayTB.dsp / DropArrayTB.dsw | VC6 工程文件 | 自动生成 |
| DropArrayTB.ncb / .opt / .clw | IDE 缓存,非源码 | 可删 |
我在实际维护老项目时,第一件事就是先把 .ncb、.opt、.clw 这三个文件挪走,它们经常在打开工程时触发奇怪的 IDE 崩溃,删掉让 IDE 重建反而更稳。接下来读代码的顺序也有讲究:先读 resource.h 里各 ID 的数值范围,再读 DropArrayTB.cpp 的消息映射,最后才看 MainFrm.cpp 里按钮是怎么挂上去的。这条顺序能避免你被 MFC 的宏绕晕——消息映射宏和资源 ID 之间是一一对应的,ID 错位的时候,按钮怎么点都没反应。
2.2 从 WM_LBUTTONDOWN 到 TrackPopupMenu:消息是怎么走的
下拉按钮的交互链路在 MFC 里并不复杂,核心就三步:鼠标按下、判断命中区域、弹菜单。但这条链路上的每个环节都有坑,先看最典型的实现骨架。
BEGIN_MESSAGE_MAP(CDropArrayTB, CButton) ON_WM_LBUTTONDOWN() ON_WM_LBUTTONUP() ON_WM_CAPTURECHANGED() END_MESSAGE_MAP() void CDropArrayTB::OnLButtonDown(UINT nFlags, CPoint point) { // 1. 先拿到按钮矩形,判断点落在主区域还是箭头区域 CRect rc; GetClientRect(&rc); rc.right -= m_nArrowWidth; // 右侧让出箭头宽度 if (rc.PtInRect(point)) { // 主按钮区域:记录“按下主区”状态,触发默认动作 m_bMainPressed = TRUE; SetCapture(); Invalidate(FALSE); CButton::OnLButtonDown(nFlags, point); } else { // 箭头区域:立即弹出下拉菜单 m_bArrowPressed = TRUE; ShowDropMenu(); } }这段代码里的m_nArrowWidth是箭头区宽度,我一般取 10 到 14 像素,太窄手指点不准,太宽主按钮区显得累赘。PtInRect是 MFC 自带的矩形命中判断,注意rc.right -= m_nArrowWidth这行——很多人漏了它,导致整个按钮都被当成箭头区,菜单永远弹不出来。SetCapture()也关键,不捕获鼠标的话,用户在菜单弹出前松开左键,WM_LBUTTONUP就收不到,按钮状态会卡在按下态。
菜单弹出集中在ShowDropMenu()里,这是整个控件的灵魂:
void CDropArrayTB::ShowDropMenu() { CMenu menu; if (!menu.LoadMenu(m_nMenuId)) { ASSERT(FALSE); return; } CMenu* pPopup = menu.GetSubMenu(0); if (!pPopup) return; // 把按钮坐标转成屏幕坐标,菜单要出现在按钮正下方 CRect rc; GetWindowRect(&rc); // TPM_RETURNCMD 让 TrackPopupMenu 直接返回被点的菜单项 ID UINT nCmd = pPopup->TrackPopupMenu( TPM_LEFTALIGN | TPM_TOPALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD, rc.left, rc.bottom, this, NULL); if (nCmd != 0) { // 把菜单命令转发给父窗口,走正常 WM_COMMAND 流程 GetParent()->SendMessage(WM_COMMAND, nCmd); } }TrackPopupMenu的四个返回位置参数是踩坑重灾区,TPM_LEFTALIGN表示菜单左侧对齐 rc.left,TPM_TOPALIGN是顶部对齐 rc.bottom,这两个组合就是「按钮左下角往下拉」。TPM_RETURNCMD必须带上,否则这个函数只返回 TRUE/FALSE,你根本拿不到用户点了哪一项。TPM_RIGHTBUTTON是允许鼠标右键也能选中菜单项,习惯加上。
2.3 按下态、弹起态、菜单关闭后的状态恢复
下拉按钮比普通按钮多一个状态机:主按钮按下、箭头按下、菜单弹出中、菜单关闭后恢复。很多人只处理了前两个,忘了菜单关闭后按钮还留在按下态,导致界面看起来「卡住」了。解决这事靠WM_CAPTURECHANGED——当菜单弹出时,系统会把鼠标捕获从按钮转走,这个事件就是恢复按钮状态的信号。
void CDropArrayTB::OnCaptureChanged(CWnd* pWnd) { // 菜单弹出会导致捕获转移,这里把按下态全部清掉 if (m_bMainPressed || m_bArrowPressed) { m_bMainPressed = FALSE; m_bArrowPressed = FALSE; Invalidate(FALSE); // 触发重绘,恢复凸起外观 } CButton::OnCaptureChanged(pWnd); }这个Invalidate(FALSE)是我调这类控件时的固定习惯,参数 FALSE 表示不擦除背景,只重绘客户区,避免闪烁。注意别用Invalidate(TRUE),那种全量擦除在下拉菜单反复开关时特别闪。还有一点:m_nMenuId这个成员变量建议在构造函数里初始化成 0,并且在LoadMenu之前做一次有效性判断,否则资源 ID 写错时LoadMenu返回 NULL,光 ASSERT 不够,Release 版直接跳过菜单,按钮就变成「点了没反应」。
3. 把下拉箭头的绘制原理讲透:自绘按钮的坐标计算与三种画法
3.1 两种实现路线的取舍:扩展 CButton 自绘 vs 改 CToolBar 按钮
MFC 里做出下拉按钮,业界主流就两条路线。第一是继承 CButton 并设置为BS_OWNERDRAW自绘,所有外观代码自己画,自由度最高,DropArrayTB 源码走的就是这个方向。第二是扩展 CToolBar,给 TBSTYLE_DROPDOWN 风格加消息反射,这种能少画不少东西,但受工具栏管理器约束,弹菜单的位置和样式都受限。
我一般首选 CButton 自绘路线,理由很简单:CToolBar 风格按钮在 XP 之后的系统上经常出现视觉残留,鼠标悬停高亮和下拉箭头状态很难完全掌控。而继承 CButton 之后,按钮就是一个独立窗口,放在对话框、工具栏、属性页里都能用,宿主环境不挑。缺点是所有视觉效果都要自己画,包括边框、按下凹陷、箭头的立体感。
3.2 箭头和分割线的实际计算:按钮右边缘往内预留 12 像素
自绘按钮的核心在DrawItem,MFC 会通过WM_DRAWITEM把它触发。这里的参数LPDRAWITEMSTRUCT包含了按钮矩形、状态、是否聚焦等全部信息。按下态和悬停态的绘制逻辑要区分——按下时整个按钮内缩一像素,箭头区要画一条凹进去的分割线。
void CDropArrayTB::DrawItem(LPDRAWITEMSTRUCT lpDIS) { CDC* pDC = CDC::FromHandle(lpDIS->hDC); CRect rc = lpDIS->rcItem; UINT state = lpDIS->itemState; // 1. 画按钮主体边框:按下时凹陷,否则凸起 UINT nStyle = DFCS_BUTTONPUSH; if (m_bMainPressed) nStyle |= DFCS_PUSHED; CRect rcMain = rc; rcMain.right -= m_nArrowWidth; pDC->DrawFrameControl(&rcMain, DFC_BUTTON, nStyle); // 2. 画箭头区的竖分割线,带一点凹陷立体感 CRect rcArrow = rc; rcArrow.left = rc.right - m_nArrowWidth; pDC->DrawEdge(&rcArrow, EDGE_ETCHED, BF_LEFT); // 3. 在箭头区中央画一个下三角,用 Polygon 实现 CPoint pts[3]; CPoint center = rcArrow.CenterPoint(); pts[0] = CPoint(center.x - 4, center.y - 1); pts[1] = CPoint(center.x + 4, center.y - 1); pts[2] = CPoint(center.x, center.y + 3); CBrush brush(RGB(60, 60, 60)); CBrush* pOld = pDC->SelectObject(&brush); pDC->Polygon(pts, 3); pDC->SelectObject(pOld); // 4. 箭头按下时,把三角形整体向右下方移1像素,模拟被按下去 if (m_bArrowPressed) { pDC->SetViewportOrg(rcArrow.left + 1, rcArrow.top + 1); } // 5. 按钮文案绘制,居中显示 if (!m_strText.IsEmpty()) { CRect rcText = rcMain; pDC->DrawText(m_strText, &rcText, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } }这段代码有三个参数要注意。m_nArrowWidth在构造时设成 12,适配 16 像素高的典型工具栏按钮;三角形三顶点用了相对坐标,4 像素宽、3 像素高的比例在 16x16 按钮上视觉最协调;EDGE_ETCHED | BF_LEFT画出来的分割线是一条内凹细线,比EDGE_SUNKEN更精致,不会抢主按钮的视觉重量。
3.3 命中检测配合:别把整块按钮都变成菜单区
绘制只是外观,真正决定「用户点哪边」的是命中检测,也就是第二章OnLButtonDown里那行rc.right -= m_nArrowWidth之后的PtInRect。有个常见误用是直接把整个rc拿去判断,结果箭头区判定失效,用户点箭头也被当成主按钮处理。我习惯把命中判断抽成独立方法,方便单测:
BOOL CDropArrayTB::IsInArrowArea(CPoint point) const { CRect rc; GetClientRect(&rc); return point.x >= rc.right - m_nArrowWidth; }这个方法的边界条件值得写清楚:point.x 等于rc.right - m_nArrowWidth时算箭头区,等于rc.right时也算箭头区,因为GetClientRect返回的矩形右边界本身是不包含的那一侧。测试时可以用两个相邻像素验证,确保没有「点击分割线上没反应」的死角。
4. 拿到源码包后怎么跑起来:从 VC6 工程到现代 VS 环境的重建流程
4.1 编译前先做三件事:备份、清理缓存、确认运行库
DropArrayTB 这套工程是 VC6 时代用 .dsp/.dsw 组织的,拿到手直接双击 .dsw 在 VS2022 里打开,大概率跳出来一个「不支持的项目类型」提示。我的标准流程是三步。第一步把整个目录复制一份做备份,因为工程重转之后 .dsp/.dsw 会被原地改写,一旦转换失败没有后悔药。第二步删掉 Debug 目录、.ncb、.opt、.clw,这些都是旧的编译产物和 IDE 缓存,不删干净会导致「打开工程就崩溃」这类玄学问题。第三步确认目标机器有 VC 运行库——VC6 写的程序默认动态链接 MFC42.dll,Win10/11 上高版本系统自带的运行库不兼容老 MFC 的导出符号,经常出现「无法定位程序输入点」的错误。
遇到这种运行库问题,我一般直接用「vc 运行库修复工具」把 VC2005 到 VS2022 的全套运行库装一遍,比手工拷贝 mfc42.dll 靠谱。或者干脆在工程设置里把「Use MFC in a Static Library」勾上,让代码全部静态链接,连可执行文件都能拷到没装运行库的机器上跑。这个静态链接选项在 VS2008 之后的版本里位于:项目属性 → 配置属性 → 常规 → 使用 MFC → 在静态库中使用 MFC。
4.2 用现有 .dsp/.dsw 重建:先读这几个后缀再决定要不要转
.dsp 是 VC6 的工程描述文件,本质上是一个文本文件,里面记录了源文件列表、编译选项、资源脚本路径。.dsw 是工作区文件,可以包含多个 .dsp。我在实战中见过不少人直接把 .dsp 拖进 VS2019,结果 VS 自动转换后,工程里的资源文件路径全部失效——这是因为 VC6 时代路径分隔符用的是\,而新版本对相对路径的处理更严格。我的做法是手动新建空工程,然后按 .dsp 里记录的源文件列表手工添加。
# 先搜索 dsp 文件里的源文件清单,比 GUI 更可靠 grep -E "^# (ADD|Begin Source File)" DropArrayTB.dspgrep抓出来的内容就是 VC6 编译器的「菜单」:ADD CPP是源文件,ADD BASE是依赖项。用这些文件列表在 VS2022 里新建一个 MFC 对话框工程(或 SDI 工程),再把 .cpp/.h/.rc 全部加进去,比让 IDE 自动转换 .dsp 更可控。GDI 资源文件 Toolbar.bmp、Doc.ico、APP.ico 也要一并加入,否则编译不报错、运行却找不到资源图标。
4.3 编译报错的常见替罪羊:cl.exe failed 与 MFC 头文件缺失
重建时最气人的错误是cl.exe failed with exit status 2,它只告诉你编译器挂了,不告诉你为什么。我排查顺序是固定的:先看完整输出里第一个error C或fatal error C,再检查字符集设置,最后确认平台工具集。DropArrayTB 这类老代码通常默认用 ANSI 字符集,如果你把工程设成 Unicode,所有CString到LPCTSTR的隐式转换都会报错,处理方式是:项目属性 → 高级 → 字符集 → 使用多字节字符集。
如果报的是fatal error C1083: Cannot open include file: 'afxwin.h',那说明这个工程没被当作 MFC 工程处理。新建项目时模板必须选「MFC 应用」,然后在项目属性里把「配置类型」保证为「应用程序 (.exe)」,再确认「使用 MFC」不是「不使用 MFC」。这一步错位是新手最容易踩的坑,我见过有人为此折腾一整天,最后发现只是工程模板选成了 Win32 控制台程序。
5. 避坑记录:MFC 下拉按钮最容易翻车的四个位置
5.1 现象:菜单点一下就没反应,TrackPopupMenu返回 0
原因:TPM_RETURNCMD漏了,或者菜单资源 ID 根本无效。TPM_RETURNCMD没写时,函数返回值是布尔值,你把它当命令 ID 处理,必然nCmd == 0,然后什么都不会发生。另外LoadMenu失败时也会静默返回。
解决:在TrackPopupMenu参数里补上TPM_RETURNCMD;在LoadMenu之后加ASSERT(m_hMenu)。我在资源脚本里总是把下拉菜单 ID 统一放在0x8000以后的范围,避免和主菜单 ID 冲突——MFC 资源 ID 很多是按 ID 范围分块的,乱用 0x100 以下的 ID 会覆盖主框架加速键表。
5.2 现象:箭头区点击不灵敏,必须点到最右边两个像素才弹菜单
原因:命中检测用的矩形没考虑按钮边框。自绘按钮客户区比按钮窗口略小,勾边阴影占的像素也要扣掉,很多人m_nArrowWidth = 8,但箭头区起点就算出了 10 像素的误差。
解决:在IsInArrowArea里把阈值从rc.right - m_nArrowWidth调成rc.right - m_nArrowWidth - 1,同时把m_nArrowWidth提到 14。我记得有一次调了半天发现是 DPI 缩放干扰了坐标——在 125% 缩放的屏幕上,GetClientRect返回的是物理像素,而point是逻辑像素,二者相差 1.25 倍。需要先SetProcessDPIAware()或对 point 做::GetDpiForWindow换算。
5.3 现象:菜单弹出后按钮卡死在按下状态,怎么点都不弹
原因:菜单弹出会触发WM_CAPTURECHANGED,但OnCaptureChanged里没做按钮状态复位。只要m_bArrowPressed还是 TRUE,下次OnLButtonDown会直接走ShowDropMenu,而绘制逻辑又因为按钮处于按下态而画得一团糟——看起来就像整个按钮卡住了。
解决:就是 2.3 节那个OnCaptureChanged实现,复位m_bArrowPressed和m_bMainPressed,并Invalidate(FALSE)。我每次写自绘控件都会强制检查这个事件是否处理——MFC 自绘控件十个有八个翻车点都在消息捕获转移上,SetCapture之后不在WM_CAPTURECHANGED里收尾的话,各种怪异状态就来了。
5.4 现象:菜单弹出来了,但子菜单项全部灰显,不可点击
原因:CMenu::LoadMenu加载的菜单没有关联到主框架的CFrameWnd,导致UpdateCmdUI机制把所有ON_COMMAND都当成无处理函数,菜单项自动置灰。MFC 的菜单项置灰是基于ON_UPDATE_COMMAND_UI宏自动启用的,没有命令处理函数的菜单项默认状态就是灰色。
解决:在ShowDropMenu里用GetParent()->GetParent()拿到主框架指针,然后调CFrameWnd::m_bAutoMenuEnable = FALSE。但更稳妥的做法是让下拉菜单走WM_COMMAND发到父窗口后,父窗口里有对应的ON_COMMAND处理函数——这才是真正让菜单项可用的关键。先自查父窗口的消息映射,别急着改框架的自动菜单启用开关。
6. 进阶技巧:把下拉状态玩出动态感——菜单随按钮状态换
用熟了静态下拉菜单之后,我每次都会在控件上预留两个运行时接口,一个是切换菜单资源 ID,一个是开关箭头区。这两个接口看起来不起眼,但能把一个死按钮变成灵活的工具按钮——比如根据当前选中文本动态换菜单项,或者主按钮没内容时把箭头区隐藏。
void CDropArrayTB::SetDropMenu(UINT nMenuId) { if (m_pMenu) { m_pMenu->DestroyMenu(); m_pMenu = NULL; } m_nMenuId = nMenuId; if (nMenuId != 0) { m_pMenu = new CMenu; if (!m_pMenu->LoadMenu(nMenuId)) { delete m_pMenu; m_pMenu = NULL; m_nMenuId = 0; } } // 箭头区是否可见也在这里联动 m_bShowArrow = (m_nMenuId != 0); Invalidate(FALSE); }注意m_pMenu用了指针而不是栈对象,因为菜单资源要跨多次弹出存活,栈对象会在每次 ShowDropMenu 返回时析构,句柄就泄漏了。用new CMenu之后,一定要记得在控件析构函数里DestroyMenu并delete。我见过有人直接m_pMenu = new CMenu后忘了释放,程序频繁弹菜单几十次后 GDI 对象耗尽,界面白屏。后来我把释放逻辑统一放进了ReleaseDropMenu()方法,每次换菜单时先调它,这已经成了我写所有自绘控件的一条铁律:先释放旧句柄,再加载新资源。
还有一个细节值得分享:在切换菜单的瞬间,最好把按钮强制重绘两遍——第一次用SetRedraw(TRUE)恢复默认状态,第二次才Invalidate(FALSE)刷新箭头区。这样做能避免菜单 ID 切换时箭头区的分割线残影。从那以后我每次自绘控件,都强制走一遍「句柄清理 → 状态复位 → 双重重绘」的流程,踩过的坑基本都堵住了。希望这个流程对你也有用。
本文还有配套的精品资源,点击获取