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

Win32 API 中的自定义控件:控件自定义

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (63投票s)

2014年3月17日

CPOL

19分钟阅读

viewsIcon

102511

downloadIcon

4234

定制现有控件的技术概述。

本系列文章

引言

本系列文章旨在介绍自定义控件的实现。然而,从头开始实现一个新控件通常需要大量工作,在许多情况下,通过增强现有控件的行为、外观或两者,也可以实现所需的效果,而且通常只需付出更少的努力。在今天的文章中,我们将介绍几种定制现有控件的技术。

此外,当您从头开始实现新控件时,该主题也很有趣:好的控件允许应用程序进行一定程度的定制,并且一些定制技术确实需要控件本身的一些支持。因此,我们还将讨论如何在新的控件中实现此类支持。

请注意,“定制控件”和“使用控件”之间没有严格的界限。这两种情况相互重叠,不同的人可能对一个在哪里结束,另一个在哪里开始有不同的看法。实际上,即使是设置控件样式(例如,指示控件以不同方式绘制)也可以被理解为控件定制的一个简单示例。就我们的目的而言,我们将“控件定制”一词用于增强控件的外观或行为,这涉及应用程序端的(非平凡的)代码。

本文将介绍几种定制技术。它们在许多方面有所不同,例如

  • 它们是否专门用于特定目的(例如,仅定制控件的外观)?
  • 它们是否需要控件本身提供特殊支持?
  • 或者它们是否主要需要父窗口(通常是对话框)的配合?

自绘

我们首先介绍的定制技术是自绘。该技术仅用于改变特定控件(或其项目)的绘制方式,并且只能用于支持此功能的控件。

提供此类支持的控件总是有一个开关,应用程序可以使用它来指定由自己绘制控件(或其项目)。根据控件的不同,该开关可以是一个窗口样式位或某个结构中的一个标志(例如,描述控件项的结构)。

当使用该开关时,它会导致控件在处理 WM_PAINTWM_PRINTCLIENT 时,不使用正常的绘制代码,而是向其父级发送 WM_DRAWITEM 消息。此外,根据控件的不同,WM_MEASUREITEM 消息可能会在 WM_DRAWITEM 之前发送。

对于某些控件,该开关启用整个控件的自绘,对于其他控件,它启用部分或所有项目的自绘。

当启用自绘时,控件可能会发送 WM_MEASUREITEM 以让应用程序确定项目的大小,以便它能够正确处理项目(例如,设置滚动条和项目布局等)。通常,如果自绘覆盖整个控件的绘制,则不会发送此消息。如果所有项目都应该具有相同的大小,则只发送一次,通常在 CreateWindow() 上下文中的控件创建期间。如果项目大小可以不同,则为控件中的每个项目发送此消息(例如,插入项目时)。

下表总结了可自绘的标准控件。它指定了启用自绘的开关、控件是否发送 WM_MEASUREITEM(如果发送,是为所有项目发送一次还是为每个项目发送),以及 WM_DRAWITEM 是用于绘制整个控件还是用于每个适用自绘的项目。

