ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MFC 自绘工具栏实战:CToolBar 图片与文字混排及状态绘制

MFC 自绘工具栏实战:CToolBar 图片与文字混排及状态绘制

简介:面向需要定制工具栏的 MFC 开发者,这份示例围绕 CToolBar 类的实际应用,演示了如何为按钮添加图片与文字,并实现工具栏的停靠与浮动控制,是一个非常贴近实际项目需求的入门参考。资源包共包含 24 个文件,以头文件、C++ 源文件和资源脚本为主,并附有图标、Visual Studio 工程配置、说明文档,以及可直接复用的“工具栏图片素材及代码”压缩包,总体积约 324KB。目前已有 1685 人学习浏览,适合正在学习 MFC 界面开发或需要快速改造现有工具栏的读者,工程打开后可直接运行和修改。通过 TimeClient 示例工程,可以完整看到在 MainFrm 中创建工具栏、加载位图、添加按钮、设置按钮信息以及响应点击操作的代码路径;文档部分还解释了导入资源、配置 CToolBar 和绑定按钮事件的方法,帮助开发者将带图文的工具栏快速整合到自己的项目中。

1. 从原生 CToolBar 到“图片 + 文字”混排:这份 MFC 工具栏 Demo 解决的是真痛点

用 MFC 写界面的人早晚会遇到一个坎:CToolBar 默认只能塞一排纯位图按钮,想让它显示“图标在上、文字在下”的现代工具栏样式,要么自己翻 MSDN 查 TB_ADDBITMAP,要么被 CMFCToolBar 的一堆配置项绕晕。这个 MFC CToolBar 工具栏自定义图片及文字的 Demo,核心就是绕过系统默认绘制,用自绘方式把图标和文字同时画到按钮上,配合悬停、按下、禁用三种状态,做成一个可以直接拖进工程的派生类。它解决的不只是“怎么贴图”,而是“怎么让按钮看起来像个正经工具栏”。适合正在做维护型 MFC 项目、不想把工具栏换成 Qt 或 WPF 的开发者——你缺的就是一个能少踩坑的成品封装。

2. 选型与类结构:为什么继承 CToolBar 而不是改用 CMFCToolBar

2.1 三条路线对比:标准 CToolBar、CMFCToolBar、自绘派生类

拿到“自定义图片及文字工具栏”这个需求,第一反应通常是上网搜 CToolBar 贴图,搜出来的老帖子全是 TB_ADDBITMAP 配 CBitmap 的套路,这套方案在 VC6 时代没问题,放到现在的 VS 工程里就露怯:位图是 16 色的,图标边缘锯齿明显,而且根本不支持图标文字混排。你只能先往工具栏塞图片,再用 SetButtonText 硬塞文字,结果文字要么被裁掉,要么盖在图标上,怎么调都别扭。

第二条路是换 CMFCToolBar。MFC Feature Pack 之后提供的这个类确实支持图文混排,也支持大图标小图标切换。但代价是整套架构要跟着换:CMFCToolBar 通常配 CMFCMenuBar、CMFCStatusBar 一起用,老代码里那些直接操作 CToolBar 的 GetToolBarCtrl、CommandToIndex 全要改一遍。更麻烦的是它内部有一套自己的状态同步机制,按钮样式受 Visual Manager 支配,想精调一个按钮的图标偏移量,你得先弄懂 CMFCVisualManager 的继承关系。对一个小功能来说,引入成本偏高。

第三条路就是资源里采用的:从 CToolBar 派生一个类,把 Owner Draw 机制接上。MFC 的 CToolBar 本身有自绘支持的钩子,只要在派生类里拦下绘制消息,自己控制图标绘制位置、文字绘制位置,以及不同状态下的背景色。好处是接口完全兼容老代码,你原来怎么 AddButton 现在还怎么 AddButton,只是按钮变好看了。坏处是细节多——哪些消息要拦、状态怎么维护、DPI 变了怎么处理,都是资源里已经踩过坑的部分。对维护老工程的场景来说,这条路性价比最高。

2.2 资源里的核心类 CDrawToolBar:声明、接口与职责边界

这份 Demo 的主体是一个叫 CDrawToolBar 的派生类,它把“贴图 + 文字 + 状态”全部封装在内部,对外暴露的接口控制在 5 个以内。直接看头文件的核心片段:

