MFC(CDialogEx)のチェックボックス/ラジオボタンをダークモード対応:テーマ記号を保って文字色だけ明るくする方法

Windows のダークモードに合わせて、MFC(CDialogEx)アプリのチェックボックス/ラジオボタンも暗い背景で見やすくしたいのに、テーマを外すと記号(○や✓)がクラシック化、外さないと文字が黒のまま…。本記事では「記号はテーマのまま」「文字色だけ明るく」を両立する実装を、コピペしやすい形で解説します。

目次

現象:テーマを外すと記号が崩れ、外さないと文字が黒い

CDialogEx ベースの MFC アプリで、チェックボックス/ラジオボタンをダーク表示に合わせようとすると、よく次のジレンマに当たります。

  • SetWindowTheme(hControl, L" ", L" "); でテーマを外すと、文字色は狙い通りに変わるが、チェック/ラジオの記号がクラシック描画になる
  • テーマを外さないと、記号(○や✓)は正しくテーマ描画されるが、文字色が黒のまま変えられない
  • ダイアログ側の OnCtlColor で頑張っても、チェックボックス/ラジオボタンの「文字色」だけを思った通りに変えられない

ダーク背景での視認性を考えると、記号は OS テーマに合わせたままにしたい一方、文字は明るい色にしたいというニーズが強いはずです。

やりたいこと現実に起きることつまずきポイント
テーマを外して文字色を変える記号がクラシックになり、UI が浮く「記号のテーマ描画」が失われる
テーマを保って見た目を統一する文字が黒のままで暗い背景に埋もれる標準描画だと「文字色」だけ差し替えにくい
OnCtlColor で文字色だけ変える効かない/効いても上書きされるテーマ描画の内部で text が描かれる

なぜ OnCtlColor では思い通りにならないのか

MFC のダーク化でよく使うのが、ダイアログ側で WM_CTLCOLOR*(MFC の OnCtlColor)を受け取り、SetTextColor や SetBkMode を設定するやり方です。静的テキスト(STATIC)やエディット(EDIT)には効くことが多いのに、チェックボックス/ラジオボタン(BUTTON クラス)だと狙い通りにいかないケースがあります。

理由はシンプルで、ビジュアルスタイル(テーマ)が有効な場合、チェックボックス/ラジオボタンは「テーマ側の描画ロジック」で記号と文字をまとめて描くことがあるためです。結果として、あなたが OnCtlColor で設定した文字色よりも、コントロール自身のテーマ描画が優先され、黒文字に戻ってしまいます。

一方、SetWindowTheme(hWnd, L" ", L" ") でテーマを外すと、テーマ描画が使われなくなるので、GDI の古典的な描画パスに戻り、OnCtlColor が効きやすくなります。ただし副作用として、記号(○や✓)もクラシック描画になり、モダンな Windows の UI から浮いてしまいます。

つまり「テーマの記号」と「明るい文字色」を両立するには、テーマを維持したまま、文字だけ別ルートで描く必要があります。

結論:テーマは維持したまま、NM_CUSTOMDRAW で文字だけ自前描画する

最も現実的で再利用しやすい落とし所は、NM_CUSTOMDRAW(カスタムドロー)で描画を乗っ取り、記号はテーマ API で描き、文字は明るい色で自前描画する方法です。

ポイントは次の通りです。

  • テーマは外さない(記号はテーマのままにする)
  • NM_CUSTOMDRAW を受け取って、標準描画(黒文字)をスキップする
  • チェック/ラジオの記号は DrawThemeBackground で描く(OS テーマ準拠)
  • 文字は SetTextColor + DrawText(または DrawThemeTextEx)で明るく描く
  • 無効(Disabled)状態の色も自分で分岐して描く

通知の取り方は 2 つあります。

方式受け取り場所向いているケース運用のしやすさ
親で WM_NOTIFY を拾うCDialogEx 側コントロール数が少ない、個別の例外が多いダイアログごとに実装が散らばりがち
コントロール自身で反射(推奨)CButton 派生クラス(ON_NOTIFY_REFLECT)大量のチェック/ラジオをまとめてダーク対応したい部品化でき、貼り替えるだけで流用しやすい

以降は、CButton 派生クラス側で NM_CUSTOMDRAW を受ける(反射)方式で、実装例を示します。ダイアログ側に「毎回ゴリゴリ書く」必要が減り、最終的に保守が楽になります。

実装に必要な前提:テーマ API(UxTheme)を使う準備

テーマの記号(チェック/ラジオ)を OS テーマ準拠で描くには、UxTheme の API を使います。次を準備してください。

  • インクルード:<uxtheme.h> と <vssym32.h>
  • リンク:uxtheme.lib
