MFC MDIでWM_MDINEXTがCMainFrameに届かない原因と対処:Ctrl+Tabを確実にフックする方法

目次

MFCのMDIアプリでWM_MDINEXTが捕捉できない原因と、確実にフックする実装ガイド

CMainFrameON_MESSAGE(WM_MDINEXT, &CMainFrame::OnMdiNext) を書いたのに、Ctrl + Tab で子ウィンドウ(ドキュメント)を切り替えてもハンドラが呼ばれない」――MDI(Multiple Document Interface)でしばしば遭遇する悩みです。本記事では、この現象が起きる根本原因と、最も安全で再利用性の高い対処法を、コード断片とあわせて詳しく解説します。合わせて、タブ付きMDI(Tabbed MDI)利用時の注意点、代替アプローチ(コマンドハンドラ/キー捕捉/MDIメッセージの別ルート)も整理し、実装チェックリストやデバッグの勘所まで網羅します。

結論(最短の答え)

  • WM_MDINEXT は MDI クライアント ウィンドウ(m_hWndMDIClient)に送られるため、CMainFrame には届きません。よって ON_MESSAGE で待っても反応しないのは仕様どおりです。
  • 確実に受け取るには、MDIクライアントをサブクラス化して処理します。Windows共通コントロールの SetWindowSubclass / RemoveWindowSubclass を用いるのが安全で後始末も容易です。
  • 標準のウィンドウ切替(次へ/前へ、タブ移動など)を壊さないために、最終的に DefSubclassProc を呼ぶことが重要です。

まずは現象の理解:MDIメッセージの行き先

MDIでは、ユーザー操作(メニューの「次/前のウィンドウ」、Ctrl+Tab、Ctrl+F6 等)により、MDIクライアントが子ウィンドウのアクティブ切替を司ります。このとき投げられるのが WM_MDINEXT であり、宛先はフレームではなく「MDIクライアント」です。MFC であれば CMDIFrameWnd / CMDIFrameWndEx のメンバー m_hWndMDIClient がそれにあたります。

メッセージ/コマンド主なトリガー本来の宛先フレームで受けられるか推奨フック箇所
WM_MDINEXTCtrl+Tab / Ctrl+F6、メニュー「次/前のウィンドウ」MDIクライアント(m_hWndMDIClientいいえMDIクライアントのサブクラス
WM_MDIACTIVATEMDI子のアクティブ変更MDI子(アクティブ/非アクティブ)間接的(子側)MDI子(CMDIChildWnd(Ex))側でハンドル
ID_WINDOW_NEXT / ID_WINDOW_PREVメニュー/アクセラレータコマンドルート(MFCコマンドルーティング)はいフレームの ON_COMMAND

再現コード(うまくいかない例)

次のようなメッセージマップを CMainFrame に書いても、WM_MDINEXT は反応しません。

BEGIN_MESSAGE_MAP(CMainFrame, CMDIFrameWndEx)
    ON_MESSAGE(WM_MDINEXT, &CMainFrame::OnMdiNext) // ← 反応しない
END_MESSAGE_MAP()

理由は前述の通り、メッセージの宛先がフレームではないからです。

解決策A:SetWindowSubclass で MDI クライアントをサブクラス化(おすすめ)

Win32のサブクラスAPIを使う方法です。MFC内部のクラス階層に干渉しにくく、後述の RemoveWindowSubclass による解除も確実で、タブ付きMDIを含む近年のMFCでも安定しやすいのが利点です。

1) ヘッダー宣言

// MainFrm.h
#pragma once
#include <commctrl.h>  // SetWindowSubclass, RemoveWindowSubclass

class CMainFrame : public CMDIFrameWndEx
{
DECLARE_DYNAMIC(CMainFrame)
public:
static LRESULT CALLBACK MDIClientProc(
HWND hwnd, UINT msg, WPARAM wp, LPARAM lp,
UINT_PTR id, DWORD_PTR ref);

```
// ...(通常の宣言)...
```

protected:
DECLARE_MESSAGE_MAP()
}; 

2) サブクラスのセットアップ(OnCreate

// MainFrm.cpp
#pragma comment(lib, "Comctl32.lib") // 忘れがち:リンカに追加

int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct)
{
if (CMDIFrameWndEx::OnCreate(lpCreateStruct) == -1)
return -1;

```
// ここで MDI クライアントにサブクラスを仕掛ける
// 42 は一意なら何でもよい ID(ジョークで「究極の答え」を使う例)
if (!::SetWindowSubclass(m_hWndMDIClient, &CMainFrame::MDIClientProc,
                         42, reinterpret_cast&lt;DWORD_PTR&gt;(this)))
{
    TRACE(_T("SetWindowSubclass 失敗\n"));
    // 必須ではないが、ここで return -1; してもよい(以降の処理を止めたい場合)
}

return 0;
```

} 

