Programming Field

チェックボックス・ラジオボタン・グループボックスのオーナードロー

Windowsにおける「チェックボックス」、「ラジオボタン」(またはオプションボタン)、「グループボックス」(またはフレーム)は、すべて「Button」クラスのウィンドウですが、これらを独自に描画したい(オーナードローにしたい)場合はテクニックが必要となります。

なぜテクニックが必要であるかというと、チェックボックスもオプションボタンもグループボックスも同じ「Button」クラスであり、Buttonクラスの種類(ウィンドウスタイルにBS_TYPEMASKを使って得られる値)にある「BS_OWNERDRAW」は他の種類(それぞれBS_CHECKBOX・BS_AUTOCHECKBOX・BS_3STATE・BS_AUTO3STATE、BS_RADIOBUTTON・BS_AUTORADIOBUTTON、BS_GROUPBOX)と組み合わせられないため、「BS_OWNERDRAW」を使うとすべて「ボタン」の扱いとなり、チェックボックスなどの機能を失ってしまいます。そこで、

  1. 「BS_OWNERDRAW」を使い、サブクラス化してチェックボックスやラジオボタンなどの機能を実装する
  2. 「BS_OWNERDRAW」を使わずに、サブクラス化してWM_PAINTを実装する(その他の機能の実装はWindowsに任せる)

のいずれかの方法を使う必要があります。「オーナードロー」と考えると1番の方法を取りがちですが、2番の方が断然簡単です(描画するだけなので…)。ただ、いずれにしてもサブクラス化が必要なため、その分他のオーナードローよりも手間がかかります。

(1番の方法を取る場合は、チェックボックス・ラジオボタンの場合はWM_CHAR・WM_GETDLGCODE・WM_KEYUP・WM_LBUTTONUP・WM_LBUTTONDBLCLK・WM_MOUSEMOVE(テーマを使う場合)・WM_SETFOCUS(ラジオボタンのみ)・BM_GETSTATE・BM_GETCHECK・BM_SETCHECKの各メッセージ、グループボックスの場合はWM_GETDLGCODE・WM_NCHITTESTの各メッセージを実装する必要があります。これらの実装には「Button Messages」(MSDN Library; 英語)の「Button Default Message Processing」が参考になります。)

それぞれの実際の描画方法は次の通りです。

グループボックスを描く

まは標準的なグループボックスを描画する方法を紹介します。

なお、ここで使っている文字列はUnicode文字列ですが、テーマを使わない方はWindows 95でも動作します。

・テーマを使う場合

テーマを使う場合はOpenThemeDataで「Button」クラスのテーマを取得し、iPartIdで「BP_GROUPBOX」を利用します。ただし、DrawThemeBackground関数が描画する枠はpRectで指定した矩形ぎりぎりなため、枠の上に文字列を重ねる場合は微調整が必要となります。

