分类: C/C++
2008-03-18 17:00:11
甚至在成为Windows 95的通用控件之前,使用属性表来表示一些选项就已经成为一种很流行的方式。向导模式的属性表通常用来引导用户安装软件或完成其他复杂的工作。WTL对这两种方式的属性表都提供了很好的支持,可以使用前面介绍的与对话框相关的特性,如DDX和DDV。在本章我将演示如何创建一个基本的属性表和向导,如何处理属性页发送的通知消息和事件。
WTL 的属性表类实现一个属性表需要CPropertySheetWindow和CPropertySheetImpl两个类联合使用,它们都定义在atldlgs.h头文件中。CPropertySheetWindow类是一个窗口接口类(也就是说是一个CWindow派生类),CPropertySheetImpl有消息映射链和窗口的完整实现,这和ATL的基本窗口类相似,它需要CWindow和CWindowImpl两个类联合使用。
CPropertySheetWindow类封装了对各种PSM_* 消息的处理,例如,SetActivePageByID()封装了PSM_SETCURSELID消息。CPropertySheetImpl类管理一个PROPSHEETHEADER结构和一个HPROPSHEETPAGE类型的数组,CPropertySheetImpl类还提供了
一些方法用来填充PROPSHEETHEADER结构,添加或删除属性页,你也可以使用m_psh成员变量直接操作PROPSHEETHEADER结构。
最后,CPropertySheet类是CPropertySheetImpl类的一个特例,你可以直接使用它而不需要定制整个属性表。
CPropertySheetImpl 的方法
下面是CPropertySheetImpl类的一些重要方法。由于许多方法仅仅是对窗口消息的封装,所以就不在这里列出,你可以查看atldlgs.h中完整的函数清单。
CPropertySheetImpl(_U_STRINGorID title = (LPCTSTR) NULL, UINT uStartPage = 0, HWND hWndParent = NULL)
CPropertySheetImpl类的构造函数允许你使用一些常用的属性(默认值),所以就不需要在调用其他的方法设置它们。title指定显示在属性表的标题栏的文字,_U_STRINGorID是一个WTL的工具类,它可以自动转换LPCTSTR和资源ID,例如,下面的两行代码都是正确的:
CPropertySheetImpl mySheet ( IDS_SHEET_TITLE ); CPropertySheetImpl mySheet ( _T("My prop sheet") );
IDS_SHEET_TITLE 是字符串的ID。 uStartPage 是属性表启动时激活的属性页,是一个从0开始的索引。hWndParent 是属性表的父窗口的句柄。
BOOL AddPage(HPROPSHEETPAGE hPage)
BOOL AddPage(LPCPROPSHEETPAGE pPage)
添加一个属性页。如果这个属性页已经创建了,你可以使用第一个重载函数,使用属性页的句柄(HPROPSHEETPAGE)作为参数。通常是使用第二个重载函数,使用这个重载函数只需设置一个PROPSHEETPAGE数据结构(后面会讲到,它和CPropertyPageImpl一起协同工作),CPropertySheetImpl会为你创建并管理这个属性页。
BOOL RemovePage(HPROPSHEETPAGE hPage)
BOOL RemovePage(int nPageIndex)
移除一个属性页,可以使用属性页的句柄或索引。
BOOL SetActivePage(HPROPSHEETPAGE hPage)
BOOL SetActivePage(int nPageIndex)
设置属性表的活动页面。可以使用属性页的句柄或索引。你可以在属性表创建(显示)之前使用这个方法动态的设置处于激活的属性页。
void SetTitle(LPCTSTR lpszText, UINT nStyle = 0)
使之属性表窗口的标题文字。nStyle可以是0或PSH_PROPTITLE,如果是PSH_PROPTITLE,则属性表就具有PSH_PROPTITLE样式,这样系统会在你通过lpszText参数指定的窗口标题前添加字符串“Properties for”。
void SetWizardMode()
设置PSH_WIZARD样式,将属性表改称向导模式,这个函数必须在属性表显示之前调用。
void EnableHelp()
设置PSH_HASHELP样式,将在属性表中添加帮助按钮。需要注意的是你还要在每个属性页中使帮助按钮可用并提供帮助才能使之生效。
INT_PTR DoModal(HWND hWndParent = ::GetActiveWindow())
创建并显示一个模式的属性表,返回正值表示操作成功,有关PropertySheet() API的帮助文档有有关返回值的详细解释,如果发生错误,属性表无法创建,DoModal()返回-1。
HWND Create(HWND hWndParent = NULL)
创建并显示一个无模式的属性表,返回值是窗口的句柄,如果发生错误,属性表无法创建,Create()返回NULL。
WTL 的属性页类WTL对属性页的封装类与属性表的封装类相似,有一个窗口接口类 CPropertyPageWindow 和一个实现类 CPropertyPageImpl 。CPropertyPageWindow 很小,包含最常用的需要在作为父窗口的属性表中调用的方法。
CPropertyPageImpl 是从 CDialogImplBaseT派生,由于属性页是从对话框资源中创建的,这就意味着所有可以在对话框中使用的WTL的特性都可以在属性页中使用,如DDX和DDV。CPropertyPageImpl 有两个主要作用:管理一个PROPSHEETPAGE数据结构(保存在成员变量m_psp中),处理所有PSN_开头的通知消息。对于很简单的属性页可以直接使用CPropertyPage类,这个类只适合与用户没有任何交互的属性页,例如“关于”页面或者向导中的介绍页面
也可以创建含有ActiveX控件的属性页。首先,这需要在stdafx.h文件中添加对atlhost.h的包含,还要使用CAxPropertyPageImpl代替CPropertyPageImpl。对于简单的页面可以使用CAxPropertyPage代替CPropertyPage。
CPropertyPageImpl 的方法CPropertyPageImpl 管理着一个 PROPSHEETPAGE 结构,也就是公有成员 m_psp。CPropertyPageImpl还重载了PROPSHEETPAGE*操作符,所以你可以将CPropertyPageImpl传递给需要LPPROPSHEETPAGE 或 LPCPROPSHEETPAGE 类型的参数的方法,例如CPropertySheetImpl::AddPage()。
CPropertyPageImpl的构造函数允许你设置页面的标题,标题通常显示在页面的Tab标签上:
CPropertyPageImpl(_U_STRINGorID title = (LPCTSTR) NULL)
如果你不想让属性表创建属性页面而是想手工创建页面,你可以调用Create():
HPROPSHEETPAGE Create()
Create() 只是调用用m_psp做参数调用了 CreatePropertySheetPage() 。如果你向一个已经创建的属性表添加属性页或者向另一个不在控制的属性表添加属性页(例如,处理系统Shell扩展的属性表),那就只需要调用Create()函数。
下面的三个方法用于设置属性页的各种标题文本:
void SetTitle(_U_STRINGorID title)
void SetHeaderTitle(LPCTSTR lpstrHeaderTitle)
void SetHeaderSubTitle(LPCTSTR lpstrHeaderSubTitle)
第一个方法改变页面标签的文字,另外几个用来设置Wizard97样式的向导中属性页顶部的文字。
void EnableHelp()
设置m_psp中的PSP_HASHELP标志,当本页面激活时使属性表的帮助按钮可用。
处理通知消息CPropertyPageImpl有一个消息映射处理WM_NOTIFY。如果通知代码是PSN_*的值,OnNotify()就会调用相应的通知处理函数。这使用了编译阶段虚函数机制,从而使得派生类可以很容易的重载这些处理函数。
由于WTL 3和WTL 7设计的改变,从而存在两套不同的通知处理机制。在WTL 3中通知处理函数返回的值与PSN_*消息的返回值不同,例如,WTL 3是这样处理PSN_WIZFINISH的:
case PSN_WIZFINISH:
lResult = !pT->OnWizardFinish();
break;
OnWizardFinish()期望返回TRUE结束向导,FALSE阻止关闭向导。这个方法很简陋,但是IE5的通用控件对PSN_WIZFINISH处理的返回值添加了新解释,他返回需要获得焦点的窗口的句柄。WTL 3的程序将不能使用这个特性,因为它对所有非0的返回值都做相同的处理。
在WTL 7中,OnNotify() 没有改变 PSN_* 消息的返回值,处理函数返回任何文档中规定的合法数值和正确的行为。当然,为了向前兼容,WTL 3 仍然使用当前默认的工作方式,要使用WTL 7的消息处理方式,你必须在中including atldlgs.h一行之前添加一行定义:
#define _WTL_NEW_PAGE_NOTIFY_HANDLERS
编写新的代码没有理由不使用WTL 7的消息处理函数,所以这里就不介绍WTL 3的消息处理方式。
CPropertyPageImpl 为所有消息提供了默认的通知消息处理函数,你可以重载与你的程序有关的消息处理函数完成特殊的操作。默认的消息处理函数和相应的行为如下:
创建一个属性表int OnSetActive() - 允许页面成为激活状态
BOOL OnKillActive() - 允许页面成为非激活状态
int OnApply() - 返回 PSNRET_NOERROR 表示应用操作成功完成
void OnReset() - 无相应的动作
BOOL OnQueryCancel() - 允许取消操作
int OnWizardBack() - 返回到前一个页面
int OnWizardNext() - 进行到下一个页面
INT_PTR OnWizardFinish() - 允许向导结束
void OnHelp() - 无相应的动作
BOOL OnGetObject(LPNMOBJECTNOTIFY lpObjectNotify) - 无相应的动作
int OnTranslateAccelerator(LPMSG lpMsg) - 返回 PSNRET_NOERROR 表示消息没有被处理
HWND OnQueryInitialFocus(HWND hWndFocus) - 返回 NULL 表示将按Tab Order顺序的第一个控件设为焦点状态
关于这些类的解释就全部讲完了,现在需要一个例子程序演示如何使用它们。本章的例子工程是一个简单的SDI程序,它在客户区显示一幅图片并使用一总颜色填充背景,使用的图片和颜色可以通过一个选项对话框(一个属性表)来设置,还有一个向导(稍后会介绍)。
最简单的属性表首先用WTL的向导创建一个SDI工程,然后为关于对话框添加一个属性表。首先改变向导创建的关于对话框样式,使它用起来像个属性页。
第一步就是去除OK按钮,因为属性表不希望属性页自己关闭。在Style Tab中,将对话框样式改为Child,Thin Border,选择Title Bar,在More Styles tab,选择Disabled。
第二步(也是最后一步)是在OnAppAbout()的处理函数中创建一个属性表,我们使用非定制的CPropertySheet 和 CPropertyPage类:
LRESULT CMainFrame::OnAppAbout(...) { CPropertySheet sheet ( _T("About PSheets") ); CPropertyPagepgAbout; sheet.AddPage ( pgAbout ); sheet.DoModal(); return 0; }
结果看起来向下面这样:
创建一个有用的属性页并不是每一个属性表中的每一个属性页都像关于对话框这么简单,大多数属性页需要使用CPropertyPageImpl的派生类,所以我们现在就看一个这样的类。我们创建了一个新的属性页用来设置客户区背景显示的图片,它是这个样子的:
这个对话框的样式和关于页面相同,我们需要一个新类来和这个属性页协同工作,我们将其命名为CBackgroundOptsPage。这个类是从CPropertyPageImpl类派生的,它有一个CWinDataExchange来支持DDX。
class CBackgroundOptsPage : public CPropertyPageImpl, public CWinDataExchange { public: enum { IDD = IDD_BACKGROUND_OPTS }; // Construction CBackgroundOptsPage(); ~CBackgroundOptsPage(); // Maps BEGIN_MSG_MAP(CBackgroundOptsPage) MSG_WM_INITDIALOG(OnInitDialog) CHAIN_MSG_MAP(CPropertyPageImpl ) END_MSG_MAP() BEGIN_DDX_MAP(CBackgroundOptsPage) DDX_RADIO(IDC_BLUE, m_nColor) DDX_RADIO(IDC_ALYSON, m_nPicture) END_DDX_MAP() // Message handlers BOOL OnInitDialog ( HWND hwndFocus, LPARAM lParam ); // Property page notification handlers int OnApply(); // DDX variables int m_nColor, m_nPicture; };
关于这个类需要注意几点:
OnApply() 非常简单,它调用 DoDataExchange() 更新 DDX 变量,然后返回一个代码标识是否可以关闭这个属性表:
int CBackgroundOptsPage::OnApply() { return DoDataExchange(true) ? PSNRET_NOERROR : PSNRET_INVALID; }
我们还要在主窗口添加一个Tools|Options菜单来打开属性表,这个菜单的处理函数创建一个属性表,但是添加了一个新属性页CBackgroundOptsPage。
void CMainFrame::OnOptions ( UINT uCode, int nID, HWND hwndCtrl ) { CPropertySheet sheet ( _T("PSheets Options"), 0 ); CBackgroundOptsPage pgBackground; CPropertyPagepgAbout; pgBackground.m_nColor = m_view.m_nColor; pgBackground.m_nPicture = m_view.m_nPicture; sheet.m_psh.dwFlags |= PSH_NOAPPLYNOW; sheet.AddPage ( pgBackground ); sheet.AddPage ( pgAbout ); if ( IDOK == sheet.DoModal() ) m_view.SetBackgroundOptions ( pgBackground.m_nColor, pgBackground.m_nPicture ); }
属性表的构造函数的第二个参数是0,表示将索引是0的页面初始是可见的,你可以将其设为1,使得属性表第一次显示时显示关于页面。既然是演示代码,我就偷个懒,使用一个公有变量与CBackgroundOptsPage属性页的radio button建立关联,在主窗口中直接为其赋初始值,当用户单击属性表的OK按钮时在将其读出来。
如果用户点击OK按钮,DoModal()发挥IDOK,我们通知视图窗口使用新的图片和背景颜色。下面是几个屏幕截图显示几个不同的样式的视图:
创建一个更好的属性表类
在OnOptions()中创建属性表是个好主意,但是在这里使用很多初始化代码却非常糟糕,这不是CMainFrame应该做得事情。更好的方法是从CPropertySheetImpl派生一个新类,在这个类中完成这些任务。
#include "BackgroundOptsPage.h" class CAppPropertySheet : public CPropertySheetImpl{ public: // Construction CAppPropertySheet ( _U_STRINGorID title = (LPCTSTR) NULL, UINT uStartPage = 0, HWND hWndParent = NULL ); // Maps BEGIN_MSG_MAP(CAppPropertySheet) CHAIN_MSG_MAP(CPropertySheetImpl ) END_MSG_MAP() // Property pages CBackgroundOptsPage m_pgBackground; CPropertyPage m_pgAbout; };
我们使用这个类封装属性表中各个属性页的细节,将初始化代码移到属性表内部完成,构造函数完成添加页面,并设置其他必需的标志:
CAppPropertySheet::CAppPropertySheet ( _U_STRINGorID title, UINT uStartPage, HWND hWndParent ) : CPropertySheetImpl( title, uStartPage, hWndParent ) { m_psh.dwFlags |= PSH_NOAPPLYNOW; AddPage ( m_pgBackground ); AddPage ( m_pgAbout ); }
这样一来,OnOptions()处理函数就变得简单了一些:
void CMainFrame::OnOptions ( UINT uCode, int nID, HWND hwndCtrl ) { CAppPropertySheet sheet ( _T("PSheets Options"), 0 ); sheet.m_pgBackground.m_nColor = m_view.m_nColor; sheet.m_pgBackground.m_nPicture = m_view.m_nPicture; if ( IDOK == sheet.DoModal() ) m_view.SetBackgroundOptions ( sheet.m_pgBackground.m_nColor, sheet.m_pgBackground.m_nPicture ); }创建一个向导样式的属性表
创建一个向导和创建一个属性表很相似,这并不奇怪,只需稍做修改添加“上一步”和“下一步”按钮就行了。和MFC一样,你需要重载OnSetActive()函数并调用SetWizardButtons()使相应的按钮可用。我们先从一个简单的介绍页面开始,它的ID是IDD_WIZARD_INTRO:
注意这个页面没有标题栏文字,因为向导中的所有的页面通常都有相同的标题,我更愿意在CPropertySheetImpl的构造函数中设置这些文字,然后每个页面使用这个字符串资源。这就是为什么我只需要改变一个字符串就能改变所有页面标题文字的原因。
关于这个页面的实现代码在CWizIntroPage类中:
class CWizIntroPage : public CPropertyPageImpl{ public: enum { IDD = IDD_WIZARD_INTRO }; // Construction CWizIntroPage(); // Maps BEGIN_MSG_MAP(COptionsWizard) CHAIN_MSG_MAP(CPropertyPageImpl ) END_MSG_MAP() // Notification handlers int OnSetActive(); };
构造函数使用(引用)一个字符串资源ID来设置页面的文字:
CWizIntroPage::CWizIntroPage() : CPropertyPageImpl( IDS_WIZARD_TITLE ) { }
当这个页面激活时,字符串IDS_WIZARD_TITLE ("PSheets Options Wizard")将出现在向导的标题栏。OnSetActive()仅仅使“下一步”按钮可用:
int CWizIntroPage::OnSetActive() { SetWizardButtons ( PSWIZB_NEXT ); return 0; }
为了实现一个向导,我们需要创建一个类COptionsWizard,还要在主窗口添加菜单Tools|Wizard。COptionsWizard类的构造函数和CAppPropertySheet类的构造函数一样,只是设置必要的样式标志和添加页面。
class COptionsWizard : public CPropertySheetImpl{ public: // Construction COptionsWizard ( HWND hWndParent = NULL ); // Maps BEGIN_MSG_MAP(COptionsWizard) CHAIN_MSG_MAP(CPropertySheetImpl ) END_MSG_MAP() // Property pages CWizIntroPage m_pgIntro; }; COptionsWizard::COptionsWizard ( HWND hWndParent ) : CPropertySheetImpl ( 0U, 0, hWndParent ) { SetWizardMode(); AddPage ( m_pgIntro ); }
CMainFrame类的Tools|Wizard菜单处理函数是这个样子:
void CMainFrame::OnOptionsWizard ( UINT uCode, int nID, HWND hwndCtrl ) { COptionsWizard wizard; wizard.DoModal(); }
这就是向导的效果:
添加更多的属性页,使用DDV为了使这个向导能够有点用处,我们要为其添加一个设置视图背景颜色的页面。这个页面还将有一个checkbox演示如何处理DDV验证失败并阻止向导进行到下一页。下面就是新的页面,ID是IDD_WIZARD_BKCOLOR:
这个类的实现代码在CWizBkColorPage类中,下面是相关的部分代码
class CWizBkColorPage : public CPropertyPageImpl, public CWinDataExchange { public: // some stuff removed for brevity... BEGIN_DDX_MAP(CWizBkColorPage) DDX_RADIO(IDC_BLUE, m_nColor) DDX_CHECK(IDC_FAIL_DDV, m_bFailDDV) END_DDX_MAP() // Notification handlers int OnSetActive(); BOOL OnKillActive(); // DDX vars int m_nColor; protected: int m_bFailDDV; };
OnSetActive()的工作和前面的介绍页面相同,它使“上一步”和“下一步”按钮可用。OnKillActive()是个新的处理函数,它触发DDV,然后检查m_bFailDDV的值,如果是TRUE就表示checkbox处于选中状态,OnKillActive()将阻止向导进行到下一页。
int CWizBkColorPage::OnSetActive() { SetWizardButtons ( PSWIZB_BACK | PSWIZB_NEXT ); return 0; } int CWizBkColorPage::OnKillActive() { if ( !DoDataExchange(true) ) return TRUE; // prevent deactivation if ( m_bFailDDV ) { MessageBox ( _T("Error box checked, wizard will stay on this page."), _T("PSheets"), MB_ICONERROR ); return TRUE; // prevent deactivation } return FALSE; // allow deactivation }
需要注意的是OnKillActive()中做的事情也可以在OnWizardNext()中完成,因为这两个处理函数都可以使向导维持在当前页面。它们的不同之处在于OnKillActive()在用户单击“上一步”和“下一步”按钮时被调用,而OnWizardNext()只是在用户单击“下一步”按钮时被调用。OnWizardNext()还被用来完成其它目的,比如,它可以直接将向导引导到指定的页面而不是按顺序的下一页。
例子工程的向导还有另外两个页面,CWizBkPicturePage 和 CWizFinishPage,由于它们和前面的两个页面相似,我就不再详细介绍它们,想了解它们的细节可以查看源代码。
其他的界面考虑 置中一个属性表属性页和向导的默认位置是出现在父窗口的左上角:
这看起来有点不爽,还好有方法可以补救。第一种方法是重载CPropertySheetImpl::PropSheetCallback()函数,在这个函数中将属性表置中。PropSheetCallback()是MSDN中介绍的PropSheetProc()的回调函数,操作系统在属性表创建时调用这个函数,WTL也是利用这个时间子类化属性表窗口的。所以我们的第一种尝试是:
class CAppPropertySheet : public CPropertySheetImpl{ //... static int CALLBACK PropSheetCallback(HWND hWnd, UINT uMsg, LPARAM lParam) { int nRet = CPropertySheetImpl ::PropSheetCallback ( hWnd, uMsg, lParam ); if ( PSCB_INITIALIZED == uMsg ) { // center sheet... somehow? } return nRet; } };
正如你看到的,我们遇到了棘手的问题。PropSheetCallback()是一个静态方法,不能使用this指针访问属性表窗口。那将这些代码从CPropertySheetImpl::PropSheetCallback()中拷贝出来,然后添加我们自己的方法行不行呢?撇开刚才将代码和特定版本的WTL联系在一起的方法(这已经被证明不是各好方法),现在代码应该是这样的:
class CAppPropertySheet : public CPropertySheetImpl{ //... static int CALLBACK PropSheetCallback(HWND hWnd, UINT uMsg, LPARAM) { if(uMsg == PSCB_INITIALIZED) { // Code copied from WTL and tweaked to use CAppPropertySheet // instead of T: ATLASSERT(hWnd != NULL); CAppPropertySheet* pT = (CAppPropertySheet*) _Module.ExtractCreateWndData(); // subclass the sheet window pT->SubclassWindow(hWnd); // remove page handles array pT->_CleanUpPages(); // Our own code follows: pT->CenterWindow ( pT->m_psh.hwndParent ); } return 0; } };
这从理论上讲很完美,但是我试过,属性表的位置并未改变。显然,通用控件的代码在我们调用CenterWindow()之后又改变了属性表窗口的位置。
必须放弃这个将代码封装到属性表类的方法,尽管它是个好的解决方案。我又回到原来的方案,即使用属性页窗口和属性表窗口相互协作是属性表窗口置中。我添加了一个用户定义消息UWM_CENTER_SHEET:
#define UWM_CENTER_SHEET WM_APP
CAppPropertySheet 在它的消息映射链中处理这个消息:
class CAppPropertySheet : public CPropertySheetImpl{ //... BEGIN_MSG_MAP(CAppPropertySheet) MESSAGE_HANDLER_EX(UWM_CENTER_SHEET, OnPageInit) CHAIN_MSG_MAP(CPropertySheetImpl ) END_MSG_MAP() // Message handlers LRESULT OnPageInit ( UINT, WPARAM, LPARAM ); protected: bool m_bCentered; // set to false in the ctor }; LRESULT CAppPropertySheet::OnPageInit ( UINT, WPARAM, LPARAM ) { if ( !m_bCentered ) { m_bCentered = true; CenterWindow ( m_psh.hwndParent ); } return 0; }
然后,每个属性页的OnInitDialog() 方法发送这个消息到属性表窗口:
BOOL CBackgroundOptsPage::OnInitDialog ( HWND hwndFocus, LPARAM lParam ) { GetPropertySheet().SendMessage ( UWM_CENTER_SHEET ); DoDataExchange(false); return TRUE; }
添加m_bCentered标志确保属性表窗口只响应收到的第一个UWM_CENTER_SHEET消息。
在属性页中添加图标如果要使用属性表和属性页的未被成员函数封装的特性,就需要直接访问相关的数据结构:CPropertySheetImpl类中的PROPSHEETHEADER类型(结构)成员m_psh和CPropertyPageImpl类中的PROPSHEETPAGE类型(结构)成员m_psp。
例如:为例子中Option属性表中的Background页面添加一个图标,就需要添加一个标志并设置属性页的PROPSHEETPAGE结构中的几个成员:
CBackgroundOptsPage::CBackgroundOptsPage() { m_psp.dwFlags |= PSP_USEICONID; m_psp.pszIcon = MAKEINTRESOURCE(IDI_TABICON); m_psp.hInstance = _Module.GetResourceInstance(); }
下面是这些代码的效果:
继续我将在第九章介绍WTL的一些工具类,还有GDI对象和通用对话框的包装类。
修改记录September 13, 2003: 文章第一次发布。