#include <uxtheme.h>
#include <vssym32.h>

// どちらかでリンク(好みで)
// #pragma comment(lib, "uxtheme.lib")

また、ビジュアルスタイル(comctl32 v6)が有効であることが前提になります。通常は MFC プロジェクトであればマニフェストが適切に入っていますが、もし記号が常にクラシックになる場合は、アプリ側のマニフェストや共通コントロールのバージョン設定を見直してください。

状態判定がキモ:BM_GETSTATE と GetCheck でテーマ状態を選ぶ

チェックボックス/ラジオボタンの見た目は、通常/ホット(マウスオーバー)/押下/無効などの状態によって変わります。テーマで正しく描くには、現在状態に合う iStateId を選んで DrawThemeBackground に渡す必要があります。

状態取得の基本は次の 2 つです。

  • SendMessage(BM_GETSTATE):押下・フォーカス・ホットなど(BST_PUSHED, BST_HOT, BST_FOCUS など)
  • GetCheck():未チェック/チェック/中間(BST_UNCHECKED, BST_CHECKED, BST_INDETERMINATE)

チェックボックスのテーマ状態(例)はこんな対応になります。

有効/無効マウス状態チェック状態テーマ state(CheckBox)
有効通常未チェックCBS_UNCHECKEDNORMAL
有効通常チェックCBS_CHECKEDNORMAL
有効ホット未チェックCBS_UNCHECKEDHOT
有効押下チェックCBS_CHECKEDPRESSED
無効(任意)未チェックCBS_UNCHECKEDDISABLED
無効(任意)チェックCBS_CHECKEDDISABLED
有効通常中間CBS_MIXEDNORMAL

ラジオボタンも同様で、RBS_UNCHECKED* と RBS_CHECKED* を使います(ラジオは通常「中間」は使いません)。

実装例:CButton 派生クラスで「テーマ記号+明るい文字」を描く

ここからが本題です。コントロール自身で NM_CUSTOMDRAW を受け取り、標準描画をスキップしつつ、テーマ記号と明るい文字を描くサンプルを示します。

設計としては、共通処理をベースクラスにまとめ、チェックボックス/ラジオボタンで「テーマの part と state 計算」だけを切り替えます。これにより、大量のコントロールにも横展開しやすい構成になります。

ヘッダー:CDarkOptionButtonBase / CDarkCheckBox / CDarkRadioButton

#pragma once

#include 
#include 
#include 

class CDarkOptionButtonBase : public CButton
{
public:
CDarkOptionButtonBase()
: m_clrTextEnabled(RGB(230, 230, 230))
, m_clrTextDisabled(::GetSysColor(COLOR_GRAYTEXT))
{}


void SetTextColors(COLORREF enabled, COLORREF disabled)
{
    m_clrTextEnabled  = enabled;
    m_clrTextDisabled = disabled;
    Invalidate();
}


protected:
virtual int ThemePartId() const = 0; // BP_CHECKBOX or BP_RADIOBUTTON
virtual int ThemeStateId(BOOL enabled, UINT bmState, int checkState) const = 0;


static UINT GetDpiForWindowSafe(HWND hwnd)
{
    HMODULE hUser32 = ::GetModuleHandleW(L"user32.dll");
    if (hUser32)
    {
        auto pGetDpiForWindow = reinterpret_cast<UINT(WINAPI*)(HWND)>(
            ::GetProcAddress(hUser32, "GetDpiForWindow"));
        if (pGetDpiForWindow)
            return pGetDpiForWindow(hwnd);
    }
    HDC hdc = ::GetDC(hwnd);
    UINT dpi = hdc ? (UINT)::GetDeviceCaps(hdc, LOGPIXELSX) : 96;
    if (hdc) ::ReleaseDC(hwnd, hdc);
    return dpi ? dpi : 96;
}

afx_msg void OnNMCustomdraw(NMHDR* pNMHDR, LRESULT* pResult);

DECLARE_MESSAGE_MAP()


protected:
COLORREF m_clrTextEnabled;
COLORREF m_clrTextDisabled;
};

