MFC MDIアプリにWinMerge由来CMDITabBarを正しく組み込む手順と実装のコツ【ドッキング不要で安定表示】

WinMerge 由来の CMDITabBar を MFC の MDI アプリに組み込むと「作れたのに出ない」「DockControlBar で ASSERT」になりがちです。原因は“ドッキング前提”で扱っていること。この記事では、ドッキングを使わず固定配置(手動レイアウト)で確実に表示・連動させるための実装手順、落とし穴、検証ポイント、運用Tipsまでをまとめて解説します。WinMerge 同等の操作感を、最小限の改修であなたのテスト用 MDI アプリに移植できます。

目次

結論(最初に要点)

課題解決策
タブバーが表示されないドッキングを使わず独自にレイアウトする。m_wndTabBar.Create(this) の直後に RecalcLayout() を呼ぶ。EnableDocking/DockControlBar は使わない。
DockControlBar 使用時に ASSERT(pDockBar != NULL)CMDITabBar は「CBRS_* によるツールバー系ドッキング」のプロトコルに非対応。フレーム側のドッキングサイトと整合が取れないため。ドッキングは避ける。
タブが MDI 子ウィンドウと連動しないフレームで RecalcLayout() をオーバーライドし、MDI クライアント直上にタブバーを配置。MDI クライアント領域をその分だけ下に縮める。
タブ内容が更新されないMDI の生成/破棄/アクティブ変更などで m_wndTabBar.UpdateTabs() と UpdateActive() を呼ぶ。

なぜ表示されないのか ― 失敗のメカニズム

CMDITabBar は WinMerge のために作られた「MDI 子ウィンドウ群のタイトルを並べて切り替えるカスタムタブ」。MFC Feature Pack の CMFCTabCtrl や CMFCDockingManager と異なり、「コントロールバーとしてのドッキング契約(CBRS_*)」を実装していません。EnableDocking() と DockControlBar() を呼ぶと、フレームは「CBRS_* を持つ CControlBar 派生物」を前提にドッキングサイトを探しますが該当がなく、pDockBar != NULL の ASSERT に到達します。

つまり、CMDITabBar は“ドッキングしない前提”で作るのが正解です。上部に固定配置し、RecalcLayout() で MDI クライアントを下にシフトすれば、表示も連動も安定します。

完成形の動作イメージ

  • アプリ起動直後からタブバーがフレーム上部に表示される。
  • MDI 子ウィンドウを開くとタブが増える/閉じると消える。
  • アクティブ変更で選択タブが切り替わる。タブクリックで子ウィンドウが前面化。
  • 子ウィンドウ最大化時もタブは常に「メニューバーと MDI クライアントの間」に固定表示。

最小実装の流れ(コピペで動くレベル)

フレームにタブバーをメンバー追加

// MainFrm.h
class CMainFrame : public CMDIFrameWndEx
{
    // ... 省略 ...
public:
    CMDITabBar m_wndTabBar; // WinMerge 由来のタブバー
protected:
    void RecalcLayout(BOOL bNotify = TRUE) override;
    afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct);
    afx_msg void OnMDIChildActivate(CWnd* pWndActivate, CWnd* pWndDeactivate);
    DECLARE_MESSAGE_MAP()
};

作成&初期レイアウト(ドッキングはしない)

// MainFrm.cpp
BEGIN_MESSAGE_MAP(CMainFrame, CMDIFrameWndEx)
    ON_WM_CREATE()
    ON_WM_MDICHILDACTIVATE()
END_MESSAGE_MAP()

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


// 1) タブバーをドッキング無しで生成
if (!m_wndTabBar.Create(this)) // 親はフレーム
    return -1;

// 必要に応じて調整(自動最大幅の抑制など)
m_wndTabBar.SetAutoMaxWidth(FALSE);

// 2) 初回レイアウト
RecalcLayout(); // ※ EnableDocking も DockControlBar も呼ばない
return 0;


} 

手動レイアウト:タブバー分だけ MDI クライアントを下げる

