MFC(C++)ダークモードでメニューアイコン背景が合わない原因と対策(透過PNG・オーナードロー・GetThemeColor)

MFC(C++)でメニューにアイコンを付けていると、Windows のダークモード有効化(DarkMode_Explorer)時に「メニュー背景は暗いのに、アイコン背景だけ白い」問題に遭遇しがちです。本記事では原因と、実装コスト別の2つの解決策を具体例付きで整理します。

目次

発生する症状:ダークモードでメニューアイコンだけ浮く

従来の MFC アプリでは、メニューアイコンを HBITMAP(ビットマップ)としてメニューに関連付け、必要に応じて背景色を塗り替える方式がよく使われます。たとえば、アイコン画像が「白背景で描かれている」場合に、メニュー背景色に合わせて塗り潰してから表示する、といった実装です。

ライトテーマだけを想定している間は、GetSysColor(COLOR_MENU)UpdateBitmapBackground に渡しておけば、見た目は破綻しにくいでしょう。しかし、Windows のエクスプローラー系ダークテーマを

SetWindowTheme(pDialog->GetSafeHwnd(), L"DarkMode_Explorer", nullptr);

のように適用すると、メニュー背景は暗くなるのに、アイコン側は「ライトテーマ前提の背景色のまま」になり、背景が四角く目立ってしまいます。

MFCで透過PNGのメニューアイコンはそのまま使えるのか

結論から言うと、「標準のビットマップメニュー(HBITMAP を貼る方式)だけで、透過 PNG(アルファ)を安定して扱う」のは難しいことが多いです。

  • MFC のメニューアイコンは歴史的経緯から「マスク(1bit 透過)+背景色前提」の実装に寄りやすい
  • 環境によっては 32bit ARGB を渡しても、アルファが期待通りに合成されない/マスク扱いになる
  • ダークモードでは背景色がテーマ描画で決まるため、固定色で塗り替えると破綻しやすい

そのため、透過 PNG を本気で使うなら、オーナードローで自分で描くのが最も再現性の高いルートになります(後述の「アプローチA」)。

原因:メニューの背景色は「単一の色」ではなく、テーマが描いている

ダークモード時のメニュー背景は、単純なシステムカラーではなく、uxtheme(Visual Styles)側が部品(パーツ)として描画しているケースが多くなります。つまり、

  • 背景の塗り(Fill)
  • 境界線
  • 選択時のハイライト
  • 無効(Disabled)時の表現
  • テキスト色(通常・選択・無効)

といった要素が、テーマの定義に基づいて描かれます。結果として「メニュー背景色=この 1 色」という前提でビットマップ背景を塗り替えると、ダークモードやハイコントラストでズレやすくなります。

GetSysColor(COLOR_MENU) が当てにならなくなる理由

GetSysColor(COLOR_MENU) は、昔から「メニューの背景色」を取る定番の方法でした。ただし Windows 10 以降では、OS のテーマやアクセント、ダークモードなどの影響で、返ってくる色が実際の描画結果と一致する保証が弱い状況が増えています。ドキュメント上でも、テーマ依存の外観に対してシステム色を頼り切るのは推奨されない、という整理になっています。

方法狙いダークモード追従注意点
GetSysColor(COLOR_MENU)「昔ながらのシステム色」取得弱い(ズレやすい)テーマ描画の色と一致しないことがある
OpenThemeData + DrawThemeBackgroundOS に「背景を描かせる」強い(自動)オーナードローが必要になる場合が多い
OpenThemeData + GetThemeColorテーマ定義から色を「読む」条件付きで可能テーマクラス名やパーツが将来変わるリスク

結論:現実的な解決アプローチは2つ

今回の「ダークモード時にメニューアイコン背景が合わない」問題は、次の 2 方向のどちらかで解決できます。

アプローチ狙い向いているケース
アプローチA:透過PNG+ImageList+オーナードロー背景塗り替えをやめて「透過で描く」見た目品質を上げたい/将来のテーマ変更に強くしたい
アプローチB:テーマから背景色を取得して既存の塗り替え関数を継続最小改修で「正しい背景色」を使う既存コード資産を活かしたい/オーナードローは避けたい

アプローチA:透過PNGをメニューに自然に馴染ませる(ImageList+オーナードロー)

