65.9K
CodeProject 正在变化。 阅读更多。
Home

Win32 API 中的自定义控件:标准消息

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (37投票s)

2013年7月30日

CPOL

23分钟阅读

viewsIcon

71934

downloadIcon

2404

让你的控件响应系统或应用程序可能提出的问题。

本系列文章

引言

在之前的文章中,我们讨论了控件的绘制。今天,我们将重点关注处理许多消息,这些消息介导了控件、其对话框或其他父窗口之间的交互,以及控件与操作系统本身的交互。

请注意,与本系列所有文章一样,这并非参考文档。本文并非旨在取代 MSDN,而是对其进行补充。MSDN 通常会描述消息的作用、如何通过 WPARAMLPARAM 传递参数,我认为在此重复这些内容毫无意义。相反,本文仅概述了控件通常需要实现的消息,并提供了一些从控件角度(而非尝试向控件发送消息的应用程序角度)进行实现的提示。

Unicode 和 ANSI 字符串

在我们开始讨论消息之前,让我们简要谈谈字符串以及标准控件如何处理 Win32 API 中的 Unicode 与 ANSI 双重性,因为这显然也会影响自定义控件如何处理字符串参数。

注意:在 Win32 API 上下文中,*Unicode* 表示采用小端字节序的 UTF-16 编码。(在 Windows 2000 上,它仅支持 UCS-2,即 UTF-16 的一个子集,不支持代理对。)*Windows ANSI* 或 Win32 API 上下文中的 *ANSI* 是一个不恰当的术语,表示由当前*代码页*确定的面向字节的编码。与 Unicode 不同,相同的字节序列在不同的代码页中具有不同的含义,并且通常可能无效。通常,所有新代码都应优先使用 Unicode,至少在内部如此。

大多数以字符串作为参数(或指向具有字符串成员的结构的指针)的控件特定消息实际上是两个不同的消息。Unicode 格式的消息带后缀 W,ANSI 格式的消息带后缀 A。当应用程序源代码使用不带任何后缀的消息名称时,预处理器宏 UNICODE 会在公共头文件中控制使用哪个。

然而,这主要适用于控件特定消息。你可以在 *commctrl.h* 中找到许多这种方法的例子,用于负责插入或设置列表视图或树视图等控件项的消息。

标准消息,特别是 WM_NCCREATEWM_CREATEWM_SETTEXTWM_GETTEXT,工作方式略有不同。这些消息没有 Unicode/ANSI 版本。相反,系统会记住窗口类是使用 RegisterClassW() 还是 RegisterClassA()(或其 RegisterClassEx() 对应项)注册的。根据这一点,我们将生成的 HWND 称为*Unicode 窗口*或*ANSI 窗口*。

所有相关函数,如 CreateWindow()SetWindowText()GetWindowText(),都有 Unicode 或 ANSI 版本(同样,函数名称中分别带有后缀 WA)。每当调用该函数时,它会自动进行所需的转换,以相应形式将字符串传递给窗口过程。

随着全球化的推进,国际化和本地化成为任何现代软件的自然需求。因此,Unicode 在这个世界中越来越占据主导地位,标准控件在内部始终使用 Unicode(至少自 Windows 2000 以来)。我建议你在控件中遵循这一经验法则,除非你有非常充分的理由不这样做。

这通常意味着遵循以下步骤

  • 使用 Unicode 版本的 RegisterClass() 注册您的控件。
  • 将标准消息中的字符串解释为 Unicode。
  • 在控件结构中以 Unicode 格式保存字符串。
  • 实现新的控件特定消息时,要么为 Unicode 实现它,要么实现两个不同的消息。下面的代码演示了这一点。

注意:列表的前三点可以通过显式使用函数(如 RegisterClassW() 等)的 W 版本来实现,或者通过使用 Unicode 解析宏(不带任何后缀的名称)并通过适当的编译器选项将项目构建为 Unicode 项目来实现。在 Visual Studio 中,可以在项目属性中配置此项,gcc 编译器为此目的提供了 * -municode* 选项。

/* custom.h
 * (public control interface) */

...

#define XXM_SOMESTRINGMESSAGEW     (WM_USER+100)
#define XXM_SOMESTRINGMESSAGEA     (WM_USER+101)
#ifdef UNICODE
    #define XXM_SOMESTRINGMESSAGE     XXM_SOMESTRINGMESSAGEW
#else
    #define XXM_SOMESTRINGMESSAGE     XXM_SOMESTRINGMESSAGEA
#endif

...


/* custom.c
 * (control implementation) */
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    switch(uMsg) {

        ...

        case XXM_SOMESTRINGMESSAGEW:
            ... // Handle input (WPARAM/LPARAM) as Unicode.
            return 0;

        case XXM_SOMESTRINGMESSAGEA:
            ... // Handle input (WPARAM/LPARAM) as ANSI.
                // (Assuming the control holds the strings in Unicode,
                // this usually involves converting the string with
                // MultiByteToWideChar() (when string is input parameter)
                // or WideCharToMultiByte() (output parameter).
            return 0;

        ...

    }
}

