MFC CFindReplaceDialogのダークモードでGroupBoxの文字色が変わらない原因とSetWindowTheme対策

Windowsのダークモードに合わせてMFCアプリを調整していると、CFindReplaceDialog(検索/置換の共通ダイアログ)だけ配色が崩れることがあります。特にGroupBox(BS_GROUPBOX)の見出し文字が黒いまま残る問題は定番です。ここでは原因と、SetWindowTheme+OnCtlColorで最小変更で直す方法を実装例つきで解説します。

目次

症状:チェックボックスやラジオは暗くなるのに、GroupBoxのキャプションだけ黒いまま

MFCでダークモード対応を入れるとき、まずはダイアログ背景を暗色にし、OnCtlColorで各コントロールの文字色を明るくする、という流れが一般的です。自作ダイアログならこの方法でうまくいくのに、CFindReplaceDialog(Find/Replaceの共通ダイアログ)を派生して同じことをすると、次のような「一部だけ言うことを聞かない」状態になりがちです。

  • チェックボックス/ラジオボタン:文字色が期待どおり変わる
  • Static(ラベル類):文字色が変わる(または比較的調整しやすい)
  • GroupBox(例:「Direction」などの枠と見出し):見出し文字だけ既定色のまま

見た目としては、ダイアログ全体は暗いのに「枠のタイトル」だけ黒や濃いグレーで残り、ダークテーマとして非常に目立ちます。しかもFind/Replaceはモデルレスの共通ダイアログで、リソースを書き換えたり、いつもの「テーマ無効化+完全オーナードロー」を入れにくいのが厄介です。

やりたいこと自作ダイアログCFindReplaceDialog(共通ダイアログ)
背景を暗色にする比較的簡単(OnEraseBkgndや背景ブラシ)可能(ただし子コントロールの描画が混在しやすい)
文字色を明色にするOnCtlColor中心で対応しやすい多くは対応できるが、GroupBoxだけ残りやすい
枠線・見出しも統一するテーマ無効化/オーナードローが取りやすい作り込みは可能だが、最短ルートは別にある

なぜGroupBoxだけ色が変わらないのか:テーマ描画(Visual Styles)が「描き切っている」

結論から言うと、GroupBoxがVisual Styles(テーマ)で描画されていることが原因です。OnCtlColorは主に「GDIで描くときの色」を決めるフックですが、テーマが有効なコントロールは、内部的にテーマAPI(UxTheme)による描画に切り替わり、文字色や枠線の扱いがこちらの想定とズレます。

特にGroupBoxは「ボタン系コントロール(クラス名はButton)」でありつつ、BS_GROUPBOXスタイルを持った特殊な描画です。Visual Stylesが有効だと、GroupBoxはテーマ側のパーツとして描かれるため、次の現象が起きます。

  • OnCtlColorでSetTextColorしても、テーマ側が別の色で描いてしまい反映されない
  • NM_CUSTOMDRAWを期待しても、想定どおりのタイミングで拾えない/拾えても最終描画が上書きされることがある
  • 「枠」と「見出し文字」が同じ描画パスに乗っているため、局所的な修正が効きにくい
アプローチ狙いGroupBoxで効きにくい理由
OnCtlColor(WM_CTLCOLOR*)文字色・背景色をGDI側で指定テーマ描画が有効だと、テーマ側で文字や枠を描いてしまい、GDI側の設定が反映されない
NM_CUSTOMDRAW描画の途中で色や描画方法を差し替えるGroupBoxはボタン扱いでも、テーマ・内部実装の都合で期待どおりの通知や上書きタイミングにならないことがある
オーナードロー(BS_OWNERDRAWなど)枠線も含めて完全に自前描画共通ダイアログではリソースやスタイル変更の自由度が低く、導入コストが上がる

最短で効く解決策:該当GroupBoxだけテーマ描画を無効化し、OnCtlColorを効かせる

ここで効くのが、対象のGroupBoxだけテーマ描画を無効化する方法です。Windowsはコントロール単位で「テーマを使う/使わない」を切り替えられ、SetWindowThemeでそれができます。

テーマ描画を無効にすると、GroupBoxはクラシック(非テーマ)の描画に戻ります。すると、OnCtlColorで設定したSetTextColorが効くようになり、ダークモードの配色に合わせて見出し文字を明色にできます。

  • ポイントは「そのGroupBoxに対して一度だけ」SetWindowThemeを呼ぶこと
  • 文字列一致(例:「Direction」)で探すより、BS_GROUPBOXスタイル判定で一般化すると壊れにくい
  • OnCtlColorのシグネチャはMFCの正式形(引数の型・順番)にする

見た目が少し変わる(枠線の太さ、陰影、角の雰囲気)のは仕様です。テーマ描画(DrawTheme…)とクラシック描画はそもそも別物なので、完全一致させたい場合は後述の「追加の揃え方」を検討します。

実装例:CFindReplaceDialog派生での具体コード(SetWindowTheme+OnCtlColor)

