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

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (61投票s)

2015年11月3日

CPOL

13分钟阅读

viewsIcon

91510

downloadIcon

6688

如何为您的控件添加滚动支持。

本系列文章

引言

许多控件被设计用来显示动态内容或大量数据,这些内容或数据可能无法完全放入控件或对话框中。在这种情况下,控件通常需要配备滚动条,以便用户可以浏览这些数据。标准的控件,如列表视图或树状视图,可以作为这种方法的典型示例。

Windows API 直接支持每个窗口内的滚动条,而在今天的文章中,我们将展示如何利用这一点。

请注意,在COMCTL32.DLL中,Windows 还提供了一个独立的滚动条控件,但我们在这里不会涵盖它。一旦您理解了我们将要讨论的隐式滚动条支持,使用独立的滚动条控件就会变得非常简单直接。

非客户区

在我们开始讨论滚动条之前,我们需要了解非客户区的概念。在 Windows 中,每个窗口 (HWND) 都区分其客户区和非客户区。客户区(通常)覆盖屏幕上窗口的大部分(或全部)区域,实际上控件的基本内容或多或少都是在其中绘制的。

非客户区是客户区周围的可选边距,可用于一些辅助内容。对于顶层窗口,这包括窗口边框、带窗口标题和最小化、最大化和关闭按钮的窗口标题栏,菜单栏以及窗口周围的边框。

子窗口也可以并且相当频繁地利用非客户区。在大多数情况下,简单的边框以及可能的(如果需要)滚动条会在其中绘制。通常(即,除非被覆盖),如果控件具有 WS_BORDER 样式或扩展样式 WS_EX_CLIENTEDGE,则控件会获得边框。

类似地,如果控件决定需要滚动条,Windows 会在控件的右侧和/或底部非客户区为滚动条预留更多空间。

这种边框和滚动条的行为是在 DefWindowProc() 函数中实现的,该函数处理许多消息。

  • WM_NCCALCSIZE 确定非客户区的尺寸。默认实现会查看 WS_BORDER 样式、WS_EX_CLIENTEDGE 扩展样式以及滚动条的状态来完成此操作。
  • 各种鼠标消息的 WM_NCxxxx 对应消息以及 WM_NCHITTEST 共同处理非客户区的交互性。对于子控件,这通常涉及对滚动条按钮的响应,而 DefWindowProc() 为我们处理了这些。
  • WM_NCPAINT 用于绘制非客户区。同样,DefWindowProc() 中消息的处理程序知道如何绘制边框和滚动条。

所有这些标准行为都可以通过在您的窗口过程中处理这些消息来覆盖,但这并非我今天想讨论的内容。为了滚动目的,我们可以坚持使用 DefWindowProc() 提供的默认行为。

设置滚动条

每个 HWND 都存储两组整数值,用于描述水平和垂直滚动条的状态。这组值对应于 SCROLLINFO 结构体(除了辅助的 cbSizefMask 成员)。

typedef struct tagSCROLLINFO
{
    UINT    cbSize;
    UINT    fMask;
    int     nMin;
    int     nMax;
    UINT    nPage;
    int     nPos;
    int     nTrackPos;
}   SCROLLINFO, FAR *LPSCROLLINFO;
滚动条的结构

请注意,您可以为滚动选择任何单位。使用最适合您的控件逻辑的单位。这些值可以是像素、行数(或列数)、文本行数或任何其他内容。

nMinnMax 值决定了滚动条的范围,即滚动条的滑块移动到顶部(或左侧)和底部(或右侧)位置对应的最小和最大位置。在大多数情况下,nMin 可以始终设置为零,控件只需更新上限 nMax

nPage 值描述了在 nMinnMax 之间的内容部分,这些内容可以在控件的客户区大小下显示。Windows 也以滚动条滑块的比例大小可视化此值。

nPos 值决定了当前的滚动条位置,因此控件应绘制其内容的相应部分。

nTrackPos 值是在拖动滑块到新位置时滑块的位置。此值是只读的,不能通过编程直接更改。

支持滚动的控件可以通过 SetScrollInfo() 函数更新这些值,或者使用一些不太通用的函数,如 SetScrollPos()SetScrollRange(),它们只能更新部分值。