最もモダンで破綻しにくいのは、ビットマップの背景塗り替えをやめ、アルファ付き PNG をそのまま透過描画する方式です。MFC の「標準メニューに HBITMAP を付ける」流れでは、アルファ透過が安定して扱えないことがあるため、メニュー項目をオーナードロー(MF_OWNERDRAW)にして自前描画に切り替えます。

全体像:OSに背景・文字を任せ、アイコンだけ透過描画する

  • アイコン:PNG → 32bit ARGB の HBITMAPHIMAGELIST に格納
  • 背景:OpenThemeData(nullptr, L"MENU")DrawThemeBackground で OS に描画させる
  • 文字:DrawThemeText で OS に描画させる(ダーク時の文字色も自動)

この構成の要点は、「背景色を取得して合わせる」発想ではなく、背景そのものをOSに描かせる点です。これにより、ライト/ダーク切り替えだけでなく、将来のテーマ変更にも追従しやすくなります。

手順:PNGからHIMAGELISTを作る

PNG を読み込む方法はいくつかありますが、手軽なのは GDI+ です。以下は「ファイルから PNG を読み込み、32bit ARGB の HBITMAP を作る」最小例です(実運用ではエラーハンドリングやリソース化を追加してください)。

// GDI+ 初期化はアプリ起動時に一度だけ
#include <gdiplus.h>
#pragma comment(lib, "gdiplus.lib")

static HBITMAP LoadPngAsHBitmap(const wchar_t* path, int cx, int cy)
{
    using namespace Gdiplus;

    Bitmap src(path);
    if (src.GetLastStatus() != Ok) return nullptr;

    // 必要ならリサイズ(高 DPI を考えるなら DPI/スケールで cx,cy を決める)
    Bitmap resized(cx, cy, PixelFormat32bppPARGB);
    Graphics g(&resized);
    g.SetInterpolationMode(InterpolationModeHighQualityBicubic);
    g.DrawImage(&src, 0, 0, cx, cy);

    HBITMAP hbmp = nullptr;
    // 背景色は透明でOK
    if (resized.GetHBITMAP(Color(0,0,0,0), &hbmp) != Ok) return nullptr;
    return hbmp;
}

作った HBITMAP はイメージリストに格納します。アルファを維持したい場合、ILC_COLOR32 を指定し、背景色は CLR_NONE にしておくのが無難です。

HIMAGELIST CreateMenuImageList(int cx, int cy)
{
    HIMAGELIST himl = ImageList_Create(cx, cy, ILC_COLOR32, 16, 16);
    ImageList_SetBkColor(himl, CLR_NONE); // 背景は透過扱い

    HBITMAP hbmp = LoadPngAsHBitmap(L"icons\\open.png", cx, cy);
    if (hbmp)
    {
        // 32bit ARGB の場合は Add を使うとアルファが残りやすい
        ImageList_Add(himl, hbmp, nullptr);
        DeleteObject(hbmp);
    }
    return himl;
}

手順:オーナードローメニューに必要なデータを持たせる

オーナードローでは、WM_DRAWITEM で「どの項目を描くか」を判断するために、メニュー項目ごとの情報を dwItemData にぶら下げるのが定番です。

保持したい情報理由
表示テキスト(例:”開く\tCtrl+O”)ショートカット表記を右寄せで描くため
ImageList のインデックスどの PNG を描くかを決めるため
任意のフラグ(チェック状態など)描画状態に応じて見た目を変えるため
struct ODM_DATA
{
    int imageIndex;
    std::wstring text; // "メニュー名\tCtrl+O" の形式
};

項目追加は AppendMenu でも InsertMenuItem でも構いません。ここでは「分かりやすさ優先」で AppendMenu 例を示します。

// pData はメニューが閉じるまで生きている必要がある
auto pData = new ODM_DATA{ 0, L"開く\tCtrl+O" };
AppendMenu(hMenu, MF_BYCOMMAND | MF_OWNERDRAW, ID_FILE_OPEN, (LPCWSTR)pData);

重要:ここで確保した pData は、メニュー破棄時に必ず解放してください(後述の「メモリリーク対策」参照)。

手順:WM_MEASUREITEMで項目サイズを決める

オーナードローでは、OS が「必要な幅・高さ」を知りません。WM_MEASUREITEM(MFC なら OnMeasureItem)でサイズを返す必要があります。ポイントは、

  • アイコン領域(例:16px or 20px)
  • 左右パディング
  • テキストの幅(メニュー名+ショートカット)