ここでは、CFindReplaceDialogを派生したダイアログで、(1) GroupBoxだけテーマ無効化、(2) OnCtlColorで文字色を設定、という最小構成の例を示します。既にアプリ側でダークモードのON/OFF判定や背景ブラシを持っている前提でも、そのまま当てはめられるようにしています。

ヘッダとリンク設定

SetWindowThemeはUxThemeにあります。ヘッダ追加とリンクを用意します。

#include <uxtheme.h>
#pragma comment(lib, "uxtheme.lib")

派生クラスの宣言(OnCtlColorは正式シグネチャ)

class CDarkFindReplaceDialog : public CFindReplaceDialog
{
public:
    CDarkFindReplaceDialog() = default;

protected:
// MFC正式形:CDC* / CWnd* / UINT
afx_msg HBRUSH OnCtlColor(CDC* pDC, CWnd* pWnd, UINT nCtlColor);
virtual BOOL OnInitDialog();


DECLARE_MESSAGE_MAP()


private:
void DisableThemeForGroupBoxesOnce();


bool   m_groupBoxThemeDisabled = false;
CBrush m_brushDialogBk; // ダーク背景用(例)


};

メッセージマップ

BEGIN_MESSAGE_MAP(CDarkFindReplaceDialog, CFindReplaceDialog)
    ON_WM_CTLCOLOR()
END_MESSAGE_MAP()

OnInitDialogで「一度だけ」GroupBoxのテーマを切る

共通ダイアログのコントロールは初期化後に揃うため、まずCFindReplaceDialog::OnInitDialog()を呼び、その後で子コントロールを走査してBS_GROUPBOXのものだけにSetWindowThemeを当てます。

BOOL CDarkFindReplaceDialog::OnInitDialog()
{
    const BOOL ret = CFindReplaceDialog::OnInitDialog();

    // 例:背景ブラシをダーク色で作る(色はアプリの方針に合わせて)
    if (!m_brushDialogBk.GetSafeHandle())
    {
        m_brushDialogBk.CreateSolidBrush(RGB(30, 30, 30));
    }

    DisableThemeForGroupBoxesOnce();

    // テーマ切り替え後は再描画しておくと安心
    Invalidate(TRUE);
    UpdateWindow();

    return ret;
}

void CDarkFindReplaceDialog::DisableThemeForGroupBoxesOnce()
{
    if (m_groupBoxThemeDisabled)
        return;

    for (CWnd* pChild = GetWindow(GW_CHILD); pChild != nullptr; pChild = pChild->GetNextWindow())
    {
        const DWORD style = static_cast<DWORD>(pChild->GetStyle());

        // GroupBoxはBUTTONクラス+(style & BS_TYPEMASK) == BS_GROUPBOX が基本
        if ((style & BS_TYPEMASK) == BS_GROUPBOX)
        {
            const HWND h = pChild->GetSafeHwnd();
            if (h)
            {
                // よく使われる「空文字/空白」指定。環境差が気になる場合は空白版を採用。
                ::SetWindowTheme(h, L" ", L" ");
                // ::SetWindowTheme(h, L"", L""); // こちらでも動くケースが多い

                pChild->Invalidate();
            }
        }
    }

    m_groupBoxThemeDisabled = true;
}

OnCtlColorでGroupBoxのキャプション色を指定する

テーマ無効化したGroupBoxはクラシック描画になります。ここでOnCtlColor側のSetTextColorが効くようになります。GroupBoxの背景はダイアログ背景と合わせたいので、TRANSPARENTにし、戻り値にはダイアログ背景ブラシを返すのが扱いやすいです。

HBRUSH CDarkFindReplaceDialog::OnCtlColor(CDC* pDC, CWnd* pWnd, UINT nCtlColor)
{
    // まず既定処理
    HBRUSH hbr = CFindReplaceDialog::OnCtlColor(pDC, pWnd, nCtlColor);

    if (!pDC || !pWnd)
        return hbr;

    // 例:アプリ側の設定でダークモード中だけ実行する
    const bool darkMode = true; // 実際は自前のフラグに置き換え
    if (!darkMode)
        return hbr;

    const DWORD style = static_cast<DWORD>(pWnd->GetStyle());

    // GroupBoxかどうかをスタイルで判定(文字列一致より堅牢)
    if ((style & BS_TYPEMASK) == BS_GROUPBOX)
    {
        pDC->SetBkMode(TRANSPARENT);
        pDC->SetTextColor(RGB(220, 220, 220)); // ダーク背景で読める色

        // GroupBoxの背景は親と同じにする
        return (HBRUSH)m_brushDialogBk.GetSafeHandle();
    }

    // 他のコントロールも必要に応じてここで調整(チェックボックス等)
    // if (nCtlColor == CTLCOLOR_STATIC) { ... }

    return hbr;
}