请记住,所有这些设置函数都会隐式确保 nPosnPage 始终在允许的范围内,以便以下条件始终成立:

  • 0 <= nPage < nMax - nMin
  • nMin <= nPos <= nMax - nPage

如果逻辑上无法满足这些条件,例如因为 nPage > nMax - nMin,则不需要滚动,Windows 会将 nPos 重置为零并隐藏滚动条(这将导致客户区大小调整和 WM_SIZE 消息)。

这意味着您,作为这些函数的调用者,不必过多担心边界情况。例如,如果您处理对按键的响应[PAGE UP]为向上滚动一页,您可以这样做:

// Get current nPos and nPage:
int scrollbarId = (isVertical ? SB_VERT : SB_HORZ);
SCROLLINFO si;
si.cbSize = sizeof(SCROLLINFO);
si.fMask = SIF_POS | SIF_PAGE;
GetScrollInfo(hwnd, scrollbarId, &si);

// Set new position one page up.
// Note we do not care whether we underflow below nMin as SetScrollInfo()
// does that for us automatically.
si.fMask = SIF_POS;
si.nPos -= si.nPage;
SetScrollInfo(hwnd, scrollbarId, &si);

// If we need to count with nPos below, we may need to ask again for the fixed
// up value of it:
GetScrollInfo(hwnd, scrollbarId, &si);

通常,支持滚动的控件需要在以下情况下更新滚动条的状态:

  • 当控件可见内容的数量或大小发生变化时,控件必须更新 nMin 和/或 nMax。例如,在类似于标准树状视图的控件中,当添加或删除新的(可见)项时,或者当项展开或折叠时。
  • 当客户区大小改变时(即在处理 WM_SIZE 时),控件必须更新 nPage,以便它能反映出能容纳多少内容。
  • 当控件响应 WM_VSCROLLWM_HSCROLL 所描述的滚动事件时,控件必须更新 nPos。我们将在本文后面更详细地介绍这一点。

当滚动条状态需要更新的其他情况可能包括某些内容元素的尺寸发生变化时,例如当控件开始使用具有不同大小的字体时。通常,相关工作的量取决于您如何智能地选择滚动单位:考虑一个树状视图控件和垂直滚动:如果它使用像素作为滚动单位,那么项高度的变化(例如,WM_SETFONT 的结果)必须通过重新计算滚动条的状态来反映;但如果您使用行作为滚动单位,则不需要。

一个小陷阱

当您的控件同时支持水平和垂直滚动条时,有一个小陷阱。请记住,当设置例如垂直滚动条时,如果值发生变化导致滚动条可见或隐藏,其客户区的大小会发生变化。

客户区大小的这种变化也可能导致需要更新另一个滚动条的状态。

以下代码演示了这个问题:

static void
CustomOnWmSize(HWND hWnd, UINT uWidth, UINT uHeight)
{
    SCROLLINFO si;

    si.cbSize = sizeof(SCROLLINFO);
    si.fMask = SIF_PAGE;

    si.nPage = uWidth;
    SetScrollInfo(hWnd, SB_HORZ, &si, FALSE);

    // BUG: The SetScrollInfo() above can result in yet another resizing of
    // the client area and recursive WM_SIZE message if the new page size
    // causes the scrollbar gets visible or gets hidden, and hence causes change
    // of the non-client area size.
    //
    // But after the recursive call returns the next call to SetScrollInfo()
    // may break the vertical scrollbar with possibly not-longer-valid value
    // of uHeight.

    si.nPage = uHeight;
    SetScrollInfo(hWnd, SB_VERT, &si, TRUE);
}

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

        case WM_SIZE:
            CustomOnWmSize(hWnd, LOWORD(lParam), HIWORD(lParam));
            return 0;

        ...
    }
}

一旦您理解了这个问题,解决方法就很简单:

