尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MFC自绘按钮封装实战:从CButton到圆角悬停的优雅实现

MFC自绘按钮封装实战:从CButton到圆角悬停的优雅实现 简介面向Windows平台上使用MFC进行界面开发的C程序员这份按钮类增强包针对标准CButton类功能单一、样式扩展不便等痛点提供了可直接集成到工程的BtnST类。资源仅含2个文件——1个cpp实现与1个h头文件压缩包仅17KB轻量简洁适合在已有MFC项目中快速引入。类内重点封装了动态按钮常见能力如运行期实时更新文本、切换按钮图像、自定义圆角/渐变色等外观样式以及按下、禁用、悬停等多状态视觉反馈同时可扩展事件回调支持长按等交互行为有助于提升用户界面体验。目前已有122人学习浏览。将头文件和源文件加入工程后开发者只需声明对象、调用接口设置属性并在消息映射中绑定事件处理即可避免大量底层绘图与状态切换代码将更多精力放在业务逻辑上对希望丰富Windows桌面应用交互又需要控制开发节奏的MFC开发者而言是一份简洁实用的组件参考。 写MFC程序的人十有八九是从按钮开始折腾的。我最早用VS2013写MFC那会儿从工具栏拖一个Button出来跑起来就是系统默认的灰白方块想换个品牌色、加个悬停效果网上翻半天不是讲得云里雾里就是只给一段不能跑的半成品。后来项目做多了我就把最常用的这个控件彻底封装了一遍做成了自己的MFC按钮类。这样一来后续不管新开多少个工程只要把这个类拖进去设置几个颜色参数就能直接出效果省下的时间不是一点半点。这篇文章就来讲讲我封装的这套按钮类。它解决的是MFC里最扎手的问题原生按钮外观不可控、hover状态没人管、代码复用难。适合被MFC自带控件逼疯的新手也适合想把自己的公共控件库沉淀下来的老手。文章会从自绘原理讲到完整代码最后附上我在项目里踩过的坑保证看完能直接抄作业。1. 为什么要把CButton再封装一层1.1 原生MFC按钮的几个痛点MFC的CButton本身并不是不能用但它有几个常年被吐槽的老毛病。第一是外观完全被系统主题绑架。默认按钮在XP、Win7、Win10下长得都不一样想在程序里统一成自己的品牌色几乎只能靠自绘。第二是按钮状态反馈太少原生按钮只有按下、焦点、禁用这几个状态鼠标悬停时UI完全没反应。现在做客户端的人都知道没有hover反馈的按钮简直像没做完的demo。第三是代码重复度太高几乎每个工程都要给按钮设置统一的字体、背景、文字颜色代码散落在各个对话框里想全局改一次样式就得找半天。还有一个更深层的问题扩展能力不足。想给按钮加个圆角、加一张图标、做一个加载进度动画CButton都没有现成接口全得自己重新子类化。既然都要子类化那不如一开始就做一个足够灵活的基类后期所有按钮特性都往里面加。1.2 自绘按钮的两种路线我为什么选了Owner Draw自绘按钮在MFC里主要有两条路一条是WM_PAINT全自绘彻底接管按钮的绘制另一条是利用Owner DrawBS_OWNERDRAW样式在系统发来的WM_DRAWITEM消息里完成绘制。WM_PAINT全自绘的方案能力强但工程量也大。你得自己处理WM_PAINT、WM_ERASEBKGND还要考虑父窗口重绘、失血闪烁等问题。但MFC的CButton早就内置了一个DrawItem虚函数配合BS_OWNERDRAW样式系统会在需要绘制按钮时把绘制权交给你平常的点击区域管理、热键响应、焦点逻辑全部由系统继续处理。等于说我们只接管画画这件事其他还是系统管省心不少。所以我的选择是在CButton的派生类里重写DrawItem让一个专门负责绘制的函数来处理所有状态和外观。这个方案实现简单、稳定、可复用性高还能被MFC的DDX机制无缝接进对话框非常贴合“方便好用”这个目标。2. 核心原理WM_DRAWITEM是怎么工作的2.1 从按下按钮到屏幕上绘制中间发生了什么很多人第一次写自绘按钮都会被WM_DRAWITEM搞晕其实链路很清晰。当一个按钮带有BS_OWNERDRAW样式时系统就不再直接绘制它的外观而是向父窗口发送WM_DRAWITEM消息。消息里带着一个DRAWITEMSTRUCT结构体里面包含了控件的ID、控件类型这里就是ODT_BUTTON、当前状态按下/聚焦/禁用、绘制用的HDC、绘制区域矩形rcItem、以及控件关联的数据指针。MFC把这条消息反射封装成了CButton::DrawItem虚函数所以我们不需要手动处理WM_DRAWITEM只需要在派生类里重写DrawItem从结构体里取出绘制上下文和矩形区域然后画上去即可。要注意的是DRAWITEMSTRUCT里的rcItem坐标是相对于按钮窗口本身的不是相对于对话框的直接在内存DC上填色、写字就行不用做坐标转换。2.2 一个被忽略的关键点为什么要“先画内存再贴屏”我第一次做自绘按钮时只用一个CDC直接往屏幕上刷结果按钮一刷新就肉眼可见地闪烁那叫一个酸爽。这个问题在所有GDI绘图中都存在按钮这种需要频繁刷新鼠标状态的控件尤其明显。解决方案就是双缓冲先在内存里创建一个兼容DC把背景、圆角、文字全部画到这块内存位图上最后用BitBlt一次性把整块位图拷贝到真正的屏幕DC上。因为实际屏幕只被写入了一次所以不会产生闪动。这个技巧在自绘按钮里不是可选项而是必选项只要你的按钮状态一多鼠标移入移出都刷新没有双缓冲一定会翻车。3. 实战封装一个能直接抄作业的CMyButton3.1 类的接口设计我封装这个类时有一个原则只提供必要的接口让使用方三行代码内搞定一个按钮的外观配置。所以对外暴露的方法不多主要就是设圆角、设背景色、设文字颜色其余细节全部藏在内部。// MyButton.h #pragma once #include afxwin.h class CMyButton : public CButton { public: CMyButton(); virtual ~CMyButton(); void SetRound(int nRadius); // 设置圆角半径 void SetBgColor(COLORREF clrNormal, COLORREF clrHover, COLORREF clrPressed); // 三态背景色 void SetTextColor(COLORREF clrNormal, COLORREF clrHover RGB(255, 255, 255)); // 文字颜色 protected: virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct); afx_msg void OnMouseMove(UINT nFlags, CPoint point); afx_msg void OnMouseLeave(); afx_msg BOOL OnSetCursor(CWnd* pWnd, UINT nHitTest, UINT message); DECLARE_MESSAGE_MAP() private: void PreSubclassWindow(); // 强制加上自绘样式 void DrawRoundRect(CDC* pDC, CRect rc, int nRadius, COLORREF clrFill, COLORREF clrBorder); private: BOOL m_bHover; // 鼠标是否悬停 BOOL m_bPressed; // 鼠标是否按下 BOOL m_bTracking; // 是否已注册MouseLeave int m_nRadius; // 圆角半径 COLORREF m_clrBgNormal; // 普通状态背景色 COLORREF m_clrBgHover; // 悬停状态背景色 COLORREF m_clrBgPressed; // 按下状态背景色 COLORREF m_clrTextNormal; // 普通文字颜色 COLORREF m_clrTextHover; // 悬停文字颜色 };3.2 DrawItem里的关键实现核心代码就是这个DrawItem函数所有状态判断和绘制都在这里完成。下面我贴一段精简但完整的实现注释写得比较细方便直接改。void CMyButton::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC* pDC CDC::FromHandle(lpDrawItemStruct-hDC); CRect rc lpDrawItemStruct-rcItem; UINT state lpDrawItemStruct-itemState; // 双缓冲 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 根据状态选择颜色 COLORREF clrBg m_clrBgNormal; COLORREF clrText m_clrTextNormal; if (m_bPressed || (state ODS_SELECTED)) { clrBg m_clrBgPressed; } else if (m_bHover) { clrBg m_clrBgHover; clrText m_clrTextHover; } // 绘制圆角矩形 DrawRoundRect(memDC, rc, m_nRadius, clrBg, RGB(180, 180, 180)); // 绘制文字 CString strText; GetWindowText(strText); memDC.SetBkMode(TRANSPARENT); memDC.SetTextColor(clrText); CFont* pFont GetFont(); CFont* pOldFont NULL; if (pFont) pOldFont memDC.SelectObject(pFont); CRect rcText rc; memDC.DrawText(strText, rcText, DT_CENTER | DT_VCENTER | DT_SINGLELINE); // 焦点框用虚线表示 if (state ODS_FOCUS) { CRect rcFocus rc; rcFocus.DeflateRect(3, 3); memDC.DrawFocusRect(rcFocus); } if (pOldFont) memDC.SelectObject(pOldFont); // 一次性贴回屏幕防闪烁 pDC-BitBlt(rc.left, rc.top, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }这里的DrawRoundRect我实际用的是GDI画的圆角路径因为自带抗锯齿边缘比GDI的RoundRect平滑得多。你也可以直接用CDC::RoundRect效果差别不大但颜值党建议上GDI。用GDI的话记得在程序初始化时调用GdiplusStartup否则会静默失败。3.3 鼠标悬停和按下的状态管理按钮的hover效果靠WM_MOUSEMOVE和WM_MOUSELEAVE配合完成。MFC没有默认处理WM_MOUSELEAVE必须自己调用TrackMouseEvent注册这是很多新手忽略的地方。void CMyButton::OnMouseMove(UINT nFlags, CPoint point) { if (!m_bTracking) { TRACKMOUSEEVENT tme { sizeof(TRACKMOUSEEVENT), TME_LEAVE, m_hWnd, 0 }; TrackMouseEvent(tme); m_bTracking TRUE; } if (!m_bHover) { m_bHover TRUE; Invalidate(FALSE); } CButton::OnMouseMove(nFlags, point); } void CMyButton::OnMouseLeave() { m_bHover FALSE; m_bTracking FALSE; Invalidate(FALSE); CButton::OnMouseLeave(); }按下状态的正确做法是在OnLButtonDown里SetCapture在OnLButtonUp里ReleaseCapture这样才能保证用户把鼠标拖出按钮再松开时按钮能正确恢复到普通状态。按下状态我直接用了ODS_SELECTED判断同时配合m_bPressed标志位这样不会漏掉任何一条状态路径。3.4 在对话框里替换普通按钮的3个步骤这个类封装好之后使用起来非常简单。第一步在资源编辑器里放一个Button控件右键属性把Owner Draw改成True等价于加上BS_OWNERDRAW样式。第二步给按钮添加关联变量把变量类型从CButton手动改成CMyButton并include头文件。第三步在对话框的OnInitDialog里调用接口设置外观。BOOL CMyDialog::OnInitDialog() { CDialogEx::OnInitDialog(); // 加载按钮类头文件后把变量类型改成 CMyButton m_btnOK.SetRound(8); m_btnOK.SetBgColor(RGB(52, 152, 219), RGB(41, 128, 185), RGB(33, 97, 140)); m_btnOK.SetTextColor(RGB(255, 255, 255), RGB(240, 240, 240)); return TRUE; }如果你不想在资源编辑器里手动修改Owner Draw属性可以在PreSubclassWindow里动态加上这样即使资源里忘了改也能正常工作void CMyButton::PreSubclassWindow() { CButton::PreSubclassWindow(); ModifyStyle(0, BS_OWNERDRAW); }我把这个逻辑内置到类里后往后任何新工程再也不用记“记得勾选Owner Draw”便宜了很多弯路。3.5 接口属性速查表接口参数说明效果SetRound(nRadius)圆角半径0为直角控制按钮四角的圆润程度SetBgColor(normal, hover, pressed)三个状态的背景色对应普通、悬停、按下三态背景SetTextColor(normal, hover)普通和悬停状态的文字颜色让文字随状态变化反馈更明显SetHandCursor(TRUE)是否显示手型光标提升按钮的人机交互体验我实际使用时通常还会加一个SetIconEx接口来设置图标逻辑就是在DrawItem里先画一张位图再画文字本质没有变化这里不再展开。4. 从按钮类到MFC常用控件的经验扩展4.1 圆形按钮和不规则形状怎么玩圆角矩形搞定之后很多人还想做圆形按钮。圆形按钮其实和圆角按钮的绘制逻辑差不多只是在DrawItem里画椭圆同时给窗口设置一个椭圆区域让点击区域和显示区域保持一致。设置区域用SetWindowRgn区域句柄用CreateEllipticRgn创建。但是有一个坑区域坐标是窗口客户区坐标和rcItem的尺寸要对上。如果窗口有边框还需要用GetWindowRect换算后减去边框否则点击区域会偏。当初我直接拿客户区大小创建区域按钮右上角怎么点都没反应排查了半天才发现是边框偏移的问题。4.2 图像按钮和动画按钮的扩展思路给按钮加图片的思路不复杂用一个GDI的Bitmap对象加载PNG在DrawItem里先DrawImage再DrawText就能实现图文按钮。关键是图片要带Alpha通道否则透明背景会变成难看的白块。动画按钮的思路则是在类里加一个定时器周期触发Invalidate重绘在DrawItem里绘制进度条、波浪、加载旋转等效果。比如我做下载按钮的时候就在按钮底部画一个窄长的进度矩形进度值由外部SetProgress接口传入刷新由内部定时器驱动视觉上非常直观。4.3 和MFC控件自适应屏幕分辨率怎么配合很多人搜MFC按钮的时候都顺带搜“控件自适应屏幕分辨率”其实按钮封装和布局自适应是两件独立的事但可以配合得很好。做法是在OnSize里根据初始比例重新MoveWindow所有子控件或者用一个布局基类统一管理。我的建议是做一个CDialog布局基类在OnInitDialog里记录所有子控件的初始矩形按窗口宽高比例在OnSize里重新计算位置。这样CMyButton负责外观布局基类负责位置两者各管一摊复用起来很清晰。不要试图把布局逻辑塞进按钮类里那会让一个单纯的控件类变得臃肿违背了我们封装的初衷。5. 常见问题与排查记录5.1 高频问题速查表症状原因解决办法按钮刷新时闪烁严重直接在屏幕DC上绘制没有双缓冲用内存DC BitBltInvalidate时用FALSE已经重写DrawItem但完全没有生效控件没有设置BS_OWNERDRAW样式在PreSubclassWindow里ModifyStyle(0, BS_OWNERDRAW)文字显示不出来文字颜色和背景色相同或者背景模式没设透明调SetBkMode(TRANSPARENT)检查文字颜色hover状态不更新没有处理WM_MOUSELEAVE用TrackMouseEvent注册TME_LEAVE按下后状态卡住不恢复鼠标按下后拖出按钮没有正确捕获/释放鼠标在按下时SetCapture抬起时ReleaseCapture圆角矩形边缘有锯齿使用GDI的RoundRect绘制换GDI抗锯齿绘制或开启AA运行时报GDI相关错误程序启动时没有初始化GdiplusStartup在InitInstance开始处初始化和退出时GdiplusShutdown按钮背景和对话框背景不一致按钮自绘没有画父窗口背景在绘制前先按父窗口背景色填充或处理WM_CTLCOLORBTN5.2 几条从项目里总结出来的硬经验代码写多了以后真正值钱的往往是那些文档里没有的教训。这里分享几条我踩过之后总结出来的经验。第一不要在DrawItem里反复创建GDI对象。如果你在绘制函数里每次new一个CBrush或者CreateFont又不及时删除内存句柄会慢慢泄漏程序跑几个小时之后按钮就开始有拖影、变色。正确做法是把常用对象作为成员变量在接口里创建一次析构时统一释放或者用栈对象让RAII自动管理。第二设置字体之前一定要先GetFont()。我遇到过很多次按钮文字看起来特别“土”就是因为DrawText用的还是系统默认字体。在DrawItem里把父类的字体SelectObject进来文字形态才和对话框其他控件统一。第三Invalidate参数用FALSE而不是TRUE。Invalidate(TRUE)会先擦除背景再重绘配合自绘控件很容易产生白色闪块用FALSE只触发WM_PAINT靠双缓冲一次画完彻底告别闪烁。第四做按钮类的接口时尽量稳定。我早年的版本SetBgColor只接收一个参数后来要加hover和pressed状态接口一变所有调用点全要改。现在设计接口的时候一定会把三种状态一次性传进来虽然调用时多写两个参数但后期扩展省事太多。最后说一个我常用的实用小技巧写这种自绘按钮类可以顺手在类里加一个调试用的开关比如把绘制区域边框画成红色方便在布局时看清按钮实际占用的矩形范围。调试完关掉开关就恢复正常。这个小功能帮我排查过不少按钮位置和文字居中问题成本极低收益却很实在。本文还有配套的精品资源点击获取
返回列表