描画コードは以下のようになります。

    LPCWSTR lpszCaption;  // コントロールの Unicode 文字列
    int nAlign;           // LEFT, CENTER, RIGHT の三種類の値を使用する
    HWND hWnd;            // コントロールの HWND
    HDC hDC;              // コントロールの HDC

    HTHEME hTheme;
    RECT rc;
    int nStateID;
    SIZE szText;
    RECT rcText;
    int nLen;
    bool bDisabled;

    bDisabled = !IsWindowEnabled(hWnd);
    GetClientRect(hWnd, &rc);

    hTheme = OpenThemeData(hWnd, L"Button");

    nLen = lpszCaption ? (int) wcslen(lpszCaption) : 0;

    memcpy(&rcText, &rc, sizeof(rcText));

    if (bDisabled)
        nStateID = GBS_DISABLED;
    else
        nStateID = GBS_NORMAL;

    // グループボックスは既定では背景を塗りつぶさない
    // 塗りつぶす場合は以下の2行を使う
    //if (IsThemeBackgroundPartiallyTransparent(hTheme, BP_GROUPBOX, nStateID))
    //    DrawThemeParentBackground(hWnd, hDC, &rc);

    if (nLen > 0)
        GetTextExtentPoint32W(hDC, lpszCaption, nLen, &szText);
    else
    {
        // テキストが無い場合でもテキストの高さを取得する
        GetTextExtentPoint32W(hDC, L"A", 1, &szText);
        szText.cx = 0;
    }
    // テキストが枠の中央にくるように枠の上座標を調整
    rc.top += szText.cy / 2;

    rcText.bottom = rcText.top + szText.cy;
    // マージンを取る
    rcText.left += 9;
    rcText.right -= 9;
    if (nAlign == CENTER)
    {
        // 中央に寄せる計算
        rcText.left += ((rcText.right - rcText.left) - szText.cx) / 2;
    }
    else if (nAlign == RIGHT)
    {
        // 右に寄せる計算
        rcText.left = rcText.right - szText.cx;
    }
    rcText.right = rcText.left + szText.cx + 2;
    rcText.left -= 2;

    // テキストがある場合、テキストの部分に枠が描かれないように
    // その領域をクリップしておく
    if (nLen > 0)
        ExcludeClipRect(hDC, rcText.left, rcText.top, rcText.right, rcText.bottom);
    DrawThemeBackground(hTheme, hDC, BP_GROUPBOX, nStateID, &rc, NULL);
    if (nLen > 0)
    {
        SelectClipRgn(hDC, NULL);
        // テキストが描かれる部分の背景を消去しておく
        if (IsThemeBackgroundPartiallyTransparent(hTheme, BP_GROUPBOX, nStateID))
            DrawThemeParentBackground(hWnd, hDC, &rcText);
        rcText.left += 2;
        rcText.right -= 2;
        DrawThemeText(hTheme, hDC, BP_GROUPBOX, nStateID, lpszCaption, nLen,
            DT_LEFT | DT_TOP | DT_SINGLELINE, 0, &rcText);
    }

    CloseThemeData(hTheme);

・テーマを使わない場合

テーマを使わない場合は、DrawEdge関数を軸として描画を行います。文字列の描画の際に文字列の背景を塗りつぶす方法がある(SetBkModeを使う)ので、文字列の部分をクリップする必要はありません。

    LPCWSTR lpszCaption;  // コントロールの Unicode 文字列
    int nAlign;           // LEFT, CENTER, RIGHT の三種類の値を使用する
    HWND hWnd;            // コントロールの HWND
    HDC hDC;              // コントロールの HDC

    RECT rc;
    RECT rcText;
    int nLen;
    bool bDisabled;
    COLORREF crText, crBack;
    SIZE szText;

    bDisabled = !IsWindowEnabled(hWnd);
    GetClientRect(hWnd, &rc);

    nLen = lpszCaption ? (int) wcslen(lpszCaption) : 0;

    // フレームでは文字列の部分に背景色が入るのでその準備をする
    // (背景色は SetBkColor で設定)
    nMode = SetBkMode(hDC, OPAQUE);

    GetTextExtentPoint32W(hDC, L"A", 1, &szText);
    rc.top += szText.cy / 2;
    // EDGE_ETCHED で意図したフレームを書くことが出来る
    DrawEdge(hDC, &rc, EDGE_ETCHED, BF_RECT);

    // テキストの描画に移る
    rc.top = 0;
    rc.bottom = rc.top + szText.cy + 1;

    // マージンを取る
    rc.left += 7;
    rc.right -= 7;
    // 無効状態の場合影を付ける
    if (bDisabled)
    {
        rc.left += 1;
        rc.top += 1;
        rc.right += 1;
        rc.bottom += 1;
    }
    if (nLen > 0)
    {
        if (bDisabled)
            crText = SetTextColor(hDC, GetSysColor(COLOR_3DHIGHLIGHT));
        else
            crText = SetTextColor(hDC, GetSysColor(COLOR_BTNTEXT));

        // 文字列の背景色を設定する
        crBack = SetBkColor(hDC, GetSysColor(COLOR_BTNFACE));

        GetTextExtentPoint32W(hDC, lpszCaption, nLen, &szText);
        if (nAlign == CENTER)
        {
            // 中央に寄せる計算
            rc.left += ((rc.right - rc.left) - szText.cx) / 2;
        }
        else if (nAlign == RIGHT)
        {
            // 右に寄せる計算
            rc.left = rc.right - szText.cx;
        }
        rc.right = rc.left + szText.cx;
        TextOutW(hDC, rc.left, rc.top, lpszCaption, nLen);
        if (bDisabled)
        {
            // テキストのうち浮き上がった方を描画する
            rc.left--;
            rc.top--;
            SetTextColor(hDC, GetSysColor(COLOR_GRAYTEXT));
            TextOutW(hDC, rc.left, rc.top, lpszCaption, nLen);
        }
        SetBkColor(hDC, crBack);
        SetTextColor(hDC, crText);
    }

    SetBkMode(hDC, nMode);

