ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

VC2008 MFC开发:CTabSheet多页面对话框实现与踩坑指南

VC2008 MFC开发:CTabSheet多页面对话框实现与踩坑指南 简介面向VC2008环境下MFC项目开发人员的一份选项卡示例工程演示如何借助CTabSheet类在对话框窗口中集成多页面Tab界面解决屏幕空间不足与功能分组问题适合需要快速上手Tab控件的C开发者。压缩包约200KB共23个文件包含8个头文件与6个C源文件另有Visual Studio解决方案sln/vcproj/suo、已编译的Release可执行程序、界面资源文件rc/rc2/ico/aps及说明文档。已有305人学习使用。代码中可看到TabSheet.h/cpp对TabCtrl的封装以及各页面子类的创建、AddPage添加Tab页和消息映射的具体写法通过Dia_Child1/Dia_Child2等不同页面示例能清楚对比多页签的挂载方式。打开dlgTab.sln即可编译运行适合在现成对话框工程中嵌入选项卡场景直接参考其类设计与调用流程。 做MFC界面开发的基本都绕不过CTabSheet这个类。在工控上位机、内部小工具、维护期的老项目里VC2008 MFC 9.0这套组合还很常见大家想给对话框加一组多页面切换时网上翻来翻去大概率找到的就是CTabSheet。这次我把我实际在VC2008里跑通的一套完整流程拆出来类文件怎么接、子页面资源怎么建、切换消息怎么处理还有那些没人明说的坑。如果你手头正好有维护期老项目想加多页面但又不想上CPropertySheet这篇可以直接当操作手册参考。1. 方案选型为什么用CTabSheet而不是CPropertySheet1.1 CTabSheet的由来与核心思路CTabSheet不是MFC标准类库里的东西它是社区里长期流传的一个轻量封装。核心思路很简单把一个CTabCtrl和若干个无边框、Child风格的CDialog组合在一起。CTabCtrl负责画出上面的页签和切换效果下面的每一个页面实际上都是一个普通的CDialog资源模板界面元素可以像做普通对话框一样直接拖拽摆放。这个设计在VC2008时代特别好用因为MFC自带的CPropertySheet虽然也是多页但它的页面规范、按钮区、向导流程都限制得比较死。做一个标准设置向导CPropertySheet没问题但做一个内部工具要放自定义工具栏、状态栏、大量控件交互CPropertySheet就明显束手束脚。CTabSheet这种“Tab控件当壳、对话框当面”的组合相当于把多页导航和传统对话框开发的优势都保留了。1.2 三种Tab实现方式的对比我把常见的三种方案放在一起对比过实际情况基本是这样的方案代码量界面灵活性消息处理适合场景裸用CTabCtrl大高但所有控件要动态创建手工控制容易乱列表型界面页面内容较少CPropertySheet中低受属性页框架限制向导式页面间无法自由交互标准设置向导、项目配置CTabSheet小高每个子页仍是Dialog资源CDialog原有机制内部工具、老项目快速改造实际项目里更推荐CTabSheet的两点理由是第一子页面是CDialog派生所有已有的DDX数据交换、OnInitDialog初始化逻辑都能无缝迁移第二页面之间如果想互相传数据拿个主对话框指针就能直接访问不用像CPropertySheet那样绕来绕去。当然它也有短板外观比较老派没有圆角、动画、渐变这些现代UI效果。但老项目维护最怕的是改一处功能带出一堆界面兼容问题CTabSheet恰好能控制住这类风险所以它能在老工程里活十几年不是没道理的。2. 开工第一步准备类文件、子页面与资源2.1 把CTabSheet类文件接入VC2008工程网上流传的CTabSheet通常只有两个文件TabSheet.h和TabSheet.cpp。拿到之后在解决方案资源管理器里右键项目名选择“添加 - 现有项”把这两个文件加进去即可。文件本身不依赖第三方库只用到MFC的CTabCtrl和CArray所以接入成本很低。这里要特别注意一个前置条件TabSheet.cpp里用到CTabCtrl所以编译单元必须能看到afxcmn.h。VC2008生成的MFC工程stdafx.h里一般会包含afxwin.h但不见得包含afxcmn.h。保险起见打开stdafx.h确认里面有几行#include afxwin.h // MFC核心和标准组件 #include afxext.h // MFC扩展 #include afxcmn.h // MFC 通用控件CTabCtrl依赖这里 #include afxtempl.h // CArray等MFC集合类如果编译报错说CTabCtrl未定义十有八九就是缺了afxcmn.h如果报CArray未定义缺的就是afxtempl.h。这两个头是VC2008下最常见的坑我在自己机器上第一次编译时就翻车在这里。2.2 子页面对话框资源模板设置子页面的本质是CDialog所以首先要创建几个对话框资源。假设做一个三页的工具窗口我习惯在资源编辑器里插入三个DialogID分别设为IDD_PAGE_ONE、IDD_PAGE_TWO、IDD_PAGE_THREE。每个对话框的模板属性必须做如下设置这是最容易翻车的细节Style: Child不能选Popup否则子页面会变成一个独立窗口Border: None去掉边框System Menu: FalseTitle Bar: False字体保持和主对话框一致设置成Child Border None后子页面在资源编辑器里会没有标题栏拖控件时可能会感觉空间变窄这是正常现象。建议在资源编辑器里把对话框尺寸调成和Tab控件客户区差不多大小这样运行时子页面尺寸和设计时基本一致。每个子对话框都生成对应的CDialog派生类比如CPageOne、CPageTwo、CPageThree。这一步和平时创建普通对话框类完全一样只需要在类向导里关联IDD然后正常给控件添加变量和处理函数。2.3 主对话框的布局方式选择CTabSheet在多数版本里内部封装了一个CTabCtrl成员使用时不要求主对话框上预先放置Tab控件而是直接动态创建。这样做的好处是整个Tab区域的位置和大小完全由代码控制避免和资源编辑器里静态摆放的Tab控件搞混。我自己在项目里用的是自定义一个控件ID来实现定位在resource.h里单独定义一个ID比如#define IDC_TAB_MAIN 1001然后在主对话框OnInitDialog里让CTabSheet以这个ID创建Tab控件。这样既不需要在对话框资源上画Tab控件又能让CTabSheet的Create参数稳定不冲突。如果确实想在资源编辑器里先放一个Tab控件占位也可以但那就需要用SubclassDlgItem的方式让CTabSheet接管这个已有控件不同版本的CTabSheet支持程度不一样反而不如动态创建干净。所以下面的例子我都按动态创建来写。3. 核心实现在VC2008中把Tab页面跑起来3.1 TabSheet.h与TabSheet.cpp关键源码解读虽然网上版本很多但核心方法大同小异。我这个项目里用的版本主要包含这几个接口// TabSheet.h #pragma once class CTabSheet { public: CTabSheet(void); virtual ~CTabSheet(void); BOOL Create(CWnd* pParentWnd, UINT nID); // 创建Tab控件 BOOL AddPage(const CString strTitle, CDialog* pDialog); // 添加页面 void SetRect(); // 调整所有子页面尺寸 void Show(); // 显示当前页隐藏其他页 int GetCurSel(); // 获取当前页索引 int SetCurSel(int nIndex); // 切换当前页 private: CTabCtrl m_tab; // Tab控件 CArrayCDialog*, CDialog* m_pages; // 页面指针数组 CArrayCString, CString m_titles; // 页签标题数组 int m_nNumOfPages; // 页面总数 int m_nCurrentPage; // 当前页面索引 };构造函数把m_nNumOfPages 0; m_nCurrentPage 0;析构函数里把所有CDialog对象delete掉并调用m_pages.RemoveAll()。这是非常典型的MFC老式写法简单直接。AddPage是整个类的关键我用的版本在添加页面时就直接把子页面创建出来了BOOL CTabSheet::AddPage(const CString strTitle, CDialog* pDialog) { if (pDialog NULL) return FALSE; m_titles.Add(strTitle); m_pages.Add(pDialog); m_nNumOfPages; if (pDialog-GetSafeHwnd() NULL) { pDialog-Create(pDialog-GetIDD(), m_tab); } CRect rc; m_tab.GetClientRect(rc); rc.top 25; // 给页签区域留出高度 rc.bottom - 2; rc.left 2; rc.right - 4; pDialog-MoveWindow(rc); pDialog-ShowWindow(SW_HIDE); return TRUE; }注意这里的rc.top 25它是给页签区域留高度。如果系统主题、字体大小不一样25像素可能不够标签高的情况下子页面会被盖住一部分。更稳妥的做法是调用m_tab.GetItemRect(0, rcItem)后用rcItem.bottom来确定标签高度不过常见工程里25px基本够用代码简单我就没改。如果你做国际化界面或者用大字体建议自己测量一下实际值再改。SetRect方法在窗口尺寸变化时调用逻辑和AddPage里的尺寸计算一样逐个MoveWindow所有子页面。Show方法则按当前索引切换显示void CTabSheet::Show() { for (int i 0; i m_nNumOfPages; i) { if (m_pages.GetAt(i)-GetSafeHwnd()) m_pages.GetAt(i)-ShowWindow(i m_nCurrentPage ? SW_SHOW : SW_HIDE); } }3.2 主对话框初始化代码有了CTabSheet和三个子页面类主对话框头文件里声明成员变量// CTestTabDlg.h #include TabSheet.h #include PageOne.h #include PageTwo.h #include PageThree.h class CTestTabDlg : public CDialogEx { public: enum { IDD IDD_TESTTAB_DIALOG }; protected: virtual BOOL OnInitDialog(); afx_msg void OnTcnSelchangeTab(NMHDR* pNMHDR, LRESULT* pResult); DECLARE_MESSAGE_MAP() private: CTabSheet m_tabSheet; CPageOne m_pageOne; CPageTwo m_pageTwo; CPageThree m_pageThree; };对应的消息映射BEGIN_MESSAGE_MAP(CTestTabDlg, CDialogEx) ON_NOTIFY(TCN_SELCHANGE, IDC_TAB_MAIN, CTestTabDlg::OnTcnSelchangeTab) END_MESSAGE_MAP()在OnInitDialog里初始化BOOL CTestTabDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 创建Tab控件IDC_TAB_MAIN是自定义ID m_tabSheet.Create(this, IDC_TAB_MAIN); // 添加三个页面 m_tabSheet.AddPage(_T(页面一), m_pageOne); m_tabSheet.AddPage(_T(页面二), m_pageTwo); m_tabSheet.AddPage(_T(页面三), m_pageThree); // 调整子页面尺寸并显示当前页 m_tabSheet.SetRect(); m_tabSheet.Show(); // 初始选中第0页 m_tabSheet.SetCurSel(0); return TRUE; }这里有一个细节需要强调AddPage里的子页面创建是发生在Create之后因为AddPage内部通过pDialog-GetSafeHwnd()判断如果还没创建就用pDialog-Create(pDialog-GetIDD(), m_tab)创建父窗口是m_tab这个Tab控件所以子页面会一直被限制在Tab控件客户区内。3.3 TCN_SELCHANGE切换消息的处理CTabSheet不是窗口类它只是普通C类所以它收不到Windows消息。用户点击页签时Tab控件会把TCN_SELCHANGE通知发送给它的父窗口也就是主对话框。因此必须在主对话框里处理这个消息void CTestTabDlg::OnTcnSelchangeTab(NMHDR* pNMHDR, LRESULT* pResult) { // 根据Tab控件的当前选中项切换显示对应子页面 m_tabSheet.SetCurSel(m_tabSheet.GetCurSel()); *pResult 0; }粗看这段代码有点绕SetCurSel(m_tabSheet.GetCurSel())好像自己调自己。实际上GetCurSel是获取Tab控件当前用户点中的页签索引SetCurSel内部会更新m_nCurrentPage并调用Show来切换页面显示。动手写的时候可以简化为m_tabSheet.SetCurSel(m_tabSheet.GetCurSel())这是最常见用法。如果某版本的CTabSheet内部已经处理了通知这段映射不写也不会报错但写了更保险能确保不同版本CTabSheet行为统一。消息路由的顺序也值得说清楚子页面CDialog收到WM_CTLCOLOR之类的绘制消息这会沿着窗口链往上走而页签点击通知是直接发给Tab控件的父窗口也就是主对话框。理解了这条链路后面排查焦点、绘制问题都会快很多。3.4 支持窗口大小变化主对话框加一个OnSize处理让Tab控件和子页面跟着缩放void CTestTabDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (m_tabSheet.GetSafeHwnd()) { CRect rcClient; GetClientRect(rcClient); m_tabSheet.MoveWindow(rcClient); m_tabSheet.SetRect(); } }CTabSheet默认没有暴露MoveWindow实际是通过内部m_tab的父窗口句柄来移动。我用的版本在Create之后可以直接通过创建时传的父窗口来调整大小。更简单的做法是在主对话框OnSize里利用GetDlgItem(IDC_TAB_MAIN)拿到Tab控件窗口句柄MoveWindow后调用m_tabSheet.SetRect()。这里的核心点是子页面永远紧贴Tab控件客户区SetRect必须被调用否则页面大小永远是初始值窗口拉大了页面不跟着变窗口缩小时下面控件就被切掉。4. 常见问题与排查技巧实录4.1 子页面显示不出来或大小不对这个问题出现的概率最高。如果AddPage后页面没显示先检查子页面对话框模板的Style是不是Child、Border是不是None。如果Style还是Popup子页面会脱离Tab控件形成一个独立窗口通常还带一个标题栏乍一看像程序崩溃弹了个多余窗口。尺寸不对的原因则集中在rc.top 25这个硬编码上。如果页签高度被系统主题撑大子页面顶部会被盖住一截如果字体很大页签上文字还可能显示不全。排查方法很简单在AddPage里让rc.top的值从25调大比如35或40重新编译看效果。更严谨的做法是用m_tab.GetItemRect(0, rcItem)拿到第一个页签的矩形然后用rcItem.bottom 2代替25。4.2 页签背景闪白、颜色不一致CTabSheet这套方案里子页面背景颜色和页签区域背景颜色经常对不齐。子对话框默认背景是系统3D face色也就是COLOR_BTNFACE页签所在区域通常也是这个颜色理论上应该一致。但有些机器装了第三方主题或者程序里给Tab控件设置了不同的背景色页签底下就会露出一条白边或者深色边。我处理这个问题的实际办法是在子页面类里重写OnCtlColor统一给对话框内的控件返回系统3D face画刷给Tab控件设置m_tab.SetBackgroundColor(::GetSysColor(COLOR_BTNFACE))让整个Tab区域背景色和子页面保持一致。这样整块界面颜色统一视觉上不会出现拼接感。如果子页面Override了背景色比如做成浅灰色甚至渐变那就要连Tab控件背景一起改否则页签区域肯定突兀。另外切换页签时如果出现闪烁多半是WM_ERASEBKGND和WM_PAINT处理顺序问题。可以试试在子页面重写OnEraseBkgnd直接返回TRUE减少背景擦除次数对老机器上的内部工具效果很明显。4.3 子页面里的控件无法点击或焦点异常CTabSheet方案里子页面的父窗口是Tab控件而Tab控件的父窗口是主对话框。消息路径多了一层偶尔会出现按钮点击无响应的情况。最常见原因是主对话框DoDataExchange里把IDC_TAB_MAIN关联成了某个CButton或CStatic的变量导致消息分发时把子控件事件错误路由了。排查方式检查主对话框头文件里的控件成员变量去掉多余关联只保留CTabSheet的封装对象。如果页签切换后焦点还留在主对话框按空格或回车可能触发主对话框默认按钮而不是子页面控件。解决办法是在SetCurSel里调用m_tab.SetFocus()或者在Show之后让当前子页面第一个控件获得焦点。CTabSheet内部如果用了TCS_FOCUSNEVER样式Tab控件自己不抢焦点这时候尤其要手动处理。还有一类低级错误是多个子页面共用了同一个资源ID结果创建时产生ID冲突。子页面资源ID必须互不相同这个纯粹是资源管理习惯问题我见过不止一次把IDD_PAGE_ONE和IDD_PAGE_TWO都填成同一个ID。4.4 VC2008编译警告与Unicode字符集问题VC2008默认新建工程很多是Unicode字符集CString默认也是宽字符。在用AddPage传字符串时必须写成_T(页面一)不能直接写页面一否则在Unicode编译环境下编译器会报cannot convert parameter 1 from const char [6] to const wchar_t []之类的错误。另一个编译坑是CTabSheet.cpp里如果没有包含stdafx.h编译会直接失败因为MFC类都没声明。网上不少版本可能没注意这个复制到工程里编译报一堆C2065错误。检查方法就是看cpp文件第一行是否#include stdafx.h没有就加上。还有VC2008对循环变量的作用域和VS2010之后不一样老标准下在for内部声明变量可能报C2065或“未声明的标识符”如果代码里用的是for(int i0; im_nNumOfPages; i)需要确认工程设置里有没有关闭“强制循环作用域”相关选项或者干脆把i在for外面声明。4.5 常见问题速查表现象可能原因处理办法子页面显示成独立窗口对话框模板Style是Popup改成Child去掉边框和标题栏子页面顶部被页签盖住rc.top硬编码25不够调大该值或用GetItemRect动态取高度页签背景和页面颜色不一双方背景色不一致统一设置背景画刷设置Tab背景色切换后控件无法点击IDC_TAB_MAIN被错误关联变量删除关联检查消息映射编译报CTabCtrl未定义缺少afxcmn.h在stdafx.h中加入该头文件编译报CArray未定义缺少afxtempl.h在stdafx.h中加入该头文件Unicode下字符串报错直接用字面量而非_T()统一使用_T(字符串)5. 一点个人心得CTabSheet这套东西技术本身不新甚至可以说有点土但它在VC2008老工程里确实管用。我维护过一个内部工具界面里原本有五个模式切换按钮加一堆CWnd手工ShowWindow的代码改造成CTabSheet之后每个模式变成一个子对话框维护成本肉眼可见地降下来了。改造过程也没动主框架基本就是新增资源类然后OnInitDialog里多几行AddPage调用。这种低成本改造正是它在老项目里一直没被淘汰的真正原因。如果你是在新项目里从零搭建我反而建议优先考虑CPropertySheet或者更现代的界面库但如果你面对的是一套跑了好多年的VC2008程序还带着一堆难以迁移的控件和逻辑那CTabSheet绝对是最务实的选择。用的时候记得把子页面尺寸计算那段封装好消息处理放在父窗口背景色统一规划基本上就能很稳地跑起来。后面如果还想扩展可以把CTabSheet再封装一层让它支持页面懒加载、记忆上次选中页、甚至在页签上增加右键菜单这些都是顺理成章的事。本文还有配套的精品资源点击获取
返回列表