通知对 Unicode 和 ANSI 字符串的处理方式不同,我们将在本文末尾讨论。

控件的创建和销毁

我们已经在本系列第一部分中讨论了控件的创建和销毁。然而,让我们更仔细地审视一下。

消息 WM_NCCREATE 是控件在其生命周期中接收到的第一个消息。相应地,消息 WM_NCDESTROY 是最后一个消息。对于自定义控件的实现,这些消息适用于分配和设置控件所需的资源(例如,一些结构来保存控件的内部数据和状态),或分别释放它们。

注意,对于 WM_NCCREATEDefWindowProc() 将通过 LPARAM 作为 CREATESTRUCT::lpszNameCreateWindow() 传播的窗口文本设置。如果您的控件使用窗口文本,您应该将消息传播到 DefWindowProc()。有关窗口文本的更多信息将在本文后面的专门章节中讨论。

在窗口创建期间,即在 CreateWindow() 的上下文中(并假设创建过程中没有失败),会发送 WM_NCCREATEWM_CREATE 消息。但是在这两个消息之间还可能发送其他消息(通常是 WM_NCCALCSIZE),以及在 WM_CREATE 之后(例如 WM_SIZEWM_MOVEWM_SHOWWINDOW)。因此,WM_NCCREATE 应该执行最少的工作,以使其数据处于一致状态(这通常意味着为状态分配结构,并将其初始化为一些默认值)。这保证了其他消息处理程序可以毫无问题地使用数据。

作为经验法则,控件的大部分其他(不太关键的)设置应在 WM_CREATE 中处理。因此,例如,控件的非必需资源应在 WM_NCCREATE 中初始化为 NULL,然后在 WM_CREATE 中分配。特别是如果资源分配涉及调用其他 Win32 API 函数:它们可能会导致向控件发送其他消息,而这种设计则保证了控件数据处于一致状态,并有助于避免细微的错误。一些设置甚至可以进一步推迟到其他消息,如 WM_SIZE(例如,如果复杂控件需要计算一些布局)和其他适当的消息处理程序中。

当窗口被销毁时,即在 DestroyWindow() 的上下文中,控件会收到 WM_DESTROY,然后是 WM_NCDESTROY。好的做法是在 WM_NCDESTROY 中撤销 WM_NCCREATE 所做的工作,并在 WM_DESTROY 中撤销 WM_CREATE 所做的工作。我还建议 WM_DESTROY 保持控件处于一致状态,特别是将 CustomData 结构中所有已释放的指针重置为 NULL

注意:即使之前 WM_NCCREATE 消息失败(即返回 FALSE),也会发送 WM_NCDESTROY。同样,即使 WM_CREATE 失败(即返回 -1),也会发送 WM_DESTROY。这会影响错误路径,如下面的代码摘录所示。

如果通过自定义现有窗口类来实现控件,则必须特别小心。因此,良好的做法是在派生类中本地处理 WM_NCCREATEWM_CREATE 之前,将其传播到原始窗口过程。然而,这是另一个话题,我们将在以后探讨。

/* custom.c
 * (control implementation) */

typedef struct CustomData_tag CustomData;
struct CustomData_tag {
    HWND hwnd;
    HTHEME hTheme;
    // ...
};

static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
    switch(uMsg) {
        // ...

        case WM_NCCREATE:
            if(!DefWindowProc(hwnd, uMsg, wParam, lParam))
                return FALSE;
            pData = malloc(sizeof(CustomData));
            if(pData == NULL)
                return FALSE;
            ZeroMemory(pData, sizeof(CustomData));
            pData->hwnd = hwnd;
            // ... Set pData to consistent state (e.g. some reasnable default values)
            SetWindowLongPtr(hwnd, 0, (LONG_PTR)pData);
            return TRUE;

        case WM_CREATE:
        {
            LRESULT lres;
            lres = DefWindowProc(hwnd, uMsg, wParam, lParam);
            if(lres == -1)
                return -1;

            // Further setup, e.g. opening theme handle if the control supports themed painting.
            pData->hTheme = OpenThemeData(hwnd, L"...");
            // ...
            return lres;
        }

        case WM_DESTROY:
            if(pData->hTheme != NULL) {
                CloseThemeData(pData->hTheme);
                pData->hTheme = NULL;
            }
            break;

        case WM_NCDESTROY:
            if(pData != NULL) {  // pData is NULL if WM_NCCREATE failed before SetWindowLongPtr()!!!
                // ... free all the other resources we alloc'ed in WM_NCCREATE
                free(pData)
            }
            break;
    }

    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

窗口样式和扩展样式

每个窗口都有与之关联的样式和扩展样式。两者实际上都是双字(DWORD)位掩码,它们增强了控件的外观或行为。最初,样式和扩展样式根据 CreateWindow()CreateWindowEx() 的参数设置。样式在 WM_NCCREATEWM_CREATE 中作为 CREATESTRUCT 的成员传播。

稍后,在控件的生命周期中,可以通过使用 GWL_STYLEGWL_EXSTYLE 索引的 SetWindowLong() 函数来更改它们。

