Windows FormsのMDIを極限までモダン化する:Visual Studio風タブUI構築の全技術
業務アプリケーションの開発現場において、いまだに根強い需要があるWindows Forms。しかし、標準のMDI(マルチドキュメントインターフェイス)親ウィンドウの中に子ウィンドウがバラバラと浮遊するレイアウトは、現代のUX基準から見れば明らかにレガシーです。
ウィンドウが重なり合い、どこに行ったか分からなくなる……。この生産性の低いUIを、ChromeやVisual Studioのように洗練された「タブ切り替え型IDE風インターフェイス」へと生まれ変わらせる。
今回は、APIの裏側を知り尽くしたアーキテクトの視点から、標準の`MdiClient`をハックし、バグの起きない堅牢なタブ化MDIコンテナを構築する極限の知見を伝授する。
—
1. なぜ標準のMDIではダメなのか?(アーキテクチャの課題)
VB.NETでMDIを実装する場合、親フォームの `IsMdiContainer = True` を設定するだけで簡単にコンテナを作ることができる。しかし、ここには重大な罠がある。
1. ウィンドウ管理の破綻: 子フォームが増えるほどタスクバーやメニューが煩雑になり、ユーザーが迷子になる。
2. `MdiClient`のブラックボックス: MDIの親内部には `MdiClient` という見えないコントロールが存在するが、VB.NETの標準プロパティだけではこの領域をコントロール(背景色の変更や独自描画など)することが困難。
3. リソースリークの温床: 子フォームのクローズイベントとタブの同期を誤ると、メモリリークや二重解放の例外(ObjectDisposedException)が多発する。
これを解決するためには、「MDIのウィンドウ管理機能」と「TabControlのタブUI」を完全に同期させるラッパー構造を設計する必要がある。
—
2. 設計方針:MdiClient隠蔽とTabControlの融合
目指すアーキテクチャはこうだ:
- 親フォームに `TabControl` をドック(Dock = Fill)配置する。
- 子フォームが生成された際、それを通常のMDI子としてではなく、独自のパネル/タブコンテナの配下に置きつつ、MDIとしてのルーティング(フォーカス管理やウィンドウリスト)を維持する……のではなく、 思い切って `MdiClient` 自体を隠蔽し、子フォームの `TopLevel = False` を利用してタブページ内に埋め込む。
これにより、子フォームは通常のフォーム設計のまま、見た目だけをモダンなタブUIに収めることが可能になる。
—
3. 実装:プロダクションコード
以下のコードは、Visual Studioやサクラエディタのような、閉じるボタン付きタブを備えたモダンMDI親フォームの完全な実装例である。そのままプロジェクトにコピーして検証してほしい。
Imports System.Windows.Forms
Imports System.Drawing
Public Class MainForm
Inherits Form
‘ UIコントロールの定義
Private WithEvents tabControlMain As New TabControl()
Private m_isClosingTab As Boolean = False
Public Sub New()
‘ フォームの基本設定
Me.Text = “Modern IDE Style MDI Container”
Me.Width = 1280
Me.Height = 800
Me.StartPosition = FormStartPosition.CenterScreen
‘ TabControlの設定
tabControlMain.Dock = DockStyle.Fill
tabControlMain.Appearance = TabAppearance.Normal
tabControlMain.DrawMode = TabDrawMode.OwnerDrawFixed ‘ タブに閉じるボタンを描画するため
‘ イベント配線
AddHandler tabControlMain.DrawItem, AddressOf TabControlMain_DrawItem
AddHandler tabControlMain.MouseDown, AddressOf TabControlMain_MouseDown
AddHandler tabControlMain.SelectedIndexChanged, AddressOf TabControlMain_SelectedIndexChanged
Me.Controls.Add(tabControlMain)
‘ テスト用のメニューを追加
InitializeMenu()
End Sub
Private Sub InitializeMenu()
Dim menuStrip As New MenuStrip()
Dim menuFile As New ToolStripMenuItem(“ファイル(&F)”)
Dim menuNewDoc As New ToolStripMenuItem(“新規ドキュメント(&N)”, Nothing, AddressOf OnNewDocumentClick)
menuFile.DropDownItems.Add(menuNewDoc)
menuStrip.Items.Add(menuFile)
Me.MainMenuStrip = menuStrip
Me.Controls.Add(menuStrip)
End Sub
”’
”’
Private Sub OnNewDocumentClick(sender As Object, e As EventArgs)
OpenNewDocument(“ドキュメント ” & (tabControlMain.TabPages.Count + 1))
End Sub
”’
”’
Public Sub OpenNewDocument(docTitle As String)
‘ 1. タブページの作成
Dim tabPage As New TabPage(docTitle)
tabPage.Tag = docTitle
‘ 2. 中に格納する子フォームの作成 (TopLevel = False がキモ)
Dim childForm As New ChildDocumentForm()
childForm.TopLevel = False
childForm.FormBorderStyle = FormBorderStyle.None
childForm.Dock = DockStyle.Fill
‘ 子フォームの閉じるイベントを監視
AddHandler childForm.FormClosed, Sub(s, ev)
If Not m_isClosingTab Then
CloseTabByForm(childForm)
End If
End Sub
‘ 3. タブページにフォームを追加してコンテナへ登録
tabPage.Controls.Add(childForm)
tabControlMain.TabPages.Add(tabPage)
‘ アクティブにする
tabControlMain.SelectedTab = tabPage
childForm.Show()
End Sub
”’
”’
Private Sub TabControlMain_DrawItem(sender As Object, e As DrawItemEventArgs)
Dim tabPage As TabPage = tabControlMain.TabPages(e.Index)
Dim rect As Rectangle = tabControlMain.GetTabRect(e.Index)
‘ タブ自体のテキストを描画
Using sf As New StringFormat()
sf.Alignment = StringAlignment.Near
sf.LineAlignment = StringAlignment.Center
sf.FormatFlags = StringFormatFlags.NoWrap
Dim brush As Brush = Brushes.Black
e.Graphics.DrawString(tabPage.Text, e.Font, brush, rect.X + 6, rect.Y + (rect.Height / 2) – 6)
End Using
‘ 「×」マークを描画
Dim closeRect As Rectangle = GetCloseButtonRect(rect)
Using pen As New Pen(Color.DarkGray, 2)
e.Graphics.DrawLine(pen, closeRect.Left, closeRect.Top, closeRect.Right, closeRect.Bottom)
e.Graphics.DrawLine(pen, closeRect.Right, closeRect.Top, closeRect.Left, closeRect.Bottom)
End Using
End Sub
”’
”’
Private Function GetCloseButtonRect(tabRect As Rectangle) As Rectangle
Dim size As Integer = 10
Return New Rectangle(tabRect.Right – 18, tabRect.Y + (tabRect.Height – size) / 2, size, size)
End Function
”’
”’
Private Sub TabControlMain_MouseDown(sender As Object, e As MouseEventArgs)
For i As Integer = 0 To tabControlMain.TabPages.Count – 1
Dim tabRect As Rectangle = tabControlMain.GetTabRect(i)
Dim closeRect As Rectangle = GetCloseButtonRect(tabRect)
If closeRect.Contains(e.Location) Then
‘ 閉じるボタンが押された
CloseTabByIndex(i)
Exit For
End If
Next
End Sub
Private Sub CloseTabByIndex(index As Integer)
If index < 0 OrElse index >= tabControlMain.TabPages.Count Then Return
m_isClosingTab = True
Dim tabPage As TabPage = tabControlMain.TabPages(index)
‘ 内部の子フォームを安全に破棄
For Each ctrl As Control In tabPage.Controls
If TypeOf ctrl Is Form Then
DirectCast(ctrl, Form).Close()
End If
Next
tabControlMain.TabPages.Remove(tabPage)
tabPage.Dispose()
m_isClosingTab = False
End Sub
Private Sub CloseTabByForm(targetForm As Form)
For Each tabPage As TabPage In tabControlMain.TabPages
If tabPage.Controls.Contains(targetForm) Then
tabControlMain.TabPages.Remove(tabPage)
tabPage.Dispose()
Exit For
End If
Next
End Sub
Private Sub TabControlMain_SelectedIndexChanged(sender As Object, e As EventArgs)
‘ タブ切り替え時に子フォームへフォーカスを移す
If tabControlMain.SelectedTab IsNot Nothing Then
For Each ctrl As Control In tabControlMain.SelectedTab.Controls
If TypeOf ctrl Is Form Then
ctrl.Focus()
Exit For
End If
Next
End If
End Sub
End Class
”’
”’
Public Class ChildDocumentForm
Inherits Form
Public Sub New()
Me.Text = “Child Form”
Dim txtBox As New TextBox()
txtBox.Multiline = True
txtBox.Dock = DockStyle.Fill
txtBox.Text = “ここに業務データを入力…”
Me.Controls.Add(txtBox)
End Sub
End Class
—
4. プロダクション環境における必須の注意点
現場でこのアーキテクチャを採用する際、以下のポイントを抑えておかないと、思わすバグやパフォーマンス低下を引き起こす。
① 未保存データの破棄防止(Dirtyフラグの管理)
タブを閉じる際、またはアプリケーションを終了する際に、子フォーム側でデータが変更されているか(Dirty状態か)を必ずチェックさせること。
`Form.FormClosing` イベント内で未保存の変更がある場合は `e.Cancel = True` を返し、ユーザーに保存確認ダイアログを出す設計を徹底する。
② 大量タブオープン時のメモリ管理と遅延ロード
IDE風UIでよくある間違いが、何十枚もの子フォームを同時に開きっぱなしにすることだ。各フォームがデータベースのコネクションや重いデータバインドを持っている場合、メモリを圧迫しOutOfMemoryExceptionの原因となる。
もし大量のドキュメントを扱う必要があるなら、非アクティブになったタブの子コントロールのデータを一時解放する「仮想化(Virtualization)」の仕組みを検討すべきである。
③ DPIスケーリングと高解像度ディスプレイ対応
Windows Formsにおけるカスタム描画(`DrawItem`など)の最大の敵は高DPI環境(4Kディスプレイなど)でのズレである。上記のコード内の固定ピクセル値(`18` や `10` など)は、実運用では `Me.DeviceDpi` や `SizeF` を用いてスケーリング計算に置き換えることが、プロフェッショナルとしての要件となる。
—
総括
レガシーなMDIの呪縛から解放され、TabControlをベースにしたモダンなコンテナを自前で構築することは、Windows Formsアプリの寿命を劇的に延ばす。
「VB.NETだから古いUIしか作れない」というのはエンジニアの怠慢にすぎない。オブジェクトのライフサイクルを正しく理解し、コントロールのコンテナ関係をコントロールしきれば、現役の第一線で戦える洗練された業務システムを構築することは十分に可能だ。
さあ、そのレガシーなウィンドウを今すぐ書き換えよう。
