☰
MFC CToolBar工具栏自定义图片文字与高DPI自绘实践
2026/10/28 12:11:11 网站建设 项目流程

简介:这份资源面向具备一定C++基础的Windows桌面开发者,尤其是正在使用MFC搭建界面、需要为CToolBar添加图片按钮与文字标签并实现停靠、浮动效果的中级学习者。资源围绕工具栏自定义这一常见需求,给出可运行的示例工程与配套素材,帮助读者解决按钮位图加载、SetButtonInfo设置图像索引与显示文本样式、以及DockControlBar、FloatToolBar控制停靠状态等实际问题。压缩包共24个文件、约324KB,以h头文件与cpp源文件为主,另有rc资源脚本、ico图标、vcxproj与sln工程文件以及一份ReadMe说明和可直接运行的exe,工程结构完整,便于导入编译和二次修改;包内还附带工具栏图片素材及代码子包,图片资源开箱可用。目前已有1685人学习下载。通过对照MainFrm、视图类与资源文件的组织方式,读者可以快速掌握工具栏位图与文字的配置流程、按钮点击响应写法,并将其迁移到自己的项目中。

1. 从 16 色图标到可换行文字:MFC CToolBar 克服的几个常见问题

在资源编辑器里把 32 位 PNG 放进去,编译后工具栏上却是一圈黑边;好不容易把文字加上去,发现按钮被撑歪、文字被裁掉半截;换到 4K 屏上,图标糊成一团,文字又小得看不清。MFC 的 CToolBar 工具栏自定义图片及文字这件事,难点不在写代码,而在于它有三套彼此博弈的默认值:图片走位图管线、按钮尺寸走统一格、文字走字符串表,任何一层没对齐就会互相干扰。这一章先把问题说清楚,然后按图片、文字、自绘、排查、配置化五个层面往下拆,目标是一个能跑在单文档或多文档老工程里、支持自定义图片与文字、并且在高 DPI 下不糊不偏的工具栏。

2. 把图片管线从 TB_ADDBITMAP 换成 ILC_COLOR32

MFC 向导生成的工具栏,图片来源是资源里的一个 TOOLBAR 位图,CToolBar::LoadToolBar 最终走到 CToolBarCtrl::AddBitmap,也就是向控件发 TB_ADDBITMAP。这条路径是 Windows 95 时代的设计:位图必须是索引色,按钮尺寸固定,透明靠“取左上角像素当掩码色”。你拿一张带 Alpha 通道的 32 位 PNG 喂进去,系统会按掩码色规则处理,边缘半透明像素要么被硬切成不透明,要么变成黑块,这就是“图标有黑边”的来源。

想用真彩图标,思路不是改 CToolBar,而是绕过它的位图管线,自己建一个 32 位 CImageList,然后通过 TB_SETIMAGELIST 挂到工具栏控件上。CToolBarCtrl 没有暴露对应的成员函数,直接用 SendMessage 发消息即可,这条消息替换的是控件内部的图像列表,不影响按钮数量、顺序和命令 ID。

2.1 搞清 CToolBar 与 CToolBarCtrl 的分工再动手

CToolBar 派生自 CControlBar,它本身是个“带边框和停靠能力的容器”,真正的按钮绘制在它内部持有的 CToolBarCtrl 子控件里,通过 GetToolBarCtrl() 拿到。这个区分决定了你能改什么:

  • 改停靠行为、行数、是否可浮动:在 CToolBar 这一层,用 SetSizes、SetWindowPos、CreateEx 的样式位。
  • 改图片、按钮文本、按钮状态、自绘:在 CToolBarCtrl 这一层,用 SendMessage 发 TB_ 开头的消息。
  • 从资源加载:CToolBar::LoadToolBar 一次性把位图和按钮定义都塞进控件,后续你想换图,必须考虑它已经加进去的那份旧位图。

一个容易忽略的点:CToolBar::LoadToolBar 加载资源时,按资源里 BUTTON 出现的顺序给按钮分配 iBitmap 索引 0、1、2……这个索引就是你后面要替补的图像列表下标。如果你的 CImageList 里图片顺序与资源顺序不一致,替换之后图标会整体错位,而不是报错。