扩展样式的所有位和样式的高 16 位具有系统定义的含义。样式中的低 16 位可用于控件特定目的。

系统定义的样式和扩展样式位通常由传递给 DefWindowProc() 的适当消息处理。例如,如果控件具有样式 WM_BORDER,则 WM_NCCALCSIZE 的处理会通过使控件的客户区稍微小一点来为边框保留空间,而 WM_NCPAINT 则在空间中绘制边框。

(实际上,边框是未主题化的。我们最终会在后续文章中介绍非客户区的绘制和相关内容。)

然而,至少对于控件特定的样式,控件实现必须知道何时更改样式以反映它。通过消息 WM_STYLECHANGINGWM_STYLECHANGED 监视样式和扩展样式位的更改。WM_STYLECHANGING 在更改执行之前发送,如果控件希望,可以拒绝更改或强制进行不同的更改。

尽管控件总是可以通过 GetWindowLong() 查询当前样式,但通常更好、更方便的做法是将样式(至少是我们可能需要经常访问的低 16 位样式)本地缓存到我们的控件数据结构中。

/* custom.c
 * (control implementation) */

typedef struct CustomData_tag CustomData;
struct CustomData_tag {
    DWORD style : 16;
    // ...
};

static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
    switch(uMsg) {
        // ...

        case WM_NCCREATE:
        {
            CREATESTRUCT* cs = (CREATESTRUCT*) lParam;
            // ...
            pData->style = cs->style;
            return TRUE;
        }

        case WM_STYLECHANGING:
        {
            /* Sometimes, for example, a control has to allow setting some style only
             * during its creation, and it needs to block the change of it thenafter.
             * It can be done by augmenting STYLESTRUCT::styleNew as we wish. */
            if(wParam == GWL_STYLE) {
                STYLESTRUCT* ss = (STYLESTRUCT*) lParam;

                /* Prevent change in some styles we do not allow to change during control's life: */
                if((ss->styleOld ^ ss->styleNew) & (XXS_CONSTSTYLE1 | XXS_CONSTSTYLE2)) {
                    ss->styleNew &= ~(XXS_CONSTSTYLE1 | XXS_CONSTSTYLE2);
                    ss->styleNew |= ss->styleOld & (XXS_CONSTSTYLE1 | XXS_CONSTSTYLE2);
                }
            }
            break;
        }

        case WM_STYLECHANGED:
            if(wParam == GWL_STYLE) {
                STYLESTRUCT* ss = (STYLESTRUCT*) lParam;

                /* Remember new style in our data structure for simple use */
                pData->style = ss->styleNew;

                /* If a style affecting look of the control changes, we may need to repaint it. */
                if((ss->styleOld ^ ss->styleNew) & (XXS_ANOTHERLOOK | XXS_MORECOLOROUS)) {
                    if(!pData->bNoRedraw)
                        InvalidateRect(hwnd, NULL, TRUE);
                }
            }
            break;
    }

    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

窗口几何

每当控件被创建、调整大小、其(普通或扩展)样式改变或其状态栏状态改变时,系统都会向控件发送 WM_NCCALCSIZE,以便它可以重新排列控件区域的哪些部分是非客户区或客户区。DefWindowProc() 中的默认实现会根据样式、扩展样式以及滚动条所需的空间(如果有)为边框、客户区边缘和滚动条保留一些空间。

此消息的自定义实现可能非常复杂,因为它需要考虑所有参数组合和预期的返回值,但它很少需要,默认实现几乎总是足够的。唯一的例外可能是当我们希望在粗糙控件的类样式 CS_HREDRAWCS_VREDRAW 不足的情况下,告诉系统在调整大小后控件客户区的哪些部分保持有效。在这种情况下,实现可能如下所示

/* custom.c
 * (control implementation) */

static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
    switch(uMsg) {
        // ...

        case WM_NCCALCSIZE:
            if(wParam) {
                // Return the desired combination of WVR_xxx constants, to
                // specify which part of client should be preserved after the
                // resize. For example:
                return WVR_ALIGNTOP | WVR_ALIGNRIGHT;
            }

            // In other cases, pass the message into DefWindowProc().
            break;
    }

    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

系统知道控件的哪些部分属于非客户区后,它会向控件发送 WM_MOVE(如果客户区位置改变)和 WM_SIZE(如果客户区大小改变),并根据 WM_NCCALCSIZE 的返回值使客户区的部分无效。

每当控件的可见性发生改变时,控件会收到 WM_SHOWWINDOW 消息。这包括调用 ShowWindow() 时,也包括父窗口最小化/恢复时,或者当其他窗口最大化并因此完全遮挡控件时。可以通过分析其参数来区分这些情况。

窗口文本

为了设置和检索与窗口关联的文本,Win32 API 提供了 WM_SETTEXTWM_GETTEXT 消息。然而,应用程序通常不直接使用这些消息,而是调用 SetWindowText()GetWindowText() 函数,这些函数可以在调用者和窗口对字符串类型的看法不匹配时,在 Unicode 和 ANSI 之间转换字符串。