を考慮することです。最小例は次のようになります。

void CMainFrame::OnMeasureItem(int nIDCtl, LPMEASUREITEMSTRUCT lpMIS)
{
    if (lpMIS->CtlType != ODT_MENU) { CFrameWnd::OnMeasureItem(nIDCtl, lpMIS); return; }

    const int iconCx = 16;
    const int iconCy = 16;
    const int paddingX = 12;
    const int paddingY = 6;

    // 高さ:アイコン+上下余白、ただしシステムの最小値も考慮
    int cy = max(iconCy + paddingY * 2, GetSystemMetrics(SM_CYMENU));
    lpMIS->itemHeight = cy;

    // 幅:ここはだいたいでOK(必要ならテキスト測定を行う)
    lpMIS->itemWidth = iconCx + paddingX * 6 + 200;
}

実用上は GetTextExtentPoint32 などで文字幅を測り、ショートカット側を含めて余裕を持たせると、DPI/フォント変更に強くなります。

手順:WM_DRAWITEMで背景・アイコン・テキストを描く

描画の基本は「背景はテーマに任せ、アイコンは ImageList、文字もテーマに任せる」です。OpenThemeDataMENU クラスを開き、DrawThemeBackgroundDrawThemeText を使います。

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

void CMainFrame::OnDrawItem(int nIDCtl, LPDRAWITEMSTRUCT lpDIS)
{
    if (lpDIS->CtlType != ODT_MENU) { CFrameWnd::OnDrawItem(nIDCtl, lpDIS); return; }

    auto* pData = reinterpret_cast<ODM_DATA*>(lpDIS->itemData);
    if (!pData) return;

    const bool selected = (lpDIS->itemState & ODS_SELECTED) != 0;
    const bool disabled = (lpDIS->itemState & ODS_DISABLED) != 0;

    // テーマを開く(メニューだと hwndItem が null のこともあるので、null でもOK)
    HTHEME hTheme = OpenThemeData(nullptr, L"MENU");

    // 背景(選択状態なら HOT を使う)
    int part = MENU_POPUPITEM;
    int state = selected ? MPI_HOT : MPI_NORMAL;
    if (disabled) state = MPI_DISABLED;

    if (hTheme)
    {
        DrawThemeBackground(hTheme, lpDIS->hDC, part, state, &lpDIS->rcItem, nullptr);
    }
    else
    {
        // テーマが開けない場合のフォールバック
        FillRect(lpDIS->hDC, &lpDIS->rcItem, GetSysColorBrush(COLOR_MENU));
    }

    // アイコン領域
    RECT rc = lpDIS->rcItem;
    const int iconCx = 16;
    const int iconCy = 16;
    const int marginLeft = 10;
    const int iconX = rc.left + marginLeft;
    const int iconY = rc.top + ((rc.bottom - rc.top) - iconCy) / 2;

    UINT imgFlags = ILD_TRANSPARENT;
    if (disabled) imgFlags |= ILD_BLEND50;

    ImageList_Draw(m_hMenuImages, pData->imageIndex, lpDIS->hDC, iconX, iconY, imgFlags);

    // テキスト領域
    RECT rcText = rc;
    rcText.left = iconX + iconCx + 10;
    rcText.right -= 12;

    // "\\t" でショートカットを分離
    std::wstring left = pData->text;
    std::wstring right;
    auto tabPos = left.find(L'\\t');
    if (tabPos != std::wstring::npos)
    {
        right = left.substr(tabPos + 1);
        left.resize(tabPos);
    }

    // 左側(メニュー名)
    if (hTheme)
    {
        DrawThemeText(hTheme, lpDIS->hDC, part, state,
                      left.c_str(), (int)left.size(),
                      DT_SINGLELINE | DT_VCENTER | DT_LEFT, 0, &rcText);
    }
    else
    {
        DrawText(lpDIS->hDC, left.c_str(), (int)left.size(), &rcText,
                 DT_SINGLELINE | DT_VCENTER | DT_LEFT);
    }

    // 右側(ショートカット)を右寄せ
    if (!right.empty())
    {
        RECT rcAccel = rcText;
        rcAccel.left = rcText.left + 10;
        if (hTheme)
        {
            DrawThemeText(hTheme, lpDIS->hDC, part, state,
                          right.c_str(), (int)right.size(),
                          DT_SINGLELINE | DT_VCENTER | DT_RIGHT, 0, &rcAccel);
        }
        else
        {
            DrawText(lpDIS->hDC, right.c_str(), (int)right.size(), &rcAccel,
                     DT_SINGLELINE | DT_VCENTER | DT_RIGHT);
        }
    }

    if (hTheme) CloseThemeData(hTheme);
}

