MFC单文档自定义标题栏与非客户区绘制实践
简介面向C与MFC开发者的窗口自绘实践素材聚焦单文档SDI框架下自行绘制边框、标题栏与关闭按钮适合已掌握基本消息映射、希望摆脱系统默认外观、实现皮肤化界面的初中级开发者。压缩包共32个文件、约95KB以8个h头文件与7个cpp源文件为核心配套ico图标、bmp位图、rc资源脚本及vcproj、sln工程文件另附说明文档与可运行示例程序模块划分清楚。实现主线是派生自CFrameWnd的窗口类重写OnEraseBkgnd与OnPaint接管背景清除和绘制借助CPaintDC及MoveTo、LineTo、FillRect等GDI函数勾勒边框与标题栏在OnNcLButtonDown中判定点击区域以响应关闭按钮并兼顾按钮高亮、按下状态与不同分辨率、DPI下的显示适配。已有394人学习。读者可获得非客户区自绘的完整思路、可直接编译对照的示例工程与皮肤资源组织方式便于迁移到自己的MFC项目。1. 从一块换不掉的灰色标题栏说起去年接手一个 MFC 单文档的老工具客户区已经全部换成深色主题工具栏、树控件、状态栏都跟着配色调好了唯独顶上那条标题栏还是 Win95 味的灰白渐变一截图就像两个软件拼在一起。搜「MFC 边框 标题栏」翻出来的帖子大多是十年前的讲来讲去不是 DrawCaption就是直接 SetWindowLong 摘掉 WS_CAPTION照着抄十有八九在最大化那一步崩掉。这条路其实没那么玄核心就四个非客户区消息画什么、什么时候重画、点在哪、算多大。适合手上正好有一个 MFC 单文档工程、想把标题栏和边框换成自己设计的样子、又不愿意为这点需求整套引入界面库的开发者。下面按消息、绘制、交互、避坑、抽基类的顺序把这套东西一层层拆开。2. 消息与窗口结构MFC 单文档里非客户区到底归谁管2.1 CMainFrame 上面还压着一层你没直接写过的东西单文档程序的骨架是 CWinApp 拉起 CDocument、CView、CFrameWnd 三件套CMainFrame 派生自 CFrameWnd套着视图当子窗口菜单、工具栏、状态栏都挂在它身上。很多人下意识觉得标题栏是 MFC 画的其实不是。MFC 只负责把 Windows 消息翻译成afx_msg成员函数真正画那圈边框和标题栏的是 USER32 里的窗口管理器它在你没有拦截 WM_NCPAINT 的时候走自己的默认绘制流程。这就解释了一个常见现象你在 OnPaint 里怎么折腾标题栏纹丝不动因为 OnPaint 只管客户区WM_PAINT 的绘制范围被系统裁掉了非客户区那一条。要让标题栏变色必须从 WM_NCPAINT 这条口子进去而这个消息在 MFC 里对应的是CWnd::OnNcPaint()默认实现里就一句Default()。非客户区这块地盘的划分也是系统定的。窗口矩形减去客户区矩形剩下的上、下、左、右四条边加上顶部标题栏统统叫非客户区。系统在窗口尺寸变化时会先发 WM_NCCALCSIZE 问你客户区要多大你不吭声它就按SM_CYCAPTION加边框厚度自己算。所以我们能改的空间其实很明确要么只改「长什么样」要么连「多大」一起改。2.2 六个要抢的消息想把标题栏和边框捏在自己手里下面这几个消息基本上一个都跑不掉缺一个就会在某个操作路径上露馅。消息触发时机需要做的事WM_NCCALCSIZE窗口尺寸、位置变化前决定客户区矩形间接决定非客户区剩多少WM_NCPAINT非客户区需要重画把标题栏、边框真正画出来WM_NCACTIVATE窗口激活/失活切换换配色并触发一次重绘WM_NCHITTEST鼠标移动、按下之前告诉系统光标下面这块是标题栏、按钮还是边框WM_NCMOUSEMOVE鼠标在非客户区移动做按钮悬停高亮WM_GETMINMAXINFO最大化、最小化之前修正最大化时的尺寸溢出前四个是必须的后两个属于体验补全。WM_NCHITTEST 尤其容易被忽略很多人画得好好的一上手发现标题栏拖不动窗口、按钮点不动问题就出在这个消息的返回值上系统完全靠它决定「这一下点的是什么」。还有一点值得先讲清楚这几个消息的处理函数在 MFC 里的签名和普通消息不一样。WM_NCHITTEST 走的是LRESULT OnNcHitTest(CPoint point)参数是屏幕坐标WM_NCCALCSIZE 是void OnNcCalcSize(BOOL bCalcValidRects, NCCALCSIZE_PARAMS* lpncsp)两个参数都得看bCalcValidRects为 FALSE 时表示这是窗口创建阶段的第一次计算不能按常规逻辑处理。写错签名编译器不一定报错但行为会很怪。2.3 两条路线只换皮还是连骨头一起换动手前先做一个决定这个决定会影响后面所有的代码量。第一条路线是保留系统的 WS_CAPTION 和 WS_THICKFRAME只拦截 WM_NCPAINT 把标题栏重新画一遍。好处是窗口阴影、贴边吸附、最大化动画、Aero Snap 这些系统行为全都还在缩放拖拽也不需要自己写命中测试。坏处是标题栏高度被SM_CYCAPTION锁死一般二十几个像素想做 40 像素高的现代风格标题栏就没戏边框宽度也只能是系统给的 4 到 8 像素。第二条路线是在 WM_NCCALCSIZE 里自己算客户区把系统标题栏的区域整个吃掉客户区从标题栏下面开始非客户区只剩自绘的那一条。标题栏高度想多高就多高边框宽度想多细就多细。代价是要自己处理最大化时的尺寸溢出、边框命中测试和窗口阴影。路线 A只改外观路线 B自算客户区代码量一个 OnNcPaint 就够至少要动四个消息标题栏高度锁死在系统值完全自定义最大化行为天然正确需要 WM_GETMINMAXINFO 补丁缩放拖拽系统负责自己做 NCHITTEST阴影与贴边系统负责大部分还在阴影可能要补后面按路线 B 写因为它覆盖了路线 A 的全部知识点把 B 写通了回头做 A 只是删代码。3. 绘制流程把标题栏真正画到窗口上3.1 用 CWindowDC 拿到覆盖非客户区的画布在 OnNcPaint 里第一个坑就是 DC 拿错了。CPaintDC、CClientDC都只能画客户区画到非客户区会被裁掉必须用CWindowDC。它在构造时调用GetWindowDC得到的设备上下文覆盖整个窗口坐标原点在窗口左上角包含标题栏和边框析构时自动 ReleaseDC。// MainFrm.h class CMainFrame : public CFrameWnd { DECLARE_DYNCREATE(CMainFrame) public: virtual BOOL PreCreateWindow(CREATESTRUCT cs); protected: afx_msg void OnNcPaint(); // 画非客户区 afx_msg BOOL OnNcActivate(BOOL bActive); afx_msg LRESULT OnNcHitTest(CPoint point); afx_msg void OnNcCalcSize(BOOL bCalcValidRects, NCCALCSIZE_PARAMS* lpncsp); DECLARE_MESSAGE_MAP() private: void DrawNcArea(CDC* pDC); // 真正的绘制入口 void RedrawNc(); // 触发一次非客户区重绘 BOOL m_bActive TRUE; // 当前是否激活 }; BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_WM_NCPAINT() ON_WM_NCACTIVATE() ON_WM_NCHITTEST() ON_WM_NCCALCSIZE() END_MESSAGE_MAP() void CMainFrame::OnNcPaint() { // 关键不要调 CFrameWnd::OnNcPaint()否则系统默认标题栏会盖在我们的成果上 CWindowDC dc(this); DrawNcArea(dc); }这段是入口CWindowDC dc(this)一行就是整个绘制的画布来源。这里有个细节OnNcPaint 是带 wParam 的wParam 里是一个需要重绘的区域句柄窗口只发生局部变化时系统会只传一小块区域过来。初期为了简单可以直接全量重绘等闪烁问题暴露出来再按 wParam 做局部裁剪也不迟。3.2 背景、图标和标题文字绘制本身分三层从下往上分别是边框底色、标题栏底色、文字和图标。所有坐标都从窗口矩形推出来不要写死数值否则换分辨率必翻车。void CMainFrame::DrawNcArea(CDC* pDC) { CRect rcWnd; GetWindowRect(rcWnd); rcWnd.OffsetRect(-rcWnd.left, -rcWnd.top); // 屏幕坐标转成窗口坐标 (0,0,w,h) if (rcWnd.IsRectEmpty()) return; // 1. 整个非客户区先铺边框色这样四周自然留出一圈边框 pDC-FillSolidRect(rcWnd, m_clrBorder); // 2. 标题栏区域宽度铺满高度用自定义值 CRect rcCaption(rcWnd.left, rcWnd.top, rcWnd.right, rcWnd.top m_nCaptionHeight); pDC-FillSolidRect(rcCaption, m_bActive ? m_clrCaptionActive : m_clrCaptionInactive); // 3. 左上角图标22x22垂直居中 int nIcon m_nCaptionHeight / 2 3; int nIconTop rcCaption.top (m_nCaptionHeight - nIcon) / 2; HICON hIcon (HICON)SendMessage(WM_GETICON, ICON_SMALL); if (hIcon) ::DrawIconEx(pDC-GetSafeHdc(), 12, nIconTop, hIcon, nIcon, nIcon, 0, NULL, DI_NORMAL); // 4. 标题文字右边界要让出三个按钮的位置 CString strTitle; GetWindowText(strTitle); int nBtnArea 3 * (m_nBtnWidth 2); CRect rcText rcCaption; rcText.left 12 nIcon 8; rcText.right - nBtnArea; pDC-SetBkMode(TRANSPARENT); pDC-SetTextColor(m_bActive ? m_clrTextActive : m_clrTextInactive); CFont* pOld pDC-SelectObject(m_fontCaption); pDC-DrawText(strTitle, rcText, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS | DT_NOPREFIX); pDC-SelectObject(pOld); }GetWindowRect拿到的是屏幕坐标OffsetRect把它平移到以窗口左上角为原点这一步不做的话所有图形都会跑到屏幕外面去是新手最常撞的一堵墙。文字用DT_VCENTER | DT_SINGLELINE组合做垂直居中DT_END_ELLIPSIS处理超长标题DT_NOPREFIX防止标题里的被当成加速键吃掉。图标用WM_GETICON取比硬编码资源 ID 灵活改图标只改资源文件就行。DrawIconEx比DrawIcon多两个缩放参数能直接按计算出来的尺寸画免去自己 StretchBlt。3.3 三个按钮的布局与状态最小化、最大化、关闭三个按钮的矩形必须只用一处函数算出来绘制和命中测试都调它。如果两处各算一遍改个宽度就会出现「看着点到了但其实没点中」的灵异事件。void CMainFrame::CalcButtonRects(CRect rcMin, CRect rcMax, CRect rcClose) const { CRect rcWnd; const_castCMainFrame*(this)-GetWindowRect(rcWnd); rcWnd.OffsetRect(-rcWnd.left, -rcWnd.top); const int w m_nBtnWidth; const int h m_nCaptionHeight; rcClose.SetRect(rcWnd.right - w, rcWnd.top, rcWnd.right, rcWnd.top h); rcMax.SetRect (rcWnd.right - 2 * w, rcWnd.top, rcWnd.right - w, rcWnd.top h); rcMin.SetRect (rcWnd.right - 3 * w, rcWnd.top, rcWnd.right - 2 * w, rcWnd.top h); } void CMainFrame::DrawCaptionButton(CDC* pDC, const CRect rc, int nType, int nState) { // nState: 0 普通 1 悬停 2 按下 if (nState 1) pDC-FillSolidRect(rc, m_clrBtnHover); else if (nState 2) pDC-FillSolidRect(rc, m_clrBtnPressed); CPen pen(PS_SOLID, 1, m_bActive ? m_clrTextActive : m_clrTextInactive); CPen* pOldPen pDC-SelectObject(pen); CPoint c rc.CenterPoint(); if (nType 0) // 最小化一根横线 { pDC-MoveTo(c.x - 5, c.y 4); pDC-LineTo(c.x 5, c.y 4); } else if (nType 1) // 最大化一个方框 { pDC-MoveTo(c.x - 5, c.y - 5); pDC-LineTo(c.x 5, c.y - 5); pDC-LineTo(c.x 5, c.y 5); pDC-LineTo(c.x - 5, c.y 5); pDC-LineTo(c.x - 5, c.y - 5); } else // 关闭一个叉 { pDC-MoveTo(c.x - 5, c.y - 5); pDC-LineTo(c.x 5, c.y 5); pDC-MoveTo(c.x 5, c.y - 5); pDC-LineTo(c.x - 5, c.y 5); } pDC-SelectObject(pOldPen); }按钮宽度m_nBtnWidth一般给 46 左右跟系统接近。三个矩形连成一排贴着右边缘关闭在最右这是 Windows 的用户习惯不要为了好看调换顺序。nState参数把悬停和按下两种状态分开了后面在 WM_NCMOUSEMOVE 里只需要更新状态值然后重绘即可。用 GDI 线条画图标是为了减少资源依赖如果设计要求更精致把这里换成 DrawIconEx 加载图标资源或者用 PNG 加 AlphaBlend 都行接口不用变。3.4 双缓冲与 WM_NCACTIVATE 的重绘时机非客户区绘制最烦人的是闪烁。拖动窗口边缘时系统会高频触发重绘每次都直接往上涂肉眼就能看到背景跳一下。解决办法跟客户区一样先画到内存 DC最后一次性 BitBlt 过去。void CMainFrame::DrawNcArea(CDC* pDC) { CRect rcWnd; GetWindowRect(rcWnd); rcWnd.OffsetRect(-rcWnd.left, -rcWnd.top); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rcWnd.Width(), rcWnd.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 所有绘制都落在 memDC 上 memDC.FillSolidRect(rcWnd, m_clrBorder); CRect rcCaption(0, 0, rcWnd.Width(), m_nCaptionHeight); memDC.FillSolidRect(rcCaption, m_bActive ? m_clrCaptionActive : m_clrCaptionInactive); // ... 文字、图标、按钮绘制 pDC-BitBlt(0, 0, rcWnd.Width(), rcWnd.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }CreateCompatibleBitmap用的是屏幕 DC 的格式不能传 memDC 进去否则会得到一张单色位图画出来全是黑白的。这是个很老的坑但每年都有人踩。激活状态切换要单独处理。窗口被点击或者失焦时系统会发 WM_NCACTIVATE默认实现会把标题栏画成灰的。我们既然自绘了就得自己记录状态并重绘同时返回 TRUE 阻止系统再画一遍。BOOL CMainFrame::OnNcActivate(BOOL bActive) { if (m_bActive ! (bActive ! FALSE)) { m_bActive (bActive ! FALSE); RedrawNc(); } return TRUE; // 返回 TRUE 表示我处理了系统不用再画 } void CMainFrame::RedrawNc() { // SWP_FRAMECHANGED 让系统重新走一遍 WM_NCCALCSIZE SetWindowPos(NULL, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); // RDW_FRAME 才会把非客户区标脏 RedrawWindow(NULL, NULL, RDW_FRAME | RDW_INVALIDATE | RDW_UPDATENOW); }RedrawWindow的 flags 里RDW_FRAME不能漏只写RDW_INVALIDATE只会刷新客户区标题栏不会有任何反应。RDW_UPDATENOW是让重绘立刻发生而不是排队切换激活状态时用它可以避免视觉上的延迟代价是稍微多一点开销可接受。4. 交互补全拖拽、缩放与按钮点击4.1 WM_NCHITTEST 的返回值就是鼠标的「身份」画完之后光标移上去会发现标题栏拖不动窗口。因为系统判断「点在哪」完全依赖 WM_NCHITTEST 的返回值你不返回HTCAPTION它就认为你点在客户区直接把消息转发给视图。LRESULT CMainFrame::OnNcHitTest(CPoint point) // point 是屏幕坐标 { CRect rcWnd; GetWindowRect(rcWnd); if (!rcWnd.PtInRect(point)) return HTNOWHERE; // 按钮优先必须放在标题栏判断之前 CRect rcMin, rcMax, rcClose; CalcButtonRects(rcMin, rcMax, rcClose); if (rcClose.PtInRect(point)) return HTCLOSE; if (rcMax.PtInRect(point)) return HTMAXBUTTON; if (rcMin.PtInRect(point)) return HTMINBUTTON; // 标题栏正文区域返回 HTCAPTION 才能拖动、双击最大化 CRect rcCaption(rcWnd.left, rcWnd.top, rcWnd.right, rcWnd.top m_nCaptionHeight); if (rcCaption.PtInRect(point)) return HTCAPTION; // 边框热区最大化时不做否则边缘会变成不可点的缩放光标 if (!IsZoomed()) { const int b m_nResizeBorder; bool L point.x rcWnd.left b; bool R point.x rcWnd.right - b; bool T point.y rcWnd.top b; bool B point.y rcWnd.bottom - b; if (T L) return HTTOPLEFT; if (T R) return HTTOPRIGHT; if (B L) return HTBOTTOMLEFT; if (B R) return HTBOTTOMRIGHT; if (L) return HTLEFT; if (R) return HTRIGHT; if (T) return HTTOP; if (B) return HTBOTTOM; } return HTCLIENT; }顺序很重要。按钮矩形必须排在标题栏矩形之前判断因为按钮本身就落在标题栏范围内先判标题栏的话按钮永远点不到全都变成拖窗口。返回HTCLOSE、HTMAXBUTTON、HTMINBUTTON之后单击的处理会由DefWindowProc自动完成不需要自己写 OnNcLButtonDown。这是很多人不知道的一点白白多写了几十行。四角判断必须在四边之前否则角落会被判成单边缩放时光标方向不对。IsZoomed()那一层也不能漏最大化状态下窗口贴满工作区如果还返回 HTLEFT鼠标移到屏幕最左边会变成缩放光标很怪。4.2 按钮 hover 与 TrackMouseEvent有了绘制和命中还差悬停高亮。这里要用 WM_NCMOUSEMOVE配合TrackMouseEvent注册离开通知否则鼠标移出窗口后按钮会一直保持高亮看着像卡住了。void CMainFrame::OnNcMouseMove(UINT nHitTest, CPoint point) { int nHot 0; CRect rcMin, rcMax, rcClose; CalcButtonRects(rcMin, rcMax, rcClose); if (rcClose.PtInRect(point)) nHot 3; else if (rcMax.PtInRect(point)) nHot 2; else if (rcMin.PtInRect(point)) nHot 1; if (nHot ! m_nHotButton) { m_nHotButton nHot; RedrawNc(); } // 注册非客户区离开通知TME_NONCLIENT 这个标志不能少 TRACKMOUSEEVENT tme { sizeof(TRACKMOUSEEVENT) }; tme.dwFlags TME_LEAVE | TME_NONCLIENT; tme.hwndTrack m_hWnd; ::TrackMouseEvent(tme); CFrameWnd::OnNcMouseMove(nHitTest, point); } void CMainFrame::OnNcMouseLeave() { if (m_nHotButton ! 0) { m_nHotButton 0; RedrawNc(); } CFrameWnd::OnNcMouseLeave(); }TME_NONCLIENT这个标志值需要_WIN32_WINNT 0x0500老工程里如果没定义TrackMouseEvent不会对非客户区生效表现出来的现象就是鼠标移出去按钮一直亮着。解决方法是把 targetver.h 里的版本号往上提。每次移动都调 RedrawNc 会不会太频繁实际上只有nHot发生变化时才重绘鼠标在同一个按钮上移动不会触发开销可以忽略。如果确实遇到卡顿可以把 RedrawNc 里的RDW_UPDATENOW去掉改成异步刷新。4.3 WM_NCCALCSIZE 与最大化溢出修正前面说了走路线 B 要自己算客户区具体做法是先让系统算一遍标准结果再在结果上做差把系统标题栏的高度换成自己的。void CMainFrame::OnNcCalcSize(BOOL bCalcValidRects, NCCALCSIZE_PARAMS* lpncsp) { if (!bCalcValidRects) { // 窗口创建阶段的第一次计算交给默认处理 CFrameWnd::OnNcCalcSize(bCalcValidRects, lpncsp); return; } // 先让系统按标准规则算出客户区此时顶部已被系统标题栏吃掉 CFrameWnd::OnNcCalcSize(bCalcValidRects, lpncsp); // 系统标题栏厚度 标题栏 边框 内边距 const int nSysTop GetDpiFor(SM_CYCAPTION) GetDpiFor(SM_CYSIZEFRAME) GetDpiFor(SM_CXPADDEDBORDER); const int nMyTop DpiScale(m_nCaptionHeight); // 把差额还回去自定义标题栏比系统高客户区就往下缩反之往上扩 lpncsp-rgrc[0].top nSysTop - nMyTop; }rgrc[0]是调整后的客户区矩形rgrc[1]和rgrc[2]在 bCalcValidRects 为 TRUE 时分别表示调整前窗口矩形和调整前客户区矩形不放心的话在最后补一句lpncsp-rgrc[1] lpncsp-rgrc[2] lpncsp-rgrc[0];更保险。这段跑通之后最大化会立刻出问题窗口会比工作区大一圈标题栏跑到屏幕外或者底部压住任务栏。原因是系统对最大化窗口的尺寸计算里包含了那圈看不见的边框厚度客户区被我们自己动了之后就对不上了。修法是拦 WM_GETMINMAXINFO。void CMainFrame::OnGetMinMaxInfo(MINMAXINFO* lpMMI) { CFrameWnd::OnGetMinMaxInfo(lpMMI); // 只在最大化时有意义 if (!IsZoomed() lpMMI-ptMaxSize.x 0) return; HMONITOR hMon MonitorFromWindow(m_hWnd, MONITOR_DEFAULTTONEAREST); MONITORINFO mi { sizeof(MONITORINFO) }; if (!GetMonitorInfo(hMon, mi)) return; // 窗口矩形对齐到工作区屏幕坐标所以要减掉监视器原点 lpMMI-ptMaxPosition.x mi.rcWork.left - mi.rcMonitor.left; lpMMI-ptMaxPosition.y mi.rcWork.top - mi.rcMonitor.top; lpMMI-ptMaxSize.x mi.rcWork.Width(); lpMMI-ptMaxSize.y mi.rcWork.Height(); }ptMaxPosition之所以要减监视器原点是因为这个值是相对监视器左上角而非屏幕左上角的。多显示器、副屏坐标是负数时不减原点窗口会飞到另一块屏幕上这个 bug 只在双屏环境下复现单屏测试完全发现不了。4.4 高 DPI 下的尺寸与字体4K 屏上跑起来第一眼就会发现按钮全挤在左上角一小块原因是用GetSystemMetrics取到的始终是 96 DPI 下的逻辑值。Win10 1607 之后有了GetSystemMetricsForDpi和GetDpiForWindow把它们包一层就不会错。int CMainFrame::DpiScale(int n) const { UINT dpi ::GetDpiForWindow(m_hWnd); if (d p a hrefhttps://download.csdn.net/download/u014677511/7471197 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p