void CMainFrame::RecalcLayout(BOOL bNotify)
{
    // まず既定のレイアウトを適用(メニュー、ツールバー、ステータスバー等)
    CMDIFrameWndEx::RecalcLayout(bNotify);


CRect rcClient;
GetClientRect(&rcClient);

// タブの高さを算出(フォントから動的計算も可)
int nTabHeight = 24;
{
    CClientDC dc(this);
    CFont* pOld = dc.SelectObject(GetFont());
    TEXTMETRIC tm{};
    dc.GetTextMetrics(&tm);
    dc.SelectObject(pOld);
    nTabHeight = max(nTabHeight, tm.tmHeight + 8); // 余白込み
}

// タブバーを最上部に配置
m_wndTabBar.SetWindowPos(nullptr,
    rcClient.left, rcClient.top,
    rcClient.Width(), nTabHeight,
    SWP_NOZORDER | SWP_NOACTIVATE | SWP_SHOWWINDOW);

// MDI クライアント(m_hWndMDIClient)を下に押し下げて高さを調整
if (m_hWndMDIClient)
{
    ::SetWindowPos(m_hWndMDIClient, nullptr,
        rcClient.left, rcClient.top + nTabHeight,
        rcClient.Width(), rcClient.Height() - nTabHeight,
        SWP_NOZORDER | SWP_NOACTIVATE);
}


} 

MDI イベントでタブを同期

void CMainFrame::OnMDIChildActivate(CWnd* pWndActivate, CWnd* pWndDeactivate)
{
    UNREFERENCED_PARAMETER(pWndActivate);
    UNREFERENCED_PARAMETER(pWndDeactivate);
    // 子ウィンドウの生成/破棄/アクティブ変更のたびに更新
    m_wndTabBar.UpdateTabs();
    m_wndTabBar.UpdateActive(TRUE);


// 子最大化・復元でクライアントの飾りが変わるので再レイアウト
RecalcLayout();


} 

※ アプリのコマンドで「ウィンドウを閉じる/並べて表示/重ねて表示」などを呼んだ場合も、該当箇所で UpdateTabs() → UpdateActive() → RecalcLayout() を入れておくと表示が崩れません。

タブ → MDI 子のアクティブ化(ユーザー操作の橋渡し)

タブクリック時に該当 MDI 子をアクティブ化する処理を、CMDITabBar の通知(TCN_SELCHANGE など)にフックして実装します。

// 例:CMDITabBar が親(フレーム)へ TCN_SELCHANGE を投げる想定
BOOL CMainFrame::OnNotify(WPARAM wParam, LPARAM lParam, LRESULT* pResult)
{
    NMHDR* pNMHDR = reinterpret_cast<NMHDR*>(lParam);
    if (pNMHDR && pNMHDR->hwndFrom == m_wndTabBar.m_hWnd && pNMHDR->code == TCN_SELCHANGE)
    {
        const int iSel = m_wndTabBar.GetCurSel();
        HWND hChild = m_wndTabBar.GetHwndByTabIndex(iSel); // 実装側の API に合わせて取得
        if (::IsWindow(hChild))
        {
            // MDI 子をアクティブ化
            ::SendMessage(m_hWndMDIClient, WM_MDIACTIVATE, reinterpret_cast<WPARAM>(hChild), 0);
        }
        *pResult = 0;
        return TRUE;
    }
    return CMDIFrameWndEx::OnNotify(wParam, lParam, pResult);
}

ショートカット(Ctrl+Tab / Ctrl+Shift+Tab)をタブに委譲したい場合は、PreTranslateMessage() で検出して MDI に WM_MDINEXT を送るのが簡単です。

チェックリスト:作っても出ない/ちらつく/位置がズレる

症状原因対処
作成成功ログは出るのに画面に見えないWS_VISIBLE が付いていない/親が違う/Z オーダーの背面Create() のスタイル確認、親をフレームに、SetWindowPos(..., SWP_SHOWWINDOW) を明示
ASSERT: pDockBar != NULLDockControlBar 経由でドッキングさせているドッキング API を一切使わない。固定配置+RecalcLayout() で調整
MDI 子最大化時にタブが消える/重なるクライアントの高さを再計算していないOnMDIChildActivate で RecalcLayout() を呼ぶ
タブの文字が切れる固定高さが低すぎる/DPI 無視フォントから高さを算出し、DPI 係数(GetDpiForWindow())で拡大
スクロール時にちらつく背景描画とクライアント描画の競合WS_CLIPCHILDREN | WS_CLIPSIBLINGS をフレームやタブ側に付与。二重描画を避ける

きれいに仕上げるための設計ポイント

DPI 対応の高さ算出

int CMainFrame::CalcTabHeightDpiAware() const
{
    const UINT dpi = ::GetDpiForWindow(m_hWnd) ? ::GetDpiForWindow(m_hWnd) : 96;
    int base = 24;
    base = MulDiv(base, dpi, 96);


CClientDC dc(const_cast<CMainFrame*>(this));
CFont* pOld = dc.SelectObject(GetFont());
TEXTMETRIC tm{};
dc.GetTextMetrics(&tm);
dc.SelectObject(pOld);

const int textHeight = MulDiv(tm.tmHeight + 8, dpi, 96);
return max(base, textHeight);


} 