Control 开关 描述
按钮 ("BUTTON") 样式 BS_OWNERDRAW 发送 WM_DRAWITEM 绘制整个控件。
静态 ("STATIC") 样式 SS_OWNERDRAW 发送 WM_DRAWITEM 绘制整个控件。
列表框 ("ListBox") 样式 LBS_OWNERDRAWFIXED 在控件创建期间发送一次 WM_MEASUREITEM,并按项目发送 WM_DRAWITEM
样式 LBS_OWNERDRAWVARIABLE 按项目发送 WM_MEASUREITEMWM_DRAWITEM
组合框 ("ComboBoxEx32") 样式 CBS_OWNERDRAWFIXED 在控件创建期间发送一次 WM_MEASUREITEM,并按项目发送 WM_DRAWITEM
样式 CBS_OWNERDRAWVARIABLE 按项目发送 WM_MEASUREITEMWM_DRAWITEM
列表视图 ("SysListView32") (仅限样式 LVS_REPORT) 样式 LVS_OWNERDRAWFIXED 在控件创建期间发送一次 WM_MEASUREITEM,并按项目发送 WM_DRAWITEM
选项卡控件 ("SysTabControl32") 样式 TCS_OWNERDRAWFIXED 按项目发送 WM_DRAWITEM。注意不发送 WM_MEASUREITEM,而是使用消息 TCM_SETITEMSIZE 指定的大小。
标题 ("SysHeader32") 项目标志 HDF_OWNERDRAW 按项目发送 WM_DRAWITEM。注意不发送 WM_MEASUREITEM,而是使用项目几何数据。
状态栏 ("msctls_statusbar32") 标志 SBT_OWNERDRAW(参见消息 SB_SETTEXT 按部分发送 WM_DRAWITEM。注意不发送 WM_MEASUREITEM,而是使用部分几何数据。
菜单项 标志 MFT_OWNERDRAW(参见 InsertMenuItem()SetMenuItemInfo() 按项目发送 WM_MEASUREITEMWM_DRAWITEM

这种技术非常简单易用,对于在新控件中实现自绘支持也同样如此:它只需在 WM_PAINTWM_PRINTCLIENT 处理程序中添加一个 if,并将上述消息发送到父窗口。

该技术最显著的局限性显而易见:要么全有,要么全无。要么控件(或特定项目)完全由控件本身绘制,要么——通过应用开关——父级承担所有工作。例如,无法仅更改某个控件项的文本颜色,而不重写父窗口过程中的所有绘制代码。

自定义绘制

自定义绘制与自绘相似,都是控件允许父级绘制自身或其部分,但它赋予父级更多自由,可以在所需结果上与控件合作。然而,更多的可能性也伴随着代价:自定义绘制更加复杂,无论是从支持它的控件的角度,还是从想要利用它的应用程序的角度。

基本思想是控件在绘制控件的各个阶段多次发送通知 NM_CUSTOMDRAW。每次,应用程序都有可能在一定程度上增强给定的绘制阶段,或自行(重新)实现该阶段,应用程序还可以要求在后续绘制阶段获取或不获取更详细的通知。

通知的 LPARAM 参数是 NMCUSTOMDRAW 结构的地址,或者(对于某些控件)是包含 NMCUSTOMDRAW 作为其第一个成员的更大结构(就像许多通知通过发送包含 NMHDR 作为其第一个成员的结构来提供更多数据一样)。结构的成员填充了控件在给定绘制阶段要使用的默认值/属性。应用程序可以通过将相应的结构成员重置为另一个值来覆盖其中一些属性(例如字体、某些颜色等),甚至可以告诉控件完全跳过绘制阶段(以便应用程序可以自行绘制一些完全不同的东西)。

NMCUSTOMDRAW 结构如下所示:

typedef struct tagNMCUSTOMDRAWINFO {
    NMHDR     hdr;
    DWORD     dwDrawStage;
    HDC       hdc;
    RECT      rc;
    DWORD_PTR dwItemSpec;
    UINT      uItemState;
    LPARAM    lItemlParam;
} NMCUSTOMDRAW, *LPNMCUSTOMDRAW;

成员 hdr 是常见的通知头,所以这里没什么特别的。

成员 dwDrawStage 对于自定义绘制处理至关重要。在每个绘制阶段,应用程序可以自定义不同的内容。我们很快会进一步讨论。

成员 hdc 是控件用于绘制的设备上下文,因此应用程序可以部分自定义它(例如,通过选择其他字体),或将其用于自己的绘制。

成员 rc 指定绘制的边界矩形,具体取决于当前阶段,但请注意,它仅定义用于阶段 (CDDS_ITEM | CDDS_PREPAINT) 和(自COMCTL32.DLL版本6)适用于 CDDS_PREPAINT

成员 dwItemSpec 指定正在绘制的项目。值的解释取决于控件。例如,标准树视图控件在此处存储 HTREEITEM,而列表视图在此处存储项目的索引。

成员 uItemState 是描述要绘制的项目状态的位掩码,因此如果应用程序覆盖了项目的绘制,它就知道该项目是否被选中、是否获得焦点、是否被禁用等等,并可以相应地绘制项目。有关所有标志,请参阅 MSDN。它们对于理解工作原理并不那么重要。

成员 lItemlParam 是与项目关联的应用程序定义数据。通常,这会从某个按项目的 LPARAM 值传播,通常在项目插入控件时指定,或稍后修改。例如,标准列表视图控件在此处传播 LVITEM::lParam

最后但同样重要的是,通知 NM_CUSTOMDRAW 的返回值也非常重要。返回值实际上是一个位掩码,应用程序可以返回的相关位取决于当前的绘制阶段。在某些阶段,应用程序还可以要求在阶段结束后获得更细粒度的通知和/或嵌套级别的通知(例如,每个项目或子项目),通过从父窗口过程返回适当的返回值。

下表列出了具有某种项目和子项的复杂控件的绘制阶段。不带项目或子项的简单控件将只使用较少的绘制阶段。

dwDrawStage 描述
CDDS_PREERASE

在控件被擦除之前发送。

通知返回值可以使用以下位:

  • CDRF_DODEFAULT (0):正常执行擦除。
  • CDRF_SKIPDEFAULT:跳过擦除(应用程序应自行擦除)。
  • CDRF_NOTIFYPOSTERASE:要求控件在擦除后也发送 CDDS_POSTERASE
CDDS_POSTERASE

如果在应用程序通过返回 CDRF_NOTIFYPOSTERASE 请求后,在擦除后发送。

返回值被忽略。

CDDS_PREPAINT

在控件开始绘制之前发送。

通知返回值可以使用以下位:

  • CDRF_DODEFAULT (0):正常执行绘制(在控件级别)。
  • CDRF_SKIPDEFAULT:跳过整个绘制,包括项目和子项目的绘制(应用程序应自行绘制)。
  • CDRF_DOERASE:控件仅绘制背景。(仅在 Vista 及更高版本上)。
  • CDRF_SKIPPOSTPAINT:跳过焦点矩形的绘制。
  • CDRF_NOTIFYITEMDRAW:要求控件也为每个项目发送自定义绘制通知。
  • CDRF_NOTIFYPOSTPAINT:要求控件在控件绘制后也发送 CDDS_POSTPAINT
(CDDS_ITEM | CDDS_PREPAINT)

在控件开始绘制每个项目之前发送。仅当上述 CDDS_PREPAINT 处理程序使用了 CDRF_NOTIFYITEMDRAW 时才发送。

通知返回值可以使用以下位:

  • CDRF_DODEFAULT (0):正常执行项目的绘制。
  • CDRF_SKIPDEFAULT:跳过项目的绘制,包括子项目的绘制(应用程序应自行绘制)。
  • CDRF_NEWFONT:应用程序在提供的设备上下文中选择了另一种字体,控件应使用它来绘制项目。
  • CDRF_NOTIFYSUBITEMDRAW:要求控件也为每个子项目发送自定义绘制通知。
  • CDRF_NOTIFYPOSTPAINT:要求控件在项目绘制后也发送 CDDS_POSTPAINT
(CDDS_SUBITEM | CDDS_PREPAINT)

在控件开始绘制每个子项之前发送。仅当上述 (CDDS_ITEM | CDDS_PREPAINT) 处理程序使用了 CDRF_NOTIFYSUBITEMDRAW 时才发送。

通知返回值可以使用以下位:

  • CDRF_DODEFAULT (0):正常执行子项的绘制。
  • CDRF_SKIPDEFAULT:跳过子项的绘制(应用程序应自行绘制)。
  • CDRF_NEWFONT:应用程序在提供的设备上下文中选择了另一种字体,控件应使用它来绘制子项。
  • CDRF_NOTIFYPOSTPAINT:要求控件在子项绘制后也发送 CDDS_POSTPAINT
(CDDS_SUBITEM | CDDS_POSTPAINT)

如果应用程序通过处理 (CDDS_SUBITEM | CDDS_PREPAINT) 返回 CDRF_NOTIFYPOSTPAINT 请求,则在子项绘制后发送。

返回值被忽略。

(CDDS_ITEM | CDDS_POSTPAINT)

如果应用程序通过处理 (CDDS_ITEM | CDDS_PREPAINT) 返回 CDRF_NOTIFYPOSTPAINT 请求,则在项目绘制后发送。

返回值被忽略。

CDDS_POSTPAINT

如果应用程序通过处理 CDDS_PREPAINT 返回 CDRF_NOTIFYPOSTPAINT 请求,则在控件绘制后发送。

返回值被忽略。

当然,您也可以在自定义控件中支持自定义绘制,以便应用程序可以根据需要增强控件。这样的 WM_PAINT 处理程序可能看起来像以下代码框架所示。该代码假设控件使用项目但没有子项目。如果您的控件也需要子项目,您只需添加一个更深的嵌套循环,其处理方式与此处项目类似

static void
CustomPaint(HWND hwnd, HDC hDC, RECT* rcDirty, BOOL bErase)
{
    NMCUSTOMDRAW nmcd;      // The custom draw structure
    LRESULT cdControlMode;  // Return value of NM_CUSTOMDRAW for CDDS_PREPAINT
    LRESULT cdItemMode;     // Return value of NM_CUSTOMDRAW for CDDS_ITEM | CDDS_PREPAINT

    // Initialize members of the custom draw structure used for all the stages below:
    nmcd.hdr.hwndFrom = hwnd;
    nmcd.hdr.idFrom = GetWindowLong(hwnd, GWL_ID);
    nmcd.hdr.code = NM_CUSTOMDRAW;
    nmcd.hdc = hDC;

    if(bErase) {
        LRESULT cdEraseMode;

        // Send control pre-erase notification:
        nmcd.dwDrawStage = CDDS_PREERASE;
        cdEraseMode = SendMessage(GetParent(hwnd), WM_NOTIFY, nmcd.hdr.code, (LPARAM) &nmcd);

        if(!(cdEraseMode & CDRF_SKIPDEFAULT)) {
            // Do the erasing:
            ...

            // Send control post-erase notification:
            if(cdEraseMode & CDRF_NOTIFYPOSTERASE) {
                nmcd.dwDrawStage = CDDS_POSTERASE;
                SendMessage(GetParent(hwnd), WM_NOTIFY, nmcd.hdr.code, (LPARAM) &nmcd);
            }
        }
    }

    // Send control pre-paint notification:
    nmcd.dwDrawStage = CDDS_PREPAINT;
    GetClientRect(hwnd, &nmcd.rc);
    cdControlMode = SendMessage(GetParent(hwnd), WM_NOTIFY, nmcd.hdr.code, (LPARAM) &nmcd);

    if(!(cdControlMode & (CDRF_SKIPDEFAULT | CDRF_DOERASE))) {
        // Do the control (as a whole) painting
        // (e.g. some kind of background or frame)
        ...

        // Iterate through all control items.
        // (If the control does not support any items, just omit all this for-loop.)
        for(...) {
            // Send item pre-paint notification (if desired by the app):
            if(cdControlMode & CDRF_NOTIFYITEMDRAW) {
                nmcd.dwDrawStage = CDDS_ITEM | CDDS_PREPAINT;
                // Set some attributes describing the items. The app. can change
                // them to augment painting of the item:
                nmcd.rc = ...;

                // Identify the item (in per control specific way) so app. knows
                // what item is augmenting.
                nmcd.dwItemSpec = ...;

                // Tell app. if selection box, focus highlight etc. should be
                // painted for the item.
                nmcd.uItemState = ...;

                // Fill in also any data the app. may have associated with the
                // item so it can use them for augmenting of the control.
                nmcd.lItemParam = ...;

                // Send the notification.
                cdItemMode = SendMessage(GetParent(hwnd), WM_NOTIFY, nmcd.hdr.code, (LPARAM) &nmcd);
            } else {
                cdItemMode = CDRF_DODEFAULT;
            }

            // Do the item painting (unlesse suppressed by the app.)
            if(!(cdItemMode & CDRF_SKIPDEFAULT)) {
                // Note you should be ready for a case hdc may have different font selected
                // if (cdItemMode & CDRF_NEWFONT)). In such case you should reset it to
                // the default control font after this particular item is painted:
                ...

                // Do the item (as a whole) painting
                // (e.g. some kind of item background or frame)
                ...

                // If the item is composed from a set of subitems, another similar
                // nested loop would be here to handle them. You would also need
                // yet another variable (cdSubitemMode) and handle the subitems
                // in similar way. nmcd.dwDrawStage would just set CDDS_SUBITEM
                // instead of CDDS_ITEM.
                ...

                // Do item "post-painting"
                // (e.g. paint a focus rectangle if the item is selected and
                // control has a focus).
                ...

                // Send item post-paint notification:
                if(cdItemMode & CDRF_NOTIFYPOSTPAINT) {
                    nmcd.dwDrawStage = CDDS_ITEM | CDDS_POSTERASE;
                    SendMessage(GetParent(hwnd), WM_NOTIFY, nmcd.hdr.code, (LPARAM) &nmcd);
                }
            }
        }

        // Do control "post-painting":
        if(!(cdControlMode & CDRF_SKIPPOSTPAINT)) {
            // ...
        }

        // Send control post-paint notification:
        if(cdControlMode & CDRF_NOTIFYPOSTPAINT) {
            nmcd.dwDrawStage = CDDS_POSTERASE;
            SendMessage(GetParent(hwnd), WM_NOTIFY, nmcd.hdr.code, (LPARAM) &nmcd);
        }
    }
}

static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    switch(uMsg) {
        // ...

        case WM_ERASEBKGND:
            return FALSE;  // Defer erasing into WM_PAINT

        case WM_PAINT:
        {
            PAINTSTRUCT ps;
            BeginPaint(hwnd, &ps);
            CustomPaint(hwnd, ps.hdc, &ps.rcPaint, ps.fErase);
            EndPaint(hwnd, &ps);
            return 0;
        }

        case WM_PRINTCLIENT:
        {
            RECT rc;
            GetClientRect(hwnd, &rc);
            CustomPaint(hwnd, (HDC) wParam, &rc, TRUE);
            return 0;
        }

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

(请注意,我们直接在控件的绘制处理程序中处理控件擦除。有关此内容的讨论,请参阅本系列之前的文章之一:《Win32 API 中的自定义控件:绘制》。如果控件在那里进行擦除,则预擦除和后擦除通知当然可以从 WM_ERASEBKGND 发送。)

下面的截图展示了使用标准列表视图控件进行自定义绘制的强大功能。本文顶部的完整 MSVC 演示应用程序项目可以下载。

Custom draw demo

自定义绘制演示截图

好的,关于绘制定制就到这里,现在我们来看看如何修改控件的行为。

决策通知

许多 Windows 标准控件会发送通知,允许修改控件的一些默认逻辑。在大多数情况下,通知会将控件在当前情况下应如何表现的一些决策留给父级。即使按照我们对控件定制的定义,即涉及应用程序端的非平凡代码,这是否意味着使用控件或定制它仍有些模糊。但无论如何,我们至少谈谈它。

标准控件中此类通知的一个示例是当用户单击树视图控件中的任何(未选择的)项目时。控件会向父窗口发送 TVN_SELCHANGING。当父级返回零时,控件通常会更改选择(然后发送 TVN_SELCHANGED 通知)。但是,当 TVN_SELCHANGING 返回非零时,它会指示控件抑制选择的更改。

这种设计模式被许多标准控件在许多情况下使用。当某些东西通常会以显着方式改变控件状态时,控件可能希望允许对该行为进行一些自定义,并且控件通常会以以下伪代码所示的方式实现它:

    ...
    if(SendNotification(hwndParent, XXN_STATECHANGING, wParam, lParam) == 0) {
        // Do the control state change by modifying the control data
        // and (if needed) invalidate the control or its part to repaint
        // it so user can see the new control state.
        ...

        // Inform the parent the state has really changed.
        SendNotification(hwndParent, XXN_STATECHANGED, wParam, lParam);
    }
    ...

无论何时实现可重用的自定义控件,您都应该考虑应用程序是否有时可能希望禁用默认行为,或将某些其他自定义功能挂接到事件。如果是这样,请不要偷懒,并添加通知以支持它。

有一点我想强调:实现应该始终确保从 XXN_STATECHANGING 返回零会触发控件被视为默认的行为。这很重要,原因有二:

  • 当父级不处理通知时,它应将其传递给 DefWindowProc(),而 DefWindowProc()WM_NOTIFY 返回零。因此,在这种情况下,控件应以默认方式运行。
  • 对于向前兼容性也很重要:通常,此类通知仅在某些后续版本中添加到控件实现中。“默认行为”通常应与旧行为对应,以便不了解新通知的旧应用程序继续以相同方式运行,而新应用程序可以通过处理通知来更改行为。

超类化和子类化

超类化和子类化是基于交换控件窗口过程的两种技术。因此,这些技术有一个非常强大的优势:它们实际上都不需要被定制的控件提供任何显式支持。然而,这种强大并非没有代价:您必须非常小心,以免破坏原始控件过程的逻辑(尤其是当您无法访问其源代码时)。

超类化

超类化定义了一个新的窗口类,它派生自一个现有窗口类。以下示例代码提供了一个函数,用于从标准按钮创建非常简单的超类化控件。

#include <tchar.h>
#include <windows.h>

typedef struct SuperButtonData_tag SuperButtonData;
struct SuperButtonData_tag {
    // ... data for the superclass implementation
};


static WNDPROC lpfnButtonProc;
static int cbButtonExtra;

static LRESULT CALLBACK
SuperButtonProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    SuperButtonData* lpData = (SuperButtonData*) GetWindowLongPtr(hwnd, cbButtonExtra);

    switch(uMsg) {
        // Handle all messages we want to customize:
        ...

        case WM_NCCREATE:
            if(!CallWindowProc(lpfnButtonProc, hwnd, uMsg, wParam, lParam))
                return FALSE;
            lpData = (SuperButtonData*) malloc(sizeof(SuperButtonData));
            if(lpData == NULL)
                return FALSE;
            ... // Setup the lpData structure
            SetWindowLongPtr(hwnd, cbButtonExtra, (LONG_PTR) lpData);
            return TRUE;

        case WM_NCDESTROY:
            if(lpData)
                free(lpData);
            break;
    }

    // Instead of DefWindowProc(), we propagate messages into the original
    // button procedure for their default handling. Note it has to be called
    // indirectly via CallWindowProc().
    return CallWindowProc(lpfnButtonProc, hwnd, uMsg, wParam, lParam);
}

void
RegisterSuperButton(void)
{
    WNDSCLASS wc;

    GetClassInfo(NULL, _T("BUTTON"), &wc);

    // Remember some original data.
    lpfnButtonProc = wc.lpfnWndProc;
    cbButtonExtra = wc.cbWndExtra;

    // Name our class differently.
    wc.lpszClassName = _T("SUPERBUTTON");

    // We register the class as local one for the .EXE module calling this function.
    wc.style &= ~CS_GLOBALCLASS;
    wc.hInstance = GetModuleHandle(NULL);

    // Add few more extra bytes for our own purposes.
    wc.cbWndExtra += sizeof(SuperButtonData*);

    // Set our own window procedure.
    wc.lpfnWndProc = SuperButtonProc;

    // Finally, register the new class.
    RegisterClass(&wc);
}

如您所见,我们不是初始化新的 WNDCLASS,而是从现有的窗口类开始,提供新的窗口类名并增强一些现有的窗口类属性。特别是,我们让新的窗口类使用我们的窗口过程(如示例代码所示,它可以调用原始窗口过程),我们还增加了 wc.cbWndExtra,以便我们的数据可以存储在那里。请特别注意 wc.cbWndExtra 的处理。原始窗口类可能会在额外的字节中存储一些数据,所以我们必须小心不要覆盖它们。我们存储原始的 wc.cbWndExtra 值并将其用作偏移量,在那里存储我们自己的数据。

子类化

子类化工作方式不同:它更改现有控件的窗口过程。这是一种根本不同的方法:超类化定义了创建任意数量(自定义)控件的新配方(即窗口类),而子类化则更改通过给定 HWND 句柄引用的单个现有控件实例。

尽管这种方法看起来比超类化更不干净,但有时它非常有用:您可以修改未创建的控件的外观或行为(例如,自定义通用控件对话框中的控件,或由您的应用程序使用的第三方 DLL 创建的对话框)。

有两种方法可以实现它

  • 使用为此目的而专门设计的 API,即函数 SetWindowSubclass() 及其相关函数。
  • 老旧的方法是使用 SetWindowLongPtr(GWLP_WNDPROC) 手动重置窗口过程指针。

让我们从传统方式开始

#include <tchar.h>
#include <windows.h>

typedef struct SubButtonData_tag SubButtonData;
struct SubButtonData_tag {
    WNDPROC lpfnButtonProc;
    // ... data for the subclass implementation
};

static LPCTSTR pstrSubButtonId = _T("SUBBUTON");


static LRESULT CALLBACK
SubButtonProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    SubButtonData* lpData = (SubButtonData*) GetProp(hwnd, pstrSubButtonId);

    switch(uMsg) {
        // Handle all messages we want to customize:
        ...
    }

    // Instead of DefWindowProc(), we propagate messages into the original
    // button procedure for their default handling. Note it is called
    // indirectly via CallWindowProc().
    return CallWindowProc(lpData->lpfnButtonProc, hwnd, uMsg, wParam, lParam);
}

void
SubclassButton(HWND hwnd)
{
    SubButtonData* lpData;

    lpData = (SubButtonData*) malloc(sizeof(SubButtonData));
    lpData->lpfnButtonProc = (WNDPROC) GetWindowLongPtr(hwnd, GWLP_WNDPROC);

    // Setup subclass data as needed.
    ...

    SetProp(hwnd, pstrSubButtonId, lpData);
    SetWindowLongPtr(hwnd, GWLP_WNDPROC, (LONG_PTR) SubButtonProc);
}

void
UnsubclassButton(HWND hwnd)
{
    SubButtonData* lpData = (SubButtonData*) GetProp(hwnd, pstrSubButtonId);
    SetWindowLongPtr(hwnd, GWLP_WNDPROC, (LONG_PTR) lpData->lpfnButtonProc);
    RemoveProp(hwnd, pstrSubButtonId);
    free(lpData);
}

这段代码足够简单,无需讨论即可理解。但是,如果您仔细查看上面的代码,可以发现一些问题:

  • 如果单个控件被多次子类化,则从第二个子类调用的 GetWindowLongPtr(GWLP_WNDPROC) 会获取第一个子类的窗口过程指针。如果第一个子类决定卸载自身,则第二个子类不会知道,并且仍然将消息传播到它记住的窗口过程:即第一个子类的过程。这很可能导致崩溃,因为第一个子类已卸载并可能释放了其正常运行所需的所有资源。
  • 没有地方可以存储子类特定的数据,因此代码使用 SetProp()GetProp()。这些效率远低于(例如)额外字节(由窗口类指定)。窗口属性函数旨在与窗口一起存储任意数量的数据槽,因此它们在底层相当复杂。

基于 SetWindowSubclass() 的 API 解决了这两个问题(假设单个控件实例的所有子类都使用此 API),因此应优先使用。但是,它仅在以下版本中可用:COMCTL32.DLL版本 6 或更高版本(即在 Windows XP 上,并且仅适用于指定与库版本 6 兼容的应用程序,如之前在《Win32 API 中的自定义控件:视觉样式》中讨论的那样)。

使用该 API,上述代码示例可以改写如下:

#include <tchar.h>
#include <windows.h>
#include <commctrl.h>

typedef struct SubButtonData_tag SubButtonData;
struct SubButtonData_tag {
    // ... data for the subclass implementation
};

// The API treats the pair (uSubclassId, SubButtonProc) as unique identification
// of the subclass. Assuming we do not need multiple subclass levels of
// the same control (which would share the subclass procedure), we do not need
// to deal much with the ID. Only if we would need such esoteric generality, we
// would use it for distinguishing among the subclasses.
static UINT_PTR uSubButtonId = 0;


static LRESULT CALLBACK
SubButtonProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uSubclassId, DWORD_PTR dwData)
{
    SubButtonData* lpData = (SubButtonData*) dwData;

    switch(uMsg) {
        // Handle all messages we want to customize:
        ...
    }

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

void
SubclassButton(HWND hwnd)
{
    SubButtonData* lpData;

    lpData = (SubButtonData*) malloc(sizeof(SubButtonData));

    // Setup subclass data as needed.
    ...

    SetWindowSubclass(hwnd, SubButtonProc, uSubButtonId, (DWORD_PTR) lpData);
}

void
UnsubclassButton(HWND hwnd)
{
    SubButtonData* lpData;

    GetWindowSubclass(hwnd, SubButtonProc, uSubButtonId, (DWORD_PTR*) &lpData);
    RemoveWindowSubclass(hwnd, SubButtonProc, uSubButtonId);
    free(lpData);
}

困难

正如我在本节引言中已经指出的,超类化和子类化需要非常小心:考虑到无论何时实现控件,其过程都会处理大量消息,并且通常许多消息都应该在某个“协调”中发挥作用。例如,如果控件响应某些鼠标单击,它可能会处理鼠标按下事件和鼠标弹起事件。如果新的窗口过程将一个事件传播给原始过程,但没有传播另一个事件,则底层控件的内部状态很容易陷入不一致的状态。这种非平凡控件中多个消息处理程序的“协调”可能涉及数十条消息,并且通过覆盖这些消息很容易引入细微的错误。

如果您尝试自定义一个不断发展的控件,这将尤其复杂。例如,如果您自定义对话框中的标准控件,则需要考虑到存在不同版本的USER32.DLLCOMCTL32.DLL在各种 Windows 版本中,并且控件实现随之发展。较新版本可能具有更多功能、新的错误、提供旧错误的修复以及其行为和实现中的其他细微更改。

另一个困难是关于通知。通常,派生窗口过程可能需要知道底层控件状态何时以及如何更改。底层窗口过程通常通过向其父级发送通知消息来告知世界此类更改。这就是问题所在:超类/子类窗口过程的代码无法获取通知,因此您无法轻松响应控件状态的更改,也无法自定义通知。这通常意味着您的应用程序需要以某种方式将通知发送回控件,以便自定义窗口过程可以相应地做出反应。我们将在本系列的下一篇文章中探讨此问题。

总而言之,编程关乎权衡取舍:本节描述的技术确实非常强大,但使用这种强大功能需要巨大的责任和谨慎,并且它也有其局限性。在开始自定义控件之前,请考虑原始控件的作用、它是如何(可能)实现的,以及它如何与您的窗口过程冲突。最后但同样重要的是,针对底层控件的所有相关版本测试您的新控件。

实际代码

在之前的文章中,我通常会提供一些指向真实代码的链接,以供研究文章中介绍的主题。本文也不例外。

Wine 项目中某些标准控件(重新)实现的自绘

Wine 项目中某些标准控件(重新)实现的自定义绘制

mCtrl 中非标准控件实现的自定义绘制支持

Wine (重新)实现的超类化COMCTL32.DLL为位于其中的基本控件提供视觉主题支持USER32.DLL:

mCtrl 中的超类化,用于将标准按钮的一些功能回溯到较旧的 Windows 版本(Windows XP 上的主题 BS_ICON 和 Windows 2000 和 XP 上的 BS_SPLITBUTTON

Wine 中的子类化,用于在一些更复杂的控件(例如列表视图)中自定义标准编辑控件以进行标签编辑,或用于 IP 地址控件中的 4 个 IP 组件

下次:自定义控件封装

今天我们探讨了几种定制现有控件而非从头实现新控件的方法。我们发现,这通常会导致将部分代码移动到父窗口的过程。

这对于在其他对话框甚至其他应用程序上下文中轻松重用此类控件可能会带来问题。如果我们无法控制父窗口的代码,问题会更大。

因此,下一篇文章将继续本文的讨论,并致力于解决这个问题,描述几种在您的代码中解决它的可能性。

© . All rights reserved.