この構成のメリットは、共通ダイアログの内部実装に深く踏み込まずに、問題のコントロールだけを狙い撃ちできることです。既存のダークモード実装(背景ブラシ、配色定数、DPI対応など)に素直に組み込めます。

実務で困りやすいポイント(落とし穴)

SetWindowThemeを呼ぶタイミング

SetWindowThemeは「ウィンドウハンドルが存在する」必要があります。よって、次のようなタイミングが安全です。

タイミング推奨度理由
OnInitDialogの後半(ベース呼び出し後)高子コントロールが作られた後なので確実。初回描画前に差し込める
OnShowWindow初回中初回表示タイミングで当てられるが、チラつきが出る場合がある
コンストラクタ低ハンドル未生成なので無効

OnCtlColorが呼ばれていない/効かない場合

「GroupBoxだけ反映されない」以前に、そもそもOnCtlColorが動いていないケースもあります。特に派生クラスでシグネチャが違うと、メッセージマップが一致せず呼ばれません。確認ポイントを表にまとめます。

チェック項目よくあるミス対策
関数シグネチャHBRUSH OnCtlColor(CDC* pDC, UINT nCtlColor)など引数が違うafx_msg HBRUSH OnCtlColor(CDC* pDC, CWnd* pWnd, UINT nCtlColor)にする
メッセージマップON_WM_CTLCOLOR()が無い/別クラスに書いた派生クラスのBEGIN_MESSAGE_MAPに追加する
背景ブラシローカル変数のブラシを返してしまうCBrushをメンバで保持し、寿命を確保する
背景モードSetBkModeを設定しておらず文字周りが四角く塗られるGroupBoxはTRANSPARENTにするのが無難

「枠線の太さ/色味が変わる」のはなぜ? 揃えたいときの選択肢

GroupBoxだけSetWindowThemeでテーマ無効化すると、ライトテーマのときは「そこだけクラシック風」に見えることがあります。これは自然な結果で、テーマ描画とクラシック描画は以下が異なるためです。

  • 枠線の陰影(ハイライト/シャドウの入れ方)
  • 線の太さや角の処理
  • フォントレンダリングの細部(環境によっては)

見た目の統一度を上げたい場合、要件に合わせて次のいずれかを選びます。

方針できることコスト向いているケース
最小修正(本記事の方法)キャプション文字色だけ確実に変える低まずはダークモードで読めることが最優先
枠線も自前で描く(疑似GroupBox)枠線・文字・余白まで完全に揃える中〜高UIの統一感が重要、テーマ差を消したい
テーマAPIで描く(テーマパーツを利用)テーマの見た目に合わせた描画中ライトテーマの見た目維持が重要、かつ描画実装に余力がある

共通ダイアログのFind/Replaceは「最小修正で破綻なく直す」価値が高い領域です。UIを完璧に揃えるより、まず読みやすさ(コントラスト)を確保し、違和感が許容できない場合にだけ作り込みに進むのが現場では合理的です。

応用:ID指定で狙い撃ちしたい場合、あるいは一般化したい場合

ID指定(固定IDが分かっている場合)

共通ダイアログのコントロールIDは環境やテンプレートで変動しやすいので乱用はおすすめしませんが、もし対象IDを把握できるなら、次のようにピンポイントでテーマ無効化できます。

if (CWnd* pGB = GetDlgItem(IDC_DIRECTION_GROUP))
{
    ::SetWindowTheme(pGB->GetSafeHwnd(), L" ", L" ");
    pGB->Invalidate();
}

スタイル判定で一般化(おすすめ)

文字列一致(例:「Direction」)はローカライズや将来の変更に弱いので、スタイル判定が堅牢です。特にFind/Replaceは言語やOSバージョン差の影響を受けやすいため、「BS_GROUPBOXかどうか」で判断するのが安全です。

ダークモードの切り替えがランタイムで起きる場合

アプリが「設定画面でダークモード切替→即時反映」を持つ場合、すでに表示中のFind/Replaceダイアログにも反映したくなります。GroupBoxのテーマ無効化は「無効化したら戻さない」方が楽で、ライト時の統一感は少し落ちますが、実装は安定します。ライト時の見た目を維持したいなら、切替時に再作成(いったん閉じて作り直す)方がトラブルが少ないです。

まとめ:GroupBoxだけ色が変わらないなら「テーマを切ってからOnCtlColor」

MFCのCFindReplaceDialogをダークモード対応するとき、GroupBoxのキャプションが既定色のまま残るのは、GroupBoxがVisual Stylesで描画されていることが主因です。該当GroupBoxだけSetWindowThemeでテーマ描画を無効化し、OnCtlColorで文字色を設定すると、共通ダイアログでも最小変更で確実に直せます。

枠線の見た目差は仕様として受け入れるか、統一感が必要なら枠線も含めて描画方針を別途検討してください。まずは「読める」ことを確保し、必要なところだけ段階的に作り込むのが、ダークモード対応を失敗させないコツです。

この記事を書いた人

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

コメント

コメントする

目次