提示:替换图像列表之前,先用 GetToolBarCtrl().GetButtonCount() 和 GetButtonInfo(i, nID, nStyle, nImage) 打印一遍每个按钮的 nImage,把这份索引表抄下来,后面的加载顺序就以它为准。

2.2 用 CImageList 加 TB_SETIMAGELIST 替换默认图源

下面是替换图源的核心函数。它做了三件事:按 DPI 算出真实图标尺寸、逐张加载资源位图进 32 位图像列表、把列表挂到工具栏控件上。

// MainFrm.h #include <atlimage.h> // CImage,需要 GDI+;工程若已 Init GDI+,退出前别急着 Shutdown class CMainFrame : public CFrameWnd { CToolBar m_wndToolBar; CImageList m_imgBar; // 工具栏专用图像列表 BOOL BuildBarImages(UINT nDpi); // 按 DPI 重建,切屏时也调它 };
// MainFrm.cpp // 顺序必须与工具栏资源里 BUTTON 的出现顺序一致,否则图标错位 static const UINT kBarIcons[] = { IDB_BAR_NEW, IDB_BAR_OPEN, IDB_BAR_SAVE, IDB_BAR_RUN }; BOOL CMainFrame::BuildBarImages(UINT nDpi) { m_imgBar.DeleteImageList(); // 重复调用(切 DPI)时先释放旧列表 const int cx = MulDiv(32, nDpi, 96); const int cy = MulDiv(32, nDpi, 96); // 关键:只给 ILC_COLOR32,不要加 ILC_MASK // 加了 ILC_MASK 会走掩码路径,Alpha 半透明像素被当成掩码丢弃 if (!m_imgBar.Create(cx, cy, ILC_COLOR32, 4, 4)) return FALSE; for (int i = 0; i < _countof(kBarIcons); ++i) { CImage img; if (FAILED(img.LoadFromResource(AfxGetInstanceHandle(), kBarIcons[i]))) { TRACE(_T("bar icon res %u load failed\n"), kBarIcons[i]); continue; } CBitmap bmp; bmp.Attach((HBITMAP)img.Detach()); // 所有权从 CImage 转到 CBitmap // 传空掩码走 ImageList_Add(hil, hbm, NULL),保留 32 位 Alpha // ImageList_Add 内部会复制像素,bmp 出作用域删除原 HBITMAP 不影响列表 m_imgBar.Add(&bmp, (CBitmap*)nullptr); } CToolBarCtrl& tb = m_wndToolBar.GetToolBarCtrl(); tb.SendMessage(TB_SETIMAGELIST, 0, (LPARAM)m_imgBar.GetSafeHandle()); return TRUE; }

参数逐个说明。CImageList::Create 的第三个参数决定颜色深度,ILC_COLOR32 表示每像素 32 位;第四个、第五个参数是初始和增长容量,给 4 就够,列表会自己扩容。MulDiv(32, nDpi, 96) 把设计尺寸从 96 DPI 换算到当前 DPI,96 DPI 下就是 32 像素。

CImage::LoadFromResource 从模块资源里读 PNG、JPG、BMP 都行,工程里如果显式调过 GdiplusStartup,注意在程序退出前别先 Shutdown,否则 CImage 析构时可能有问题。CBitmap::Attach 之后必须让 CImage::Detach 过,否则两个对象都认为自己拥有同一个 HBITMAP,析构时二次释放。

最后一条 TB_SETIMAGELIST 是全局替换,控件里原有那份由 TB_ADDBITMAP 建的图像列表会被系统释放,不需要你手动删,但你也无法再回退,所以换图前最好确认按钮索引表已经对好。

2.3 SetSizes 与 DPI:三个决定观感的参数

图源换好之后还有一类常见现象:图标明明是对的,但按钮挤在一起、图被裁、文字压到图标上。原因在尺寸参数没和解。工具栏的绘制尺寸由两个值控制:按钮整体尺寸和图片尺寸,两者必须满足“按钮宽 ≥ 图片宽 + 左右留白,按钮高 ≥ 图片高 + 文字行高”。