当传递给 DefWindowProc() 时,文本会直接从窗口管理器存储或检索。这允许 Windows 从属于另一个进程的窗口获取文本,即使该进程没有响应。Raymond Chen 在他的著名博客 The Old New Thing 中对此进行了很好的描述:GetWindowText 的秘密生活

同样值得注意的是,对于某些实用工具(例如各种辅助工具),即使控件不显示任何特定文本,窗口文本也可能非常有用,因此将其设置为合理的值可以改善有某些残疾或限制的用户的体验。

如果您决定以不同方式处理消息并(例如)将文本直接存储在控件的结构中,那么您还应该始终如一地处理 WM_NCCREATEWM_GETTEXTLENGTH 消息

/* custom.c
 * (control implementation) */

#include <tchar.h>
#include <strsafe.h>

typedef struct CustomData_tag CustomData;
struct CustomData_tag {
    TCHAR* lpszText;
    // ...
};

static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
    switch(uMsg) {
        // ...

        case WM_SETTEXT:
        {
            TCHAR* lpszText = (TCHAR*) lParam;
            TCHAR* lpszTmp;
            size_t sz;

            if(lpszText != NULL)
                StringCchLength(lpszText, STRSAFE_MAX_CCH, &sz);
            else
                sz = 0;
            lpszTmp = (TCHAR*) malloc(sizeof(TCHAR) * (sz+1));
            if(lpszTmp == NULL)
                return FALSE;
            StringCchCopyEx(lpszTmp, sz+1, lpszText, NULL, NULL, STRSAFE_IGNORE_NULLS);
            if(pData->lpszText != NULL)
                free(pData->lpszText);
            pData->lpszText = lpszTmp;
            return TRUE;
        }

        case WM_GETTEXT:
        {
            size_t sz = (size_t) wParam;
            TCHAR* lpBuffer = (TCHAR*) lParam;
            TCHAR* lpEnd;

            if(pData->lpszText == NULL)
                return 0;
            StringCchCopyEx(lpBuffer, sz, pData->lpszText, &lpEnd, NULL, 0);
            return (lpEnd - lpBuffer);
        }

        case WM_GETTEXTLENGTH:
        {
            size_t sz;

            if(pData->pszText == NULL)
                return 0;
            StringCchLength(pData->lpszText, STRSAFE_MAX_CCH, &sz);
            return sz;
        }

        case WM_NCCREATE:
        {
            CRERATESTRUCT* cs = (CREATESTRUCT*) lParam;
            size_t sz;
            // ...

            if(cs->lpszText != NULL)
                StringCchLength(cs->lpszText, STRSAFE_MAX_CCH, &sz);
            else
                sz = 0;
            pData->lpszText = (TCHAR*) malloc(sizeof(TCHAR) * (sz+1));
            if(pData->lpszText == NULL)
                return FALSE;
            StringCchCopyEx(pData->lpszText, sz+1, cs->lpszText, NULL, NULL, STRSAFE_IGNORE_NULLS);
            return TRUE;
        }

        case WM_NCDESTROY:
            // ...
            if(pData->lpszText != NULL)
                free(lpszText);
            // ...
            break;
    }

    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

注意:在大多数情况下,依赖 DefWindowProc() 并在需要时(例如在绘制代码中)调用 GetWindowText() 就足够了(而且工作量肯定更少)。除非您有充分的理由手动操作,否则您通常应该优先选择这种方式,如代码摘录所示。

窗口字体

每个在绘制过程中绘制文本的控件,都应该允许应用程序指定它应该使用的字体。为此,API 提供了 WM_SETFONTWM_GETFONT 消息。它们的实现足够简单,无需进一步解释

/* custom.c
 * (control implementation) */

typedef struct CustomData_tag CustomData;
struct CustomData_tag {
    HFONT hFont;
    // ...
};

static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
    switch(uMsg) {
        // ...

        case WM_SETFONT:
            pData->hFont = (HFONT) wParam;
            if((BOOL) lParam  && !pData->bNoRedraw)
                InvalidateRect(hwnd, NULL, TRUE);
            return 0;

        case WM_GETFONT:
            return (LRESULT) pData->hFont;
    }

    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

剩下的就是绘制代码,以便在绘制文本时将字体选择到设备上下文中。如果 hFontNULL,则控件应使用 GetStockObject(SYSTEM_FONT) 返回的字体。请注意,此字体已在新设备上下文中预选。

使用 DrawThemeText() 绘制文本时,请记住,该函数仅在其为主题类/部件/状态组合定义了字体时,才使用主题定义中指定的字体。如果未定义,则该函数使用已选入设备上下文的字体。因此,即使对于主题代码路径,将字体选入设备上下文也是一个好主意。

绘制

窗口绘制是基于处理 WM_ERASEBKGNDWM_PAINTWM_PRINTCLIENT,我们已经在本系列之前的一篇文章中描述过了。

除此之外,还有许多标准控件支持的 WM_SETREDRAW 消息。此消息允许应用程序在控件状态改变时暂时禁用其重绘功能。例如,这常用于列表视图和树视图等控件。通常,应用程序可能需要用大量项目填充这些控件,而控件在每次插入后都会刷新自身以反映其所持有的数据。