3) サブクラスプロシージャ本体

LRESULT CALLBACK CMainFrame::MDIClientProc(
    HWND hwnd, UINT msg, WPARAM wp, LPARAM lp,
    UINT_PTR id, DWORD_PTR ref)
{
    // フレーム this を取り出す(必要ならメンバーへアクセス)
    CMainFrame* pThis = reinterpret_cast<CMainFrame*>(ref);

```
if (msg == WM_MDINEXT)
{
    // lParam==0: 次へ、非0: 前へ
    const bool prev = (lp != 0);
    TRACE(_T("WM_MDINEXT 受信: %s\n"), prev ? _T("前へ") : _T("次へ"));

    // ここに独自処理(ログ、統計、切替抑止、UI更新など)
    // 例:最近使った順のスタック更新、ステータスバー表示など
    // pThis-&gt;UpdateStatusForMdiSwitch(prev);

    // 既定動作を維持(ここが重要)
    return ::DefSubclassProc(hwnd, msg, wp, lp);
}
else if (msg == WM_NCDESTROY)
{
    // 後始末(メモリリーク防止)
    ::RemoveWindowSubclass(hwnd, &CMainFrame::MDIClientProc, id);
}

return ::DefSubclassProc(hwnd, msg, wp, lp);
```

} 

ポイントと注意事項

  • DefSubclassProc を必ず呼ぶ:呼ばないと標準の次/前切替が無効になったり、MDIタブの挙動が壊れます。
  • サブクラスID(例の 42)は一意なら何でもOK:重複すると解除できなくなる可能性があります。
  • RemoveWindowSubclassWM_NCDESTROY で行うのが定石。アプリ終了や再作成時のリーク/クラッシュを防ぎます。
  • commctrl.h のインクルードと、Comctl32.lib のリンカ指定を忘れないでください。
  • タブ付きMDI(CMDIFrameWndEx + EnableMDITabs)でも有効:内部的にMDIクライアントを使うため、この方法が最も相性が良いです。

解決策B:MFCの CWnd::SubclassWindow を使う「純MFC版」

Win32 API ではなく MFC のサブクラス化を用いる手もあります。既存の MFC コードスタイルに寄せたい場合に有効です。ただし、Feature Pack 以降のタブ付きMDIやドッキング周りと二重にサブクラスが絡むと干渉するケースがあり、近年は 解決策A を推奨します。

// MDIクライアント専用の軽量ラッパ
class CMyMdiClient : public CWnd
{
public:
    BOOL Subclass(HWND hWnd) { return SubclassWindow(hWnd); }

protected:
    afx_msg LRESULT OnMdiNext(WPARAM wp, LPARAM lp)
    {
        const bool prev = (lp != 0);
        TRACE(_T("WM_MDINEXT 受信(Pure MFC): %s\n"), prev ? _T("前へ") : _T("次へ"));
        // 既定処理:CWnd::Default() で元プロシージャへ
        return Default();
    }
    DECLARE_MESSAGE_MAP()
};

BEGIN_MESSAGE_MAP(CMyMdiClient, CWnd)
    ON_MESSAGE(WM_MDINEXT, &amp;CMyMdiClient::OnMdiNext)
END_MESSAGE_MAP()

// MainFrm.h / MainFrm.cpp
CMyMdiClient m_wndMdiClient; // CMainFrame のメンバーに追加

int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct)
{
    if (CMDIFrameWndEx::OnCreate(lpCreateStruct) == -1)
        return -1;

    if (!m_wndMdiClient.Subclass(m_hWndMDIClient))
    {
        TRACE(_T("MDIクライアントのサブクラス化に失敗\n"));
    }
    return 0;
}

この方法でも受信は可能ですが、RemoveじゃなくUnsubclassのタイミング管理や、タブ付きMDIと組み合わさったときの相性に注意が必要です。

代替アプローチ:WM_MDINEXT にこだわらない設計

要件によっては、WM_MDINEXT を直接フックしなくても目的を達せられる場合があります。以下の選択肢も検討してみてください。

1) コマンドハンドラで拾う(実用度◎)