// 在 BuildBarImages 之后调用 void CMainFrame::LayoutToolBar(UINT nDpi) { CToolBarCtrl& tb = m_wndToolBar.GetToolBarCtrl(); const int icon = MulDiv(32, nDpi, 96); // 图片边长 const int padX = MulDiv(8, nDpi, 96); // 左右各留白 const int padY = MulDiv(6, nDpi, 96); // 上下留白 const int lineH = MulDiv(16, nDpi, 96); // 一行文字高度 const int rows = 2; // 预留两行文字的空间 tb.SetBitmapSize(CSize(icon, icon)); // 图片尺寸 tb.SetButtonSize(CSize(icon + padX * 2, // 按钮尺寸 icon + padY * 2 + lineH * rows)); tb.SetMaxTextRows(rows); // 允许两行文本 tb.SetRows(1, FALSE); // 强制单行排列 m_wndToolBar.SetWindowPos(nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); }
参数典型值(96 DPI)作用不调的后果
SetBitmapSize32×32每张图在按钮内占的区域图被拉伸或裁切
SetButtonSize48×68按钮命中区域与背景矩形按钮重叠、点击区域错位
SetMaxTextRows1~2文本最大行数多行文本被强制单行截断
SetRows1工具栏自身行数窗口变窄时按钮乱换行

高 DPI 的处理路径有两条:Windows 10 1607 以后可以直接调 GetDpiForWindow(m_hWnd),老系统退回用 CWindowDC dc(NULL) 取 GetDeviceCaps(LOGPIXELSX)。两者都写在 OnCreate 里,然后处理 WM_DPICHANGED,在这一步把字体、图像列表、按钮尺寸全部按新 DPI 重建一遍,只改其中一项照样会错位。

3. 按钮文字的四种来源与自绘字体

图片通了之后,文字往往是第二个出问题的地方:明明代码里调了 SetButtonText,界面上就是没有字;或者字出来了但按钮高度不够,下半截被切掉。CToolBar 的文本来源比想象中多,优先级也不直观,先把这四条路径理清,再决定在哪一层动手。

3.1 资源字符串、SetButtonText、TB_SETBUTTONINFO 的优先级

MFC 向导生成的工具栏资源在 .rc 里长这样:一个 TOOLBAR 块定义按钮顺序,紧跟一个 STRINGTABLE 给它配文本。那段字符串有固定格式——提示文本\n按钮文本,反斜杠 n 之前的部分用于状态栏和工具提示,之后的部分才会画到按钮上。很多人只在资源编辑器里点按钮改 ID,从没打开过那段 STRINGTABLE,所以按钮上永远没字。

IDR_MAIN_BAR TOOLBAR 16, 15 BEGIN BUTTON ID_FILE_NEW BUTTON ID_FILE_OPEN BUTTON ID_BAR_RUN END STRINGTABLE BEGIN IDR_MAIN_BAR "新建文档\n新建" ID_FILE_NEW "新建一个空白文档" ID_FILE_OPEN "打开已有文件\n打开" ID_BAR_RUN "执行当前配置\n运行" END

常见做法是保留资源里的提示文本,按钮文本改在代码里设置,这样多语言切换时只需要换一份字符串表,不用重编资源。运行时修改走 CToolBar::SetButtonText:

// 索引是按钮序号,不是命令 ID m_wndToolBar.SetButtonText(0, _T("新建")); m_wndToolBar.SetButtonText(2, _T("运行\nF5")); // \n 强制换行,需配合 SetMaxTextRows>=2 // 想让文字里的 & 不被当作助记符前缀(比如显示 "A&B") m_wndToolBar.GetToolBarCtrl().ModifyStyle(0, TBSTYLE_NOPREFIX);

SetButtonText 的第二个参数是 LPCTSTR,字符串里的\n会被工具栏当作换行,前提是 SetMaxTextRows 已经设成 2 或更大,否则多余的行直接被裁掉。TBSTYLE_NOPREFIX 这个样式容易被忘:只要按钮文字里出现 &,系统会把它吞掉并把后一个字符加上下划线当快捷键,路径或公式类的文字就会显示异常。