如果应用程序知道它会一次性插入数百个项目,它可以禁用重绘,插入所有项目,重新启用重绘并强制重绘

/* app.c
 * (some application) */

...

SendMessage(hwnd, WM_SETREDRAW, FALSE, 0);  // Disable repaint
...  // Huge number of messages which change control's state
SendMessage(hwnd, WM_SETREDRAW, TRUE, 0);   // Re-enable repaint
RedrawWindow(hwnd, NULL, NULL, RDW_ERASE | RDW_FRAME | RDW_INVALIDATE | RDW_ALLCHILDREN);

注意:复杂的控件可能还需要重绘其子窗口或其非客户区,因此我们使用了带有所有标志的 RedrawWindow() 而不是简单的 InvalidateRect(hwnd, NULL, TRUE)。但是,如果已知没有子窗口或框架,则 InvalidateRect() 就足够了。

如果您想支持此消息(如果设置或填充控件可能导致过多重绘,您就应该这样做),那么在控件实现端,这通常意味着只需在控件数据中记住一个标志,并且仅在未设置该标志时使控件区域无效

/* custom.c
 * (control implementation) */

typedef struct CustomData_tag CustomData;
struct CustomData_tag {
    // Note the inverted logic of this meber.
    // I.e. ZeroMemory() in WM_NCCREATE sets it correctly to FALSE.
    BOOL bNoRedraw;

    // ...
};

static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
    switch(uMsg) {
        // ...

        case WM_PAINT:
        {
            // Adapted WM_PAINT handler from the article about painting.
            // We only added the test for pData->bNoRedraw.
            PAINTSTRUCT ps;
            BeginPaint(hwnd, &ps);
            if(!pData->bNoRedraw) {
                if(GetWindowLong(hwnd, GWL_STYLE) & XXS_DOUBLEBUFFER)
                    CustomDoubleBuffer(hwnd, &ps);
                else
                    CustomPaint(hwnd, ps.hdc, &ps.rcPaint, ps.fErase);
            }
            EndPaint(hwnd, &ps);
            return 0;
        }

        case WM_SETREDRAW:
            pData->bNoRedraw = !wParam;
            if(!pData->bNoRedraw)        // Repaint automatically on re-enabling.
                InvalidateRect(hwnd, NULL, TRUE);
            return 0;

        case XXM_CHANGESTATE:
            ...  // modify control's state by modifying data in pData
                 // (e.g. insert/set/delete items of any kind in it).

            if(!pData->bNoRedraw)        // Avoid repaint when disabled by the app.
                InvalidateRect(hwnd, NULL, TRUE);
            return 0;
    }

    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

注意:根据 MSDN,当重新启用重绘时,应用程序本身应该使控件区域无效并重绘。然而,许多标准控件会自动执行此操作。我决定在上述示例中遵循这种做法。这种方法的优点还在于,控件可能更清楚地知道具体需要重绘什么,因此它可能能够仅使其区域的相关部分无效。

我推荐阅读 The Old New Thing 博客上的另一篇文章,作为关于 WM_SETREDRAW 主题的进一步补充阅读:WM_SETREDRAW 有一个默认实现,但您可能会做得更好

另一个与绘制相关的消息是 WM_NCPAINT。我们将在另一篇文章中讨论它,所以现在跳过它。

与对话框的交互

通常,控件的父窗口是对话框。对话框也因其使用对话框模板(DIALOGDIALOGEX 资源)间接创建的简单性而广受欢迎,因为它自动化了手动创建大量窗口的许多代码。当然,对话框模板或其项可以分别引用控件的窗口类。

注意:对话框模板只能引用与模板所在实例句柄(HINSTANCE)相同的实例句柄注册的控件类,或者如果该类是全局的(即使用了类样式 CS_GLOBAL)。

对话框的正常行为由其对话框过程决定,该过程通常是通过调用 DefDlgProc() 外部可用的代码,因此自定义对话框可以以与窗口过程使用 DefWindowProc() 类似的方式将消息传播给它。

对话框通常为用户的舒适和效率提供一些功能。其中一些功能需要控件的配合才能良好运行。这些功能由 DefDlgProc() 提供,它通常从对话框的过程中调用。应用程序也可以为普通(非对话框)窗口模拟其中一些功能。例如,请参阅 Raymond Chanin 的另一篇文章:在非对话框中使用 TAB 键导航

那么,让我们看看如何使控件为这种协作做好准备。

对话框资源可以指定要使用的字体。如果是这样,对话框过程会向它创建的每个控件发送 WM_SETFONT。我们之前已经讨论过这个消息,所以让我们继续。

对话框的另一个重要功能是键盘导航。用户经常通过按 *TAB* 或 *SHIFT+TAB* 热键来改变控件的焦点。对话框过程还可以使用 *空格键* 或 *回车键* 等其他键。因此,重要的是要告诉对话框你的控件需要哪些键来操作,这样对话框就知道它是否可以将其用于导航,或者是否应该将其传递给控件。这通过处理 WM_GETDLGCODE 来完成。