チェックボックス・ラジオボタンを描く

続いて、標準的なチェックボックス・ラジオボタンを描画する方法をまとめて紹介します。

なお、ここで使っている文字列はUnicode文字列で、テーマを使わない方でもDrawTextWを使用しているため、Windows 95で動作させるには「&」を正しく処理するような独自の描画関数を作る必要があります。

※ ここでは簡単のため、3-Stateのチェックボックスに関する描画は省略しています。

・下準備

描画を行う前に、チェックボックスの状態を取得しておく必要があります。

    HWND hWnd;            // コントロールの HWND

    RECT rc;
    bool bDisabled;
    bool bSelected;
    bool bChecked;
    bool bFocus;
    bool bHover;
    POINT pt;
    int nState;

    // ボタンの状態を取得する
    nState = PtrToInt(SendMessage(hWnd, BM_GETSTATE, 0, 0));
    // カーソルの位置を取得してコントロール上にあるかどうかチェックする
    GetCursorPos(&pt);
    GetWindowRect(hWnd, &rc);

    // 各種フラグを設定する
    bDisabled = (!IsWindowEnabled(hWnd));
    bSelected = ((nState & BST_PUSHED) != 0);
    bChecked = ((nState & 0x0003) != BST_UNCHECKED);
    bFocus = (GetFocus() == hWnd);
    bHover = (PtInRect(&rc, pt) != 0);

※ bHoverはテーマを使わない場合使用されませんが、独自の描画を行う場合使用できます。(ただしその場合、Windows2000以前ではカーソルをコントロールに合わせたときに再描画をするようにコードを変更する必要があります。)

また、チェックマークの描画を行う際にチェックマークのサイズを計算する必要があります。このサイズは通常固定値(13ピクセル程度)ですが、正確に計算する場合はLoadBitmap関数でOBM_CHECKBOXESを指定してビットマップをロードし、その幅と高さから計算する必要があります(OBM_CHECKBOXESを使うには windows.h をインクルードする前に「OEMRESOURCE」を定義する必要があります)。このビットマップはすべてのチェックボックスとラジオボタンのマークの状態を含んでいるため、横に4つ、縦に3つの計12個のマークを含んでいます。

チェックマークのサイズを計算するコードは以下のようになります。

    HBITMAP hbmCheckBoxes;
    BITMAP bm;
    // ビットマップをロードする
    hbmCheckBoxes = LoadBitmap(NULL, MAKEINTRESOURCE(OBM_CHECKBOXES));
    // ビットマップの情報を取得する
    GetObject(hbmCheckBoxes, sizeof(bm), &bm);
    // 横に4つ、縦に3つなので1つ当たりのサイズを計算する
    szCheckBox.cx = bm.bmWidth / 4;
    szCheckBox.cy = bm.bmHeight / 3;
    // ビットマップを破棄する
    DeleteObject(hbmCheckBoxes);

これで準備完了です。

・テーマを使う場合

テーマを使う場合はOpenThemeDataで「Button」クラスのテーマを取得し、iPartIdで「BP_CHECKBOX」「BP_RADIOBUTTON」を利用します。チェックマークの部分はDrawThemeBackground関数が描画しますが、チェックマークはpRectで指定した矩形の中央に描かれるため、その位置の調整が必要となります。また、フォーカスの存在を示す枠を描くのを忘れないように。