MFC のコマンドルーティングには、MDIの「次/前のウィンドウ」に対応するコマンドIDが用意されています。アクセラレータ(Ctrl+Tab / Ctrl+F6 等)がこれらにマップされていれば、CMainFrameON_COMMAND を使って確実に捕捉できます。

BEGIN_MESSAGE_MAP(CMainFrame, CMDIFrameWndEx)
    ON_COMMAND(ID_WINDOW_NEXT, &CMainFrame::OnWindowNext)
    ON_COMMAND(ID_WINDOW_PREV, &CMainFrame::OnWindowPrev)
END_MESSAGE_MAP()

void CMainFrame::OnWindowNext()
{
// 独自処理(ログ、アナリティクス、UI更新など)
TRACE(_T("ID_WINDOW_NEXT\n"));
// 既定動作へ(必要なら送出)
::SendMessage(m_hWndMDIClient, WM_MDINEXT, 0, 0);
}

void CMainFrame::OnWindowPrev()
{
TRACE(_T("ID_WINDOW_PREV\n"));
::SendMessage(m_hWndMDIClient, WM_MDINEXT, 0, 1);
} 

利点は「MFCの既定アクセラレータやメニュー操作を包括的に拾える」こと。アプリに独自のショートカットが存在しても、メニュー/アクセラの定義をこのIDに揃えるだけで収束できます。

2) PreTranslateMessage でキーを監視(要配慮)

Ctrl+Tab などの特定のキー操作だけを契機にしたい場合には、フレームの PreTranslateMessage で軽くフックする選択肢もあります。ただし、「既定の処理を潰さない」ことが最重要です。

BOOL CMainFrame::PreTranslateMessage(MSG* pMsg)
{
    if (pMsg-&gt;message == WM_KEYDOWN &amp;&amp; pMsg-&gt;wParam == VK_TAB)
    {
        if (::GetKeyState(VK_CONTROL) &amp; 0x8000)
        {
            const bool prev = (::GetKeyState(VK_SHIFT) &amp; 0x8000) != 0;
            TRACE(_T("Ctrl+%sTab を検出\n"), prev ? _T("Shift+") : _T(""));
            // ここに「検出した」ことに対する副作用の少ない処理だけを書く
            // (既定アクションはベースへ流す)
        }
    }
    return CMDIFrameWndEx::PreTranslateMessage(pMsg); // 既定へ委譲
}

この方法は「検出して何か表示する」程度なら有効ですが、強く介入するとアクセラレータ解決やタブ切替のUXを壊す恐れがあります。

よくある誤解と落とし穴

  • ON_MESSAGE(WM_MDINEXT, ...) をフレームに書けば届くはず」:いいえ。WM_MDINEXT の宛先は MDICLIENT で、フレームとは別のウィンドウです。
  • 「タブ付きMDIだと WM_MDINEXT は使われない?」:内部実装はMFCのバージョンや設定により異なりますが、MDIクライアントの存在は不変で、サブクラス化アプローチ(解決策A)は概ね安定して動作します。
  • CMDIChildWnd(Ex)WM_MDIACTIVATE で十分?」WM_MDIACTIVATE は「どの子がアクティブになったか」を知るには最適ですが、「次/前の指示そのもの」を捕捉したい(切替前に介入したい)用途には合いません。
  • SetWindowLongPtr の古典的サブクラスでよい?」:使えますが、SetWindowSubclass は多重サブクラスや解除の安全性、再入を考慮した仕組みがあり、近年はこちらを推奨します。

WM_MDINEXT の仕様メモ(実装時に役立つ豆知識)

  • WPARAM:基準となる MDI 子ウィンドウのハンドル。通常は NULL(現在のアクティブ子が基準)。
  • LPARAM0 なら「次へ」、非0 なら「前へ」。
  • 「前へ」は Ctrl+Shift+Tab などで送られがち。Ctrl+F6/Ctrl+Shift+F6 も同種。

実運用での活用アイデア

  • 最近使用順(MRU)切替の実装WM_MDINEXTWM_MDIACTIVATE をトリガーに、独自のMRUスタックを更新。Ctrl+Tab で VS風の「最近使った順」切替を実現。
  • 切替抑止/フィルタ:ドキュメントの編集中は「未保存ならスキップ」などの条件分岐を挟む。標準動作を阻害しないよう、処理の最後に既定へ委譲。
  • 監査ログ/テレメトリ:ユーザーの画面遷移を匿名統計として記録し、UI改善に活かす。