// DrawToolBar.h #pragma once #include <afxext.h> enum DrawToolBarButtonState { DTB_NORMAL = 0x00, DTB_HOVER = 0x01, DTB_PUSHED = 0x02, DTB_DISABLED = 0x04, DTB_CHECKED = 0x08 }; class CDrawToolBar : public CToolBar { public: // 核心接口:设置按钮图标与文字 BOOL SetButtonImage(int nIndex, UINT nIconResID, int nIconWidth = 16, int nIconHeight = 16); BOOL SetButtonText(int nIndex, LPCTSTR lpszText); BOOL SetButtonStateEx(int nIndex, BYTE nState); protected: // 自绘核心:接管按钮绘制 afx_msg void OnDrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct); // 状态追踪:拦截鼠标消息 afx_msg void OnMouseMove(UINT nFlags, CPoint point); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnLButtonUp(UINT nFlags, CPoint point); DECLARE_MESSAGE_MAP() };

从调用方视角看,这个类只做三件事:SetButtonImage 把图标资源挂到指定索引的按钮上,SetButtonText 设置按钮文字,SetButtonStateEx 手动指定按钮状态。之前用 TB_ADDBITMAP 时你要维护一个位图索引表,现在全部由内部维护。

具体到内部实现,OnDrawItem 负责把图标和文字画出来,OnMouseMove 负责追踪悬停,OnLButtonDown / OnLButtonUp 负责判定按下状态。注意那个 DTB_CHECKED 状态位——有些工具栏按钮是开关型(比如显隐网格),按下之后要保持“选中”外观,单纯靠鼠标消息是还原不出来的,所以 SetButtonStateEx 就是给你手动设置用的。

2.3 接入步骤:从新建对话框到替换工具栏只要三步

配套的 Demo 是一个基于对话框的工程,接入方式很直接。如果你手里是一个已经跑起来的 MFC 工程,按下面三步走:

第一步,把 DrawToolBar.h 和 DrawToolBar.cpp 拖进工程。如果你用的是 VS 的类向导,也可以直接“添加类”然后粘贴代码,但注意基类要选 CToolBar,不然消息映射对不上。

第二步,在对话框头文件里把成员变量类型从 CToolBar 换成 CDrawToolBar:

// CMainDlg.h 中的成员声明 // 旧:CToolBar m_wndToolBar; CDrawToolBar m_wndToolBar;

第三步,在 OnInitDialog 里创建工具栏后,直接调用新接口:

// CMainDlg.cpp 片段 if (!m_wndToolBar.Create(this, WS_CHILD | WS_VISIBLE | CBRS_TOP)) { TRACE0("Failed to create toolbar\n"); return FALSE; } // 三个按钮:打开、保存、切换视图 m_wndToolBar.SetButtonImage(0, IDI_ICON_OPEN, 20, 20); m_wndToolBar.SetButtonText(0, _T("打开")); m_wndToolBar.SetButtonImage(1, IDI_ICON_SAVE, 20, 20); m_wndToolBar.SetButtonText(1, _T("保存")); m_wndToolBar.SetButtonImage(2, IDI_ICON_VIEW, 20, 20); m_wndToolBar.SetButtonText(2, _T("切换视图")); m_wndToolBar.SetButtonStateEx(2, DTB_CHECKED);

说明一下这段代码的逻辑:Create 的参数里 CBRS_TOP 让工具栏停靠在客户区顶部,这只是把一个窗口句柄建出来;真正给按钮“上课”的是后面三组 SetButtonImage / SetButtonText。每组调用的顺序是关键——先 SetButtonImage 再 SetButtonText,内部绘制时会用图像尺寸计算文字的行位置,顺序反了会导致第一次绘制时文字被画在错误位置,等下一次刷新才纠正。

SetButtonImage 三个参数里,第一个 nIndex 是按钮在工具栏里的逻辑索引,从 0 开始;第二、三个是图标资源 ID 和期望的像素尺寸。如果你的 .ico 文件是 32x32 的,传 16x16 也没问题,内部会用 StretchBlt 缩放,但会损失一点清晰度,建议图标文件本身尺寸和参数保持一致。这里先不展开绘制细节,到第 3 章看 OnDrawItem 的实现就清楚了。

接入时最容易忽略的一点:对话框资源模板里如果已经拖了一个 CToolBar 控件,记得把它删掉,否则你会在界面上看到两排按钮。用代码创建就不需要资源编辑器里再放一份了。