描画コードは以下のようになります。

    LPCWSTR lpszCaption;  // コントロールの Unicode 文字列
    int nAlign;           // LEFT, CENTER, RIGHT の三種類の値を使用する
    HWND hWnd;            // コントロールの HWND
    HDC hDC;              // コントロールの HDC
    bool bCheckBox;       // チェックボックスを描くかどうか(false: ラジオボタン)

    HTHEME hTheme;
    RECT rc;
    int nPartID, nStateID;
    SIZE szText;
    RECT rcText;
    int nLen;

    hTheme = OpenThemeData(hWnd, L"Button");

    nLen = lpszCaption ? (int) wcslen(lpszCaption) : 0;

    memcpy(&rcText, &rc, sizeof(rcText));

    if (IsThemeBackgroundPartiallyTransparent(hTheme, nPartID, nStateID))
        DrawThemeParentBackground(hWnd, hDC, &rcClient);

    // 右寄せの場合はチェックマークを右に描く
    if (nAlign == RIGHT)
        rc.left = rc.right - szCheckBox.cx;
    else
        rc.right = rc.left + szCheckBox.cx;

    if (lpszCaption)
        GetTextExtentPoint32W(hDC, lpszCaption, nLen, &szText);
    else
    {
        // テキストが無い場合でもテキストの高さを取得する
        GetTextExtentPoint32W(hDC, L"A", 1, &szText);
        szText.cx = 0;
    }
    // テキストがコントロールの中央にくるように計算
    rcText.top = (rcText.bottom - szText.cy) / 2;
    rcText.bottom = rcText.top + szText.cy;
    // 右寄せの場合はチェックマークのすぐ左にくるようにする
    if (nAlign == RIGHT)
        rcText.left = rcText.right - szText.cx - (szCheckBox.cx + 3);
    else
        rcText.left += (szCheckBox.cx + 3);
    rcText.right = rcText.left + szText.cx;

    // 状態に応じて nStateID の値を設定する
    if (bCheckBox)
    {
        nPartID = BP_CHECKBOX;
        if (bDisabled)
            nStateID = bChecked ? CBS_CHECKEDDISABLED : CBS_UNCHECKEDDISABLED;
        else
        {
            if (bSelected)
                nStateID = bChecked ? CBS_CHECKEDPRESSED : CBS_UNCHECKEDPRESSED;
            else if (bHover)
                nStateID = bChecked ? CBS_CHECKEDHOT : CBS_UNCHECKEDHOT;
            else
                nStateID = bChecked ? CBS_CHECKEDNORMAL : CBS_UNCHECKEDNORMAL;
        }
    }
    else
    {
        nPartID = BP_RADIOBUTTON;
        if (bDisabled)
            nStateID = bChecked ? RBS_CHECKEDDISABLED : RBS_UNCHECKEDDISABLED;
        else
        {
            if (bSelected)
                nStateID = bChecked ? RBS_CHECKEDPRESSED : RBS_UNCHECKEDPRESSED;
            else if (bHover)
                nStateID = bChecked ? RBS_CHECKEDHOT : RBS_UNCHECKEDHOT;
            else
                nStateID = bChecked ? RBS_CHECKEDNORMAL : RBS_UNCHECKEDNORMAL;
        }
    }
    DrawThemeBackground(hTheme, hDC, nPartID, nStateID, &rc, NULL);
    if (nLen > 0)
    {
        if (IsThemeBackgroundPartiallyTransparent(hTheme, nPartID, nStateID))
            DrawThemeParentBackground(hWnd, hDC, &rcText);
        DrawThemeText(hTheme, hDC, nPartID, nStateID, lpszCaption, nLen,
            DT_LEFT | DT_VCENTER | DT_SINGLELINE, 0, &rcText);
    }
    if (bFocus)
    {
        // フォーカスの枠は自前で描く(テキストを囲むようにする)
        rcText.left--;
        rcText.top--;
        rcText.right++;
        rcText.bottom++;
        DrawFocusRect(hDC, &rcText);
    }

    CloseThemeData(hTheme);