此消息的处理程序通常只是一行代码,用于指定描述控件需求的位掩码

/* custom.c
 * (control implementation) */

typedef struct CustomData_tag CustomData;
struct CustomData_tag {
    BOOL bNoRedraw;  // inverted logic, i.e. ZeroMemory() WM_NCCREATE leaves this on the right default.
    // ...
};

static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
    switch(uMsg) {
        // ...

        case WM_GETDLGCODE:
            return DLGC_flag1 | DLGC_flag2;
            // For illustrative purpose only. Refer to MSDN for all possible flags and their meaning.
    }

    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

另一个功能是对话框外观中焦点和加速键的视觉反馈。历史上,这些总是显示的。但自 Windows XP 以来,微软决定这可能会干扰主要使用鼠标而很少使用键盘的用户。因此,Windows 默认开始隐藏焦点矩形和加速键标记,除非用户开始使用键盘导航。作为最终用户,您可能喜欢或不喜欢它,作为开发人员,您应该让控件遵循它。不喜欢它的用户可以在控制面板中禁用隐藏。

该逻辑基于每个可以具有焦点或快捷方式的控件对 WM_UPDATEUISTATE 消息的处理。手动处理时,可以这样实现

/* custom.c
 * (control implementation) */

typedef struct CustomData_tag CustomData;
struct CustomData_tag {
    DWORD dwUiState;
    // ...
};

static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
    switch(uMsg) {
        // ...

        case WM_UPDATEUISTATE:
            switch(LOWORD(wParam)) {
                case UIS_CLEAR:       pData->dwUiState &= ~HIWORD(wp); break;
                case UIS_SET:         pData->dwUiState |= HIWORD(wp); break;
                case UIS_INITIALIZE:  pData->dwUiState = HIWORD(wp); break;
            }
            if(!pData->bNoRedraw)
                InvalidateRect(hwnd, NULL, FALSE);
            break;
    }

    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

其余部分只需在您的绘制代码中遵循标志即可。例如,如果条件 (pData->dwUiState & UISF_HIDEACCEL) 成立,您应该通过在 DrawText() 中使用 DT_HIDEPREFIX 标志来反映它。

或者,您可以依赖 DefWindowProc() 中的消息实现,只在绘制代码中通过向控件发送 WM_QUERYUISTATE 来查询当前状态。

再次,进一步推荐的补充阅读包括 The Old New Thing 上的一些文章

与系统的交互

控件可能还需要处理一些消息,这些消息会通知它 Windows 设置中的某些更改,这些更改可能会影响控件及其外观或行为。

其中一条消息是 WM_SYSCOLORCHANGE,每当 GetSysColor() 返回的颜色发生变化时,系统会将其发送给所有顶级窗口。顶级窗口应将此消息分发给所有子窗口,包括我们的控件。(注意:这取决于应用程序开发人员。据我所知,DefWindowProc() 不会这样做。)如果控件的绘制依赖于系统颜色,它应该用新颜色重新绘制自己。

另一个用途相似的消息是 WM_THEMECHANGED,它在主题偏好改变时发送。同样,控件应该用新的主题数据重新绘制自己,这需要重新打开主题句柄。我们已经在上一篇文章中讨论过这一点。

监控焦点

通常,控件在获得或失去焦点时需要重新绘制自身。这可以通过跟踪 WM_SETFOCUSWM_GETFOCUS 消息非常简单地完成。

/* custom.c
 * (control implementation) */

static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
    switch(uMsg) {
        // ...

        case WM_SETFOCUS:
        case WM_GETFOCUS:
            if(!pData->bNoRedraw)
                InvalidateRect(hwnd, NULL, FALSE);
            break;
    }

    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

键盘消息

处理键盘的消息有很多,下面我们来介绍一下。

在低级别,有 WM_KEYDOWNWM_KEYUP 消息。顾名思义,前者在您按下键盘上的键时发送,在您一直按住键并触发自动重复时也会发送(您可以通过检查其参数来区分这两种情况)。后者在键释放时发送。

除此之外,还有 WM_CHAR(或 WM_DEADCHAR)。其中一些可以在 WM_KEYDOWNWM_KEYUP 之间发送,将虚拟键代码翻译成实际字符。翻译是在应用程序的消息循环中通过调用 TranslateMessage() 函数完成的。当函数看到 WM_KEYDOWN 时,它只会插入相应的 WM_CHAR,然后通过另一次调用 GetNextMessage() 稍后检索。

注意:处理低级别还是高级别消息确实很重要。消息转换涉及键盘及其布局的配置。它还受大写锁定、Shift 或其他功能键状态的影响。如果您正在开发游戏,您可能更关注 WM_KEYDOWNWM_KEYUP。如果是文本编辑器,则更可能是 WM_CHAR 用于处理普通文本输入,而 WM_KEYDOWNWM_KEYUP 用于处理 <INSERT> 或光标箭头键等功能键。