如果需要在同一个工具栏上让不同按钮用不同字号(比如主按钮大一点),SetButtonText 这条路走不通,得直接构造 TBBUTTONINFO 发 TB_SETBUTTONINFO:

// 直接改单个按钮的文本指针,适用于动态生成的工具栏 TBBUTTONINFO tbi = {}; tbi.cbSize = sizeof(tbi); tbi.dwMask = TBIF_TEXT | TBIF_SIZE; tbi.pszText = const_cast<LPTSTR>(_T("导出\nCtrl+E")); tbi.cx = 84; // 单独把这个按钮拉宽 m_wndToolBar.GetToolBarCtrl().SetButtonInfo(3, &tbi);

注意 pszText 指向的缓冲区必须在按钮存活期间一直有效,临时 CString 的 GetBuffer 传进去,函数返回后缓冲区释放,按钮文本会变成乱码,这是很典型的踩坑点。稳妥做法是用成员变量或全局静态数组保存这些字符串。

3.2 整体换字体:WM_SETFONT 与它的边界

工具栏控件支持 WM_SETFONT,传一个 HFONT 进去,系统用它绘制所有按钮文本。这是改字体最省事的方式,但只能整体改,不能按按钮改。

// 字体随 DPI 创建,存在成员变量里,别用局部 CFont void CMainFrame::CreateBarFont(UINT nDpi) { if (m_fontBar.GetSafeHandle()) m_fontBar.DeleteObject(); LOGFONT lf = {}; lf.lfHeight = -MulDiv(9, nDpi, 96); // 负数表示字符高度 lf.lfWeight = FW_NORMAL; lf.lfCharSet = DEFAULT_CHARSET; lf.lfQuality = CLEARTYPE_QUALITY; // 不开 ClearType 小字会发虚 _tcscpy_s(lf.lfFaceName, _T("SimSun")); m_fontBar.CreateFontIndirect(&lf); // lParam 高位为 1 表示立即重绘 m_wndToolBar.GetToolBarCtrl().SendMessage( WM_SETFONT, (WPARAM)m_fontBar.GetSafeHandle(), MAKELPARAM(TRUE, 0)); }

高度用负数而不是正数:正数表示字符单元格高度,负数表示字符本身高度,两者在中文小字号下差一两个像素,用正数容易让文字底部被切。CLEARTYPE_QUALITY 必须显式给,默认的 DEFAULT_QUALITY 在 9 号字上锯齿明显。这个 CFont 一定要是成员或全局对象,局部 CFont 析构后 HFONT 就失效了,控件会用回一个已删除的字体句柄,表现是文字整个消失或者变成方块,属于很难追的“黑匣子”级问题。

字体换了之后按钮尺寸不一定跟着变,WM_SETFONT 不会自动重算按钮高度。正确顺序是先 CreateBarFont,再 LayoutToolBar,让 SetButtonSize 用新字体的行高重新算一遍。如果换字体后文字还是被切,八成是这两步顺序反了。

3.3 文字颜色与多行对齐:NM_CUSTOMDRAW 能做到哪一步

工具栏的 NM_CUSTOMDRAW 通知能改的东西有限。在 CDDS_ITEMPREPAINT 阶段,你可以通过 NMTBCUSTOMDRAW 的 clrText、clrBtnFace、clrMark 改文字色、按钮底色和高亮色,也能改 hbrMonoDither 之类的画刷;但改不了文字的对齐方式、行间距,也没法在图标和文字之间插额外元素。