class CDarkCheckBox : public CDarkOptionButtonBase
{
protected:
int ThemePartId() const override { return BP_CHECKBOX; }


int ThemeStateId(BOOL enabled, UINT bmState, int checkState) const override
{
    const bool pushed = (bmState & BST_PUSHED) != 0;
    const bool hot    = (bmState & BST_HOT) != 0;

    const bool checked = (checkState == BST_CHECKED);
    const bool mixed   = (checkState == BST_INDETERMINATE);

    if (!enabled)
    {
        if (mixed)   return CBS_MIXEDDISABLED;
        if (checked) return CBS_CHECKEDDISABLED;
        return CBS_UNCHECKEDDISABLED;
    }

    if (pushed)
    {
        if (mixed)   return CBS_MIXEDPRESSED;
        if (checked) return CBS_CHECKEDPRESSED;
        return CBS_UNCHECKEDPRESSED;
    }

    if (hot)
    {
        if (mixed)   return CBS_MIXEDHOT;
        if (checked) return CBS_CHECKEDHOT;
        return CBS_UNCHECKEDHOT;
    }

    if (mixed)   return CBS_MIXEDNORMAL;
    if (checked) return CBS_CHECKEDNORMAL;
    return CBS_UNCHECKEDNORMAL;
}


};

class CDarkRadioButton : public CDarkOptionButtonBase
{
protected:
int ThemePartId() const override { return BP_RADIOBUTTON; }


int ThemeStateId(BOOL enabled, UINT bmState, int checkState) const override
{
    const bool pushed = (bmState & BST_PUSHED) != 0;
    const bool hot    = (bmState & BST_HOT) != 0;
    const bool checked = (checkState == BST_CHECKED);

    if (!enabled)
        return checked ? RBS_CHECKEDDISABLED : RBS_UNCHECKEDDISABLED;

    if (pushed)
        return checked ? RBS_CHECKEDPRESSED : RBS_UNCHECKEDPRESSED;

    if (hot)
        return checked ? RBS_CHECKEDHOT : RBS_UNCHECKEDHOT;

    return checked ? RBS_CHECKEDNORMAL : RBS_UNCHECKEDNORMAL;
}


};

実装:NM_CUSTOMDRAW で描画し、既定描画はスキップする

ここが核心です。CDDS_PREPAINT のタイミングで背景・記号・文字を描き、最後に CDRF_SKIPDEFAULT を返して黒文字の既定描画を止めます。

BEGIN_MESSAGE_MAP(CDarkOptionButtonBase, CButton)
    ON_NOTIFY_REFLECT(NM_CUSTOMDRAW, &CDarkOptionButtonBase::OnNMCustomdraw)
END_MESSAGE_MAP()

