1.この記事の目的
久しぶりに実務でWindows Formアプリの開発を担当することになりまし。
2025年現在の技術でWindows Formアプリを作る場合の小さなテンプレートを先行開発しましたので公開します。
- モダンなフラットUI
- MVP(Model + View + Presenter)パターン対応
- カスタムコントロール(+ユーザーコントロール)による共通コンポーネント化
** 選択専用のDataGridView
** DataGridViewのEXCEL出力
** トースト通知
** ユーザー設定
2.画面イメージ
2.1 一覧の表示
2.2 追加+修正+削除
2.3 トースト通知
2.4 複数業務を1つのForm内でメニュー切り替え
2.5 EXCEL出力
2.6 ユーザー設定
3.前提条件
- Visual studio 2022 Version 17.13.6
- .Net 9
なお、すべてのソースコードを公開しています。
https://github.com/masayahak/WinFormsMenuDemo
4.モダンなフラットUI
4.1 個別の業務用Formを1つのメインFormで表示する方法
- メインFormに個別の業務用Formを流し込むPanelを用意する
- 各業務用FormはDictionaryにスタックし、メニューから切り替えられた場合入力途中の状態を維持し再開可能にする
public partial class FormMain : Form, Iテーマ適用可能
{
// ------------------------------------------------
// Panel切替ロジック
// ------------------------------------------------
private readonly Dictionary<string, Form> _formCache = new();
private void ShowForm(string key, Type formType)
{
if (!_formCache.TryGetValue(key, out var form) || form.IsDisposed)
{
form = CreateFormInstance(formType);
_formCache[key] = form;
}
LoadFormToPanel(form);
}
private void LoadFormToPanel(Form form)
{
PanelMain.Controls.Clear();
form.TopLevel = false;
form.FormBorderStyle = FormBorderStyle.None;
form.Dock = DockStyle.Fill;
PanelMain.Controls.Add(form);
form.Show();
}
private Form CreateFormInstance(Type formType)
{
if (formType == typeof(Form受注))
{
IForm受注View view = new Form受注();
string sqlConnectionString = ConfigurationManager.ConnectionStrings["sqlConnectionString"].ConnectionString;
I受注Repository repository = new 受注Repository(sqlConnectionString);
new 受注Presenter(view, repository);
return (Form)view;
}
if (formType == typeof(Form得意先))
{
IForm得意先View view = new Form得意先();
string sqlConnectionString = ConfigurationManager.ConnectionStrings["sqlConnectionString"].ConnectionString;
I得意先Repository repository = new 得意先Repository(sqlConnectionString);
new 得意先Presenter(view, repository);
return (Form)view;
}
if (formType == typeof(Form障害ログ))
{
IForm障害ログView view = new Form障害ログ();
string sqlConnectionString = ConfigurationManager.ConnectionStrings["sqlConnectionString"].ConnectionString;
I障害ログRepository repository = new 障害ログRepository(sqlConnectionString);
new 障害ログPresenter(view, repository);
return (Form)view;
}
if (formType == typeof(Form設定))
{
IForm設定View view = new Form設定();
view.ThemeChanged += (_, _) =>
{
ApplyTheme();
};
return (Form)view;
}
var requested = Activator.CreateInstance(formType);
if (requested is not Form f)
throw new InvalidOperationException($"{formType.Name} のインスタンスを生成できません。");
return f;
}
private void SetDefaultMenuColor(Control? parent = null)
{
parent ??= this.PanelMenu;
// ボタンの色を初期化
foreach (Control ctrl in parent.Controls)
{
if (ctrl is Button btn)
{
btn.BackColor = Properties.Settings.Default.MenuBackColor;
btn.FlatAppearance.MouseOverBackColor = Properties.Settings.Default.TopBarColor;
}
if (ctrl.HasChildren)
SetDefaultMenuColor(ctrl);
}
}
private void Btn受注_Click(object sender, EventArgs e)
{
SetDefaultMenuColor();
Btn受注.BackColor = Properties.Settings.Default.TopBarColor;
ShowForm("受注", typeof(Form受注));
}
4.2 アプリ終了時の位置とサイズを記録し再開する
- 単純にメインProperties.Settingsに記録、読み取りしてます
public partial class FormMain : Form, Iテーマ適用可能
{
public FormMain()
{
// ------------------------------------------------
// 位置記録
// ------------------------------------------------
// ロードで復元
private void FormMain_Load(object sender, EventArgs e)
{
if (Properties.Settings.Default.FormSize.Width > 0 && Properties.Settings.Default.FormSize.Height > 0)
{
this.StartPosition = FormStartPosition.Manual;
this.Location = Properties.Settings.Default.FormLocation;
this.Size = Properties.Settings.Default.FormSize;
}
}
private void FormMain_FormClosing(object sender, FormClosingEventArgs e)
{
if (this.WindowState == FormWindowState.Normal)
{
Properties.Settings.Default.FormLocation = this.Location;
Properties.Settings.Default.FormSize = this.Size;
Properties.Settings.Default.Save();
}
}
5.MVP(Model + View + Presenter)パターン
✅ MVPパターンのメリットと実装のポイント
✔️ なぜMVPを採用したか?
WinForms開発では、画面(Form)とロジックが密結合になりやすく、次のような課題がよく発生します:
- Form が肥大化してテストが困難になる
- 複数画面で同じロジックを使い回せない
- 画面の状態(初期化・入力値)を外部から制御しにくい
MVP(Model-View-Presenter)パターンを採用することで、
ロジックとUIの責務を明確に分離し、保守性と再利用性を向上させています。
✔️ このテンプレートにおけるMVP実装のポイント
要素 | 実装内容 |
---|---|
View | IForm〇〇View インターフェースで定義し、Form はその実装に徹する |
Presenter | 業務ロジックを一手に引き受け、Viewの状態操作とModel操作を担当 |
イベント連携 | View側イベント(例:検索・保存)をPresenterにデリゲート |
双方向バインディング | Presenter → View:プロパティ設定/View → Presenter:イベント通知 |
テスト容易性 | Presenter単体でテスト可能(UI依存排除) |
💡 代表的な利点
- 業務ロジックのテストがしやすい
- ViewのUI変更がロジックに影響しにくい
- 複数画面でPresenterの再利用が可能
- Form の責務が減り、読みやすく保守しやすい
5.1 Model
- 一般的なModelです
- RepositoryはSQLの自由度を優先し、Ado.NETをベースとした軽量なリポジトリクラスにしてます(あえてEntity FrameworkなどのORMは利用しません)
5.2 View(Form)
- ほとんどの業務画面で以下の機能が必要になると思います
** 登録済みのデータを一覧で確認する(検索で絞り込む)
** 新しいデータを追加で登録する
** 登録済みのデータを確認+修正する
** 登録済みのデータを削除する
これらをタブコントロールを利用し、一覧表示画面+詳細画面を実装してます。
5.3 Presenter
- 本テンプレートでは、業務画面のロジック制御を Presenter クラスに集約しており、Webアプリケーションでいう Controller(MVC)の役割に近い構成を取っています
Presenter は、UIに依存せずに業務ロジックを集中管理する役割を担っており、View(Form)の肥大化を防ぎ、保守性・再利用性の高いコードを実現します。
WebのMVCと異なり、WinFormsでは View がイベント主体で動作するため、Presenter に明確な入力ポイントを集中させやすく、双方向のやりとりに強いMVPが適しています。
(1) Presenterの役割1:イベント制御の中心
- View から発行されるイベント(検索・追加・保存・削除など)をすべて Presenter がハンドリング
(2) Presenterの役割2:Modelとのやりとり
- View の入力内容を 受注Model に変換し、リポジトリを通じてDB操作を実行
- 結果に応じて View に成功/失敗メッセージを返す
(3) Presenterの役割3:Viewの状態制御
- データ一覧の更新、入力項目の初期化・再表示などを View に反映
- 編集/新規の切替フラグ(IsEdit)やメッセージの表示制御も担当
(4) Presenterの役割4:入力検証の責任
- 数値・日付などの型変換チェックを Presenter で実施
(必須や桁長などModelで検証可能なものはModelで検証) - バリデーションエラー時のフィードバックも担当
(5) Presenterの役割5:エラーログ記録
- 例外発生時は ErrorLogger.Log() を使って障害ログに記録(運用面の責務)
WebのMVCと異なり、WinFormsでは View がイベント主体で動作するため、Presenter に明確な入力ポイントを集中させやすく、双方向のやりとりに強いMVPが適しています。
(6) Presenterのサンプル
using WinFormsMenuDemo.Common;
using WinFormsMenuDemo.Models;
using WinFormsMenuDemo.Repositories;
using WinFormsMenuDemo.Views;
using WinFormsMenuDemo.Presenters.Common;
namespace WinFormsMenuDemo.Presenters
{
public class 受注Presenter : PresenterBase
{
//Fields
private readonly IForm受注View _view;
private readonly I受注Repository _repository;
private readonly BindingSource _受注bindingSource;
private IEnumerable<受注Model> _受注List;
//コンストラクタ
public 受注Presenter(IForm受注View view, I受注Repository repository)
{
this._受注bindingSource = new BindingSource();
this._view = view;
this._repository = repository;
this._view.SearchEvent += Search受注;
this._view.AddNewEvent += AddNew受注;
this._view.EditEvent += LoadSelected受注ToEdit;
this._view.DeleteEvent += Delete受注;
this._view.SaveEvent += Save受注;
this._view.CancelEvent += Cancel受注;
this._view.Set受注ListBindingSource(_受注bindingSource);
_受注List = [];
LoadAll受注();
this._view.Show();
}
public override void HandleWithErrorLogging(Action action)
{
try
{
action();
}
catch (Exception ex)
{
_view.IsSuccessful = false;
_view.Message = ex.Message;
ErrorLogger.Log(ex, (Form)_view);
}
}
private void LoadAll受注()
{
Action action = () =>
{
var result = _repository.GetAll();
if (result.Is上限超過)
{
_view.Message = $"表示件数が上限を超えました。\n対象「{result.実際の件数}」件中の上位{result.表示上限}件を表示してます。";
}
_受注List = result.List;
_受注bindingSource.DataSource = _受注List;
};
HandleWithErrorLogging(action);
}
private void Search受注(object? sender, EventArgs e)
{
Action action = () =>
{
受注一覧結果 result = new();
bool emptyValue = string.IsNullOrWhiteSpace(this._view.SearchValue);
if (!emptyValue)
result = _repository.GetByValue(this._view.SearchValue);
else
result = _repository.GetAll();
if (result.Is上限超過)
{
_view.Message = $"表示件数が上限を超えました。\n対象「{result.実際の件数}」件中の上位{result.表示上限}件を表示してます。";
}
_受注List = result.List;
_受注bindingSource.DataSource = _受注List;
};
HandleWithErrorLogging(action);
}
private void AddNew受注(object? sender, EventArgs e)
{
// 追加時の初期値
_view.IsEdit = false;
_view.受注Id = "0";
_view.受注日 = DateTime.Now.ToString("yyyy/MM/dd");
}
private void LoadSelected受注ToEdit(object? sender, EventArgs e)
{
Action action = () =>
{
if (_受注bindingSource.Current is not 受注Model) return;
var current = (受注Model)_受注bindingSource.Current;
_view.受注Id = current.受注Id.ToString();
_view.得意先Id = current.得意先Id.ToString();
_view.得意先名 = current.得意先名;
_view.受注日 = current.受注日.ToString("yyyy/MM/dd");
_view.合計金額 = current.合計金額.ToString();
_view.Is売上済み = current.Is売上済み;
_view.備考 = current.備考 ?? string.Empty;
_view.Version = current.Version;
_view.IsEdit = true;
};
HandleWithErrorLogging(action);
}
// 型変換チェック
private bool CanConvertTo()
{
if (!int.TryParse(_view.受注Id, out _))
{
_view.Message = "受注IDは整数で入力してください。";
return false;
}
if (!DateTime.TryParse(_view.受注日, out _))
{
_view.Message = "受注日は日付を入力してください。";
return false;
}
if (!int.TryParse(_view.得意先Id, out _))
{
_view.Message = "得意先を選択してください。";
return false;
}
_view.合計金額 = _view.合計金額.Replace(",", "").Trim();
_view.合計金額 = _view.合計金額.Replace("\\", "").Trim();
if (!int.TryParse(_view.合計金額, out _))
{
_view.Message = "合計金額は整数で入力してください。";
return false;
}
return true;
}
private void Save受注(object? sender, EventArgs e)
{
if (!CanConvertTo())
{
_view.IsSuccessful = false;
return;
}
var model = new 受注Model();
model.受注Id = int.Parse(_view.受注Id);
model.得意先Id = int.Parse(_view.得意先Id);
model.得意先名 = _view.得意先名;
model.受注日 = DateTime.Parse(_view.受注日);
model.合計金額 = int.Parse(_view.合計金額);
model.Is売上済み = _view.Is売上済み;
model.備考 = _view.備考;
model.Version = _view.Version;
Action action = () =>
{
Common.ModelDataValidation.Validate(model);
if (_view.IsEdit)
{
bool ret = _repository.Edit(model);
if (ret)
{
_view.IsSuccessful = true;
_view.Message = "受注情報を修正しました。";
}
else
{
_view.IsSuccessful = false;
_view.Message = "他のユーザーによって同じデータが更新されています。\nこの修正をキャンセルして、もう一度初めから修正してください。";
}
}
else
{
bool ret = _repository.Add(model);
if (ret)
{
_view.IsSuccessful = true;
_view.Message = "受注情報を登録しました。";
}
else
{
_view.IsSuccessful = false;
_view.Message = "受注情報の登録に失敗しました。";
}
}
LoadAll受注();
if (_view.IsSuccessful)
{
CleanViewFields();
}
};
HandleWithErrorLogging(action);
}
private void CleanViewFields()
{
_view.受注Id = "0";
_view.得意先Id = "0";
_view.得意先名 = string.Empty;
_view.受注日 = string.Empty;
_view.合計金額 = string.Empty;
_view.Is売上済み = false;
_view.備考 = string.Empty;
}
private void Cancel受注(object? sender, EventArgs e)
{
CleanViewFields();
}
private void Delete受注(object? sender, EventArgs e)
{
Action action = () =>
{
if (_受注bindingSource.Current is not 受注Model current) return;
bool ret = _repository.Delete(current);
if (ret)
{
_view.IsSuccessful = true;
_view.Message = "受注情報を削除しました。";
}
else
{
_view.IsSuccessful = false;
_view.Message = "他のユーザーによって同じデータが更新されています。\nもう一度初めから削除してください。";
}
LoadAll受注();
};
HandleWithErrorLogging(action);
}
}
}
6.カスタムコントロール(+ユーザーコントロール)による共通コンポーネント化
6.1 SelectableGridView(選択専用のDataGridView)
✅ 目的:
- 再利用可能な 選択可能な DataGridView ユーザーコントロール
- 主に業務画面のデータ一覧表示で利用されることを想定
✅ 主な機能:
機能 | 説明 |
---|---|
デザイン共通化 | 背景色・選択色・フォント・高さなどを統一したスタイルを適用 |
ダブルクリックイベント転送 | CellDoubleClick を外部に公開し、画面ごとの処理を簡単に追加可能 |
カスタムバーグラフ描画 | BarGraphColumnName で指定した列に数値のバーグラフを表示 |
データバインディングサポート | BindingSource を直接割り当て可能 |
テーマ再適用対応 | ApplyTheme() によって色やスタイルを動的に再適用できる |
✅ 特徴的な機能:スケールバー表示
- BarGraphColumnName プロパティを使って指定された列に、最大値を基準とした横棒グラフを描画
- データ数や表示内容に応じて柔軟に対応できる
6.2 ExcelExportButton(DataGridViewのEXCEL出力)
✅ 目的:
- SelectableGridView に表示中のデータを、ワンクリックでExcelに出力するための専用ボタンコントロール
- フォーム上に配置し、TargetGrid プロパティで対象グリッドを指定するだけで利用可能
✅ 主な機能:
機能 | 説明 |
---|---|
Excelエクスポート機能内蔵 | ボタンクリックでグリッドの内容をExcelへ出力 |
対象グリッドの指定 | TargetGrid プロパティに SelectableGridView を指定 |
表示列のみ出力 | DataGridViewの表示中の列だけを対象にエクスポート |
ヘッダ装飾 | ヘッダ行には背景色(濃青)+白文字+太字装飾を適用 |
セル値の書式保持 | 列の書式設定(例:日付・通貨)をExcelのセル書式に自動反映 |
COMリソース解放 | Excelオブジェクト使用後は適切に解放してメモリリークを防止 |
✅ 実装の工夫:
- DataPropertyName で対応プロパティを自動抽出し、リフレクションで値取得
- DefaultCellStyle.Format をもとに Excel側の書式設定を自動適用
- 非表示列は無視され、画面の見た目と一致した出力が行われる
💡 利用例:
excelExportButton1.TargetGrid = this.selectableGridView1;
6.3 ToastPanel(トースト通知)
✅ 目的:
- アプリ内でエラーメッセージや通知などを一時的に表示する、カスタムトーストパネル
- モダンな見た目と使いやすさを兼ね備えた一時表示用UI部品
✅ 主な機能:
機能 | 説明 |
---|---|
メッセージ表示 | 中央にメッセージを表示するトースト形式のパネル |
自動非表示 | DisplayTime(既定3秒)経過後に自動で非表示化 |
可変サイズ対応 | メッセージ内容に応じて自動でサイズ調整 |
角丸&装飾ボーダー | カスタム描画で角丸&太枠(テーマ色)を実現 |
スタイル統一 | フォント、パディング、背景色などを一括設定 |
✅ 実装の工夫:
- MaximumSize と AutoSize を組み合わせて、複数行メッセージにも対応
- Timer により一定時間後に自動で消える仕組みを構築
- GraphicsPath による角丸描画とテーマカラーでの縁取りで、視認性と美しさを両立
- DoubleBuffered = true でちらつきを抑制
💡 利用例:
toastPanel1.ShowToast("登録に成功しました!", this);
7.まとめ
本記事では、2025年の技術スタックを活用したWinFormsアプリのモダンUIテンプレートを紹介しました。MVPパターンの適用やカスタムコントロールの導入により、保守性と拡張性の高いアプリケーションの構築を目指してます。
このように、テンプレートにMVPパターンを採用することで、UIとロジックの分離が徹底され、新規画面の追加や保守作業が圧倒的に効率化されました。