上述大部分键盘消息也有其 *SYS* 对应消息:WM_SYSKEYDOWNWM_SYSKEYUPWM_SYSCHAR。实际上,它们在相同的情况下发送,但用于通常用于 Windows 定义任务的组合键。例如,与 *<ALT>* 组合以进入窗口菜单等。处理它们允许您覆盖默认处理(假设您没有将它们传递给 DefWindowProc())。在正常情况下,这种分离允许您专注于处理非 SYS 消息并将这些消息保留在 DefWindowProc() 上,而不用担心任何冲突。

此外,还有 WM_UNICHAR 消息,它能够处理任何 UTF-32 Unicode 码点。由于 Windows 内部以 UTF-16(自 Windows XP 起支持代理对)工作,因此系统永远不会向您发送此消息。这更多是为了您自己的便利:如果支持此消息是有益的,您可以在您的窗口过程中实现它,并且您以及第三方应用程序(通常是用于亚洲语言的各种输入法编辑器)都可以将其发送给您。

尽管标题悲观,但有关处理键盘消息的更多深入阅读可以在文章 WM_KEYDOWN-WM_CHAR 输入模型出了什么问题?中找到。

鼠标消息

鼠标处理涉及更多消息。有用于处理鼠标指针移动的消息(WM_MOVE)、鼠标按钮操作的消息(例如,WM_LBUTTONDOWN 用于左键按下),以及用于复合操作(如单击或双击)的消息(例如,WM_LBUTTONDBLCLK)。大多数消息也有两种类型:普通(客户区)和非客户区,其名称以 WM_NC 开头(例如,WM_NCMOUSEMOVEWM_NCNCBUTTONUP)。

当鼠标事件发生在控件的客户区内,或者鼠标捕获生效时,会发送普通消息:控件可以根据其逻辑,通过 SetCapture() 函数要求系统向它发送所有鼠标消息,直到它通过 ReleaseCapture() 释放捕获(或者直到另一个窗口通过 SetCapture() 窃取捕获。当控件失去捕获时,它会收到 WM_RELEASEDCAPTURE 通知)。当鼠标操作发生在非客户区(且捕获未生效)时,会发送“NC”消息。

鼠标捕获经常被使用,例如,在实现类似按钮的控件(或控件的一部分具有类似按钮的性质)时。当其按钮被按下(WM_LBUTTONDOWN)时,您会进行捕获,这样即使后续按钮释放发生在控件的客户区之外,也会发送给控件

/* custom.c
 * (control implementation) */

typedef struct CustomData_tag CustomData;
struct CustomData_tag {
    BOOL bLeftButtonPressed;
    // ...
};


static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
    switch(uMsg) {
        // ...

        case WM_LBUTTONDOWN:
            pData->bLeftButtonPressed = TRUE;
            SetCapture(hwnd);
            break;

        case WM_LBUTTONUP:
        {
            POINT pt = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
            RECT rc;
            GetClientArea(hwnd, &rc);
            if(PtInRect(&rc, pt))
                SendMessage(GetParent(hwnd), WM_COMMAND, GetWindowLong(hwnd, GWL_ID), 0);
            ReleaseCapture();
        }
            // No break here, fall through

        case WM_RELEASEDCAPTURE:
            pData->bLeftButtonPressed = FALSE;
            break;
    }

    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

注意:当鼠标捕获生效时,持有它的窗口会收到所有鼠标消息。因此,根据其逻辑,对于其中一些消息,控件可能需要手动检查鼠标消息是否确实发生在它内部。

同样重要的是要记住,许多消息只有在您明确请求时才会发送:双击消息(WM_LBUTTONDBLCLKWM_RBUTTONDBLCK 等)仅在窗口类使用类样式 CS_DBLCLKS 注册时才会发送。

消息 WM_MOUSELEAVEWM_MOUSEHOVER 仅在其跟踪通过 TrackMouseEvent() 启用时发送。请注意,该函数启用一次消息发送,并且 WM_MOUSELEAVE 会完全禁用跟踪,因此通常需要再次调用该函数

/* custom.c
 * (control implementation) */

typedef struct CustomData_tag CustomData;
struct CustomData_tag {
    BOOL fTrackingMouse;
    // ...
};

static void
TrackMouse(HWND hwnd)
{
    TRACKMOUSEEVENT tme;
    tme.cbSize = sizeof(TRACKMOUSEEVENT);
    tme.dwFlags = TME_LEAVE | TME_HOVER;
    tme.hwndTrack = hwnd;
    tme.dwHoverTime = HOVER_DEFAULT;
    TrackMouseEvent(&tme);
}

static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
    switch(uMsg) {
        // ...

        case WM_MOUSEMOVE:
            if(!pData->fTrackingMouse) {
                TrackMouse();
                pData->fTrackingMouse = TRUE;
            }
            break;

        case WM_MOUSEHOVER:
            ... // handle mouse hover (often used for example to show a tooltip)
            TrackMouse();
            break;

        case WM_MOUSELEAVE:
            pData->fTrackingMouse = FALSE;
            ...  // handle mouse leave
            break;
    }

    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

另请注意,没有 WM_MOUSEENTER 消息。您可以将 WM_MOUSEMOVE 用于此目的:每当 WM_MOUSEMOVE 到来且标志 fTrackingMouse 未设置时,则鼠标进入了窗口的客户区。