static void
CustomOnWmSize(HWND hWnd, UINT uWidth, UINT uHeight)
{
    SCROLLINFO si;

    si.cbSize = sizeof(SCROLLINFO);
    si.fMask = SIF_PAGE;

    si.nPage = uWidth;
    SetScrollInfo(hWnd, SB_HORZ, &si, FALSE);

    // FIX: Make sure uHeight has the right value:
    {
        RECT rc;
        GetClientRect(hWnd, &rc);
        uHeight = rc.bottom - rc.top;
    }

    si.nPage = uHeight;
    SetScrollInfo(hWnd, SB_VERT, &si, TRUE);
}

处理 WM_VSCROLL 和 WM_HSCROLL

当滚动条可见时(即,当 nMax - nMin > nPage 时),并且用户与之交互(例如,通过单击滚动箭头按钮或拖动滑块),窗口过程会收到相应的非客户鼠标消息。当这些消息传递给 DefWindowProc() 时,它们会被转换为 WM_VSCROLL 消息(用于垂直滚动条)和 WM_HSCROLL 消息(用于水平滚动条)。

控件的窗口过程应按如下方式处理它们:

  1. 分析用户请求的操作。
  2. 相应地更新 nPos
  3. 刷新客户区,以便控件显示相应的内容部分。

因此,典型的处理程序代码可能如下所示:

static void
CustomHandleVScroll(HWND hwnd, int iAction)
{
    int nPos;
    int nOldPos;
    SCROLLINFO si;

    // Get current scrollbar state:
    si.cbSize = sizeof(SCROLLINFO);
    si.fMask = SIF_RANGE | SIF_PAGE | SIF_POS | SIF_TRACKPOS;
    GetScrollInfo(pData->hwnd, SB_VERT, &si);

    nOldPos = si.nPos;

    // Compute new nPos.
    // Note we do not care where nPos falls between nMin and nMax. See below.
    switch (iAction) {
    case SB_TOP:            nPos = si.nMin; break;
    case SB_BOTTOM:         nPos = si.nMax; break;
    case SB_LINEUP:         nPos = si.nPos - 1; break;
    case SB_LINEDOWN:       nPos = si.nPos + 1; break;
    case SB_PAGEUP:         nPos = si.nPos - CustomLogicalPage(si.nPage); break;
    case SB_PAGEDOWN:       nPos = si.nPos + CustomLogicalPage(si.nPage); break;
    case SB_THUMBTRACK:     nPos = si.nTrackPos; break;
    default:
    case SB_THUMBPOSITION:  nPos = si.nPos; break;
    }

    // Update the scrollbar state (nPos) and repaint it. The function ensures
    // the nPos does not fall out of the allowed range between nMin and nMax
    // hence we ask for the corrected nPos again.
    SetScrollPos(hwnd, SB_VERT, nPos, TRUE);
    nPos = GetScrollPos(hwnd, SB_VERT);

    // Refresh the control (repaint it to reflect the new nPos). Note we
    // here multiply with some unspecified scrolling unit which specifies
    // amount of pixels corresponding to the 1 scrolling unit.
    // We will discuss ScrollWindowEx() more later in the article.
    ScrollWindowEx(hwnd, 0, (nOldPos - nPos) * scrollUnit
                   NULL, NULL, NULL, NULL, SW_ERASE | SW_INVALIDATE);
}

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

        case WM_VSCROLL:
            // LOWORD(wParam) determines the desired scrolling action.
            CustomHandleVScroll(hwnd, LOWORD(wParam));
            return 0;

        ...
    }

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

对于 WM_HSCROLL,代码会非常相似。

更新客户区

在上面的代码片段中,我们使用了 ScrollWindowEx() 函数。现在让我们仔细看看它。

绘制客户区是控件通常在处理 WM_PAINT 消息时执行的任务。对于支持滚动的控件,该函数必须考虑当前的 nPos 值。(或者实际上是两个值,nPosHoriznPosVert,如果控件支持两个方向的滚动。)

通常这意味着控件内容以垂直和水平偏移绘制,偏移量为 -(nPosVert * uScrollUnitHeight)-(nPosHoriz * uScrollUnitWidth),这样当滚动条不在最小位置时,控件就会显示更靠下的和更靠右的内容。(uScrollUnitWidthuScrollUnitHeight 以像素为单位确定滚动单位的宽度和高度。)

