ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MFC工具栏CToolBar图文自定义:图片替换与文字显示的完整实战

MFC工具栏CToolBar图文自定义:图片替换与文字显示的完整实战 简介面向MFC Windows窗体应用开发者的CToolBar工具栏自定义示例包内含完整的TimeClient演示项目与配套图片素材专门解决工具栏按钮同时显示图片与文字、自由停靠与浮动等定制需求适用于桌面工具、编辑器快捷操作栏等界面开发场景。压缩包共24个文件源码以C头文件h与实现文件cpp为主体用于类声明与功能逻辑资源文件rc/rc2与图标ico定义了菜单、位图、程序图标等界面元素工程配置vcxproj、sln、filters可让项目在Visual Studio中直接打开构建。另附可执行Demo以及“工具栏图片素材及代码.rar”整体仅324KB轻量且目录清晰方便按需提取。已有1682人学习适合有一定MFC基础、希望在Windows界面开发中快速实现工具栏自定义效果的初学者或中级开发者。通过示例可完整掌握CToolBar的创建与初始化、LoadBitmap加载位图、SetButtonInfo为按钮设置图像与文字、DockControlBar和FloatToolBar控制工具栏停靠或浮动等关键方法同时附带的素材方便后续替换位图、制作个性化图标文档则提供代码使用说明与按钮事件响应思路可显著降低接入MFC工具栏的试错成本。 做MFC界面开发的朋友估计都遇到过这个场景窗口上方那一排CToolBar按钮上永远只有一张小图标新建、保存、删除这些操作鼠标不悬停个两秒钟根本看不懂是干嘛的。翻MSDN找到了SetButtonText满心欢喜写了两行代码一编译运行界面上还是光秃秃的图标。这个反直觉的坑我今天专门把它聊透。这篇文章不绕弯子直接从CToolBar的底层绘制机制讲起把图片和文字自定义这件事拆成三块第一摸清CToolBar到底怎么切分位图、怎么存储按钮文字第二自定义图片的三种可行路线从资源编辑器改图到运行时换图标都有第三让文字真正显示在按钮上的两种方案包含完整代码。最后把我实际开发中踩过的坑一并整理出来比如Unicode工程下CString转char*、按钮文字不刷新、图标透明色冲突这些。适合正在用MFC做界面、且不想停留在“能用就行”这个层面的开发者参考。1. 到底是什么逼着我给CToolBar做自定义图文1.1 MFC默认工具栏的三个“老毛病”CToolBar用起来确实方便LoadToolBar(IDR_MAINFRAME)一行代码就能把资源编辑器里画好的一排按钮加载出来。但默认行为有三个让人头疼的限制。第一按钮上只显示位图图标不显示文字。MFC提供的CToolBar封装里按钮文字信息存在内部结构里但界面默认不渲染。第二图标必须是资源编辑器里预先画好的位图程序跑到一半想换个图标或者想从外部配置文件加载一套皮肤默认机制根本不支持。第三按钮的交互反馈非常“古典”按下、悬停、禁用的视觉差异很小想做成现在流行的扁平化带文字按钮原生样式完全不够用。这三个限制单独看都能忍但凑到一起在真实项目里就非常难受。我接手过一个维护了十年的老项目客户要求工具栏按钮像Web后台那样“图标文字”并排显示图标还要支持从外部主题目录加载。当时第一反应是换个UI库重写整个Frame工程量太大最后只能硬着头皮在CToolBar上做扩展。做完之后发现这事比想象中简单前提是你得先搞清楚它底层的机制。1.2 真实项目里的三个典型需求我总结了日常开发中最高频的三种自定义需求。第一种就是给按钮加文字标签。操作类按钮只用图标确实不直观尤其对不熟悉系统的用户带文字的按钮能显著降低学习成本。第二种是动态替换图标比如根据用户权限显示不同状态的图标或者支持多套皮肤切换这要求程序运行时不能依赖资源文件里写死的那张位图。第三种是按钮文字内容动态变化像“开始/暂停/继续”这种状态切换按钮文字要跟着状态变。这三种需求分别对应本文第3、4、5章的内容。如果你也有类似的诉求按图索骥就行。2. 先摸清CToolBar的底细图片分割、文字存储与显示模式2.1 CToolBar只是包装干活的是CToolBarCtrlCToolBar是MFC对Win32工具栏控件的C封装类真正的底层控件是CToolBarCtrl。CToolBar提供的Create、LoadToolBar、SetButtons等方法最终都会调用CToolBarCtrl的对应接口。搞明白这个包装关系非常重要。因为很多高级功能CToolBar没有直接暴露方法必须通过GetToolBarCtrl()拿到底层接口去操作。比如设置TBSTYLE_LIST样式SetStyle只在CToolBarCtrl里才有CToolBar本身没有。网上很多人问“为什么我调了SetButtonText按钮没反应”原因之一就是没有通过GetToolBarCtrl()把需要补充的样式设置到位。2.2 图片的分割规则16像素口径与透明色约定CToolBar的图片位图不是“一个按钮一张图”而是一整张位图水平排列着所有按钮的图标加载后按图标宽度自动分割。资源编辑器默认生成的工具栏位图每个图标宽度是16像素高度是15像素。注意这里是15不是16这是老版Visual Studio生成工具栏资源时的遗留约定如果你新建工具栏资源后直接用图标尺寸就是这个值。还有一个极其重要的隐藏约定位图左上角第一个像素的颜色会被当作透明色处理。工具栏绘制图标时会把位图中所有与左上角同色的像素镂空掉。这意味着如果你加载的位图里恰好也用了这个颜色画图标的主体那么画出来就是缺一块的。很多新手自定义了位图发现图标是“破”的绝大多数都是这个原因。解决办法很简单不要让图标主体颜色和左上角色块颜色相同。按钮本身的尺寸和图片尺寸可以分开设置通过SetSizes(CSize btnSize, CSize imgSize)指定。比如想让按钮大一点、图标清晰一点可以设成SetSizes(CSize(40, 40), CSize(24, 24))。注意图片尺寸必须和位图里每个图标分割出来的实际尺寸一致否则会出现图标错位。2.3 文字存储与显示模式的真相工具栏按钮的文字并不是不存在而是默认没画出来。CToolBar内部会把按钮文字通过AddString加入工具栏自己的字符串池然后把字符串索引记录在TBBUTTON.iString字段里。SetButtonText干的事就是把文字写进这个字符串池并更新iString它本身并不会主动把文字画到按钮上。那什么时候画文字关键在显示模式只有当工具栏设置了TBSTYLE_LIST样式时系统才会把按钮文字渲染到界面上而且文字默认位于图标的右侧。这个样式的名字容易让人误解成“列表模式”实际上它还有一个作用就是控制文字标签的显示。你需要在创建工具栏之后通过GetToolBarCtrl().SetStyle(GetStyle() | TBSTYLE_LIST)把样式加上之前设置的文字才会冒出来。具体的完整过程第4章会专门写。3. 自定义按钮图片的三条路线从改资源到运行时替换3.1 路线一资源编辑器直接改如果图标只在开发期确定运行期不需要变化最省事的方式就是直接编辑工具栏资源。在VS的资源视图里展开.rc文件找到IDR_MAINFRAME这个Toolbar资源双击就能打开位图编辑器直接在网格上画每个按钮的图标。保存编译后运行图标就自动更新了。这条路线的好处是零代码所见即所得。适合UI冻结、不再频繁变动的工具栏。缺点也很明显位图编辑器是16色或256色模式画出来的图标效果一般程序运行时图标完全写死换肤、换状态完全做不到。3.2 路线二运行时从外部文件加载位图需要从外部文件加载图标时可以用CImage把图片文件读进来取出HBITMAP再交给工具栏。CImage img; HRESULT hr img.Load(_T(D:\\icons\\toolbar_new.png)); if (SUCCEEDED(hr)) { HBITMAP hBmp img.Detach(); m_wndToolBar.SetBitmap(hBmp); // 注意Detach之后HBITMAP由调用方负责管理 // 控件退出前需要记得DeleteObject(hBmp)释放 }这里有个细节必须提醒SetBitmap只是告诉工具栏“用这张位图”工具栏不会替你管理HBITMAP的生命周期。你设置的HBITMAP在程序整个生命周期内都要保持有效最好把它作为成员变量保存在窗口销毁时释放否则程序退出阶段会崩或者报警告。用这种方式替换图片时位图里图标的宽度要和当前工具栏的图标尺寸对应上。比如按钮图标宽度设定为24像素那外部位图里每个图标的宽度也得是24像素总共几个按钮就画几个图标从左到右依次排列不然会被错误切割。3.3 路线三用ImageList整体替换图片集合如果需要在运行时灵活地增删图标或者想要支持带透明效果的图标更推荐走ImageList路线。思路是先创建一个CImageList把图标按索引加进去然后通过TB_SETIMAGELIST把它设置给工具栏控件。// m_imageList 必须是类的成员变量不能是局部变量 m_imageList.Create(20, 20, ILC_COLOR32 | ILC_MASK, 4, 1); HICON hIcon (HICON)::LoadImage(AfxGetInstanceHandle(), MAKEINTRESOURCE(IDI_ICON_NEW), IMAGE_ICON, 20, 20, LR_DEFAULTCOLOR); m_imageList.Add(hIcon); // 继续Add其他图标... m_wndToolBar.GetToolBarCtrl().SetImageList(m_imageList);这里有一个非常经典的坑CImageList对象如果定义成局部变量函数返回后对象析构图片列表就没了工具栏显示出来就是一片空白甚至崩溃。必须把CImageList对象作为工具栏所在窗口类的成员变量保证它的生命周期长于工具栏控件。ImageList方案的一大优势是支持32位带Alpha通道的图标图标边缘的锯齿和背景问题比传统位图好得多。缺点是代码量略大要维护图标索引和按钮索引的对应关系。3.4 三条路线怎么选场景推荐路线理由图标固定、开发期确定资源编辑器直接改零代码、速度快图标来自外部文件、数量固定SetBitmap加载位图简单直接适合整体替换图标需要动态增减、支持透明ImageList SetImageList灵活支持运行时维护实际项目中我大部分用的是第三种。尤其是需要做皮肤切换的时候一个ImageList对应一套图标主题切换时重建ImageList并调用SetImageList整个工具栏的图标瞬间就能刷新体验远好于重新加载位图。4. 让文字真正显示在按钮上系统排版与自绘叠加两种方案4.1 方案一TBSTYLE_LIST让系统帮你摆文字先给结论想让CToolBar原生支持在按钮右侧显示文字关键就是设置TBSTYLE_LIST样式。完整步骤是这样// 1. 创建并加载工具栏通常在CMainFrame::OnCreate中 if (!m_wndToolBar.CreateEx(this, TBSTYLE_FLAT | TBSTYLE_LIST, WS_CHILD | WS_VISIBLE | CBRS_TOP | CBRS_TOOLTIPS | CBRS_FLYBY, CRect(0, 0, 0, 0), AFX_IDW_TOOLBAR) || !m_wndToolBar.LoadToolBar(IDR_MAINFRAME)) { TRACE0(Failed to create toolbar\n); return -1; } // 2. 重要通过底层控件设置列表样式CreateEx里的TBSTYLE_LIST只是辅助这里再确保一次 m_wndToolBar.GetToolBarCtrl().SetStyle( m_wndToolBar.GetToolBarCtrl().GetStyle() | TBSTYLE_LIST); // 3. 给每个按钮设置文字 m_wndToolBar.SetButtonText(0, _T(新建)); m_wndToolBar.SetButtonText(1, _T(打开)); m_wndToolBar.SetButtonText(2, _T(保存)); // 4. 让按钮宽度自动适配文字或者手工给足宽度 m_wndToolBar.SetSizes(CSize(72, 36), CSize(20, 20));第四步特别关键。工具栏按钮的默认宽度是按图标宽度来的加了文字之后如果按钮宽度不够文字会被裁剪掉看起来就像没设置成功一样。SetSizes的第一个参数是按钮整体尺寸第二个参数是图标尺寸我给的72像素宽度大约能放下4个汉字加一个图标具体值根据你的文案长度调整。这种方案的优点是实现成本低文字位置由系统排版刷新、重绘都不用自己操心按钮的悬停和按下效果也是系统原生绘制。缺点是不够灵活文字只能固定在图标右侧不能放在图标下方也不能自己设置颜色和字体。如果你的UI设计稿要求文字在图标的正下方这条路就走不通了。4.2 方案二NM_CUSTOMDRAW自绘叠加文字需要更自由的排版或者想要自定义文字外观就得在NM_CUSTOMDRAW通知里自己动手画。思路是保留系统对按钮背景和图标图片的默认绘制在绘制完成的POSTPAINT阶段把文字叠加到按钮上。这样既保留了原生的交互反馈又能完全控制文字区域。先在消息映射里加上通知处理BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_NOTIFY(NM_CUSTOMDRAW, AFX_IDW_TOOLBAR, CMainFrame::OnToolbarCustomDraw) END_MESSAGE_MAP()注意这里的AFX_IDW_TOOLBAR是MFC默认工具栏的控件ID。如果你的工具栏不是用AFX_IDW_TOOLBAR创建的就需要改成实际的控件ID这一点容易漏很多人照着例子写了却发现消息根本没触发多半就是这个ID对不上。然后实现处理函数void CMainFrame::OnToolbarCustomDraw(NMHDR* pNMHDR, LRESULT* pResult) { LPNMTBCUSTOMDRAW lpNMCD (LPNMTBCUSTOMDRAW)pNMHDR; *pResult CDRF_DODEFAULT; switch (lpNMCD-nmcd.dwDrawStage) { case CDDS_PREPAINT: // 请求系统对每个按钮单独回调 *pResult CDRF_NOTIFYITEMDRAW; break; case CDDS_ITEMPREPAINT: // 系统画完按钮背景和图标之后再通知一次给我们叠加文字 *pResult CDRF_NOTIFYPOSTPAINT; break; case CDDS_ITEMPOSTPAINT: { CDC* pDC CDC::FromHandle(lpNMCD-nmcd.hdc); CRect rc(lpNMCD-nmcd.rc); int nIndex (int)lpNMCD-nmcd.dwItemSpec; // 从你自己的文字数组中取按钮文字 if (nIndex 0 || nIndex m_arrButtonText.GetSize()) break; CString strText m_arrButtonText[nIndex]; if (strText.IsEmpty()) break; BOOL bPressed (lpNMCD-nmcd.uItemState CDIS_SELECTED) ! 0; BOOL bDisabled (lpNMCD-nmcd.uItemState CDIS_GRAYED) ! 0; pDC-SetBkMode(TRANSPARENT); if (bDisabled) pDC-SetTextColor(GetSysColor(COLOR_GRAYTEXT)); else pDC-SetTextColor(GetSysColor(COLOR_BTNTEXT)); // 文字区的左侧留出图标位置这里假设图标占前28像素 CRect rcText(rc); rcText.left 28; rcText.top (bPressed ? 1 : 0); // 按下时文字向右下微移模拟物理按压感 rcText.left (bPressed ? 1 : 0); pDC-DrawText(strText, rcText, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); } break; } }这段代码有几个细节值得展开。绘制文字前必须调用SetBkMode(TRANSPARENT)否则DrawText默认会用不透明背景填充文字区域按钮上的图标和背景会被一块白底盖住效果非常难看。文字颜色我用了系统按钮文字色禁用状态用灰色这是Windows标准的视觉规范。需要强调文字跟随按钮状态时可以根据CDIS_SELECTED、CDIS_GRAYED、CDIS_HOT这几个状态位来切换颜色和位置。文字区和图标区的位置关系是这套方案里唯一的“魔法数字”。我这里写的左侧28像素是从按钮左边缘到文字起点的距离等于“按钮内边距 图标宽度 图标到文字间距”。这个值要根据你实际设置的图标大小调整。如果想排版更严谨可以在ITEMPREPAINT阶段用Rect手动计算图标右边界再把文字区的left设成图标右边界加6像素的间距这样就不会出现图标挡住文字或者文字离图标太远的情况。DrawText的格式标志里我加了DT_END_ELLIPSIS这是常用技巧。当按钮文字过长超出按钮宽度时自动显示成省略号避免文字溢出到下一个按钮上。4.3 布局参数实测心得搞图片加文字这种混排最烦的就是尺寸没算好出来效果要么图标挤成一坨要么文字被截断。我自己折腾了几轮总结出一组比较稳妥的经验值按钮高度36像素图标20x20像素按钮左右内边距各8像素图标和文字间距6像素。按这个组合中文四个字的按钮总宽度大约需要70到80像素。另外提醒一点如果用系统DPI缩放Windows 10/11上常见这些像素值在缩放比例不是100%的显示器上会显得很小。MFC默认不做DPI自适应你需要处理WM_DPICHANGED消息按比例重新计算SetSizes的数值。这部分工作我在项目里是单独抽了一个UpdateToolbarLayout函数收到DPI变化通知后重新设置否则在高分屏上按钮字会糊成一团。5. 图文自定义后容易踩的坑字符集、刷新、尺寸与悬停提示5.1 Unicode工程下CString转char*的正确姿势VS2010之后的MFC项目默认字符集是Unicode也就是说CString底层是宽字符wchar_t。很多老项目代码是从VC6时代迁移过来的那时候字符串是单字节char*一旦打开Unicode字符集原来的强转写法就会出问题。工具栏做自定义文字时如果要把CString里的值传给某个只认char*的接口比如写配置文件、拼接命令、传给第三方库这一关绕不过去。网上最常见的错误写法是CString str _T(新建); char* p (char*)(LPCTSTR)str; // 错误Unicode工程下LPCTSTR是wchar_t*这种写法在Unicode工程下编译能过C允许强转运行结果要么是乱码要么是空字符串。原因是(LPCTSTR)str拿到的是宽字符缓冲区首地址而不是字符数据本身强转成char*后按ASCII逐字节解析自然全错。正确的做法是用CT2A转换宏这也是我日常最推荐的写法CString str _T(新建); CT2A ascii(str); // 默认转换到系统ANSI代码页 const char* pChar ascii; // 使用pChar...CT2A会自动处理Unicode到ANSI的转换而且转换后的缓冲区生命周期跟随ascii对象作用域内使用都是安全的不用手工释放内存。如果目标不是ANSI而是UTF-8比如要把文字写进一个UTF-8编码的JSON文件就得用WideCharToMultiByteCString str _T(新建); int nLen ::WideCharToMultiByte(CP_UTF8, 0, str, -1, NULL, 0, NULL, NULL); char* pBuf new char[nLen]; ::WideCharToMultiByte(CP_UTF8, 0, str, -1, pBuf, nLen, NULL, NULL); // pBuf里就是UTF-8编码的文本 delete[] pBuf;这里WideCharToMultiByte的第五个参数传NULL调一次是为了拿到转换后需要的缓冲区大小这是Win32 API的标准两段式用法。需要提醒的是UTF-8下的nLen包含了结尾的\0分配缓冲区时不要漏掉。5.2 图片不刷新或文字被截断工具栏的自绘图层叠加上去之后最常遇到的问题就是改了文字内容但界面上纹丝不动。我查过很多次最后都定位在同一个原因只改了数据没触发重绘。GDI绘制是基于窗口绘制消息驱动的数据变了不主动让控件失效画面上还是旧内容。解决办法很直接修改完文字或图标后调用刷新接口m_wndToolBar.GetToolBarCtrl().InvalidateButton(nIndex); // 刷新单个按钮 // 或者偷懒一点整个工具栏刷新 m_wndToolBar.Invalidate(FALSE);InvalidateButton是工具栏控件专门提供的刷新单个按钮的方法不会导致整条工具栏闪烁推荐优先使用。注意第二个参数FALSE表示不擦除背景能减少闪烁。文字被截断的问题在自绘方案里也很常见。前面代码里我用了DT_END_ELLIPSIS兜底但这只是防御性措施治本还是要让按钮宽度和文字长度匹配。在工具栏创建初期手动调整每个按钮的宽度比较繁琐可以在SetSizes时给按钮宽度留足余量图文混排情况下我一般会估算最长的按钮文字然后统一使用这个宽度。5.3 透明色冲突与图标显示异常自定义位图时最常见的显示异常是图标某些区域变成镂空的或者整张图看上去被“咬”掉一块。原因就在第2章说的那个规则——位图左上角第一个像素的颜色被当作透明色了。我之前做过一张渐变背景的位图左上角恰好是浅灰色结果图标里所有浅灰色的像素全消失了整体看起来像被虫蛀过。排查过程很折腾最后才恍然想起透明色约定。规避办法有两种一是在位图左上角专门留一个2x2像素的纯色块作为透明色锚点保证这个颜色不会出现在图标本体里二是干脆放弃位图改用带Alpha通道的PNG配合ImageList彻底绕开透明色机制。5.4 悬停提示与按钮状态文字联动工具栏默认带悬停提示CToolBar会在鼠标悬停时查找按钮ID对应的字符串资源并弹出气泡。自定义了按钮文字之后悬停提示并不会自动显示新的文字因为系统依然使用字符串资源表。要让Tooltip和按钮上的显示文字保持一致需要处理TTN_GETDISPINFO通知动态返回字符串。BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_NOTIFY_EX(TTN_NEEDTEXT, 0, CMainFrame::OnToolTipText) END_MESSAGE_MAP() BOOL CMainFrame::OnToolTipText(UINT id, NMHDR* pNMHDR, LRESULT* pResult) { TOOLTIPTEXT* pTTT (TOOLTIPTEXT*)pNMHDR; UINT nID pNMHDR-idFrom; if (nID 0x8000) // 按钮ID一般是命令ID用高位判断 { CString strTip; if (GetTipTextById(nID, strTip)) { _tcscpy_s(pTTT-szText, strTip); pTTT-hinst NULL; // 让系统使用szText里的内容 *pResult 0; return TRUE; } } return FALSE; }处理TTN_NEEDTEXT的动态文本时有个细节TOOLTIPTEXT::szText是固定大小的缓冲区长度上限是80个字符包含结尾\0文案别写太长否则会被静默截断。另外设置hinst NULL是告诉系统“我已经在szText里放好了字符串不需要再去资源里查”这个标志位不设的话Tooltip还是显示资源里的旧文字。工具栏禁用按钮的文字颜色问题也要提一下。系统原生绘制时禁用按钮的文字自动变灰。但自绘叠加文字时禁用状态完全由你的代码决定如果忘了判断CDIS_GRAYED禁用的按钮上照样是黑色文字用户会误以为按钮还能点。我在示例代码里专门处理了这个分支如果你要做生产级工具栏别把这段删掉。最后再分享一个我实际开发里的体会工具栏这种控件平时看着不起眼但它是用户每天点得最多的界面元素之一体验好坏直接影响对整套软件的第一印象。自定义图片和文字这块的坑大多集中在“机制不熟”和“字符集混乱”两件事上。把CToolBar的位图分割规则、TBSTYLE_LIST显示模式、Unicode字符集转换这三个底层逻辑理顺剩下的事就是用代码堆细节了。项目里如果时间允许我建议把自绘部分封装成一个独立的CCustomToolBar类把按钮文本数组、图标列表、DPI布局这些全收进去后续维护起来会省心很多。本文还有配套的精品资源点击获取
返回列表