導入から確認まで:チェックリスト

  1. ビルド設定<commctrl.h> をインクルードし、Comctl32.lib をリンカへ追加。
  2. サブクラス化のタイミングCMDIFrameWndEx::OnCreate 成功後、m_hWndMDIClient が有効になってから SetWindowSubclass
  3. 解除WM_NCDESTROYRemoveWindowSubclass。異常終了や再作成のケースでも漏れない構造に。
  4. 動作検証Ctrl+TabCtrl+Shift+Tab、メニューの「次/前」、Ctrl+F6/Ctrl+Shift+F6 の全パスでトレース発火を確認。
  5. 回帰影響:タブ付きMDIのタブドラッグ/タブグループ分割、ドッキング操作に影響がないかを実機確認。

トラブルシューティング

症状原因の可能性対処
ハンドラが一切発火しないサブクラス化が失敗/タイミング早すぎ/m_hWndMDIClient 無効OnCreate 内で SetWindowSubclass の戻り値をチェック。CMDIFrameWndEx::OnCreate 成功後に行う。
切替が効かなくなったDefSubclassProc を呼んでいない/早期 return最後に必ず DefSubclassProc を返す。条件分岐でも抜け道を作らない。
終了時にクラッシュ解除漏れ/二重サブクラスWM_NCDESTROY での RemoveWindowSubclass を徹底。IDの一意性も確認。
一部のキー操作で発火しないアクセラレータが別IDに割り当て/独自処理が先に消費ID_WINDOW_NEXT/ID_WINDOW_PREV の併用、PreTranslateMessage での早期消費をやめる。

実装テンプレート(そのまま貼れる最小構成)

// MainFrm.h
#pragma once
#include <commctrl.h>

class CMainFrame : public CMDIFrameWndEx
{
DECLARE_DYNAMIC(CMainFrame)
public:
static LRESULT CALLBACK MDIClientProc(
HWND, UINT, WPARAM, LPARAM, UINT_PTR, DWORD_PTR);

protected:
afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct);
DECLARE_MESSAGE_MAP()
};

// MainFrm.cpp
#include "pch.h"
#include "framework.h"
#include "MainFrm.h"
#pragma comment(lib, "Comctl32.lib")

#ifdef _DEBUG
#define new DEBUG_NEW
#endif

IMPLEMENT_DYNAMIC(CMainFrame, CMDIFrameWndEx)

BEGIN_MESSAGE_MAP(CMainFrame, CMDIFrameWndEx)
ON_WM_CREATE()
END_MESSAGE_MAP()

int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct)
{
if (CMDIFrameWndEx::OnCreate(lpCreateStruct) == -1)
return -1;

```
// MDIクライアントにサブクラスをセット
if (!::SetWindowSubclass(m_hWndMDIClient, &amp;CMainFrame::MDIClientProc,
                         42, reinterpret_cast&lt;DWORD_PTR&gt;(this)))
{
    TRACE(_T("SetWindowSubclass failed\n"));
}
return 0;
```

}

LRESULT CALLBACK CMainFrame::MDIClientProc(
HWND hwnd, UINT msg, WPARAM wp, LPARAM lp,
UINT_PTR id, DWORD_PTR ref)
{
CMainFrame* pThis = reinterpret_cast(ref);

```
if (msg == WM_MDINEXT)
{
    const bool prev = (lp != 0);
    TRACE(_T("WM_MDINEXT: %s\n"), prev ? _T("Prev") : _T("Next"));
    // 任意の処理:例)ステータスバー更新など
    // if (pThis) pThis-&gt;GetStatusBar().SetPaneText(0, prev ? _T("前へ") : _T("次へ"));
    return ::DefSubclassProc(hwnd, msg, wp, lp);
}
else if (msg == WM_NCDESTROY)
{
    ::RemoveWindowSubclass(hwnd, &amp;CMainFrame::MDIClientProc, id);
}
return ::DefSubclassProc(hwnd, msg, wp, lp);
```

} 

「サブクラスIDの 42」について

サンプル中の 42 は単なる一意な整数で、値は何でも構いません。有名なSF小説のジョークにちなむ数字を例示しているだけです。プロダクションでは、重複しない管理方針(enum や名前付き定数)で扱うと安全です。