MDI 子の列挙とタイトル同期

タブ側の UpdateTabs() 実装では、MDI クライアント配下の子ウィンドウを列挙し、各 MDICLIENT 子のタイトル(またはドキュメント名)を取得してタブに反映します。列挙順は GW_CHILD→GW_HWNDNEXT で安定化できます。

タイトル更新のトリガ

  • MDI 子の OnUpdateFrameTitle()/OnUpdateDocumentTitle() 相当で通知
  • フレームの OnMDIChildActivate()
  • 文書の保存/名前変更(保存ダイアログ確定後に更新)

サンプル:フレーム全体の雛形(MainFrm)

// MainFrm.h(抜粋)
class CMainFrame : public CMDIFrameWndEx
{
public:
    CMDITabBar m_wndTabBar;
    CStatusBar      m_wndStatusBar;
    CMFCToolBar     m_wndToolBar;

protected:
virtual void RecalcLayout(BOOL bNotify = TRUE) override;
afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct);
afx_msg void OnMDIChildActivate(CWnd* pWndActivate, CWnd* pWndDeactivate);
virtual BOOL PreTranslateMessage(MSG* pMsg) override;
DECLARE_MESSAGE_MAP()
};

// MainFrm.cpp(抜粋)
BEGIN_MESSAGE_MAP(CMainFrame, CMDIFrameWndEx)
ON_WM_CREATE()
ON_WM_MDICHILDACTIVATE()
END_MESSAGE_MAP()

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


// 既存のツールバー等を先に作成
m_wndToolBar.CreateEx(this);
m_wndToolBar.LoadToolBar(IDR_MAINFRAME);
m_wndStatusBar.Create(this);

// タブバーを生成(非ドッキング)
if (!m_wndTabBar.Create(this))
    return -1;
m_wndTabBar.SetAutoMaxWidth(FALSE);

RecalcLayout();
return 0;


}

void CMainFrame::RecalcLayout(BOOL bNotify)
{
CMDIFrameWndEx::RecalcLayout(bNotify);


CRect rc;
GetClientRect(&rc);

const int h = CalcTabHeightDpiAware();
m_wndTabBar.SetWindowPos(nullptr, rc.left, rc.top, rc.Width(), h,
    SWP_NOZORDER | SWP_NOACTIVATE | SWP_SHOWWINDOW);

if (m_hWndMDIClient)
{
    ::SetWindowPos(m_hWndMDIClient, nullptr, rc.left, rc.top + h,
        rc.Width(), rc.Height() - h, SWP_NOZORDER | SWP_NOACTIVATE);
}


}

void CMainFrame::OnMDIChildActivate(CWnd* pWndActivate, CWnd* pWndDeactivate)
{
UNREFERENCED_PARAMETER(pWndActivate);
UNREFERENCED_PARAMETER(pWndDeactivate);
m_wndTabBar.UpdateTabs();
m_wndTabBar.UpdateActive(TRUE);
RecalcLayout();
}

BOOL CMainFrame::PreTranslateMessage(MSG* pMsg)
{
if (pMsg->message == WM_KEYDOWN && (GetKeyState(VK_CONTROL) & 0x8000))
{
if (pMsg->wParam == VK_TAB)
{
// Ctrl+Tab で次の MDI 子へ
::SendMessage(m_hWndMDIClient, WM_MDINEXT, 0, (GetKeyState(VK_SHIFT) & 0x8000) ? TRUE : FALSE);
return TRUE;
}
}
return CMDIFrameWndEx::PreTranslateMessage(pMsg);
} 

「ドッキングしない」アーキテクチャの利点

  • ASSERT を根本回避:CBRS 契約に依存しないため DockControlBar 経由の不整合が発生しない。
  • レイアウトの可搬性:既存のメニューバー/ツールバー構成に影響を与えない。MDI クライアントの矩形だけ制御すればよい。
  • DPI とテーマ対応が容易:高さ・余白・フォントの決定を自前で統一管理できる。

運用・メンテナンス Tips

イベントの最小セットを押さえる

  • OnMDIChildActivate:アクティブ切替・最大化/復元で必ず飛ぶ。最重要。
  • ドキュメント保存直後:タイトル変更を拾って UpdateTabs()。
  • アプリの「すべて閉じる」「次/前のウィンドウ」コマンド:実行箇所で UpdateActive()。

タブの幅と省略記号