この描画では、背景や文字色は DrawThemeBackground/DrawThemeText が決めるため、ダークモード時も自然に馴染みます。アイコンは ILD_TRANSPARENT でアルファを活かし、無効状態は ILD_BLEND50 で「薄く表示」できます。

ダークモード追従のために SetPreferredAppMode を使う場合の考え方

アプリ内のコンテキストメニューを OS のダーク/ライトに追従させたい場合、UXTheme.dllSetPreferredAppMode(AllowDark) を呼び出す実装が紹介されることがあります(環境によってはエクスポート番号で取り出す例もあります)。これは実務上よく使われますが、公式に安定APIとして保証されているわけではないため、次の方針で組み込むのが安全です。

  • 存在チェックをして、見つからなければ何もしない(ライト表示にフォールバック)
  • OS バージョンやハイコントラストでは無理にダーク化しない
  • テーマ描画(uxtheme)を使う側に寄せ、色の直指定は極力減らす

メモリリーク対策:dwItemDataに渡したポインタの寿命管理

dwItemData には生ポインタを渡しがちですが、そのままだとリークしやすくなります。おすすめは「メニュー表示中だけ生存させ、メニュー終了後にまとめて破棄する」方式です。たとえばコンテキストメニューなら、TrackPopupMenu の前後で管理できます。

std::vector<std::unique_ptr<ODM_DATA>> m_ownerDrawItems;

void ShowPopupMenu(HWND hwnd, POINT pt)
{
    HMENU hMenu = CreatePopupMenu();

    // 追加
    {
        auto item = std::make_unique<ODM_DATA>();
        item->imageIndex = 0;
        item->text = L"開く\\tCtrl+O";
        AppendMenu(hMenu, MF_BYCOMMAND | MF_OWNERDRAW, ID_FILE_OPEN, (LPCWSTR)item.get());
        m_ownerDrawItems.push_back(std::move(item));
    }

    TrackPopupMenu(hMenu, TPM_LEFTALIGN | TPM_TOPALIGN, pt.x, pt.y, 0, hwnd, nullptr);

    DestroyMenu(hMenu);
    m_ownerDrawItems.clear(); // ここで一括破棄
}

unique_ptr を使うと、「例外や早期 return があっても破棄される」形にできるため、保守性が上がります。

高DPI・スケーリング対応のコツ

ダークモード対応と同じくらい、実運用で効いてくるのが高 DPI 対応です。アイコンサイズを固定値の 16px にすると、125% 以上の環境で粗く見えたり、縦位置がずれたりします。

  • アイコンの基準サイズ(16/20/24)を用意し、DPI に応じて選ぶ
  • GDI+ でリサイズする場合は補間を高品質にする
  • WM_MEASUREITEM で高さを十分に取り、文字が潰れないようにする

アプローチB:テーマから背景色を取得して既存の塗り替え関数を活かす

「オーナードローに踏み切るほどではない」「既存の UpdateBitmapBackground 資産を活かしたい」という場合は、ダークモード時だけ uxtheme から背景色を取得し、塗り替えに使う方法が現実的です。

実装例:DarkMode_ImmersiveStart::Menu から塗り色を読む

ダークモード用のメニューは、テーマのクラス(例:DarkMode_ImmersiveStart::Menu)に定義されていることがあります。そこから MENU_POPUPBACKGROUNDTMT_FILLCOLOR を取得し、その色で背景を塗り替えます。