実装後の検証観点(回帰リスクを未然に)

  • ドキュメント数が多い状態(10以上)でループ切替しても不具合が出ない。
  • タブグループ(水平/垂直分割)にしても挙動が安定している。
  • アクセラレータ定義(Ctrl+Tab、Ctrl+F6 等)を変更してもハンドラが確実に発火。
  • マウス操作(タブクリック/ホイール)での切替でも副作用がない。
  • アプリ終了時のクラッシュやハングが発生しない(RemoveWindowSubclass 確認)。

応用:切替前に確認ダイアログを出す(安全な介入例)

未保存ドキュメントがある場合にだけ「本当に切り替えますか?」を出したい、といった要件では、WM_MDINEXT の受信時点で 状態を判定して通知だけ行い、既定動作は殺さない 方針がUXを損ねません。どうしても阻止が必要な場面は、該当条件のときだけ DefSubclassProc を呼ばずに 0 を返す、という選択もありますが、タブ付きMDIの内部挙動に影響する恐れがあるため、十分なテストが必須です。

まとめ

  • WM_MDINEXT をフレームで受けられないのは仕様。MDIクライアントをサブクラス化して捕捉するのが正攻法。
  • 実装は SetWindowSubclass / RemoveWindowSubclass を用いた方法が堅牢でおすすめ。必ず DefSubclassProc を呼び、標準動作を維持する。
  • 用途によっては ID_WINDOW_NEXT/ID_WINDOW_PREV のコマンドハンドラや、軽量なキー検出(PreTranslateMessage)も有効。
  • 導入後は、全経路の切替操作(ショートカット/メニュー/マウス)で副作用がないか丁寧に検証する。

付録:ショートカットと挙動の早見表

ショートカット既定の意味典型的に送られるもの捕捉のおすすめ
Ctrl+Tab次のMDI子(タブ)へWM_MDINEXT(lParam=0)MDIクライアントのサブクラス(A)
Ctrl+Shift+Tab前のMDI子(タブ)へWM_MDINEXT(lParam≠0)MDIクライアントのサブクラス(A)
Ctrl+F6次のMDI子へWM_MDINEXT or ID_WINDOW_NEXTサブクラス or コマンド(A/B)
Ctrl+Shift+F6前のMDI子へWM_MDINEXT or ID_WINDOW_PREVサブクラス or コマンド(A/B)

最後に:なぜこの実装が「保守しやすい」のか

MDI周りはフレーム、クライアント、子ウィンドウ、タブコントロール、ドッキングマネージャなど複数のレイヤーが絡みます。将来のMFCアップデートや設定変更(タブ有無、アクセラレータの再割り当て)にも耐える設計の鍵は、メッセージが本来届くウィンドウで受けることと、既定動作を壊さないこと。ここで示したサブクラス化パターンは、その2点を満たす素直な実装であり、長期運用でも破綻しづらいのが最大のメリットです。


実装要点のクイックリファレンス

  • 受信先WM_MDINEXTm_hWndMDIClient(MDIクライアント)。
  • 推奨実装SetWindowSubclassOnCreate 後に設定、WM_NCDESTROY で解除)。
  • 互換性:タブ付きMDI/従来MDIどちらでもOK。
  • 代替ID_WINDOW_NEXT/ID_WINDOW_PREVON_COMMAND、または軽量なキー監視。
  • 定数 42:単なる一意ID。値は任意で良い。

備考:本記事のサンプルは CMDIFrameWndEx をベースに記述していますが、CMDIFrameWnd でも同様の考え方で実装できます。プロジェクトのMFCバージョンやタブ設定(EnableMDITabs 等)に応じて、検証範囲を少し広めに取ると安心です。


回答・解決策の要約

  1. メッセージが届くウィンドウが違う: WM_MDINEXT は MDI クライアントに送られるため、CMainFrameON_MESSAGE では受け取れない。
  2. MDI クライアントをサブクラス化: SetWindowSubclass を使い、WM_MDINEXT を確実に受信。処理の最後に DefSubclassProc
  3. ON_MESSAGE(WM_MDINEXT, ...) は不要: MDIクライアントで受け止める設計に切り替える。
  4. サブクラスID(例の 42): 任意の一意な整数。ジョークに由来する例示に過ぎない。
  5. 補足: 標準の切替動作を残すには DefSubclassProc を必ず呼ぶ。OnCreate 完了後にサブクラス化し、全経路での発火を確認。

以上で、「WM_MDINEXT を捕捉できない」問題の原因と、壊れにくい実装パターンを整理できました。既存プロジェクトに段階的に導入し、テストしながら安定運用へ移行していきましょう。

この記事を書いた人

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

コメント

コメントする

目次