// 只改颜色时,用默认绘制 + 改字段,成本最低 void CMainFrame::OnBarCustomDraw(NMHDR* pNMHDR, LRESULT* pResult) { LPNMTBCUSTOMDRAW pcd = reinterpret_cast<LPNMTBCUSTOMDRAW>(pNMHDR); *pResult = CDRF_DODEFAULT; if (pcd->nmcd.dwDrawStage == CDDS_PREPAINT) { *pResult = CDRF_NOTIFYITEMDRAW; // 声明关心每个按钮 return; } if (pcd->nmcd.dwDrawStage == CDDS_ITEMPREPAINT) { pcd->clrText = RGB(32, 32, 32); // 正常文字色 pcd->clrTextBk = CLR_NONE; // 透明背景 pcd->clrBtnFace = RGB(245, 245, 245); // 按钮底色 pcd->clrBtnHighlight = RGB(255, 255, 255); // 立体边高光 *pResult = TBCDRF_USECDCOLORS; // 告诉系统用你改的颜色 } }

TBCDRF_USECDCOLORS 这个返回值必须带上,否则你改了结构体字段系统也不用,这是最常被漏掉的一行。如果程序要跟随系统深色模式,这一层就是切换文字色的入口:监听 WM_SETTINGCHANGE 里的 ImmersiveColorSet,重新取一次系统主题色,再让工具栏重绘。

但如果你要的是“图标在左、文字在右”“文字居中且带两行行距”“禁用态灰色替换成半透明”,NM_CUSTOMDRAW 到此为止,必须上第 4 章的完全自绘。

4. 完全自绘:在 NM_CUSTOMDRAW 里接管每个按钮

自绘不是炫技,是被需求逼出来的。常见的触发条件有三个:图标与文字需要横向排布(系统默认只能上下)、按钮背景需要跟随主题渐变、禁用态需要做整体透明度而不是简单灰化。这三类需求 NM_CUSTOMDRAW 的字段级修改都覆盖不到。

4.1 交付之前先判断是否真的需要接管绘制

动手之前先做一次成本核算。完全自绘意味着你要自己处理:正常、热区、按下、选中、禁用五种状态的视觉差异;图标绘制时的透明混合;文字的两行排版与省略号;键盘焦点框;以及工具提示本身由控件负责这点不能变。任何一项漏了,用户看到的就是“看起来能用但总有点怪”的界面。

如果只是换图、换字色、换字体,止步于第 3 章。如果工程已经不打算再用传统 CToolBar,另一个选型是 MFC 特性包里的 CMFCToolBar,它原生支持真彩图标、可停靠的按钮分组和主题切换,代价是整个框架要跟着迁移到较新的 MFC 版本,老工程里那些自己派生的 CControlBar 可能要重写。三条路的取舍大致是这样:

方案改动量能力上限适用场景
换 CImageList小真彩图标、字号统一只是图标糊、文字缺
NM_CUSTOMDRAW 改字段中主题色、文字色、背景色跟随深浅色主题
NM_CUSTOMDRAW 接管绘制大任意排版、任意状态图标文字横排、异形按钮

4.2 在 CDDS_ITEMPREPAINT 返回 SKIPDEFAULT 自己画

接管的入口只有一行:在 CDDS_ITEMPREPAINT 阶段把返回值从默认改成 CDRF_SKIPDEFAULT,系统就不再画这个按钮,改由你调用的绘制函数全权负责。

// 注意:通知发给工具栏的父窗口,ID 是工具栏资源 ID BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_NOTIFY(NM_CUSTOMDRAW, IDR_MAIN_BAR, &CMainFrame::OnBarCustomDrawFull) END_MESSAGE_MAP() void CMainFrame::OnBarCustomDrawFull(NMHDR* pNMHDR, LRESULT* pResult) { LPNMTBCUSTOMDRAW pcd = reinterpret_cast<LPNMTBCUSTOMDRAW>(pNMHDR); *pResult = CDRF_DODEFAULT; switch (pcd->nmcd.dwDrawStage) { case CDDS_PREPAINT: *pResult = CDRF_NOTIFYITEMDRAW; return; case CDDS_ITEMPREPAINT: { CDC* pDC = CDC::FromHandle(pcd->nmcd.hdc); DrawBarButton(pDC, (UINT)pcd->nmcd.dwItemSpec, // 命令 ID pcd->nmcd.uItemState, // CDIS_HOT / SELECTED / DISABLED pcd->nmcd.rc); // 按钮矩形 *pResult = CDRF_SKIPDEFAULT; // 交给自己的绘制,系统别再画 return; } } }