void CMeetingScheduleAssistantApp::SetBitmapBackgroundAsMenuColour(HBITMAP hbmp)
{
    // デフォルトは従来どおり
    COLORREF crMenu = GetSysColor(COLOR_MENU);

    // アプリがダークモードを使う設定になっている場合のみ上書き
    if (DarkModeTools::InvokeUseDarkModeFunction())
    {
        // 実際のダークモード用メニューのテーマを開く
        HTHEME them = OpenThemeData(0, L"DarkMode_ImmersiveStart::Menu");
        if (them)
        {
            // ポップアップメニューの背景色(塗りつぶし色)を取得
            GetThemeColor(them, MENU_POPUPBACKGROUND, 0, TMT_FILLCOLOR, &crMenu);
            CloseThemeData(them);
        }
    }

    // 取得した背景色でビットマップの背景を塗り替え
    UpdateBitmapBackground(hbmp, true, crMenu);
}

この方法が成立する条件と落とし穴

このアプローチは「最小改修」で効く一方、次の前提を理解しておく必要があります。

項目内容対策
テーマクラス名の安定性クラス名やパーツ定義が将来変わる可能性開けない場合はフォールバック(GetSysColor など)
「背景色」は状態で変わる選択時・無効時・区切り線などは別の見た目必要なら状態別に色を取る/根本解決はアプローチA
高DPIとカラーマネジメント塗り替えたビットマップはスケールで劣化しやすい複数サイズを用意/なるべく透過 PNG に移行

また、背景を塗り替える方式は、アイコンが「本当に透過である」わけではなく、あくまで背景を近似色で埋めるだけです。選択時ハイライトや、テーマがグラデーションやテクスチャを使っている場合には、どうしても違和感が残ることがあります。

どちらを選ぶべきか:判断基準を表で整理

実装判断は「見た目の完成度」と「工数」のトレードオフになりやすいので、要件別に整理します。

観点アプローチA:透過PNG+オーナードローアプローチB:背景色取得+塗り替え
改修量大(描画ロジック追加、サイズ計測も必要)小(背景色取得処理の追加が中心)
見た目の再現度高(OSの背景・文字に自然に馴染む)中(背景の近似色に依存、状態変化でズレやすい)
ライト/ダーク切り替え強い(テーマ描画に任せる)条件付き(色取得が成功する範囲で)
ハイコントラスト対応比較的強い(テーマに寄せられる)弱め(色の取得や塗り替えが破綻しやすい)
将来のWindows更新耐性高(標準テーマAPIに寄せる)中〜低(テーマクラス名依存が残る)

長期保守や UI 品質を重視するなら、最終的にはアプローチA(透過 PNG+オーナードロー)へ寄せるのが安定です。一方、短期間で「とりあえず暗い背景に合わせたい」場合は、アプローチBが現実的な着地点になります。

実装でよくあるハマりどころ

PNGを入れたのに透過にならない

  • ImageList を ILC_COLOR32 で作っていない
  • ImageList_AddMasked を使っていてアルファが落ちている
  • PNG → HBITMAP 変換時に 32bit ARGB になっていない

まずは「HBITMAP が 32bit になっているか」「ImageList が 32bit で保持しているか」を疑い、可能なら ImageList_Add を試すのが早道です。

選択時の背景が変になる(色が合わない)

オーナードローで背景を自分で塗ってしまうと、ダークモード時のハイライト色がズレます。背景は必ず DrawThemeBackground で描き、状態(通常/選択/無効)に応じた state(MPI_NORMAL/MPI_HOT/MPI_DISABLED)を渡してください。

メニューの幅が狭く、ショートカットが切れる

WM_MEASUREITEM で幅を適当に固定すると、フォントや言語、DPI で切れます。実用では次のような測定が効果的です。

  • DC のフォントをメニュー用に合わせる(NONCLIENTMETRICS から取得するのが定番)
  • メニュー名とショートカットを別々に測り、余白を足した合計を返す

まとめ:ダークモード対応は「色を合わせる」より「テーマに任せる」

Windows のダークモード(DarkMode_Explorer)でメニュー背景が暗くなる環境では、GetSysColor(COLOR_MENU) に頼ってビットマップ背景を塗り替えるだけだと、見た目が破綻しやすくなります。

  • 見た目と将来耐性を重視するなら:透過PNG+ImageList+オーナードローで、背景・文字は DrawThemeBackground/DrawThemeText に任せる
  • 最小改修で当面しのぐなら:テーマから実背景色を取得して UpdateBitmapBackground に渡す

どちらを選んでも、「OS のテーマ描画を尊重し、直指定の色を減らす」ほど、ライト/ダークだけでなく、ハイコントラストや将来の Windows 更新にも強い実装になります。

この記事を書いた人

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

コメント

コメントする

目次