简介:本资源面向MFC界面开发初学者与需要美化按钮控件的C++程序员,围绕“自定义按钮”这一常见需求,给出图片按钮的完整实现思路。内容以继承CButton派生自定义类为主线,涵盖位图成员变量声明、OnPaint重绘、图像加载与SetBitmap关联、鼠标事件响应,以及在对话框或视图中替换标准按钮的用法,帮助读者理解MFC消息映射与设备上下文绘制机制。压缩包共47个文件,约28.91MB,包含4个cpp与5个h源码文件、6个bmp位图素材、rc资源脚本及vcxproj、sln等工程配置,另有obj、pdb、ilk等编译中间文件,可直接用Visual Studio打开运行调试。目前已有335人学习下载,适合作为MFC控件自绘与界面美化的练手参考,便于对照源码快速掌握图片按钮的绘制与事件处理流程。
1. 从自绘按钮到消息映射:MFC自定义按钮到底在解决什么问题
对话框里拖一个标准 CButton,改改 Caption、调调字体,大部分场景够用了。可一旦 UI 要求按钮带圆角、渐变背景、悬停变色、按下位移,或者要贴一张 PNG 图标,标准按钮立刻露怯——它连背景色都不让你随便改。这时候就得走自绘这条路:接管按钮的绘制流程,自己拿 CDC 画。MFC 自定义按钮的核心,说白了就是三件事——重写 DrawItem 或走 OwnerDraw、处理鼠标状态切换、把点击事件正确映射回父窗口。听起来简单,但真动手会发现坑不少:按钮不刷新、焦点框乱闪、点击没反应、资源编辑器里属性对不上。这篇笔记按“能跑起来”的标准拆一遍,从最朴素的 CButton 派生到自绘消息处理,再到资源脚本和调试手段,每一步都给出可抄的代码和参数说明。适合正在做 MFC 界面、被标准控件卡住、想自己掌控按钮外观的开发者。
2. 自绘按钮的三种实现路线:从 CButton 派生到 OwnerDraw
2.1 为什么优先选 CButton 派生 + DrawItem
MFC 里做自定义按钮,常见做法有三条路:一是从 CButton 派生一个类,重写 DrawItem;二是用 BS_OWNERDRAW 风格,在父窗口的 WM_DRAWITEM 里画;三是完全自绘一个 CWnd 派生类,自己处理所有鼠标和绘制消息。第三条最灵活但工作量最大,第一条最省事且和现有对话框兼容性最好。
选 CButton 派生的理由很直接:它保留了按钮原有的消息处理逻辑(BN_CLICKED、焦点管理、键盘空格触发),你只需要覆盖绘制部分。DrawItem 是 MFC 为 owner-draw 控件提供的虚函数,当按钮带 BS_OWNERDRAW 风格时,框架会调用它。你在这个函数里拿到 CDC 指针和绘制矩形,想怎么画就怎么画。
但有个前提:按钮必须设置 Owner Draw 属性。在资源编辑器里把按钮的 Owner Draw 勾上,或者在代码里用 ModifyStyle 加 BS_OWNERDRAW。漏了这一步,DrawItem 永远不会被调用——这是新手最常见的翻车点。
2.2 派生类骨架与 DrawItem 重写
先给一个最小可用的派生类骨架。假设类名叫 CCustomButton,继承 CButton。
// CustomButton.h #pragma once class CCustomButton : public CButton { DECLARE_DYNAMIC(CCustomButton) public: CCustomButton(); virtual ~CCustomButton(); protected: virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct); afx_msg void OnMouseMove(UINT nFlags, CPoint point); afx_msg void OnMouseLeave(); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnLButtonUp(UINT nFlags, CPoint point); DECLARE_MESSAGE_MAP() private: BOOL m_bHover; // 鼠标是否悬停 BOOL m_bPressed; // 是否按下 COLORREF m_clrNormal; // 正常背景色 COLORREF m_clrHover; // 悬停背景色 COLORREF m_clrPressed; // 按下背景色 COLORREF m_clrText; // 文字颜色 };// CustomButton.cpp #include "pch.h" #include "CustomButton.h" IMPLEMENT_DYNAMIC(CCustomButton, CButton) CCustomButton::CCustomButton() : m_bHover(FALSE) , m_bPressed(FALSE) , m_clrNormal(RGB(60, 120, 200)) , m_clrHover(RGB(80, 140, 220)) , m_clrPressed(RGB(40, 90, 160)) , m_clrText(RGB(255, 255, 255)) { } CCustomButton::~CCustomButton() {} BEGIN_MESSAGE_MAP(CCustomButton, CButton) ON_WM_MOUSEMOVE() ON_WM_MOUSELEAVE() ON_WM_LBUTTONDOWN() ON_WM_LBUTTONUP() END_MESSAGE_MAP() void CCustomButton::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC* pDC = CDC::FromHandle(lpDrawItemStruct->hDC); CRect rc = lpDrawItemStruct->rcItem; UINT state = lpDrawItemStruct->itemState; // 根据状态选背景色 COLORREF clrBg = m_clrNormal; if (state & ODS_SELECTED) clrBg = m_clrPressed; else if (m_bHover) clrBg = m_clrHover; // 画圆角矩形背景 pDC->SetBkMode(TRANSPARENT); CPen pen(PS_SOLID, 1, clrBg); CBrush brush(clrBg); CPen* pOldPen = pDC->SelectObject(&pen); CBrush* pOldBrush = pDC->SelectObject(&brush); pDC->RoundRect(rc, CPoint(8, 8)); // 画文字 CString strText; GetWindowText(strText); pDC->SetTextColor(m_clrText); pDC->DrawText(strText, rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); // 焦点框 if (state & ODS_FOCUS) { CRect rcFocus = rc; rcFocus.DeflateRect(3, 3); pDC->DrawFocusRect(rcFocus); } pDC->SelectObject(pOldPen); pDC->SelectObject(pOldBrush); }这段代码的逻辑说明:DrawItem 从 lpDrawItemStruct 里取出设备上下文、矩形和状态标志。ODS_SELECTED 表示按钮被按下,ODS_FOCUS 表示有焦点。m_bHover 是自己维护的悬停标志,因为 ODS 里没有悬停状态。RoundRect 的第二个参数是圆角半径,CPoint(8,8) 表示横纵方向各 8 像素。画完记得把旧 GDI 对象选回去,否则会泄漏。
参数方面,m_clrNormal、m_clrHover、m_clrPressed 三个颜色可以按设计稿改。圆角半径 8 是个经验值,按钮高度 30 像素左右时比较协调。文字用 DT_VCENTER | DT_SINGLELINE 保证垂直居中,如果按钮高度不够,文字会被裁掉。
2.3 鼠标状态跟踪与 TrackMouseEvent
悬停效果不是自动的,得自己跟踪。OnMouseMove 里判断鼠标是否进入,然后调用 TrackMouseEvent 注册离开通知。
void CCustomButton::OnMouseMove(UINT nFlags, CPoint point) { if (!m_bHover) { m_bHover = TRUE; Invalidate(); // 触发重绘 TRACKMOUSEEVENT tme = { 0 }; tme.cbSize = sizeof(TRACKMOUSEEVENT); tme.dwFlags = TME_LEAVE; tme.hwndTrack = m_hWnd; _TrackMouseEvent(&tme); } CButton::OnMouseMove(nFlags, point); } void CCustomButton::OnMouseLeave() { m_bHover = FALSE; m_bPressed = FALSE; Invalidate(); CButton::OnMouseLeave(); }TrackMouseEvent 的 TME_LEAVE 标志告诉系统:鼠标离开这个窗口时给我发 WM_MOUSELEAVE。如果不注册,OnMouseLeave 永远不会触发,悬停状态就卡住了。注意 _TrackMouseEvent 每次进入都要重新调用,因为它是一次性的。
OnLButtonDown 和 OnLButtonUp 里更新 m_bPressed 并 Invalidate,保证按下和弹起都有视觉反馈。这里有个细节:如果鼠标按下后拖出按钮再松开,OnLButtonUp 不会触发,m_bPressed 会一直为 TRUE。稳妥做法是在 OnMouseLeave 里也重置 m_bPressed,上面代码已经处理了。
3. 消息映射与资源脚本:让自定义按钮真正响应点击
3.1 对话框里替换标准按钮的完整步骤
光有派生类还不够,得把它挂到对话框上。步骤不复杂,但顺序错了就白干。
第一步,在资源编辑器里把目标按钮的 ID 改成有意义的名称,比如 IDC_BTN_SUBMIT。右键属性,勾上 Owner Draw。如果忘了勾,DrawItem 不会被调用,按钮会显示成默认灰色。
第二步,给对话框类添加成员变量。在头文件里把 CButton 类型改成 CCustomButton:
// DlgMain.h #include "CustomButton.h" class CDlgMain : public CDialogEx { // ... public: CCustomButton m_btnSubmit; // 原来是 CButton };第三步,在 DoDataExchange 里用 DDX_Control 绑定:
void CDlgMain::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Control(pDX, IDC_BTN_SUBMIT, m_btnSubmit); }第四步,如果按钮在资源里没勾 Owner Draw,可以在 OnInitDialog 里补一刀:
BOOL CDlgMain::OnInitDialog() { CDialogEx::OnInitDialog(); m_btnSubmit.ModifyStyle(0, BS_OWNERDRAW); return TRUE; }ModifyStyle 的第一个参数是要移除的风格,第二个是要添加的。这里加 BS_OWNERDRAW,确保 DrawItem 被调用。
第五步,点击事件照常映射。在消息映射里加 ON_BN_CLICKED(IDC_BTN_SUBMIT, &CDlgMain::OnBnClickedBtnSubmit),然后实现处理函数。自定义按钮不影响 BN_CLICKED 的触发,因为点击逻辑还是 CButton 在处理。
3.2 资源脚本里容易忽略的属性
资源编辑器里改属性,背后改的是 .rc 文件。有时候编辑器抽风,属性没写进去,就得手动改。按钮的 .rc 定义大概长这样:
IDD_DLG_MAIN DIALOGEX 0, 0, 320, 200 STYLE DS_SETFONT | DS_MODALFRAME | WS_POPUP | WS_CAPTION | WS_SYSMENU CAPTION "Main Dialog" FONT 9, "Segoe UI", 400, 0, 0x1 BEGIN PUSHBUTTON "Submit",IDC_BTN_SUBMIT,20,150,80,30,BS_OWNERDRAW END关键就是最后的 BS_OWNERDRAW。如果这里没有,编辑器里勾了也可能不生效。另外注意按钮的尺寸,自绘按钮的圆角和文字位置都依赖实际像素,资源里设的宽高要和 DrawItem 里的假设一致。
有个常见坑:对话框用了 DS_SETFONT 指定字体,但自绘时没考虑字体缩放。在高 DPI 下,按钮尺寸会被系统放大,但你的圆角半径和文字偏移还是固定像素,结果就是圆角看起来变小、文字偏上。解决办法是用 GetDpiForWindow 获取缩放比例,动态调整圆角和边距。这个后面在避坑章节细说。
3.3 点击响应与父窗口通知
自定义按钮的点击通知走的是标准路径:按钮收到 WM_LBUTTONUP 后,CButton 内部会向父窗口发送 BN_CLICKED。父窗口的消息映射里用 ON_BN_CLICKED 接收。
BEGIN_MESSAGE_MAP(CDlgMain, CDialogEx) ON_BN_CLICKED(IDC_BTN_SUBMIT, &CDlgMain::OnBnClickedBtnSubmit) END_MESSAGE_MAP() void CDlgMain::OnBnClickedBtnSubmit() { // 处理点击 AfxMessageBox(_T("Submit clicked")); }如果发现点击没反应,先检查三件事:按钮 ID 是否和消息映射里一致、DDX_Control 是否绑定成功、按钮是否被禁用(EnableWindow(FALSE) 会屏蔽点击)。还有一种情况是按钮被其他透明控件盖住了,鼠标消息被上层窗口截走,这种用 Spy++ 查窗口层级最快。
4. 避坑与排查:自绘按钮不刷新、焦点乱跳的五个血泪经验
4.1 按钮不刷新,悬停变色失效
现象:鼠标移上去颜色不变,或者变了之后移开不恢复。
原因:OnMouseMove 里没有调用 Invalidate,或者 TrackMouseEvent 没注册导致 OnMouseLeave 不触发。
解决:确认 OnMouseMove 里在状态变化后调用了 Invalidate(FALSE)。参数用 FALSE 表示不擦背景,减少闪烁。TrackMouseEvent 的 tme.hwndTrack 必须是按钮自己的 m_hWnd,不能填父窗口的。
4.2 点击后按钮卡在按下状态
现象:点一下按钮,颜色一直是按下色,不再恢复。
原因:OnLButtonUp 没触发,或者 m_bPressed 没重置。常见于鼠标按下后拖出按钮再松开。
解决:在 OnMouseLeave 里重置 m_bPressed 和 m_bHover,并 Invalidate。另外 OnLButtonUp 里也要确保重置。如果按钮有 Capture,记得 ReleaseCapture。
4.3 焦点框闪烁或位置偏移
现象:按 Tab 键切换焦点时,焦点虚线框跳动或者画在按钮外面。
原因:DrawFocusRect 用的矩形没做内缩,或者在高 DPI 下像素计算错误。
解决:焦点矩形要在按钮矩形基础上 DeflateRect,内缩量按 DPI 缩放。可以用 GetDpiForWindow(m_hWnd) 拿到 DPI,然后 96 为基准算比例。比如内缩 3 像素在 150% 缩放下应该是 3 * dpi / 96。
4.4 高 DPI 下圆角和文字错位
现象:在 4K 屏或系统缩放 150% 时,按钮圆角看起来变小,文字偏上或偏左。
原因:DrawItem 里的圆角半径、文字偏移都是硬编码像素,没有随 DPI 缩放。
解决:在 OnInitDialog 或 DrawItem 里获取 DPI 缩放因子,动态计算。常见做法是:
int CCustomButton::GetDpiScale() { HDC hdc = ::GetDC(m_hWnd); int dpi = GetDeviceCaps(hdc, LOGPIXELSX); ::ReleaseDC(m_hWnd, hdc); return dpi; // 96 为 100% }然后圆角半径用 MulDiv(8, dpi, 96),文字边距同理。这样在 150% 缩放下圆角自动变成 12 像素,视觉一致。
4.5 资源编辑器改了属性但不生效
现象:在资源编辑器里勾了 Owner Draw,编译后按钮还是标准样式。
原因:.rc 文件没更新,或者对话框用了内存中的旧资源。有时候编辑器保存了但没触发重新编译。
解决:手动打开 .rc 文件确认 BS_OWNERDRAW 写进去了。如果没有,手动加上。然后清理解决方案重新编译。另外检查按钮是否被 DDX_Control 绑定到了正确的派生类,如果绑定的是 CButton 而不是 CCustomButton,DrawItem 不会被调用。
5. 进阶技巧:用双缓冲和状态机把自绘按钮做稳
5.1 双缓冲消除闪烁
自绘按钮在快速悬停切换时容易闪,因为每次 Invalidate 都擦背景再重画。双缓冲的思路是先在内存 DC 里画好,再一次性贴到屏幕。
void CCustomButton::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC* pDC = CDC::FromHandle(lpDrawItemStruct->hDC); CRect rc = lpDrawItemStruct->rcItem; // 创建内存 DC CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 在 memDC 上绘制,坐标从 0,0 开始 CRect rcClient(0, 0, rc.Width(), rc.Height()); // ... 绘制逻辑同前,但用 memDC 代替 pDC ... // 一次性贴到屏幕 pDC->BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }逻辑说明:CreateCompatibleDC 创建和屏幕兼容的内存 DC,CreateCompatibleBitmap 创建同样大小的位图。所有绘制在 memDC 上完成,最后 BitBlt 一次性拷贝。这样屏幕只刷新一次,不会看到中间过程。参数上,位图尺寸必须和按钮矩形一致,否则会拉伸或裁剪。
5.2 用状态机管理按钮的多种视觉状态
按钮状态不止正常、悬停、按下三种。还有禁用、默认按钮、选中(toggle 模式)。用枚举和状态机管理比一堆 BOOL 清晰。
enum BtnState { BS_NORMAL, BS_HOVER, BS_PRESSED, BS_DISABLED, BS_DEFAULT }; BtnState CCustomButton::GetCurrentState() const { if (!IsWindowEnabled()) return BS_DISABLED; if (m_bPressed) return BS_PRESSED; if (m_bHover) return BS_HOVER; if (GetStyle() & BS_DEFPUSHBUTTON) return BS_DEFAULT; return BS_NORMAL; }然后在 DrawItem 里 switch 这个状态,每种状态对应一组颜色和边框样式。这样加新状态时只改枚举和 switch,不用动鼠标消息处理。
5.3 验证方法:用 Spy++ 和日志确认消息流
自绘按钮出问题时,光看代码很难定位。我一般会开 Spy++ 看消息流向:按钮是否收到 WM_MOUSEMOVE、WM_MOUSELEAVE、WM_LBUTTONDOWN。如果 WM_MOUSELEAVE 没出现,就是 TrackMouseEvent 没注册成功。如果 WM_LBUTTONUP 没到按钮,可能是被父窗口截走了。
另一个手段是在 DrawItem 里加日志,输出 itemState 的值。ODS_SELECTED、ODS_FOCUS、ODS_DISABLED 这些标志位能告诉你按钮当前处于什么状态。如果 DrawItem 根本没被调用,那就是 BS_OWNERDRAW 没生效,回去检查资源脚本。
从那以后我每次做自绘按钮,都强制走一遍:先确认 .rc 里有 BS_OWNERDRAW,再确认 DDX_Control 绑定的是派生类,然后在 DrawItem 第一行加日志,最后用 Spy++ 验证鼠标消息。这三步走完,基本不会翻车。希望帮到你。
本文还有配套的精品资源,点击获取