void CDarkOptionButtonBase::OnNMCustomdraw(NMHDR* pNMHDR, LRESULT* pResult)
{
    auto* pCD = reinterpret_cast<LPNMCUSTOMDRAW>(pNMHDR);

    if (pCD->dwDrawStage != CDDS_PREPAINT)
    {
        *pResult = CDRF_DODEFAULT;
        return;
    }

    CDC* pDC = CDC::FromHandle(pCD->hdc);
    CRect rcClient;
    GetClientRect(&rcClient);

    // 1) 背景:親の背景を引き継ぐ(ダーク背景でも自然に馴染む)
    ::DrawThemeParentBackground(m_hWnd, pCD->hdc, &rcClient);

    // 2) 状態判定
    const BOOL enabled = IsWindowEnabled();
    const UINT bmState = (UINT)SendMessage(BM_GETSTATE, 0, 0);
    const int  check   = GetCheck();

    const int partId  = ThemePartId();
    const int stateId = ThemeStateId(enabled, bmState, check);

    // 3) テーマで記号を描く(○や✓をテーマのまま維持)
    HTHEME hTheme = ::OpenThemeData(m_hWnd, L"Button");

    SIZE glyphSize{ 0,0 };
    if (hTheme)
    {
        ::GetThemePartSize(hTheme, pCD->hdc, partId, stateId, nullptr, TS_TRUE, &glyphSize);
    }
    if (glyphSize.cx <= 0) glyphSize.cx = MulDiv(13, (int)GetDpiForWindowSafe(m_hWnd), 96);
    if (glyphSize.cy <= 0) glyphSize.cy = MulDiv(13, (int)GetDpiForWindowSafe(m_hWnd), 96);

    const UINT dpi = GetDpiForWindowSafe(m_hWnd);
    const int  pad = MulDiv(6, dpi, 96);
    const int  gap = MulDiv(6, dpi, 96);

    // 4) 記号とテキストの位置決め(BS_LEFTTEXT も考慮)
    const bool leftText = ((GetStyle() & BS_LEFTTEXT) != 0);

    CRect rcGlyph = rcClient;
    rcGlyph.top    = rcClient.top + (rcClient.Height() - glyphSize.cy) / 2;
    rcGlyph.bottom = rcGlyph.top + glyphSize.cy;

    CRect rcText = rcClient;

    if (!leftText)
    {
        // 記号が左、文字が右(一般的)
        rcGlyph.left  = rcClient.left + pad;
        rcGlyph.right = rcGlyph.left + glyphSize.cx;

        rcText.left = rcGlyph.right + gap;
    }
    else
    {
        // 文字が左、記号が右(BS_LEFTTEXT)
        rcGlyph.right = rcClient.right - pad;
        rcGlyph.left  = rcGlyph.right - glyphSize.cx;

        rcText.right = rcGlyph.left - gap;
    }

    if (hTheme)
    {
        ::DrawThemeBackground(hTheme, pCD->hdc, partId, stateId, &rcGlyph, nullptr);
    }
    else
    {
        // テーマが無効な環境向けのフォールバック(最低限)
        UINT uState = DFCS_BUTTONCHECK;
        if (check == BST_CHECKED) uState |= DFCS_CHECKED;
        if (!enabled) uState |= DFCS_INACTIVE;
        ::DrawFrameControl(pCD->hdc, &rcGlyph, DFC_BUTTON, uState);
    }

    // 5) 文字を明るい色で自前描画
    int oldBkMode = pDC->SetBkMode(TRANSPARENT);
    COLORREF oldText = pDC->SetTextColor(enabled ? m_clrTextEnabled : m_clrTextDisabled);

    // ボタンのフォントを使う
    CFont* pFont = GetFont();
    CFont* pOldFont = nullptr;
    if (pFont) pOldFont = pDC->SelectObject(pFont);

    CString text;
    GetWindowText(text);

    UINT dt = DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS;

    // アクセラレータの下線表示を標準動作に寄せる(Alt まで隠す)
    const DWORD uiState = (DWORD)SendMessage(WM_QUERYUISTATE, 0, 0);
    if (uiState & UISF_HIDEACCEL)
        dt |= DT_HIDEPREFIX;

    if (GetStyle() & BS_MULTILINE)
    {
        dt &= ~DT_SINGLELINE;
        dt &= ~DT_VCENTER;
        dt |= DT_WORDBREAK;
    }

    ::DrawText(pCD->hdc, text, text.GetLength(), &rcText, dt);

    // 6) フォーカス枠(必要なら)
    if (bmState & BST_FOCUS)
    {
        CRect rcFocus = rcText;
        rcFocus.DeflateRect(MulDiv(1, dpi, 96), MulDiv(1, dpi, 96));
        ::DrawFocusRect(pCD->hdc, &rcFocus);
    }

    if (pOldFont) pDC->SelectObject(pOldFont);
    pDC->SetTextColor(oldText);
    pDC->SetBkMode(oldBkMode);

    if (hTheme) ::CloseThemeData(hTheme);

    // 既定描画(黒文字)を抑止
    *pResult = CDRF_SKIPDEFAULT;
}

この実装で、チェック/ラジオの記号はテーマのまま、文字だけを明るい色で描画できます。さらに、IsWindowEnabled() で分岐しているため、無効状態の色もコントロールできます。

ダイアログへの組み込み:DDX/Subclass で貼り替えるだけにする

運用面でのポイントは「毎回ダイアログに描画コードを書かない」ことです。コントロールを派生クラスへ置き換え、DDX か Subclass で割り当てれば、同じ部品を流用できます。

DDX_Control で割り当てる例

// ダイアログのヘッダー(例)
#include "DarkOptionButtons.h"

class CMyDialog : public CDialogEx
{
// ...
CDarkRadioButton m_radioA;
CDarkRadioButton m_radioB;
CDarkCheckBox    m_checkX;
// ...
};

// DoDataExchange
void CMyDialog::DoDataExchange(CDataExchange* pDX)
{
CDialogEx::DoDataExchange(pDX);
DDX_Control(pDX, IDC_RADIO_A, m_radioA);
DDX_Control(pDX, IDC_RADIO_B, m_radioB);
DDX_Control(pDX, IDC_CHECK_X, m_checkX);
}

// OnInitDialog
BOOL CMyDialog::OnInitDialog()
{
CDialogEx::OnInitDialog();


// ダーク用の文字色(好みに合わせて)
m_radioA.SetTextColors(RGB(235,235,235), ::GetSysColor(COLOR_GRAYTEXT));
m_radioB.SetTextColors(RGB(235,235,235), ::GetSysColor(COLOR_GRAYTEXT));
m_checkX.SetTextColors(RGB(235,235,235), ::GetSysColor(COLOR_GRAYTEXT));

return TRUE;


}

SubclassDlgItem で割り当てる例(コントロール変数を増やしたくない場合)

コントロール変数が増えるのが嫌な場合は、派生クラスのインスタンスをメンバに持ちつつ、SubclassDlgItem で結びつける運用もあります。