3. 贴图与重绘:把按钮画成“图标在上、文字在下”的关键代码

3.1 TB_ADDBITMAP 老路线的局限,以及这次为什么走自绘

老 MFC 教程里贴图有三板斧:定义一个位图资源、CToolBar::LoadToolBar 加载、再 GetToolBarCtrl().AddBitmap 加图。这条路线有两个硬伤:第一,位图里的所有图标被横向排在一张图里,按钮靠索引去“切”对应区域,你加一个图标就要改整张位图,图标顺序一变,所有按钮的图全错位;第二,这种模式下按钮只有一种外观,按下和抬起只是系统画一个边框变化,你没法精细控制悬停高亮、图标偏移这些细节。

所以资源里放弃了 AddBitmap,直接改走 Owner-Draw。MFC 的 CToolBar 创建时,如果按钮指定了自绘风格,系统会在需要重绘时发送 WM_DRAWITEM 消息。这个消息携带 DRAWITEMSTRUCT 结构体,里面包含了按钮的矩形区域、状态标志(ODS_SELECTED、ODS_DISABLED、ODS_FOCUS 等)、以及一个 itemID 告诉你画的是哪个按钮。

你不需要在资源文件里拼接位图,只需要为每个按钮准备一个 HICON 句柄和一个 CString 文字。绘制时就是纯粹的 GDI 调用:画背景矩形、画图标、画文字。这样每个按钮都是独立的绘制单元,想调整某个按钮的图标位置、文字颜色,只改对应分支的判断就行,索引错位的风险直接消失。

3.2 OnDrawItem 的绘制顺序:背景、图标、文字、状态边框

先贴出核心函数,再逐行拆逻辑:

// DrawToolBar.cpp —— 自绘核心 void CDrawToolBar::OnDrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { // 1. 获取按钮索引与状态 int nIndex = lpDrawItemStruct->itemID; CRect rcBtn(lpDrawItemStruct->rcItem); CDC* pDC = CDC::FromHandle(lpDrawItemStruct->hDC); BOOL bSelected = (lpDrawItemStruct->itemState & ODS_SELECTED); BOOL bDisabled = (lpDrawItemStruct->itemState & ODS_DISABLED); // 2. 根据状态选背景色 if (bDisabled) pDC->FillSolidRect(rcBtn, RGB(240, 240, 240)); else if (bSelected) pDC->FillSolidRect(rcBtn, RGB(200, 210, 230)); else if (m_bHoverIndex == nIndex) pDC->FillSolidRect(rcBtn, RGB(230, 235, 245)); else pDC->FillSolidRect(rcBtn, GetSysColor(COLOR_BTNFACE)); // 3. 保存 DC 状态,开启透明背景 int nSavedDC = pDC->SaveDC(); pDC->SetBkMode(TRANSPARENT); // 4. 绘制图标(图标居中,偏上) if (m_mapIcon[nIndex].hIcon) { int nIconWidth = m_mapIcon[nIndex].nWidth; int nIconHeight = m_mapIcon[nIndex].nHeight; int nIconX = rcBtn.left + (rcBtn.Width() - nIconWidth) / 2; int nIconY = rcBtn.top + 4; DrawIconEx(pDC->GetSafeHdc(), nIconX, nIconY, m_mapIcon[nIndex].hIcon, nIconWidth, nIconHeight, 0, NULL, DI_NORMAL); } // 5. 绘制文字(居中,靠图标下方) CString strText = m_mapText[nIndex]; if (!strText.IsEmpty()) { CRect rcText = rcBtn; rcText.top += 4 + m_mapIcon[nIndex].nHeight + 2; pDC->DrawText(strText, rcText, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } // 6. 按下状态画一个凹陷边框 if (bSelected) { pDC->Draw3dRect(rcBtn, GetSysColor(COLOR_3DSHADOW), GetSysColor(COLOR_3DHILIGHT)); } pDC->RestoreDC(nSavedDC); }

这段代码的绘制顺序是刻意安排的。先画背景色,再画图标,再画文字,最后画边框——这个顺序决定了视觉层级,如果先画图标再 FillSolidRect 背景,图标会被整个盖掉。背景色的三种分支:悬停用浅蓝灰、按下用更深一点的蓝灰、禁用用纯灰,这套配色是通用做法,你要换成公司主题色,直接改 RGB 宏即可。