当应用程序更改滚动条的状态时(即范围、位置,甚至页面大小),它通常需要重绘自身。它可以简单地使其客户区无效,然后让 WM_PAINT 从头开始重绘所有内容。

或者它可以做得更智能。在大多数滚动情况下,屏幕上通常已经有很多正确绘制的内容。它只是绘制在不正确的位置,对应于旧的 nPos 值,对吧?

解决方案是简单地将所有仍然有效的内容从旧位置移动到新位置,并且仅使确实需要从头开始重绘的客户区部分无效,即仅使大致对应于在滚动操作期间从“角落后面”移入可见视口的水平或垂直条带的区域无效。

这正是 ScrollWindowEx() 函数的用途。您告诉它一个矩形,告诉它像素的水平和垂直偏移量(旧 nPos 和新 nPos 之间的差值),它会完成所有魔术。它实际上会将一些图形内存从一个地方复制/移动到另一个地方,以尽可能多地重复使用旧内容,并且仅使矩形中真正需要重绘的部分无效。假设 WM_PAINT 处理程序已正确实现,并且仅重绘脏矩形(请参阅 本系列关于绘图的第二部分),它将需要更少的工作。

使用键盘滚动

在许多情况下,通过键盘上的相应按键进行滚动非常有用。例如,假设箭头键、[HOME], [END], [PAGE DOWN][PAGE UP]应该直接转换为滚动命令,代码可以非常简单:

static void
CustomHandleKeyDown(CustomData* pData, UINT vkCode)
{
    switch (vkCode) {
    case VK_HOME:   CustomHandleVScroll(pData, SB_TOP); break;
    case VK_END:    CustomHandleVScroll(pData, SB_BOTTOM); break;
    case VK_UP:     CustomHandleVScroll(pData, SB_LINEUP); break;
    case VK_DOWN:   CustomHandleVScroll(pData, SB_LINEDOWN); break;
    case VK_PRIOR:  CustomHandleVScroll(pData, SB_PAGEUP); break;
    case VK_NEXT:   CustomHandleVScroll(pData, SB_PAGEDOWN); break;
    }
}

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

        case WM_KEYDOWN:
            CustomHandleKeyDown(pData, wParam);
            return 0;

        ...
    }

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

使用鼠标滚轮滚动

添加鼠标滚轮滚动支持有些意思。它不那么简单的一个主要原因是可用硬件的多样性。鼠标滚轮在很多情况下并非真正的滚轮,而且常常根本没有鼠标。例如,考虑现代触控板,它们可以将某些手势映射到虚拟鼠标滚轮。

即使在鼠标之间,也存在巨大的差异。您应该知道,计算机主要处理数字。因此,滚动鼠标滚轮会转换为一个数字,我们可以称之为“delta”。根据硬件、其驱动程序、系统配置以及太阳系中行星的位置,相同的鼠标滚轮操作有时会一次产生较大的 delta,或者连续产生一系列较小的 delta。

在 Windows 中,delta 作为 WM_MOUSEWHEEL 消息(用于垂直滚轮)或 WM_MOUSEHWHEEL 消息(用于水平滚轮)的参数传播:它存储在 WPARAM 的高位字中。

因此,为了处理这些消息,应用程序(或在本例中为控件)必须累积 delta,直到它达到某个阈值,该阈值表示“向下滚动一行”(或向上;或向左或向右进行水平滚动)。

此外,控件应尊重系统中配置的滚轮灵敏度。在 Windows 中,此设置可以通过 SystemParametersInfo(SPI_GETWHEELSCROLLLINES)(用于垂直滚轮)和 SystemParametersInfo(SPI_GETWHEELSCROLLCHARS)(用于水平滚轮)来检索。这两个值都对应于当累积的 delta 值达到由宏 WHEEL_DELTA(120)定义的值时,控件应滚动的垂直或水平滚动单位的数量。

上面的内容可能看起来相当困难,但实际上并非如此。此外,我们实际上可以实现一次:Windows 只支持一个鼠标指针,这意味着永远不会有超过一个垂直滚轮和一个水平滚轮。因此,我们可以为累积值使用全局变量和一个处理这些变量的包装函数,而不是充斥每个控件的数据结构并重新实现它。

