COddButton






4.98/5 (26投票s)
2001年8月21日
12分钟阅读

356975

6159
如何使所有者绘制按钮处理默认状态
引言
一般来说,所有者绘制(owner-draw)按钮很酷。然而,这种酷炫是有代价的:按钮不再像普通按钮那样处理其默认状态。本文介绍了一种几乎未被文档记录的技术,以帮助所有者绘制按钮表现得与标准按钮一样。它还提供了一个新的基类,您可以从中派生您的所有者绘制按钮,以轻松启用此功能。
以下是本文的简短摘要,您可以直接跳转到感兴趣的部分:
问题所在:默认按钮
所有者绘制按钮与众不同。它们通常看起来很漂亮,是GUI设计中的顶尖艺术杰作。然而,当涉及到作为默认按钮时,它们的行为却不正确。这可能会以多种不同的方式表现出来,具体取决于特定的实现。
例如,请看下图并试着说出如果按下回车键会发生什么
正如您所见,所有者绘制按钮最危险的“特性”可能是,当它们获得焦点时,并不会成为默认按钮。这常常导致过早地“点击确定”。当用户期望使用Tab键导航时,获得焦点的按钮会处理回车键的输入,但实际上对话框的默认按钮——通常是“确定”按钮——却被按下了。如果将不同类型的按钮混合在一起,情况会变得更加复杂,因为有些按钮会正常工作,而有些则不会。
好的,让我们看另一张图(能猜到吗?)
这说明了第二个问题:多个按钮可能同时显示为默认状态,从而造成混淆,不清楚哪个按钮会处理请求。这种情况通常在通过编程方式设置默认按钮时发生。
有趣的是,位图(bitmap)或图标(icon)按钮工作正常。也就是设置了 `BS_BITMAP` 或 `BS_ICON` 样式的按钮。但 MFC 提供的 CBitmapButton 却不行。
总而言之,用户根本无法判断如果按下回车键会发生什么!
要理解问题出现的原因,让我们首先总结一下系统在处理标准按钮的默认状态时的行为:
- 用户按下 Tab 键、点击某个控件或执行某些操作,导致输入焦点发生变化
- 系统向当前拥有焦点的控件发送 `WM_GETDLGCODE` 消息
- 如果该控件声称自己是默认按钮(返回 `DLGC_DEFPUSHBUTTON`),系统会向它发送一个 `BM_SETSTYLE` 消息以移除其默认状态(`wParam == BS_PUSHBUTTON`)
- 然后系统向接收焦点的控件发送 `WM_GETDLGCODE` 消息
- 如果该控件声称自己是非默认按钮(返回 `DLGC_UNDEFPUSHBUTTON`),系统会向它发送一个 `BM_SETSTYLE` 消息以设置其默认状态(`wParam == BS_DEFPUSHBUTTON`)
- 系统向当前拥有焦点的控件发送 `WM_KILLFOCUS` 消息
- 系统向接收焦点的控件发送 `WM_SETFOCUS` 消息
- 如果之前的任何 `BM_SETSTYLE` 消息涉及到重绘(`lParam != 0`),系统会发布 `WM_PAINT` 消息
- 消息不一定按此顺序发送,并且 `WM_GETDLGCODE` 可能会被发送多次。
了解了这些之后,要实现一个行为良好的所有者绘制按钮需要做些什么呢?如下文所述,我们需要做的正是系统对标准按钮所做的事情,不同之处在于,标准按钮可以将默认状态标志存储在按钮的样式中,而我们必须提供自己的存储空间。
问题在于,所有者绘制按钮对于 `WM_GETDLGCODE` 消息返回 `DLGC_BUTTON`,这告诉系统它们不想干预默认状态。另一方面,如果您除了 `DLGC_BUTTON` 之外还返回 `DLGC_DEFPUSHBUTTON` 或 `DLGC_UNDEFPUSHBUTTON`,您将会收到 `BM_SETSTYLE` 消息,并失去所有者绘制的样式。
`BS_OWNERDRAW` 样式会丢失,因为它与 `BS_PUSHBUTTON`、`BS_DEFPUSHBUTTON` 以及所有其他指定不同类型按钮控件(单选按钮、分组框等)的样式是互斥的。因此,看起来所有者绘制样式与默认状态无法兼容。
更重要的是,要正确响应 `WM_GETDLGCODE` 消息,您必须知道您的按钮是否是默认按钮。标准按钮通过查看当前窗口的样式来完成此任务,但所有者绘制按钮无法使用相同的方法。
您可能会想到向父窗口(通常是一个对话框)发送 `DM_GETDEFID` 消息,然后将结果与按钮的ID进行比较,但这种方法行不通。对话框的默认按钮与当前的默认按钮不同,后者会随着焦点的变化而改变。
因此,总而言之,您必须将默认状态保存在一个内部变量中,因为窗口样式中没有空间存放它。您应该使用该变量来决定如何响应 `WM_GETDLGCODE` 消息,以及何时需要在按钮周围绘制默认状态边框。当您收到 `BM_SETSTYLE` 消息时,您更新该变量并在需要时使控件无效。
解决方案:一个支持默认状态的所有者绘制按钮
`COddButton` 应运而生,这是一个用于所有者绘制按钮的新基类,它为处理默认状态提供了基本支持。这件事从一开始就应该很简单,而我们最终也让它变得简单了。这个按钮是您见过的最奇特的所有者绘制按钮,因为……它没有绘图代码!它应该被用作所有者绘制按钮的基类,以替代 `CButton`。
它所拥有的是为其他所有者绘制按钮提供支持的代码。所有复杂的细节都隐藏在类的内部,因此从 `COddButton` 派生的按钮能够自行正确地获得默认状态。在派生类中剩下要做的,就是在适当的时候指示出默认状态。这可以通过简单调用 `COddButton::IsDefault()` 来确定默认状态,并在按钮成为默认按钮时通常在其周围绘制黑色边框来完成。
COddButton::IsDefault
BOOL IsDefault()
- 您应该使用此函数来了解您的按钮何时处于默认状态,以便相应地重绘您的控件。默认按钮返回 `TRUE`,否则返回 `FALSE`。
大多数情况下,`COddButton::IsDefault()` 就足以让一切正常工作。不过,还有一些辅助方法可以提供更大的灵活性和控制权,如下所述。
COddButton::EnableDefault
BOOL EnableDefault(BOOL bEnable)
- 此函数可用于确定控件是否支持/需要默认状态。如果参数值为 `TRUE`,则控件被启用以接收默认状态;如果为 `FALSE`,则禁用此处理,按钮将表现得像一个未经修复的所有者绘制按钮。
COddButton::GetControlType
UINT GetControlType()
- 您应该使用此函数来替代 `GetStyle()` 或 `GetButtonStyle()`,以了解在您的 `DrawItem()` 重写中应绘制哪种类型的控件。该类在 `PreSubclassWindow()` 中设置所有者绘制样式之前,会存储按钮的初始样式。
- 返回值是指定各种控件类型的按钮样式之一,但不包括 `BS_DEFPUSHBUTTON`(它被映射到 `BS_PUSHBUTTON`)以及显然的 `BS_OWNERDRAW`。
使用该类
使用该类非常直接。只需使用 `COddButton` 代替 `CButton` 作为您的所有者绘制按钮的基类即可。
#include "OddButton.h" class CMyOwnerDrawButton : public COddButton { // ... }
在资源编辑器中,您不应勾选“Owner draw”(所有者绘制)复选框,该样式将在运行时被正确设置。控件的类型在创建后不应更改,而其他样式位可以更改。
接下来要做的是在适当时处理默认状态的绘制。这可以在 `CButton::DrawItem()` 的重写中简单完成,使用 `COddButton::IsDefault()` 调用来判断默认标志的状态,并绘制一条细黑线作为指示。例如,如果按钮是矩形的,代码大致如下:
void CMyOwnerDrawButton::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { const BOOL bDefault = IsDefault(); if( bDefault ) { //deflate rectangleCRect oRect(lpDrawItemStruct->rcItem); oRect.DeflateRect(1, 1); lpDrawItemStruct->rcItem = oRect; } /*your drawing code goes here*/ if( bDefault ) { //inflate rectangleCRect oRect(lpDrawItemStruct->rcItem); oRect.InflateRect(1, 1); lpDrawItemStruct->rcItem = oRect; //draw the default frame CDC* pDC = CDC::FromHandle(lpDrawItemStruct->hDC); CPen *pOldPen = (CPen*)pDC->SelectStockObject(BLACK_PEN); CBrush *pOldBrush= (CBrush*)pDC->SelectStockObject(NULL_BRUSH); pDC->Rectangle(lpDrawItemStruct->rcItem); pDC->SelectObject( pOldPen ); pDC->SelectObject( pOldBrush ); } }
此外,所有关键设置都通过 `ASSERT` 进行了验证,因此至少在调试版本中,如果出现问题您会知道。
来龙去脉:一切是如何开始的
(本章由 Paolo Messina 撰写)我经常使用 WinCVS 来维护重要的项目和我所有的 CodeProject 文章,并且我发现 CvsIn 插件对此非常有用,特别是它的向导对话框(也因为它的大小可调)。
像每个优秀的程序员一样,我到处点击那些漂亮的按钮,并摆弄那些遮挡我小桌面的所有窗口,我发现有些按钮没有正确重绘。仔细一看,实际上有两个默认按钮。“这不可能!”是我的第一反应,为了证明这一点,我将对话框部分移出屏幕,然后再移回来,看看哪个按钮周围仍然有默认边框。
我猜对了,同一时间只能有一个按钮拥有默认状态,所以下一个浮现在我脑海中的问题是:“发生了什么?”。
“它们是所有者绘制按钮,所有这类按钮在默认状态方面都有同样的问题”。这是 Jerzy Kaczorowski(CvsIn 的创建者)的回答,这可能也是你们很多人听到过或会给出的答案。但在放弃并接受多个看起来像默认按钮的想法之前,我必须试一试。
我有 CvsIn 的源代码,有假期的空闲时间,还有一点点被高估的自信。更不用说 Jerzy 的鼓励话语、我的 VC++ 编译器和不可或缺的 Spy++。
MSDN 帮助不大,它只是模糊地解释了标准按钮如何接收默认状态以及在此过程中涉及的消息,但这足以让我开始使用 Spy++,以过滤掉不需要的消息。
监视窗口
通过 Spy++,我注意到所有者绘制按钮对 `WM_GETDLGCODE` 消息的响应与标准按钮不同,所以我认为也许返回相同的代码可以解决问题。然后我快速建立了一个基于 MFC 对话框的项目和一个 `CButton` 派生类,用于进行所有者绘制的实验。
第一次尝试是响应 `WM_GETDLGCODE` 消息,以模仿标准按钮的行为,标准按钮会返回 `DLGC_BUTTON` 与 `DLGC_DEFPUSHBUTTON` 或 `DLGC_UNDEFPUSHBUTTON` 的组合,具体取决于它是否具有默认状态。
不幸的是,我当时有个坏主意,把我的所有者绘制按钮画得像一个标准按钮,没有任何特殊的区分标志,而现在这个按钮在它应该有的时候,有了一个漂亮的黑色边框。“这也太容易了!”我惊呼道,脸上泛起一丝微笑。但我错得不能再离谱了。
事实上,正如 MSDN 在 `DM_SETDEFID` 消息的说明中简要提到的,当系统更改默认按钮时,还会向按钮发送另一个消息,那就是 `BM_SETSTYLE`。系统更改了我的所有者绘制按钮的样式,将其恢复为标准按钮,而由于它们外观相似,我被误导了。
那么,我该如何告诉系统我希望我的按钮拥有默认状态,同时又不会丢失所有者绘制样式呢?答案很简单,你可能会说,但不是太简单,因此我们有了这篇文章……
演示:它能行!
说了这么多,是时候看看 OddButton 的实际效果了。我们提供了一个示例应用程序,它演示了 `COddButton` 的用法,以及所有者绘制按钮本身存在的问题。
如您所见,演示的“测试区域”部分有三个按钮:
-
已修复的所有者绘制按钮(派生自 `COddButton`)
-
简单的所有者绘制按钮(派生自 `CButton`)
-
标准按钮
有一个编辑框,允许您模拟一个真实场景:用户正在输入数据并按回车键来处理输入。最初,默认按钮被设置为已修复的所有者绘制按钮,但您可以通过使用Tab键和“默认按钮”部分的控件来更改它。如果按下任何按钮,会弹出一个消息框告诉您发生了什么。请尝试不同的焦点和默认按钮组合,看看它是否总是符合您的预期,以及绘制是否正确。
除了基本的测试区域,还有一个微型的Spy工具,它会向您显示对话框中发送和接收消息的详细日志,以便您分析正在发生的事情。该对话框的大小可以调整,您可以扩展它以便为消息细节留出更多空间。
意外的结局:麻烦就此结束了吗?
嗯,差不多…… ;)
从所有者绘制按钮的角度来看,这已经是能做到的最好了。然而,我们越深入研究这个问题,就发现了越多奇怪的事情。
原来,在使用 `CDialog::SetDefID` 方法为对话框设置默认按钮时,即使是普通的(!)按钮也会出现一些问题。正如 MSDN 在描述 `DM_SETDEFID` 消息(`CDialog::SetDefID` 正是使用它来完成工作的)时所述,发送该消息后,可能会有多个按钮指示为默认状态。MSDN 还简要建议,在这种情况下,应用程序应该自行发送 `BM_SETSTYLE` 消息,以确保指示是准确的。
那时我们曾考虑就这个主题单独写一篇全新的文章。但进一步的搜索发现了一篇微软知识库文章 - Q67655 (HOWTO: Change or Set the Default Push Button in a Dialog Box),其中包含一段伪代码,演示了上述技术。不过,它提出的解决方案有其缺点:
- 如果代码在按钮点击处理程序内部执行,它将无法工作
- 它会破坏所有者绘制按钮,并将它们变成普通按钮
我们找到了克服(或者说是绕过)这些问题的方法,并且由于代码变得有些复杂,我们将其封装起来,并作为 `COddButton` 类的一个静态方法提供。
COddButton::SetDefID
static void SetDefID(CDialog* pDialog, const UINT nID)
- 您应该使用此函数来为您的对话框设置新的默认按钮,以替代 `CDialog::SetDefID`。
许可证:开源(当然)
`COddButton` 和演示应用程序是根据 Artistic License 的条款分发的。
该项目使用 CVS 进行开发,其代码仓库托管在 Source Forge。
要获取最新的开发代码(可能编译也可能不编译),请使用CVS在命令行提示符中输入以下行(密码提示时按回车)
cvs -d:pserver:anonymous@cvs.oddbutton.sourceforge.net:/cvsroot/oddbutton login
下一步是检出模块。要获取演示源代码,请使用模块 OddButtonDemoSrc —— 只需输入以下行(代码将被放入 ButtonsDemo 目录)
cvs -z3 -d:pserver:anonymous@cvs.oddbutton.sourceforge.net:/cvsroot/oddbutton co OddButtonDemoSrc
若只想获取 OddButton 文件,请使用模块 OddButtonSrc,输入以下行(文件将在 OddButtonSrc 目录中)
cvs -z3 -d:pserver:anonymous@cvs.oddbutton.sourceforge.net:/cvsroot/oddbutton co OddButtonSrc
就是这样!
历史
- 2001年10月27日 - 增加了为对话框设置默认按钮的功能,更新了源代码和演示。
- 2001年8月28日 - 更新了源代码和演示。
- 2001年9月5日 - 更新了源代码和演示。