ファイル名が長くなると視認性が落ちます。SetAutoMaxWidth(FALSE) で「勝手に縮められる」挙動を止め、DrawItem を持つなら中点省略(CString::Format で左右を残す)を実装すると実用的です。

二重枠の回避

子最大化時、MDI のキャプション領域とタブが視覚的に干渉する場合があります。タブ下線とクライアント上線の色を合わせる/1px 重なりを避けると見た目が安定します。

よくある誤解・ハマりどころ

誤解正しい理解
CMDITabBar は CMFCToolBar の一種だからドッキングできるはず別物。コントロールバーの CBRS 契約を持たないため、ドッキング対象ではない
MFC の EnableDocking() は呼ばないとレイアウトできない不要。固定配置+RecalcLayout() で十分。むしろ呼ぶと ASSERT の温床
タブの高さは 24px 固定でよいDPI/フォントで変わる。動的計算で安全に
描画ちらつきはダブルバッファでしか解決しないWS_CLIPCHILDREN | WS_CLIPSIBLINGS と再レイアウトの順序でも大きく改善

品質確保のためのテスト観点

  • ウィンドウ数:0 → 1 → 10 → 0 と増減し、タブ数・選択状態が一致すること。
  • 最大化/復元:最大化タブ操作で MDI 子が確実に切り替わること。復元後もタブ位置は不変。
  • タイトル更新:無題 → 保存(ファイル名付与)でタブが即時反映。
  • キーボード:Ctrl+Tab / Ctrl+Shift+Tab で前後のタブと同期。
  • DPI 変更:150%/200% でタブ高さ・アイコン・テキストが破綻しない。

トラブルシューティング(ASSERT/例外の読み方)

ログ/ASSERT意味対処
pDockBar != NULL(framewnd.cpp 付近)ドッキング先バーが見つからないドッキング API を使わない方式に切り替える
Invalid parent passed to Create親ウィンドウが MDI クライアントやツールバーになっている親はフレーム(this)にする
タブが描画されないがヒットテストは反応するMDI クライアントに覆い隠されているRecalcLayout() の順序と SetWindowPos の Z オーダーを見直す

拡張:WinMerge 風の見た目・操作に寄せる

  • アイコン表示:MDI 子の HICON(またはドキュメントの拡張子からイメージリスト)をタブに描画。
  • ミドルクリックで閉じる:WM_MBUTTONUP を捕まえて対象タブの子に WM_CLOSE。
  • コンテキストメニュー:「他を閉じる」「右側を閉じる」「エクスプローラーで開く」などを実装。
  • ドラッグで並べ替え:ヒットテスト→キャプチャ→差し替え。MDI 子との対応表を同期する。

導入から公開までの実務フロー

  1. ソース取り込み:CMDITabBar.h/.cpp をプロジェクトに追加。
  2. ビルドオプション確認:UNICODE、/permissive-、警告レベル、ランタイム。
  3. フレーム改修:本記事の OnCreate/RecalcLayout/OnMDIChildActivate を実装。
  4. イベント接続:タブ→MDI のアクティベーション、Ctrl+Tab。
  5. DPI/テーマ:高さとフォント、アイコンのスケーリング確認。
  6. テスト:前掲のチェックリストを網羅。

参考:API/メソッドの役割(手元メモ)

API役割使用タイミング
CMDIFrameWndEx::RecalcLayoutフレーム標準のレイアウト再計算毎レイアウト前提で呼ぶ(その後にタブの配置を上書き)
m_wndTabBar.Createタブバー生成OnCreate でフレームを親にして作る
UpdateTabs / UpdateActiveタブ項目と選択の同期MDI 子の生成/破棄/切替後に呼ぶ
WM_MDIACTIVATEMDI 子のアクティブ変更通知フレームの OnMDIChildActivate で受ける
WM_MDINEXT次/前の MDI 子にフォーカス移動Ctrl+Tab ショートカットから送る

実装差分のサンプル(パッチ想定)

diff --git a/MainFrm.h b/MainFrm.h
--- a/MainFrm.h
+++ b/MainFrm.h
@@ -12,6 +12,8 @@
 class CMainFrame : public CMDIFrameWndEx
 {
 public:
+    CMDITabBar m_wndTabBar;
+    int CalcTabHeightDpiAware() const;
 protected:
     void RecalcLayout(BOOL bNotify = TRUE) override;
     afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct);
@@ -24,3 +26,4 @@
 END_MESSAGE_MAP()