dwItemSpec 在工具栏里就是命令 ID 而不是按钮序号,这一点和很多其他控件不同,用序号去查表会查到错的项。uItemState 是位组合,热区、按下、禁用可以同时成立,判断顺序必须是禁用优先、按下次之、热区最后,否则按下的按钮会被画成热区色。

4.3 DrawBarButton 里把五种状态一次画对

绘制函数本身不长,关键是每种状态别漏。下面这版是图标在上、文字在下、支持两行文本的版本。

void CMainFrame::DrawBarButton(CDC* pDC, UINT nCmdID, UINT nState, CRect rc) { const BARITEM* pItem = FindBarItem(nCmdID); // 自己的配置表,含文字与图标下标 if (pItem == nullptr) return; const BOOL bDisabled = (nState & CDIS_DISABLED) != 0; const BOOL bPressed = (nState & (CDIS_SELECTED | CDIS_PUSHED)) != 0; const BOOL bHot = (nState & CDIS_HOT) != 0; // 1. 背景:按下最深,热区次之,禁用不叠加 if (!bDisabled) { if (bPressed) pDC->FillSolidRect(rc, RGB(0, 90, 158)); else if (bHot) pDC->FillSolidRect(rc, RGB(229, 241, 251)); } const int nOldMode = pDC->SetBkMode(TRANSPARENT); // 2. 图标:水平居中,贴顶部,按当前 DPI 的列表尺寸绘制 CSize szIcon = m_imgBar.GetImageSize(); if (m_imgBar.GetSafeHandle() && pItem->nIcon >= 0) { const int x = rc.left + (rc.Width() - szIcon.cx) / 2; const int y = rc.top + MulDiv(4, m_nDpi, 96); // 禁用态整体减淡,否则灰图标压在白底上几乎看不见 if (bDisabled) m_imgBar.Draw(pDC, pItem->nIcon, CPoint(x, y), ILD_BLEND50); else m_imgBar.Draw(pDC, pItem->nIcon, CPoint(x, y), ILD_TRANSPARENT); } // 3. 文字:颜色随状态走,超出宽度打省略号 pDC->SetTextColor(bDisabled ? RGB(160, 160, 160) : bPressed ? RGB(255, 255, 255) : RGB(32, 32, 32)); CRect rcText(rc.left + 2, rc.top + szIcon.cy + MulDiv(6, m_nDpi, 96), rc.right - 2, rc.bottom - 2); pDC->DrawText(pItem->szText, -1, rcText, DT_CENTER | DT_WORDBREAK | DT_END_ELLIPSIS | DT_NOPREFIX); pDC->SetBkMode(nOldMode); }

ILD_BLEND50 让图标与背景按 50% 混合,禁用态的观感比单纯灰化自然得多;ILD_TRANSPARENT 是正常态的透明绘制,依赖图像列表本身带 Alpha,如果前面建列表时错加了 ILC_MASK,这里就会看到黑边。DT_NOPREFIX 与 TBSTYLE_NOPREFIX 是同一件事的两个层面,自绘路径下必须靠这个标志挡住 & 被吞。

4.4 状态变化后让重绘真的发生

自绘最常见的“看起来没生效”,其实是没触发重绘。工具栏在鼠标进出、按下、启用禁用时自己会发 NM_CUSTOMDRAW,但你在代码里直接改按钮状态时不一定。

// 代码里改了按钮状态,必须手动触发重绘 m_wndToolBar.GetToolBarCtrl().EnableButton(ID_BAR_RUN, FALSE); m_wndToolBar.GetToolBarCtrl().Invalidate(); // 缺这一句状态不变 // 只改某个按钮的范围,比整条重绘省一点 CRect rcBtn; if (m_wndToolBar.GetToolBarCtrl().GetRect(ID_BAR_RUN, &rcBtn)) m_wndToolBar.GetToolBarCtrl().InvalidateRect(&rcBtn, FALSE);

InvalidateRect 的第二个参数 bErase 给 FALSE,让背景走你自己填的那一层,否则系统先把区域刷成底色,再被 FillSolidRect 覆盖,快速点击时会有可见的闪烁。工具栏这个控件没有内建双缓冲开关,闪烁严重的场景要靠在父窗口设 WS_CLIPCHILDREN,让父窗口绘制时跳过子控件区域。

5. 排查:真彩发虚、文字截断、换行错乱的五类现场

调这类界面的时间,八成花在“看起来差不多但就是不对”上。下面五条是我在不同工程里反复撞到的,按现象、原因、解决三段写,遇到时可以直接对号入座。

5.1 图标边缘出现黑色和锯齿

现象:PNG 图标放上去,圆形按钮的弧边出现一圈黑边,直角处还有锯齿。

原因:图像列表创建时带了 ILC_MASK,或者添加时用了带 COLORREF 掩码的重载,Alpha 通道被当作掩码信息处理,半透明边缘直接变成不透明黑。另一个可能是资源本身没带 Alpha,用图片工具另存时丢了通道。

解决:确认 Create 的参数是ILC_COLOR32且不带ILC_MASK,添加时用Add(&bmp, (CBitmap*)nullptr)这个重载。检查资源是否有 Alpha,用画图类工具看属性里的位深是否 32 位。如果只能拿到 24 位图,就在加载后用 GDI+ 生成一张 32 位副本,把左上角颜色当透明色填进去,别指望 TB_ADDBITMAP 帮你处理。

5.2 按钮文字不显示或只显示一半

现象:SetButtonText 调了,按钮上还是空白;或者字出来了,下半截被切掉。

原因通常有三个:一是资源里那段 STRINGTABLE 的\n之后没有内容,按钮文本本来就是空的,SetButtonText 传了空串;二是按钮高度不够,SetButtonSize 里没给文字留行高,SetMaxTextRows 也没开;三是按钮文字里带 &,被当成助记符吃掉了。

解决:先打印 GetButtonText 的返回值确认文本确实进去了,再量一次 GetRect 的按钮高度与字体的 tmHeight 比较,不够就重调 SetButtonSize。开多行要同时设 SetMaxTextRows 和按钮高度,只设其中一个不会生效。文字带 & 的按钮统一加 TBSTYLE_NOPREFIX。

5.3 窗口一缩窄,工具栏按钮就换行错乱

现象:窗口拖窄到某个宽度后,按钮开始往第二行跳,但工具栏高度没跟着涨,第二行被裁掉;拖回去之后布局又恢复不了。

原因:CToolBar 默认带 CBRS_SIZE_DYNAMIC 或 TBSTYLE_WRAPABLE,允许自动换行,但 CControlBar 的高度是父框架 RecalcLayout 时算的,工具栏自己换行后没有触发父窗口重算。

解决:不需要自动换行就直接GetToolBarCtrl().SetRows(1, FALSE)锁死单行,超出宽度由系统打溢出箭头。需要换行的话,在按钮增删后调m_wndToolBar.GetParentFrame()->RecalcLayout(),让框架按新高度重新布一次。CreateEx 的样式位里去掉 CBRS_SIZE_DYNAMIC 也能减少这类抖动。

5.4 高 DPI 屏上图标发虚、文字变小

现象:在 150% 缩放的屏幕上,工具栏图标像被拉伸过,文字反而比周围控件小一圈。

原因:图片资源和字体都按 96 DPI 建的,程序没有声明 DPI 感知,系统做了一次位图拉伸缩放;或者声明了感知但代码里没有按 DPI 重建资源。

解决:先用 SetProcessDpiAwarenessContext 或清单文件声明 Per-Monitor 感知,避免系统位图缩放。然后在 OnCreate 和 WM_DPICHANGED 里,用同一个 nDpi 分别重建图像列表、字体和按钮尺寸,三者缺一就会出现“图标变大了文字没变”这种错位。图标资源最好准备 100% 和 200% 两套,按 DPI 选择加载,256 的图缩到 32 也糊。

5.5 自绘之后点击区域和视觉位置对不上

现象:按钮画在位置 A,鼠标要点位置 B 才有反应。

原因:自绘里用了 pcd->nmcd.rc 的副本做偏移(比如为了画内边距缩小了几像素再画),但命中测试仍然按控件内部的原始矩形算。

解决:所有偏移只作用于绘制内容,不要改变背景填充区域的基准;如果确实要让视觉外框内缩,命中测试也得自己接管,处理 WM_LBUTTONDOWN 时用 GetRect 遍历比对。更稳的做法是绘制时用原始 rc,内缩量通过调整绘制元素的位置实现,而不是整体平移矩形。

6. 用一张配置表驱动整条工具栏

到这一步,图片、文字、自绘、排查都通了,剩下的问题是要不要在资源编辑器里手工维护这几十个按钮。我一般会放弃工具栏资源里的可视化编辑,改成一张代码里的配置表,启动时按表生成按钮,图片、文字、是否可勾选、是否分隔符全放在一处。

struct BARITEM { UINT nCmdID; UINT nIconRes; LPCTSTR szText; BYTE nStyle; // TBSTYLE_CHECK / TBSTYLE_SEP / 0 }; static const BARITEM kBarTable[] = { { ID_FILE_NEW, IDB_BAR_NEW, _T("新建\nCtrl+N"), 0 }, { ID_FILE_OPEN, IDB_BAR_OPEN, _T("打开\nCtrl+O"), 0 }, { 0, 0, nullptr, TBSTYLE_SEP }, { ID_BAR_RUN, IDB_BAR_RUN, _T("运行\nF5"), TBSTYLE_CHECK }, }; void CMainFrame::BuildBarFromTable() { CToolBarCtrl& tb = m_wndToolBar.GetToolBarCtrl(); tb.DeleteAllButtons(); for (int i = 0; i < _countof(kBarTable); ++i) { const BARITEM& it = kBarTable[i]; TBBUTTON btn = {}; btn.idCommand = it.nCmdID; btn.fsStyle = it.nStyle ? it.nStyle : TBSTYLE_BUTTON; btn.fsState = TBSTATE_ENABLED; btn.iBitmap = I_IMAGENONE; // 统一由 SetButtonInfo 指定 tb.AddButtons(1, &btn); if (it.nStyle == TBSTYLE_SEP) continue; // 图标与文本分开设置,避免 AddButtons 的索引耦合 TBBUTTONINFO tbi = {}; tbi.cbSize = sizeof(tbi); tbi.dwMask = TBIF_IMAGE | TBIF_TEXT; tbi.iImage = it.nIconRes; // 这里是图像列表下标,按表顺序 tbi.pszText = const_cast<LPTSTR>(it.szText); tb.SetButtonInfo(it.nCmdID, &tbi); } }

表驱动带来的直接好处是校验变得简单:写一个自检函数,遍历 kBarTable,检查每一项的图标下标是否落在图像列表范围内、按钮宽度是否容得下最长的一行文字、命令 ID 是否重复。这段自检放在调试版启动时跑一遍,比等测试同学截图反馈快得多。

// 调试版启动时跑一次,把问题挡在开发机上 void CMainFrame::CheckBarTable() { CSize szIcon = m_imgBar.GetImageSize(); const int nMinWidth = szIcon.cx + MulDiv(16, m_nDpi, 96); CMap<UINT, UINT, int, int> mapId; for (int i = 0; i < _countof(kBarTable); ++i) { const BARITEM& it = kBarTable[i]; if (it.nStyle == TBSTYLE_SEP) continue; ASSERT(it.nIconRes < (UINT)m_imgBar.GetImageCount()); // 下标越界最致命 ASSERT(it.szText != nullptr); CRect rcBtn; if (m_wndToolBar.GetToolBarCtrl().GetRect(it.nCmdID, &rcBtn)) ASSERT(rcBtn.Width() >= nMinWidth); } }

一个技巧:把 kBarTable 里的 szText 当成唯一的文本来源,资源里那段 STRING

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询