这样的函数代码可能如下所示:

// Lock protecting the static variables. Note you have to initialize the
// critical section before calling the function WheelScrollLines() below
// for the first time.
static CRITICAL_SECTION csWheelLock;


// Helper function for calculation of scrolling lines for provided mouse wheel
// delta value. This function is quite generic and can be used/shared among
// many controls.
int
WheelScrollLines(HWND hwnd, int iDelta, UINT nPage, BOOL isVertical)
{
    // We accumulate the wheel_delta until there is enough to scroll for
    // at least a single line. This improves the feel for strange values
    // of SPI_GETWHEELSCROLLLINES and for some mouses.
    static HWND hwndCurrent = NULL;         // HWND we accumulate the delta for.
    static int iAccumulator[2] = { 0, 0 };  // The accumulated value (vert. and horiz.).
    static DWORD dwLastActivity[2] = { 0, 0 };

    UINT uSysParam;
    UINT uLinesPerWHEELDELTA;   // Scrolling speed (how much to scroll per WHEEL_DELTA).
    int iLines;                 // How much to scroll for currently accumulated value.
    int iDirIndex = (isVertical ? 0 : 1);  // The index into iAccumulator[].
    DWORD dwNow;

    dwNow = GetTickCount();

    // Even when nPage is below one line, we still want to scroll at least a little.
    if (nPage < 1)
        nPage = 1;

    // Ask the system for scrolling speed.
    uSysParam = (isVertical ? SPI_GETWHEELSCROLLLINES : SPI_GETWHEELSCROLLCHARS);
    if (!SystemParametersInfo(uSysParam, 0, &uLinesPerWHEELDELTA, 0))
        uLinesPerWHEELDELTA = 3;  // default when SystemParametersInfo() fails.
    if (uLinesPerWHEELDELTA == WHEEL_PAGESCROLL) {
        // System tells to scroll over whole pages.
        uLinesPerWHEELDELTA = nPage;
    }
    if (uLinesPerWHEELDELTA > nPage) {
        // Slow down if page is too small. We don't want to scroll over multiple
        // pages at once.
        uLinesPerWHEELDELTA = nPage;
    }

    EnterCriticalSection(&csWheelLock);

    // In some cases, we do want to reset the accumulated value(s).
    if (hwnd != hwndCurrent) {
        // Do not carry accumulated values between different HWNDs.
        hwndCurrent = hwnd;
        iAccumulator[0] = 0;
        iAccumulator[1] = 0;
    } else if (dwNow - dwLastActivity[iDirIndex] > GetDoubleClickTime() * 2) {
        // Reset the accumulator if there was a long time of wheel inactivity.
        iAccumulator[iDirIndex] = 0;
    } else if ((iAccumulator[iDirIndex] > 0) == (iDelta < 0)) {
        // Reset the accumulator if scrolling direction has been reversed.
        iAccumulator[iDirIndex] = 0;
    }

    if (uLinesPerWHEELDELTA > 0) {
        // Accumulate the delta.
        iAccumulator[iDirIndex] += iDelta;

        // Compute the lines to scroll.
        iLines = (iAccumulator[iDirIndex] * (int)uLinesPerWHEELDELTA) / WHEEL_DELTA;

        // Decrease the accumulator for the consumed amount.
        // (Corresponds to the remainder of the integer divide above.)
        iAccumulator[iDirIndex] -= (iLines * WHEEL_DELTA) / (int)uLinesPerWHEELDELTA;
    } else {
        // uLinesPerWHEELDELTA == 0, i.e. likely configured to no scrolling
        // with mouse wheel.
        iLines = 0;
        iAccumulator[iDirIndex] = 0;
    }

    dwLastActivity[iDirIndex] = dwNow;
    LeaveCriticalSection(&csWheelLock);

    // Note that for vertical wheel, Windows provides the delta with opposite
    // sign. Hence the minus.
    return (isVertical ? -iLines : iLines);
}

