首页 / 资讯中心 / 文章详情

MFC CMFCToolBar浮动工具条无标题栏与关闭按钮实现

MFC CMFCToolBar浮动工具条无标题栏与关闭按钮实现 ★ FEATURED ARTICLE
floating 工具条的标题栏和那个关闭叉是 MFC Feature Pack 里最容易被误判归属的一处界面细节。很多人第一反应是对着CMFCToolBar一顿ModifyStyle(WS_CAPTION, 0)改完发现停靠状态下确实变了点样子一旦把工具条拖出来浮起标题栏和关闭按钮又原封不动地回来了。我在一个上位机项目里就栽过这个跟头客户要求浮动工具条做成无边框小面板我改了三天样式位最后才发现真正画标题栏的根本不是工具条本身而是 MFC 在你拖动时临时创建的那个浮动容器窗口。这篇就把CMFCToolBar的标题栏、关闭按钮从哪儿来、怎么按需隐藏、隐藏之后要补哪些交互一次讲透涉及SetControlBarStyle、CPaneFrameWnd、非客户区计算、拖动补偿和状态持久化几个关键点适合正在用 MFC 做桌面端界面、被这套 Feature Pack 折磨过一轮的同学参考。1. 标题栏到底属于谁先把窗口关系捋清楚1.1 停靠态和浮动态是两个完全不同的窗口树CMFCToolBar继承自CMFCBaseToolBar再往上追是CControlBar本质还是一个子窗口。它停靠的时候父窗口是主框架里的CDockSite更准确说是CDockSite内部的CPaneContainer此时你在屏幕上看到的所谓标题栏要么根本不存在要么来自承载它的外层容器。而当你按住它拖出去、让它变成浮动状态时MFC 会在内部调用CBasePane::FloatPane临时 new 一个CPaneFrameWnd多面板浮动时是CMultiPaneFrameWnd作为顶层弹出窗口然后把工具条SetParent到这个 frame 里面去。这一层关系决定了所有后续操作的落点你在浮动工具条顶部看到的那条带文字、带关闭叉的灰条是CPaneFrameWnd的非客户区NC 区由系统的WS_CAPTION、WS_SYSMENU样式驱动MFC 再通过视觉管理器在上面重绘。工具条自己的窗口矩形其实是从标题栏下面开始的。所以你对工具条调ModifyStyle动的只是内容区那个子窗口跟头顶那条灰条一点关系都没有。搞清楚这一点后面所有方案才有讨论的基础。工具条状态工具条的直接父窗口标题栏归属关闭按钮归属停靠在主框架CDockSite/CPaneContainer通常无除非外面包了CDockablePane外层 pane 的AFX_CBRS_CLOSE浮动CPaneFrameWnd或CMultiPaneFrameWnd浮动 frame 的WS_CAPTIONframe 系统菜单 AFX_CBRS_CLOSE自动隐藏CMFCAutoHideBar一侧的按钮无自动隐藏按钮本身放在CDockablePane里那个CDockablePaneCDockablePane的 captionpane 的关闭按钮1.2 关闭按钮的三个来路标题栏和关闭按钮经常被当成一件事其实是三条独立的链路混在一起改就会互相打架。第一条是浮动 frame 自带的系统菜单按钮也就是WS_SYSMENU带来的那个叉它的行为等价于WM_SYSCOMMANDSC_CLOSE最终会走到 frame 的关闭逻辑上第二条是 MFC 自己在CBasePane层面提供的控制位AFX_CBRS_CLOSE它控制的是 pane 标题栏右侧那个由视觉管理器绘制的关闭按钮跟系统菜单不是一回事第三条比较隐蔽是某些人Create工具条时误传了WS_SYSMENU之类的样式导致工具条自己带了一个系统菜单虽然少见但排查时如果前两条都排除了就得回头看看创建参数。这三条链路的处理手法完全不同第一条要动 frame 的窗口样式或者在WM_SYSCOMMAND上拦截第二条调SetControlBarStyle去掉对应的位就行第三条得改创建时的样式参数。我在排查一个叉点了没反应但界面上的叉还在的问题时就是因为只处理了第二条忘了第一条用户按 AltF4 依然能把整个浮动面板送走然后工具条指针变成悬空切换视图时直接崩。所以后面讲实现时我会把三条通道一起堵。1.3 为什么 ModifyStyle 常常打不中就算你找对了窗口ModifyStyle也未必立刻见效这里面有两个时机问题。第一个时机是创建浮动 frame 是在你拖动的那一刻才创建的你在OnInitDialog或者OnCreate里对浮动 frame做的任何设置那时候 frame 还不存在代码要么空指针跳过要么改了个寂寞。第二个时机是重建MFC 的LoadState、SetDockState、切换多文档视图这些操作都可能销毁旧的浮动 frame 再建一个新的你之前设好的样式跟着一起没了。还有一个更细的坑ModifyStyle改完样式位之后窗口的非客户区并不会自动重算必须补一次SetWindowPos并带上SWP_FRAMECHANGED系统才会重新走WM_NCCALCSIZE。少了这一步样式位确实变了你用 Spy 之类的工具能看到但屏幕上的标题栏纹丝不动很容易让人以为方法无效转头去试别的野路子。我个人的习惯是把改样式 发SWP_FRAMECHANGED封装成一个函数任何地方要改窗口边框都走它省得每次都要想一遍。提示判断一个窗口到底有没有WS_CAPTION别靠眼睛看也别靠GetWindowLong读一次就下结论。GetWindowLong(GWL_STYLE)拿到的是样式位而屏幕上画出来的东西还受WS_EX_*扩展样式和视觉管理器双重影响三者对照着看才不会误判。2. 四条路径的取舍从禁用浮动到接管非客户区2.1 路径一不让它浮动问题自然消失最省事的方案往往被忽略既然标题栏和关闭按钮只在浮动状态出现那就别让它浮动。MFC 给CBasePane提供了一组AFX_CBRS_*控制位通过SetControlBarStyle设置默认的AFX_DEFAULT_PANE_STYLE大致是可浮动 可关闭 可调整大小 可自动隐藏 可卷起的组合。你只要把浮力和关闭这两位置零拖动时工具条就不会被拽出去自然也就没有标题栏可藏。// 只保留可调整大小去掉浮动与关闭 DWORD dwStyle m_wndToolBar.GetControlBarStyle(); dwStyle ~(AFX_CBRS_FLOAT | AFX_CBRS_CLOSE); m_wndToolBar.SetControlBarStyle(dwStyle);这条路的最大优势是零风险不动任何窗口结构不写一行非客户区代码也不影响停靠、自动隐藏、位置持久化这些既有能力。代价是用户失去了把工具条拖出来单独摆一块的自由对于工控上位机、单文档采集软件这类界面布局相对固定的场景这个代价基本可以接受甚至可以说更符合使用习惯——谁也不想在采数据的时候误拖一下把工具条甩到副屏上去。但如果你的项目本身就把浮动工具条当成一个卖点功能这条路就直接出局了。另外要注意去掉AFX_CBRS_FLOAT之后用户还是能在停靠区之间拖动重排的只是拖不出框架这两种行为的边界别搞混。2.2 路径二只摘掉关闭按钮如果需求只是别让用户点叉关掉工具条不涉及标题栏那就更简单只清掉AFX_CBRS_CLOSE一位即可。这一位去掉之后视觉管理器绘制 pane 标题时就不会再画那个关闭按钮鼠标点上去也不会有响应属于最干净的做法。m_wndToolBar.SetControlBarStyle(m_wndToolBar.GetControlBarStyle() ~AFX_CBRS_CLOSE);这里有个容易忽略的细节去掉AFX_CBRS_CLOSE只影响 MFC 自绘的那个按钮浮动 frame 的系统菜单叉还在。如果你的工具条浮起来之后右上角依然有个能点的叉那就是系统菜单按钮在起作用得走路径三。我见过不少帖子把这两个叉混为一谈然后给出一个去掉AFX_CBRS_CLOSE就好了的结论提问的人照着做发现没变化来回扯半天。区分方法很简单把工具条浮起来看那个叉的位置——贴着窗口最右上角、跟最小化按钮排一行的是系统菜单画在标题文字右侧、跟标题同一行的是 MFC 绘制的 pane 按钮。2.3 路径三保留浮动接管浮动容器的非客户区这是真正能满足能浮动、但不要标题栏和关闭按钮的方案也是最费劲的一条。核心动作是在工具条完成浮动之后拿到它的父 frame把这个 frame 的WS_CAPTION和WS_SYSMENU去掉然后强制重算非客户区。CBasePane提供了OnAfterFloat这个虚函数正好是挂载点。void CNoCaptionToolBar::OnAfterFloat() { CMFCToolBar::OnAfterFloat(); if (m_bShowCaption) return; CFrameWnd* pFrame GetParentFrame(); if (pFrame ! nullptr pFrame-IsKindOf(RUNTIME_CLASS(CPaneFrameWnd))) { pFrame-ModifyStyle(WS_CAPTION | WS_SYSMENU, 0, SWP_FRAMECHANGED); pFrame-SetWindowPos(nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); } }为什么要用IsKindOf(RUNTIME_CLASS(CPaneFrameWnd))判断而不是直接转因为浮动的父窗口不一定就是CPaneFrameWnd多面板合并浮动的时候是CMultiPaneFrameWnd而它恰好是CPaneFrameWnd的派生类IsKindOf能同时覆盖两种情况。另外停靠状态下GetParentFrame()会返回主框架直接ModifyStyle会把整个主窗口的标题栏干掉那乐子就大了。这种改错对象的 Bug 在调试时极难定位因为现象是主窗口突然没标题栏了跟工具条毫无关联感容易往框架代码上想。去掉WS_CAPTION之后浮动窗还剩WS_THICKFRAME边框还在用户依然可以拖边框改大小只是头顶那条灰条消失了。这里要留意几个副作用拖动移动没了原本靠标题栏的HTCAPTION命中区双击回停靠位没了系统菜单没了连带 Alt空格菜单也没了。这三个交互都得自己补下一节细讲。还有一个视觉上的小问题没有标题栏之后浮动窗在浅色背景上会显得飘边界不清晰建议自己处理WM_NCPAINT画一圈一像素的边或者干脆保留WS_BORDER让系统画。2.4 路径四只改视觉管理器属于假隐藏还有一类做法是重写视觉管理器的标题绘制函数让OnDrawPaneCaption之类的虚函数什么都不画看起来标题栏就空了。这个思路在只想换外观、不想动布局的场合是有用的比如把标题栏画成纯色空白条跟工具栏背景融为一体。但它并不是真正的隐藏因为非客户区的高度还在那块区域依然占据着屏幕空间也依然响应点击和双击。如果你的目标是看起来没有标题栏这条路可行如果目标是工具栏整体高度减少一条标题栏的高度那必须走路径三改 NC 计算。我在一个界面改版需求里就吃过这个亏设计稿要求浮动工具条高度压到 28 像素我用视觉管理器把标题文字擦干净了结果实测高度还是 46 像素因为那条灰条的区域还稳稳地待着。最后还是回头去动了WS_CAPTION。2.5 四种路径的对照与选型建议把四条路放在一起比选型其实很清楚布局固定、用户不需要浮动的走路径一只是不想让人关掉面板的走路径二必须保留浮动又要极简外观的走路径三纯粹想统一视觉风格、不介意区域还在的走路径四。路径核心手段标题栏消失关闭按钮消失浮动能力改动量适用场景一清AFX_CBRS_FLOAT是因为不会浮动是失去一行工控、采集、布局固定二清AFX_CBRS_CLOSE否部分MFC 按钮保留一行只想防误关三改浮动 frame 的 NC 样式是是含系统菜单保留一个派生类需要极简浮窗四重写视觉管理器绘制视觉上否保留中等统一风格、不改布局实际项目里这四条经常是混着用的比如主工具条走路径三做成无标题浮动面板而侧边的辅助面板走路径二禁止关闭。别指望一条路通吃按面板的实际用途分别选反而更省心。3. 动手实现给 CMFCToolBar 加一个无标题浮动模式3.1 派生类骨架我习惯把这类需求做成一个可复用的派生类而不是在主框架里到处写 if。派生类只需暴露一个开关内部接管浮动前后的样式外部代码完全感知不到细节。// NoCaptionToolBar.h #pragma once class CNoCaptionToolBar : public CMFCToolBar { DECLARE_DYNAMIC(CNoCaptionToolBar) public: CNoCaptionToolBar(); virtual ~CNoCaptionToolBar(); // 外部只需要这一个开关 void SetShowCaption(BOOL bShow); BOOL IsShowCaption() const { return m_bShowCaption; } protected: virtual BOOL OnBeforeFloat(CRect rectBefore, AFX_FLOAT_SITE floatSite); virtual void OnAfterFloat(); virtual void OnAfterDock(CBasePane* pBar, LPCRECT lpRect, AFX_DOCK_METHOD dockMethod); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnLButtonDblClk(UINT nFlags, CPoint point); DECLARE_MESSAGE_MAP() private: void ApplyFrameStyle(); BOOL m_bShowCaption; };OnBeforeFloat里可以做拦截返回FALSE就不允许这次浮动返回TRUE放行。这个钩子适合做某些权限下不允许拖动工具条之类的业务限制比在OnLButtonDown里硬拦优雅得多因为拖拽的判定逻辑在 MFC 内部你去拦鼠标消息很容易漏掉键盘触发或者触摸屏触发的路径。BOOL CNoCaptionToolBar::OnBeforeFloat(CRect rectBefore, AFX_FLOAT_SITE floatSite) { // 这里可以根据业务状态决定要不要放行 if (!m_bAllowFloat) return FALSE; return CMFCToolBar::OnBeforeFloat(rectBefore, floatSite); }3.2 在浮动前后接管窗口样式OnAfterFloat是应用样式的第一现场但只在这里做是不够的。用户把工具条拖回停靠区再拖出来会重新走一遍浮动流程OnAfterFloat会再次被调用所以放在这里是对的但还有一种情况是 MFC 从注册表恢复上次的布局LoadState内部直接调SetDockState把工具条摆到浮动位置这时候OnAfterFloat未必会走一遍完整流程。保险起见OnAfterDock里也调一次ApplyFrameStyle再加上界面初始化完成后的一个显式调用。void CNoCaptionToolBar::OnAfterFloat() { CMFCToolBar::OnAfterFloat(); ApplyFrameStyle(); } void CNoCaptionToolBar::OnAfterDock(CBasePane* pBar, LPCRECT lpRect, AFX_DOCK_METHOD dockMethod) { CMFCToolBar::OnAfterDock(pBar, lpRect, dockMethod); ApplyFrameStyle(); // 停靠回去时把样式恢复成常规避免影响下次 } void CNoCaptionToolBar::ApplyFrameStyle() { CFrameWnd* pFrame GetParentFrame(); if (pFrame nullptr || !pFrame-IsKindOf(RUNTIME_CLASS(CPaneFrameWnd))) return; // 停靠态或主框架直接跳过 const BOOL bWantCaption (m_bShowCaption IsFloating()) ? TRUE : FALSE; if (!bWantCaption) pFrame-ModifyStyle(WS_CAPTION | WS_SYSMENU, 0, 0); else pFrame-ModifyStyle(0, WS_CAPTION | WS_SYSMENU, 0); pFrame-SetWindowPos(nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); }这里我把ModifyStyle的第三个参数故意传 0把SWP_FRAMECHANGED留给后面的SetWindowPos统一发一次。原因是ModifyStyle内部如果带了SWP_FRAMECHANGED它会自己调一次SetWindowPos紧接着我们再调一次等于连发两次WM_NCCALCSIZE在部分显卡驱动下会看到明显的闪烁。合并成一次能省掉这个问题尤其是浮动窗刚创建、还在做淡入动画的时候双发更容易出闪。注意ApplyFrameStyle必须在 frame 已经创建完成之后调。OnBeforeFloat里GetParentFrame()拿到的还是旧父窗口停靠区那时候改样式毫无意义还可能误伤主框架。这是我在第一版实现里犯的错现象是主窗口的标题栏偶尔会消失一下再回来查了很久才定位到调用时机。3.3 没有标题栏之后拖动和双击怎么办标题栏一去掉浮动窗就失去了唯一的拖动把手用户只能拖边框改大小没法挪位置这在体验上是退化的。补的办法是在工具条的空白区域模拟一次标题栏点击让系统自己进入窗口移动循环。原理是给父 frame 发一条WM_NCLBUTTONDOWN命中码用HTCAPTION系统收到后会认为用户按在了标题栏上进而进入SC_MOVE的拖拽循环跟有没有真实标题栏无关。void CNoCaptionToolBar::OnLButtonDown(UINT nFlags, CPoint point) { if (!m_bShowCaption IsFloating() HitTest(point) 0) { CFrameWnd* pFrame GetParentFrame(); if (pFrame ! nullptr pFrame-IsKindOf(RUNTIME_CLASS(CPaneFrameWnd))) { ::ReleaseCapture(); // 必须先释放否则移动循环拿不到鼠标 pFrame-SendMessage(WM_NCLBUTTONDOWN, HTCAPTION, 0); return; // 不再走基类避免二次捕获 } } CMFCToolBar::OnLButtonDown(nFlags, point); }这段代码有三个关键点少一个都不行。第一是HitTest(point) 0的判断必须确保用户点的是空白区而不是某个按钮否则按钮就废了点保存会变成拖窗口。第二是ReleaseCapture()MFC 的基类在某些路径下会SetCapture如果你不释放系统的移动循环拿不到鼠标输入表现出来就是按下去没反应或者窗口粘在鼠标上不跟手。第三是return拦截之后不要再调基类否则基类可能再走一遍自己的拖拽判定两套逻辑打架轻则拖不动重则工具条闪成两半。双击的行为要不要补取决于你的用户习惯。默认情况下双击浮动工具条的标题栏MFC 会把它停靠回上一次的位置这是个很好用的快捷操作标题栏去掉之后自然就没了。补起来也不难在空白区双击时手动触发一次停靠。void CNoCaptionToolBar::OnLButtonDblClk(UINT nFlags, CPoint point) { if (!m_bShowCaption IsFloating() HitTest(point) 0) { // 恢复到上一次的停靠位置具体接口按项目里的封装来 RestoreDockPosition(); return; } CMFCToolBar::OnLButtonDblClk(nFlags, point); }RestoreDockPosition这里我不给死代码因为不同项目的停靠位置管理方式差别很大有的直接用SetDockState恢复保存的状态有的自己记录了初始的DockPane调用参数还有的干脆调FloatPane的反向操作。通用的做法是在初始化时把CDockablePane或者工具条的 dock 参数存一份双击时照着再调一次DockPane。这块别照抄网上的片段DockPane的参数在不同 VS 版本里有过调整签名对不上会直接编译不过。3.4 把关闭通道彻底堵掉前面说过关闭有三条通道这里逐一收口。MFC 自绘的那个按钮靠AFX_CBRS_CLOSE控制清掉即可系统菜单靠在 frame 上拦截WM_SYSCOMMAND的SC_CLOSESC_CLOSE还可能通过 AltF4 触发同样走WM_SYSCOMMAND所以拦截点是一个地方。LRESULT CALLBACK PaneFrameSubclassProc(HWND hWnd, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) { if (msg WM_SYSCOMMAND (wp 0xFFF0) SC_CLOSE) return 0; // 吃掉 AltF4 和系统菜单里的关闭 return DefSubclassProc(hWnd, msg, wp, lp); }用SetWindowSubclass挂上去记得在 frame 销毁时RemoveWindowSubclass否则句柄复用之后可能挂到别的窗口上出现莫名其妙的别的窗口也关不掉的现象。挂载的时机同样放在OnAfterFloat里跟着 frame 的生命周期走。void CNoCaptionToolBar::OnAfterFloat() { CMFCToolBar::OnAfterFloat(); CFrameWnd* pFrame GetParentFrame(); if (pFrame ! nullptr pFrame-IsKindOf(RUNTIME_CLASS(CPaneFrameWnd))) { SetWindowSubclass(pFrame-GetSafeHwnd(), PaneFrameSubclassProc, 1, 0); } ApplyFrameStyle(); }这里有个取舍要说明吃掉SC_CLOSE意味着用户彻底没法关掉这个浮动面板连隐藏起来都做不到。如果这个工具条不是必需的比如只是个统计图表辅助条全堵死反而不友好更合适的做法是保留AFX_CBRS_CLOSE但把浮动的关闭行为改成停靠回原位也就是在SC_CLOSE的拦截里调一次停靠而不是简单return 0。这个小差异看起来无关紧要实际用起来体验差很多因为用户点叉的预期往往是我想把它收起来而不是我要它永远消失。4. 几个真会翻车的场景4.1 工具条被 CDockablePane 包着的时候这是最容易白忙活的场景。有些项目为了给工具条加个可折叠的容器会先创建一个CDockablePane再把CMFCToolBar作为子窗口塞进去然后DockPane到主框架。这种情况下你看到的标题栏和关闭按钮全都是那个CDockablePane的工具条只是它的一个住户你在工具条上做任何样式调整都不会影响外层 pane 的标题。判断方法很简单用调试器在浮动状态下打印一下GetParent()和GetParentFrame()的类名如果链路上出现CDockablePane那就得改外层。对CDockablePane来说标题栏是它作为 pane 的固有属性靠SetControlBarStyle清AFX_CBRS_CLOSE能去掉关闭按钮但标题栏本身去不掉只能用视觉管理器把标题文字和背景画成和内容区一致的颜色做出没有标题栏的观感。这种情况下非客户区的高度是改不了的因为CDockablePane的布局计算里就写死了标题高度。我在一个项目里就遇到过这个坑需求方要求工具条紧凑我在CMFCToolBar的派生类里折腾了半天怎么都矮不下去最后发现外层 pane 的标题栏占了 24 像素跟工具条一点关系都没有。所以开工之前先花两分钟看一眼窗口树能省掉半天。4.2 SaveState / LoadState 把样式吃回去MFC 的布局持久化靠SaveState和LoadState这对接口存在注册表或者流里。很多人以为它会把 pane 的所有属性都存下来其实它主要记的是停靠位置、可见性、尺寸这些几何信息SetControlBarStyle设的那些位不在持久化范围内。所以每次程序启动LoadState恢复完之后你之前设的无标题又变回默认。正确的顺序是这样的先Create工具条再EnableDocking、DockPane完成初始布局然后调LoadState恢复用户上次的布局最后在LoadState之后再统一扫一遍所有 pane把AFX_CBRS_*这些业务样式重新设一次。我习惯写一个ApplyPaneStyles()在里面遍历m_listControlBars或者逐个点名设置放在OnInitDialog的最后、LoadState之后。BOOL CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CFrameWndEx::OnCreate(lpCreateStruct) -1) return -1; if (!m_wndToolBar.CreateEx
阅读完成 · 觉得有帮助?
咨询建站