滚动消息

有一些消息允许控件支持滚动:WM_HSCROLLWM_VSCROLLWM_MOUSEWHEELWM_MOUSEHWHEEL。然而,我们将在后续文章中更详细地探讨滚动。

Accessibility

实际上,控件还应该支持与辅助功能和自动化工具的协作。这通过处理 WM_GETOBJECT 来完成,该消息应该返回指向实现特定 COM 接口(取决于 WPARAMLPARAM)的 COM 对象的指针。

然而,这是一个相当复杂的话题。此外,我承认由于我对此了解有限,目前无法对此说太多。希望我们以后会回到这个话题。

Notifications

到目前为止,我们主要讨论了系统或应用程序需要与控件通信的情况。但通常,控件也需要向应用程序告知其更改。这通常通过一些通知消息来完成。

主要有两种通知消息:WM_COMMANDWM_NOTIFY。前者只能携带控件本身的标识(即我们的控件)和一个可打包到其中的 16 位值,因此其典型用例是触发某个动作。例如,标准按钮在点击时会触发 WM_COMMAND

后一条消息 WM_NOTIFY 携带更多数据,并且可进一步扩展。参数 LPARAM 实际上是指向 NMHDR 结构或任何其他(控件和通知代码特定)包含任何额外数据的结构的指针。控件只需确保任何此类自定义结构以 NMHDR 开头

/* custom.h
 * (public control interface) */


// Notification code
#define XXN_MYNOTIFICATION    101

typedef struct XXNM_NOTIFICATIONDATA {
    NMHDR hdr; // NMHDR has to be the 1st member of the struct.
    ...        // Other data.
} XXNM_NOTIFICATIONDATA;

/* custom.c
 * (control implementation) */

static void
SendNotification(HWND hwnd)
{
    XXNM_NOTIFICATIONDATA nd;

    nd.hdr.hwndFrom = hwnd;
    nd.hdr.idFrom = GetWindowLong(hwnd, GWL_ID);
    nd.hdr.code = XXN_MYNOTIFICATION;
    ... // setup the other struct members
    SendMessage(GetParent(hwnd), WM_NOTIFY, nd.hdr.idFrom, (LPARAM) &nd);
}

然而,如果结构包含字符串,还会有一个额外的复杂性。控件负责以父窗口期望的编码提供字符串。如果父窗口不在您的控制之下,即当您无法确定父窗口想要什么时,您必须通过 WM_NOTIFYFORMAT 消息询问它,要么在每次发送此类消息之前,要么在创建控件时,然后在每次要求刷新信息时

/* custom.c
 * (control implementation) */

typedef struct CustomData_tag CustomData;
struct CustomData_tag {
    BOOL fUnicodeNotifications;
    // ...
};

static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
    switch(uMsg) {
        // ...

        case WM_NOTIFYFORMAT:
        {
            LRESULT format;
            if(lParam == NF_REQUERY) {
                format = SendMessage(cs->hwndParent, WM_NOTIFYFORMAT, (WPARAM) hwnd, NF_QUERY);
                pData->fUnicodeNotifications = (format == NFR_UNICODE);
            } else {
                format = (pData->fUnicodeNotifications ? NFR_UNICODE : NFR_ANSI);
            }
            return format;
        }

        case WM_NCCREATE:
        {
            CREATESTRUCT* ss = (CREATESTRUCT*) lParam;
            LRESULT format;
            // ...

            format = SendMessage(cs->hwndParent, WM_NOTIFYFORMAT, (WPARAM) hwnd, NF_QUERY);
            pData->fUnicodeNotifications = (format == NFR_UNICODE);
            return TRUE;
        }
    }

    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

可下载示例

附件的示例项目(在本页顶部)实现了一个按钮。它大致对应于具有 BS_PUSHBUTTONBS_DEFPUSHBUTTON 样式的标准按钮。当然,这样的代码在任何应用程序中重用都没有用,因为您可以简单地使用标准按钮。

但是,自定义控件的代码仍然演示了本文中描述的很大一部分消息的处理,以及我们之前在关于绘制和视觉样式的文章中已经学到的内容,因此它可以作为您进一步实验的代码。

示例应用程序由一个带有两个按钮的简单对话框组成:一个标准按钮和一个自定义控件实现,因此您可以实时比较它们。唯一的主要区别是(使用默认的 Aero 主题)在 Windows Vista 及更高版本上,真实按钮在改变状态(例如在热状态和普通状态之间)时使用过渡动画。后续文章将专门介绍几种控件动画方法,因此我们肯定会在以后弥补这一空白。

实际代码

今天的真实世界代码供进一步研究,包含了一些 Wine 源代码。它可以让您很好地了解这些函数对各种标准消息的期望。

注意:请记住,Wine 中尚未实现一些对于运行 Windows 应用程序不重要的消息。例如,上述代码不涉及 WM_UPDATEUISTATE 及其相关消息。

下次:调整现有控件

通常,通过调整现有控件来创建新控件比从头开始实现要省力得多。下次,我们将探讨几种实现方法及其优缺点。

© . All rights reserved.