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)と無効の反映だけでもまずは完成させ、必要に応じてホット追従を足すのが安全です。
この方法が「ちょうどいい」理由
チェックボックス/ラジオボタンのダークモード対応にはいくつか選択肢がありますが、実運用で効いてくるのは次の観点です。
| 観点 | テーマを外す | 完全 OwnerDraw | NM_CUSTOMDRAW + テーマ API(本記事) |
|---|---|---|---|
| 記号が OS テーマに一致 | ×(クラシック化) | △(自前で頑張れば可) | ○ |
| 文字色だけ変更 | ○ | ○ | ○ |
| 実装コスト | 低い | 高い(状態管理が大変) | 中(状態マッピングは必要) |
| 再利用性 | 低〜中 | 中 | 高(部品化しやすい) |
「テーマ記号+明るい文字色」という要件に対して、OS の描画資産を最大限使いながら、足りない部分(文字色)だけを自前で補うのがこの方法の強みです。
まとめ:MFC(CDialogEx)のチェック/ラジオをダークモードで読みやすくする最短ルート
MFC のチェックボックス/ラジオボタンは、テーマを有効にしたままだと文字色だけを変更しにくく、テーマを外すと記号がクラシック化してしまいます。そこで、NM_CUSTOMDRAW で標準描画を抑止し、記号はテーマ API で描き、文字は明るい色で自前描画するのが、見た目と保守性のバランスが良い解決策です。
特に、CButton 派生クラス(CDarkRadioButton/CDarkCheckBox)として部品化しておくと、ダイアログをまたいでも貼り替えるだけで使い回せます。ダークモード対応は「一度きれいに部品化してしまう」と、その後の開発体験が一気に良くなる領域なので、ぜひこの方向で整備してみてください。

コメント