・テーマを使わない場合

テーマを使わない場合は、DrawFrameControl関数を軸として描画を行います(LoadBitmap関数でOBM_CHECKBOXESを読み込んでそれを描画しても構いませんが手間がかかります)。チェックマークを描いたらオーソドックスな方法で文字列を描画します。

    LPCWSTR lpszCaption;  // コントロールの Unicode 文字列
    int nAlign;           // LEFT, CENTER, RIGHT の三種類の値を使用する
    HWND hWnd;            // コントロールの HWND
    HDC hDC;              // コントロールの HDC

    bool bCheckBox;       // チェックボックスを描くかどうか(false: ラジオボタン)

    int nMode;
    RECT rc;
    RECT rcText;
    int nLen;
    int nCState;
    SIZE szText;
    COLORREF crText;

    nLen = (int) wcslen(lpszCaption);

    // 背景を塗りつぶす
    FillRect(hDC, &rc, GetSysColorBrush(COLOR_BTNFACE));

    // 文字列の背景は透明にしておく
    nMode = SetBkMode(hDC, TRANSPARENT);

    // 種類と状態に応じて nCState を設定する
    if (bCheckBox)
        nCState = DFCS_BUTTONCHECK;
    else
        nCState = DFCS_BUTTONRADIO;
    if (bChecked)
        nCState |= DFCS_CHECKED;
    if (bDisabled)
        nCState |= DFCS_INACTIVE;
    if (bSelected)
        nCState |= DFCS_PUSHED;

    rc.top = (rc.bottom - szCheckBox.cy) / 2;
    rc.bottom = rc.top + szCheckBox.cy;
    if (nAlign == RIGHT)
        rc.left = rc.right - szCheckBox.cx;
    else
        rc.right = rc.left + szCheckBox.cx;
    // マークを描画する
    DrawFrameControl(hDC, &rc, DFC_BUTTON, nCState);

    GetClientRect(hWnd, &rc);
    // テキストの位置を微調整する(「5」はチェックマークとテキストの間隔)
    if (nAlign == RIGHT)
        rc.right -= szCheckBox.cx + 5;
    else
        rc.left += szCheckBox.cx + 5;

    if (nLen > 0)
    {
        if (bDisabled)
            crText = SetTextColor(hDC, GetSysColor(COLOR_3DHIGHLIGHT));
        else
            crText = SetTextColor(hDC, GetSysColor(COLOR_BTNTEXT));
        GetTextExtentPoint32W(hDC, pData->lpszCaption, nLen, &szText);
        rc.top = (rcClient.bottom - szText.cy + 1) / 2;
        rc.bottom = rc.top + szText.cy;
        if (nAlign == RIGHT)
            rc.left = rc.right - szText.cx;
        else
            rc.right = rc.left + szText.cx;
        if (bDisabled)
        {
            // テキストのうち影の方を描画する
            rc.left++;
            rc.top++;
            rc.right++;
            rc.bottom++;
        }
        // Windows 95 系の場合は「&」を独自に処理した描画関数を使う
        // (DrawTextW は実装されていない)
        DrawTextW(hDC, lpszCaption, nLen, &rc, DT_SINGLELINE | DT_LEFT | DT_TOP);
        if (bDisabled)
        {
            // テキストのうち浮き上がった方を描画する
            rc.left--;
            rc.top--;
            rc.right--;
            rc.bottom--;
            SetTextColor(hDC, GetSysColor(COLOR_GRAYTEXT));
            DrawTextW(hDC, lpszCaption, nLen, &rc, DT_SINGLELINE | DT_LEFT | DT_TOP);
        }
        SetTextColor(hDC, crText);

        if (bFocus)
        {
            // テキストを囲むように枠を描画する
            rc.left--;
            rc.top--;
            rc.right++;
            rc.bottom++;
            DrawFocusRect(hDC, &rc);
        }
    }

    SetBkMode(hDC, nMode);