图标绘制用的是 DrawIconEx 而不是 CBitmap 的 BitBlt,原因有两点:一是图标资源自带透明通道,DrawIconEx 能正确处理 alpha 通道,位图则需要额外处理透明色;二是 DrawIconEx 可以直接指定目标尺寸,省去了 StretchBlt 的繁琐参数。图标坐标的计算是“水平居中、垂直偏上”,留出下方 4 像素给文字。

文字绘制依赖前面保存的图标尺寸,rcText.top 的公式是“4(上边距)+ 图标高度 + 2(间隔)”,这意味着图标尺寸的一致性直接影响文字位置。如果你给按钮 A 设了 16x16 图标、给按钮 B 设了 32x32 图标,两行文字会不在一条水平线上,视觉上就是“台阶”。所以整条工具栏的图标尺寸必须统一,这也是第 5 章会重点说的问题。最后 Draw3dRect 画凹陷边框,让按住按钮有“陷下去”的反馈。

3.3 尺寸计算与参数经验:按钮宽度、图标大小、文字边距

工具栏按钮的整体尺寸不是系统自动算的,MFC 的 CToolBar 默认只按位图尺寸算,到了自绘场景,你必须自己把按钮的“期望尺寸”设置好。资源里在 Create 之后有一段配置代码:

// 设置按钮最小宽度:图标宽度 + 左右各 8 像素边距 int nMinWidth = 20 + 8 * 2; // 20 是图标宽度 int nMinHeight = 20 + 4 + 14 + 6; // 图标高(20) + 上边距(4) + 文字高(14) + 下边距(6) m_wndToolBar.GetToolBarCtrl().SetButtonSize(CSize(nMinWidth, nMinHeight));

SetButtonSize 是 Win32 通用控件接口,MFC 封装在 GetToolBarCtrl() 返回的 CToolBarCtrl 里。nMinHeight 的计算公式是:上边距(4,给图标留呼吸空间)+ 图标高度(20)+ 文字与图标间距(2)+ 文字高度(14,默认字体一行)+ 下边距(6)。这段公式你以后自己调尺寸时直接改数字即可,唯一要注意的是文字高度 14 是以“默认 9 号宋体”为前提的,如果你用大字体或加粗,这一项要按实际字体的高度替换。

还有一个参数容易被忽略:SetButtonSize 设置的是按钮矩形的“内容区”尺寸,边框是之后绘制时加的。所以你要预留 2~3 像素给边框,否则按钮之间会挤得太紧。资源 Demo 里按钮数量少,空隙看着刚好,如果你加到 8 个以上按钮,建议把左右边距从 8 提到 10,否则一排按钮会显得拥挤、文字容易截断。

这里再提一个常被忽略的消息:WM_SIZE。对话框在初始化时 SetButtonSize 生效,但如果用户拖动改变窗口大小导致布局重排,工具栏可能重算尺寸。实测中发现,自绘工具栏在 WM_SIZE 之后偶尔会“丢失”自定义尺寸,回退到系统默认按钮大小。如果你复现时遇到工具栏突然高度塌陷,检查一下父窗口的 WM_SIZE 处理里有没有重新调用 SetButtonSize,或者干脆在 CDrawToolBar 里重写 OnSize 强制刷新按钮尺寸。

4. 按钮状态与事件路由:悬停、按下、禁用都要看得见

4.1 状态机设计:跟踪 TBSTATE_ 之外的自绘状态位

CToolBarCtrl 本身维护一套 TBBUTTON 结构,里面有个 fsState 字段记录 TBSTATE_ENABLED、TBSTATE_PRESSED 等标准状态。问题是这套状态位只反映“逻辑状态”,不包含“鼠标是否悬停”这种视觉信息。自绘工具栏要想实现悬停高亮,必须自己维护一份状态表。

资源里的做法是用三个成员变量组合出状态机:

// 成员变量 int m_bHoverIndex; // 当前悬停按钮索引,-1 表示无悬停 BYTE m_arrState[MAX_TOOLBAR_BUTTONS]; // 每个按钮的自定义状态位 BOOL m_bTracking; // 是否正在跟踪鼠标(按下未抬起)

判定逻辑遵循一个简化的优先级:禁用优先于按下,按下优先于悬停。代码里 OnMouseMove 的核心判断:

void CDrawToolBar::OnMouseMove(UINT nFlags, CPoint point) { // 转换坐标到工具栏客户区 CPoint pt = point; // 遍历按钮,判断鼠标落在哪个按钮上 for (int i = 0; i < GetToolBarCtrl().GetButtonCount(); i++) { CRect rc; GetToolBarCtrl().GetRect(i, &rc); if (rc.PtInRect(pt)) { // 悬停索引变化,触发重绘 if (m_bHoverIndex != i) { StopTrackTimer(); m_bHoverIndex = i; // 通知系统重画当前按钮 InvalidateRect(rc, FALSE); } break; } } CToolBar::OnMouseMove(nFlags, point); }

这里有几个容易被忽略的细节:第一,OnMouseMove 里必须先调用 GetRect(i, &rc),这个函数拿的是屏幕坐标,而 point 是客户区坐标,必须先转换才能 PtInRect,否则点永远不在矩形里,悬停效果完全不生效。第二,悬停索引变化时只用 InvalidateRect 画当前按钮,不要用 Invalidate() 全栏刷新,否则鼠标稍微动一下就整条工具栏闪烁,非常影响观感。第三,遍历完按钮后要调用基类的 OnMouseMove,让 MFC 内部状态同步不至于停滞。

按下和抬起的处理则在 OnLButtonDown / OnLButtonUp 里,按下时设置 DTB_PUSHED 并捕获鼠标,抬起时判断是否在按钮区域内,是的话触发命令。具体到自绘表现,按下时图标和文字都向右下偏移 1 像素,营造物理按压感。这个偏移量在代码里就是绘制图标时横纵坐标各加 1,不是必须的,但视觉反馈明显。

4.2 消息拦截与命令路由:让自绘按钮继续触发 WM_COMMAND

自绘只是换了个“画法”,按钮的真正功能还是要靠 WM_COMMAND 消息触发。这里有一个关键点:CToolBar 的按钮点击事件有两种触发方式,一是系统根据按钮 ID 自动发 WM_COMMAND,二是工具栏通过 SetCommandHandler 手动绑定。自绘情况下,系统仍然会按内部状态位自动发送 WM_COMMAND 消息,关键是 fsState 里 TBSTATE_PRESSED 必须正确设置。

最常见的翻车场景:你自己在 OnLButtonUp 里做了点击判断,但忘了调用基类的 OnLButtonUp,或者忘了把鼠标捕获还给工具栏控件,结果按钮点击之后要么命令不触发,要么连续触发两次。资源里的做法是:自绘代码只负责“视觉反馈”,不直接产生命令,命令仍交给基类的消息路由机制处理。你只需要保证鼠标操作过程中,基类能正常收到 LBUTTONDOWN/LBUTTONUP 消息即可。

说白了,OnDrawItem 里画的状态只是“画出来给人看的”,系统内部判断要触发哪个按钮命令,依赖的是按钮的 ID 和内部状态位。这两套状态在资源里通过 SetButtonStateEx 钩在一起——你手动设置 DTB_CHECKED 时,函数内部会同时把 fsState 置为 TBSTATE_CHECKED,保证“看起来是选中”和“逻辑上是选中”一致。

4.3 按钮 ID 的连续区间约定:避免命令丢失的经典翻车点

用自绘工具栏时最容易翻车的一个习惯是:随手给按钮分配 ID,比如 ID_BUTTON_OPEN = 1001,ID_BUTTON_SAVE = 2001,两个 ID 之间隔了 1000。表面上看没事,但在某些 MFC 版本的命令路由里,工具栏使用连续的 ID 区间来界定“这组按钮属于该工具栏”,如果你在 ID 中间留了断档,消息路由可能找不到正确的命令目标。

实际现象是:点击按钮时断点进不到 OnButtonOpen() 的响应函数。排查半天发现是 ID 分配策略的问题。所以资源里采用了一条约定:工具栏按钮的 ID 必须是从某个基准值开始的连续整数。代码里也可以看到统一宏定义:

// Resource.h 中的工具栏按钮 ID 定义 // 必须是连续区间! #define ID_TB_OPEN 32771 // 对应 ID_FILE_OPEN 的延续 #define ID_TB_SAVE 32772 #define ID_TB_VIEW 32773 #define ID_TB_ZOOM_IN 32774 #define ID_TB_ZOOM_OUT 32775

ID 从 32771 开始是因为 MFC 内部默认把 32770 之前的 ID 留给 frame 的菜单和处理程序,你从 32771 起按顺序排,风险最小。如果你要用 1001 这种小数字,也不是绝对不行,但必须确保主菜单、其他控件的 ID 不冲突,而且区间内没有断档。

这属于“基础约定,但不定时踩雷”的规则。我遇到过一位同事,把按钮 ID 定义为 ID_BUTTON_SAVE = 0xE100,理由是“看着整齐”。结果工具栏前三个按钮能用,第四个失灵,后来一查,原来第四个按钮的 ID 恰好和某个内部资源 ID 撞了,消息被别处拦截。从那之后我接手的 MFC 工程,工具栏按钮 ID 一律连续区间,且统一加 ID_TB_ 前缀,这已经是代码审查的硬规则了。

5. 自绘工具栏避坑:五个高频问题与排查路径

5.1 现象:按钮高度参差不齐、图标被裁掉

设置完 SetButtonSize 后,有的按钮高、有的按钮矮,图标底部被切掉一块。

原因:SetButtonSize 设置的“按钮逻辑尺寸”和每次 OnDrawItem 里设置的图标尺寸不一致。比如逻辑高度 40,但某个图标实际绘制尺寸传了 48,图标必然溢出。另一个原因是按钮索引对应的图标尺寸在 SetButtonImage 时各不相同,导致第 3 章提到的“文字台阶”。

解决:在 SetButtonImage 里做一次参数钳制,图标宽高超过按钮内容区的直接按比例缩放。养成习惯——每个按钮的图标尺寸统一为同一个常量,不要一个 16 一个 20。如果必须大小不一(比如“打开”用大图标、“保存”用小图标),那就为每个按钮单独算 SetButtonSize,而不是整条工具栏用同一个尺寸。

5.2 现象:XP 风格下自绘完全不生效,按钮退化成灰底白边

在启用视觉风格的主题下,自绘代码画出来的按钮样式被系统主题覆盖,或者根本走不进 OnDrawItem。

原因:MFC 在启用 SetThemeManager 后,CToolBar 的消息路由里会优先走主题绘制的分支,WM_DRAWITEM 没有分发到派生类。常见于把工程从经典样式切到 XP 样式后,界面变了但自绘按钮没跟上。

解决:在 CDrawToolBar::OnEraseBkgnd 里直接 return TRUE,彻底拒绝系统擦背景。同时在 PreSubclassWindow 里给工具栏加上 TBSTYLE_LIST 或自行处理 WM_THEMECHANGED,让工具栏强制走 Owner-Draw 分支。另一个杀手锏是用 CWinApp::SetRegistryKey 之后调用 SetThemeManagerType 设为不启用主题,但这是全局影响,建议只在工具栏相关类里做局部处理。

5.3 现象:PNG 图标透明区域变成黑底

图标资源文件是 PNG,加载到工具栏后发现原本透明的位置全成了黑色块。

原因:直接用 CImage 的 Load 或 LoadIcon 加载 PNG,系统默认用位图方式处理,透明通道被当成普通像素画出来。DrawIconEx 能处理图标文件的 alpha,但处理不了“图片格式”的 PNG 数据(LoadImage 直接加载 ICO 可以带透明,加载 PNG 就得专门走 GDI+ 解码)。

解决:加载 PNG 不要用 LoadIcon,而是用 GDI+ 把 PNG 解码成 32 位 DIB,再通过 AlphaBlend 绘制。资源里实际上用的是一组 ICO 文件——ICO 天生带透明通道,DrawIconEx 直接用 DI_NORMAL 就能画好。如果你一定要用 PNG,就得在 OnDrawItem 里把绘图方式从 DrawIconEx 改成 AlphaBlend,并且把 PNG 的 alpha 通道保留下来,不能用 BitBlt。

5.4 现象:高 DPI 下按钮错位、文字模糊

系统缩放比例调到 125% 或 150% 后,工具栏按钮间距变大,文字发虚,图标和文字位置对不齐。

原因:MFC 工程没有启用 DPI 感知,系统做虚拟化缩放,GDI 绘制的文字被缩放后发虚;而 SetButtonSize 传的固定像素值没有跟着 DPI 换算,按钮逻辑尺寸和实际绘制区域错位。

解决:在程序入口或 OnInitInstance 里调用 SetProcessDPIAware,让系统不再为你的程序做缩放;然后在 CDrawToolBar::Create 里读取当前 DPI 比例,把图标尺寸、边距、文字高度全部乘以缩放系数再计算。注意 SetButtonSize 里的参数是按物理像素算的,DPI 120% 时就该传 24(20*1.2)而不是 20。这个坑比较隐蔽——低 DPI 下完全正常,换到高分屏立刻露馅。

5.5 现象:禁用按钮仍然响应鼠标点击,状态显示禁用但命令仍在执行

SetButtonStateEx 设置了 DTB_DISABLED,按钮显示灰色,但点击后命令还是触发。

原因:自定义状态位 DTB_DISABLED 只影响了绘制,修改 fsState 时用的是 TBSTATE_ENABLED 取反的写法,如果之前按钮的 fsState 里还有其他标志位,你的取反操作把不相关的位也清零了。更常见的是只设置了视觉禁用,没改逻辑状态。

解决:禁用逻辑必须走 CToolBarCtrl::SetState,明确指定 TBSTATE_ENABLED 的 0/1。代码里 SetButtonStateEx 的正常写法是:

BOOL CDrawToolBar::SetButtonStateEx(int nIndex, BYTE nState) { // 更新自定义状态位 m_arrState[nIndex] = nState; // 同步系统逻辑状态 TBBUTTONINFO tbi = { 0 }; tbi.cbSize = sizeof(TBBUTTONINFO); tbi.dwMask = TBIF_STATE; if (nState & DTB_DISABLED) tbi.fsState = 0; // 清除 TBSTATE_ENABLED else tbi.fsState = TBSTATE_ENABLED; return GetToolBarCtrl().SetButtonInfo(nIndex, &tbi); }

这样画出来的灰色是“真正禁用”的灰色,而不是只顾外观的假禁用。区分这两者,是自绘工具栏从“看着像”到“真的像”的关键。

6. 验证与调优:一小时内确认封装可用的小技巧

6.1 用 TRACE 日志验证绘制路径是否走自定义代码

自绘最怕的不是代码写错,而是消息压根没进到你的函数里。接入后先做一次“路径验证”:在 OnDrawItem 第一行加一句 TRACE 输出按钮索引,运行程序看 Output 窗口是否有打印。如果没有任何输出,说明 OnDrawItem 根本没被调用,问题出在工具栏创建时没加自绘风格,或者消息映射没写好。如果输出了但不刷新,那就是 InvalidateRect 的区域不对。这个步骤 5 分钟能完成,能帮你把 90% 的“没生效”类问题排查清楚。

6.2 DPI 模拟与边框像素核对

一个快速验证方式:把 Windows 显示缩放分别切到 100%、125%、150%,每次启动 Demo 后截屏,放大对比按钮边框和文字边缘。自绘工具栏最容易在缩放切换后出现“按钮大小变了但图标没变”的组合错乱。记住一条经验规律:缩放变化后按钮逻辑尺寸和图标尺寸必须同比例缩放,要么都变要么都不变,最忌讳按钮自动变了图标没跟上。

6.3 给派生类补一个快速自检接口

资源代码里没有提供自检功能,但我接入后习惯补一个小方法,专门在调试时调用:

// 调试用:打印当前工具栏按钮的关键状态 void CDrawToolBar::DumpButtonList() { int nCount = GetToolBarCtrl().GetButtonCount(); TRACE(_T("工具栏按钮总数: %d\n"), nCount); for (int i = 0; i < nCount; i++) { TBBUTTON tb = { 0 }; GetToolBarCtrl().GetButton(i, &tb); CString strText = m_mapText[i]; TRACE(_T("索引 %d, ID=%d, 文字=%s, 状态=0x%02X\n"), i, tb.idCommand, strText, tb.fsState); } }

调用的时机放在 OnInitDialog 返回之前,先确认按钮索引、ID、状态三者关系是不是你预期的。特别是跑过 5.5 的禁用逻辑后,用这个方法一眼就能看出 fsState 的位是否被误改。

这套自绘工具栏的封装做完之后,我把之前那批纯位图工具栏的项目全部替换成了这个派生类,顺手把按钮 ID 统一整理成了连续区间。从那以后我每次接 MFC 界面的第一个动作,就是先确认工具栏的图标资源是不是 ICO 格式、按钮 ID 是否连续、以及有没有在 Create 后立刻 SetButtonSize——这三件事跑通了,工具栏的活就完成了大半。希望这篇笔记里的实现和踩坑记录能帮你在类似的自绘路上少走几段弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进