注释

  • 首先,请记住函数名称中的“line”以及某些变量名称中的“line”更多地指的是通用的“滚动单位”,而不一定是此上下文中的实际行。此命名来自滚动一个滚动单位向上或向下(SB_LINEUPSB_LINEDOWN),或向左或向右(SB_LINELEFTSB_LINERIGHT)的标准符号名称。抱歉术语混乱,但对于像我这样懒惰的人来说,“滚动单位”写起来太麻烦了...
  • 如果该函数在多个线程中运行的应用程序中使用,它必须是线程安全的,以保护由多个静态变量描述的状态。因此,使用了 CRITICAL_SECTION
  • 我们在特定情况下重置累加器:当 HWND 更改时,当在滚轮活动没有发生一段时间后,或者当用户开始向相反方向滚动时。您可能会注意到不活动时间与基于 GetDoubleClickTime() 的时间段进行比较。我选择使用它,因为 双击时间在 Windows 中用作衡量您反应能力的标准
  • 出于历史原因,垂直滚轮的 delta 值符号与大多数人期望的相反,并且与水平滚轮相比方向相反。为了简化代码,我们在一个地方处理了这个问题:函数的最后一行。

WheelScrollLines() 函数非常通用且可重用。实际上,人们甚至可以认为这样的函数应该在某些标准的 Win32API 库中实现。这至少可以更好地保证鼠标滚轮在应用程序默认情况下的一致使用。但据我所知,它并没有,至少不是公开导出的。

该函数的使用非常简单:

static void
CustomHandleMouseWheel(HWND hwnd, int iDelta, BOOL isVertical)
{
    SCROLLINFO si;
    int nPos;
    int nOldPos;

    si.cbSize = sizeof(SCROLLINFO);
    si.fMask = SIF_PAGE | SIF_POS;
    GetScrollInfo(hwnd, (isVertical ? SB_VERT : SB_HORZ), &si);

    // Compute how many lines to scroll.
    nOldPos = si.nPos;
    nPos = nOldPos + WheelScrollLines(pData->hwnd, iDelta, si.nPage, isVertical);

    // Scroll to the desired location.
    nPos = SetScrollPos(hwnd, (isVertical ? SB_VERT : SB_HORZ), nPos);

    // Update the client area.
    ScrollWindowEx(hwnd, 
                   (isVertical ? 0 : (nOldPos - nPos) * scrollUnit),
                   (isVertical ? (nOldPos - nPos) * scrollUnit, 0),
                   NULL, NULL, NULL, NULL, SW_ERASE | SW_INVALIDATE);
}

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

        case WM_MOUSEWHEEL:
            CustomHandleMouseWheel(hwnd, HIWORD(wParam), TRUE);
            return 0;

        case WM_MOUSEHWHEEL:
            CustomHandleMouseWheel(hwnd, HIWORD(wParam), FALSE);
            return 0;

        ...
    }

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

可下载的示例

这次,有两个示例项目可供下载。您可以在本文最顶部找到指向它们的链接。

更简单的那个只允许垂直滚动,但除此之外大致对应于文章中提供的所有代码。

Screenshot of the simple demo

简单演示的截图

第二个(也是更复杂的)示例实现了垂直和水平滚动,它具有动态变化的内容,因此 nMinnMax 在控件的生命周期内都会发生变化,它展示了非标准滚动单位的使用,最重要的是,它展示了 ScrollWindowEx() 函数的高级用法,该函数仅滚动窗口的一部分,以使列标题和行标题始终可见。

Screenshot of the more complex demo

更复杂的演示的截图

实际代码

在本系列中,提供一些指向展示文章主题的真实代码的链接已成为传统。

为了获得更深入的了解,您可能会发现研究 Wine 中滚动条支持的(重新)实现非常有益。我尤其建议关注 SCROLL_SetScrollInfo() 函数,它实现了 SetScrollInfo() 的核心。

当然,还有一些使用隐式滚动条的 Wine 控件:

最后,还有一些 mCtrl 控件也使用了它:

下次:更多关于非客户区的内容

今天,我们讨论了如何在控件中实现滚动支持。在此过程中,我们简要地触及了非客户区的主题,因为滚动条就生活在那里。下次,我们将看看如何稍微自定义非客户区,以及如何在其中进行绘制。

© . All rights reserved.