パフォーマンスと描画最適化

  • 再レイアウトの集約:複数イベントが連続する場合、最後の 1 回だけ RecalcLayout() を呼ぶ(タイマー 0ms ポストで集約)。
  • 背景消去の最小化:タブバーの OnEraseBkgnd で TRUE を返し、背景を自前で塗りつぶす。
  • 文字幅計測のキャッシュ:GetTextExtentPoint32 の結果をキャッシュし、レイアウト時のオーダを O(n) に抑制。

まとめ:最短距離で「出る・動く・ズレない」

CMDITabBar は「ドッキングしない固定配置」で使うのが正道です。フレームに直接 Create() → RecalcLayout()、そして MDI のライフサイクルで UpdateTabs()/UpdateActive() を呼ぶだけで、ASSERT なし・崩れなし・WinMerge 並みの使い勝手を実現できます。この記事の雛形をそのままベースに、自分のプロジェクトの流儀(DPI・テーマ・ショートカット)へ最適化していけば、テスト用 MDI アプリでも本番品質のタブ体験を提供できます。

付録:実装フルスニペット(最小構成)

// ===== MainFrm.h =====
#pragma once
#include <afxframewndex.h>
#include "MDITabBar.h"

class CMainFrame : public CMDIFrameWndEx
{
public:
CMainFrame() noexcept = default;

protected:
CMDITabBar m_wndTabBar;


int  CalcTabHeightDpiAware() const;
void RecalcLayout(BOOL bNotify = TRUE) override;

afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct);
afx_msg void OnMDIChildActivate(CWnd* pWndActivate, CWnd* pWndDeactivate);
BOOL PreTranslateMessage(MSG* pMsg) override;

DECLARE_MESSAGE_MAP()


};

// ===== MainFrm.cpp =====
#include "pch.h"
#include "MainFrm.h"

#ifdef _DEBUG
#define new DEBUG_NEW
#endif

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

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


if (!m_wndTabBar.Create(this))
    return -1;

m_wndTabBar.SetAutoMaxWidth(FALSE);
RecalcLayout();
return 0;


}

void CMainFrame::RecalcLayout(BOOL bNotify)
{
CMDIFrameWndEx::RecalcLayout(bNotify);


CRect rc; GetClientRect(&rc);
const int h = CalcTabHeightDpiAware();

m_wndTabBar.SetWindowPos(nullptr, rc.left, rc.top, rc.Width(), h,
    SWP_NOZORDER | SWP_NOACTIVATE | SWP_SHOWWINDOW);

if (m_hWndMDIClient)
{
    ::SetWindowPos(m_hWndMDIClient, nullptr,
        rc.left, rc.top + h, rc.Width(), rc.Height() - h,
        SWP_NOZORDER | SWP_NOACTIVATE);
}


}

void CMainFrame::OnMDIChildActivate(CWnd* pWndActivate, CWnd* pWndDeactivate)
{
UNREFERENCED_PARAMETER(pWndActivate);
UNREFERENCED_PARAMETER(pWndDeactivate);
m_wndTabBar.UpdateTabs();
m_wndTabBar.UpdateActive(TRUE);
RecalcLayout();
}

BOOL CMainFrame::PreTranslateMessage(MSG* pMsg)
{
if (pMsg->message == WM_KEYDOWN && (GetKeyState(VK_CONTROL) & 0x8000))
{
if (pMsg->wParam == VK_TAB)
{
::SendMessage(m_hWndMDIClient, WM_MDINEXT, 0,
(GetKeyState(VK_SHIFT) & 0x8000) ? TRUE : FALSE);
return TRUE;
}
}
return CMDIFrameWndEx::PreTranslateMessage(pMsg);
}

int CMainFrame::CalcTabHeightDpiAware() const
{
UINT dpi = 96;
if (IsWindow(m_hWnd))
dpi = ::GetDpiForWindow(m_hWnd);


CClientDC dc(const_cast<CMainFrame*>(this));
CFont* pOld = dc.SelectObject(GetFont());
TEXTMETRIC tm{}; dc.GetTextMetrics(&tm);
dc.SelectObject(pOld);

const int text = tm.tmHeight + 8;
return max(MulDiv(24, dpi, 96), MulDiv(text, dpi, 96));


} 

最後に:採用判断の目安

CMDITabBar は「MDI を捨ててタブ化」ではなく、「MDI を保ったままタブで切り替える」アプローチです。既存のドキュメント/ビュー/コマンド体系を触らずに操作性を大きく引き上げたいときに最適です。将来的にドッキングフレームへ移行する場合も、フレームの手動レイアウトを中心に据える本記事の構成なら、移植コストを最小化できます。

この記事を書いた人

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

コメント

コメントする

目次