BOOL CMyDialog::OnInitDialog()
{
    CDialogEx::OnInitDialog();

    m_radioA.SubclassDlgItem(IDC_RADIO_A, this);
    m_radioA.SetTextColors(RGB(235,235,235), ::GetSysColor(COLOR_GRAYTEXT));

    return TRUE;
}

「チェック/ラジオが大量にある」場合は、この方式で CDarkRadioButton / CDarkCheckBox を共通部品化して全て割り当てるのが、結局いちばん楽になります。

無効(Disabled)状態の文字色は自分で面倒を見る

今回の方式は「標準描画をスキップ」しているため、無効状態の見た目(薄い文字色、コントラストの落ち方)もあなたが決める必要があります。ただし対処は難しくありません。

  • 有効時:ダーク背景で読みやすい明るい文字色(例:RGB(230,230,230))
  • 無効時:COLOR_GRAYTEXT 相当の色(GetSysColor(COLOR_GRAYTEXT))

「デザインの一貫性」を優先するなら独自色でも構いませんが、アクセシビリティ(高コントラストや視認性)を考えるなら、無効時はシステム色に寄せておくと破綻しにくいです。

見た目のズレを防ぐコツ:記号サイズをテーマから取得してオフセットする

文字だけ自前描画にするときにありがちな失敗が、記号と文字の間隔が OS の標準とズレて「微妙にダサい」ことです。これを避けるには、今回の実装のようにテーマから記号サイズを取得し、テキスト描画領域をそのぶん右(または左)へオフセットします。

  • OpenThemeData(..., L"Button") でテーマを開く
  • GetThemePartSize で記号サイズ(チェック/ラジオ)を取得する
  • その幅+余白(DPI でスケール)をテキスト矩形に反映する

こうすると、Windows テーマが変わってもレイアウトが破綻しにくく、結果として「テーマ準拠」に見えます。

よくあるハマりどころと対策

黒い四角が出る/背景が馴染まない

CDRF_SKIPDEFAULT を返して既定描画を止めると、背景も自前で面倒を見る必要が出ます。対策として、最初に DrawThemeParentBackground で親背景を描き、背景と馴染ませます。ダイアログ側がダーク背景を塗っているなら、これで自然に溶け込みます。

フォーカス枠が出ない/位置が変

標準描画を止めているため、フォーカス枠も自分で描く必要があります。BM_GETSTATE の BST_FOCUS を見て、DrawFocusRect をテキスト矩形に対して描くのがシンプルです。

DPI スケールで余白が不自然

固定ピクセルの余白は高 DPI で破綻します。GetDpiForWindow(取れない環境では GetDeviceCaps(LOGPIXELSX))で DPI を取得し、MulDiv で余白をスケールさせるのが定番です。

ホット(マウスオーバー)状態が反映されない

BM_GETSTATE の BST_HOT が取れる環境なら、それを stateId に反映できます。もし常に通常状態に見える場合は、コントロールのスタイルや OS バージョン差の影響があるため、押下(BST_PUSHED)と無効の反映だけでもまずは完成させ、必要に応じてホット追従を足すのが安全です。

この方法が「ちょうどいい」理由

チェックボックス/ラジオボタンのダークモード対応にはいくつか選択肢がありますが、実運用で効いてくるのは次の観点です。

観点テーマを外す完全 OwnerDrawNM_CUSTOMDRAW + テーマ API(本記事)
記号が OS テーマに一致×(クラシック化)△(自前で頑張れば可)○
文字色だけ変更○○○
実装コスト低い高い(状態管理が大変)中(状態マッピングは必要)
再利用性低〜中中高(部品化しやすい)

「テーマ記号+明るい文字色」という要件に対して、OS の描画資産を最大限使いながら、足りない部分(文字色)だけを自前で補うのがこの方法の強みです。

まとめ:MFC(CDialogEx)のチェック/ラジオをダークモードで読みやすくする最短ルート

MFC のチェックボックス/ラジオボタンは、テーマを有効にしたままだと文字色だけを変更しにくく、テーマを外すと記号がクラシック化してしまいます。そこで、NM_CUSTOMDRAW で標準描画を抑止し、記号はテーマ API で描き、文字は明るい色で自前描画するのが、見た目と保守性のバランスが良い解決策です。

特に、CButton 派生クラス(CDarkRadioButton/CDarkCheckBox)として部品化しておくと、ダイアログをまたいでも貼り替えるだけで使い回せます。ダークモード対応は「一度きれいに部品化してしまう」と、その後の開発体験が一気に良くなる領域なので、ぜひこの方向で整備してみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次