2025 業務アプリ向け WinForms モダンUIテンプレート（MVPパターン対応）

:::note
2025年5月7日(水) 追記
MVPパターンの解説記事を追加で投稿しました。
:::
https://qiita.com/masayahak/items/a83b94adef244967446b

:::note 
2025年4月26日(土) 追記
多数の方にご覧いただきありがとうございます。
ソースコードも多数の方が参照してくれたようで、お役に立てたとすればとても嬉しいです。

ただ、ソースコード参照してくれた方がそのまま実行しようとすると、DBが無いのでエラーになると思います。
GitHubのソースの中に、テーブル作成と仮データINSERTのスクリプトを添付しますのでご利用ください。

https://github.com/masayahak/WinFormsMenuDemo/blob/master/WinFormsMenuDemo/%E3%83%86%E3%82%B9%E3%83%88%E3%83%87%E3%83%BC%E3%82%BF.sql
:::


## １．この記事の目的
久しぶりに実務でWindows Formアプリの開発を担当することになりまし。
2025年現在の技術でWindows Formアプリを作る場合の小さなテンプレートを先行開発しましたので公開します。
* モダンなフラットUI
* MVP（Model + View + Presenter）パターン対応
* カスタムコントロール（＋ユーザーコントロール）による共通コンポーネント化
** 選択専用のDataGridView
** DataGridViewのEXCEL出力
** トースト通知
** ユーザー設定

## ２．画面イメージ
### ２．１ 一覧の表示
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/bc7a8ca9-925b-4966-8889-5a801f19d004.png)

### ２．２ 追加＋修正＋削除
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/340c78f8-1da0-4469-bdba-00775927e349.png)

### ２．３ トースト通知
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/14347a5e-7a4f-4ffd-ab46-44d50472672e.png)

### ２．４ 複数業務を１つのForm内でメニュー切り替え
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/5b0b4bd3-d164-4fe2-9b6c-bbba8ab09b41.png)

### ２．５ EXCEL出力
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/6deb7f26-497a-42fe-8519-5e33f4425385.png)

### ２．６ ユーザー設定
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/fc4fc6c7-b351-4287-8f43-cca89bcbf3b5.png)

## ３．前提条件
* Visual studio 2022 Version 17.13.6
* .Net 9

:::note 
なお、すべてのソースコードを公開しています。
:::
https://github.com/masayahak/WinFormsMenuDemo

## ４．モダンなフラットUI
### ４．１ 個別の業務用Formを１つのメインFormで表示する方法
* メインFormに個別の業務用Formを流し込むPanelを用意する
* 各業務用FormはDictionaryにスタックし、メニューから切り替えられた場合入力途中の状態を維持し再開可能にする
```C#
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受注));
    }

```

### ４．２ アプリ終了時の位置とサイズを記録し再開する
* 単純にメインProperties.Settingsに記録、読み取りしてます
```C#
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();
        }
    }
```

## ５．MVP（Model + View + Presenter）パターン

MVPイメージ図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/564ea082-bbde-4d14-9a60-11f223f25710.png)

✅ MVPパターンのメリットと実装のポイント

✔️ なぜMVPを採用したか？
WinForms開発では、画面（Form）とロジックが密結合になりやすく、次のような課題がよく発生します：

* Form が肥大化してテストが困難になる
* 複数画面で同じロジックを使い回せない
* 画面の状態（初期化・入力値）を外部から制御しにくい

:::note 
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 の責務が減り、読みやすく保守しやすい

### ５．１ Model
* 一般的なModelです
* RepositoryはSQLの自由度を優先し、Ado.NETをベースとした軽量なリポジトリクラスにしてます（あえてEntity FrameworkなどのORMは利用しません）

### ５．２ View（Form）
* ほとんどの業務画面で以下の機能が必要になると思います
** 登録済みのデータを一覧で確認する（検索で絞り込む）
** 新しいデータを追加で登録する
** 登録済みのデータを確認＋修正する
** 登録済みのデータを削除する
これらをタブコントロールを利用し、一覧表示画面＋詳細画面を実装してます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/6e676143-c61d-4688-8017-35f0a74fbe14.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/2fd02de7-a17b-4944-ba88-c9fc051b84d5.png)

### ５．３ Presenter
* 本テンプレートでは、業務画面のロジック制御を Presenter クラスに集約しており、Webアプリケーションでいう Controller（MVC）の役割に近い構成を取っています

:::note 
Presenter は、UIに依存せずに業務ロジックを集中管理する役割を担っており、View(Form)の肥大化を防ぎ、保守性・再利用性の高いコードを実現します。
:::

:::note
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のサンプル
```C#
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);

        }
    }
}
```




## ６．カスタムコントロール（＋ユーザーコントロール）による共通コンポーネント化

### ６．１ SelectableGridView（選択専用のDataGridView）
✅ 目的：
* 再利用可能な 選択可能な DataGridView ユーザーコントロール
* 主に業務画面のデータ一覧表示で利用されることを想定

✅ 主な機能：
| 機能 | 説明 |
|:-|:-|
| デザイン共通化 | 背景色・選択色・フォント・高さなどを統一したスタイルを適用 |
| ダブルクリックイベント転送 | CellDoubleClick を外部に公開し、画面ごとの処理を簡単に追加可能 |
| カスタムバーグラフ描画 | BarGraphColumnName で指定した列に数値のバーグラフを表示 |
| データバインディングサポート | BindingSource を直接割り当て可能 |
| テーマ再適用対応 | ApplyTheme() によって色やスタイルを動的に再適用できる |
	
✅ 特徴的な機能：スケールバー表示
* BarGraphColumnName プロパティを使って指定された列に、最大値を基準とした横棒グラフを描画
* データ数や表示内容に応じて柔軟に対応できる


### ６．２ ExcelExportButton（DataGridViewのEXCEL出力）
✅ 目的：
* SelectableGridView に表示中のデータを、ワンクリックでExcelに出力するための専用ボタンコントロール
* フォーム上に配置し、TargetGrid プロパティで対象グリッドを指定するだけで利用可能

✅ 主な機能：

| 機能 | 説明 |
|:-|:-|
|Excelエクスポート機能内蔵 | ボタンクリックでグリッドの内容をExcelへ出力 |
|対象グリッドの指定 | TargetGrid プロパティに SelectableGridView を指定 |
|表示列のみ出力 | DataGridViewの表示中の列だけを対象にエクスポート |
|ヘッダ装飾 | ヘッダ行には背景色（濃青）＋白文字＋太字装飾を適用 |
|セル値の書式保持 | 列の書式設定（例：日付・通貨）をExcelのセル書式に自動反映 |
|COMリソース解放 | Excelオブジェクト使用後は適切に解放してメモリリークを防止 |

✅ 実装の工夫：
* DataPropertyName で対応プロパティを自動抽出し、リフレクションで値取得
* DefaultCellStyle.Format をもとに Excel側の書式設定を自動適用
* 非表示列は無視され、画面の見た目と一致した出力が行われる

💡 利用例：
```C#
excelExportButton1.TargetGrid = this.selectableGridView1;
```

### ６．３ ToastPanel（トースト通知）

✅ 目的：
* アプリ内でエラーメッセージや通知などを一時的に表示する、カスタムトーストパネル
* モダンな見た目と使いやすさを兼ね備えた一時表示用UI部品


✅ 主な機能：

| 機能 | 説明 |
|:-|:-|
|メッセージ表示 | 中央にメッセージを表示するトースト形式のパネル|
|自動非表示 | DisplayTime（既定3秒）経過後に自動で非表示化|
|可変サイズ対応 | メッセージ内容に応じて自動でサイズ調整|
|角丸＆装飾ボーダー | カスタム描画で角丸＆太枠（テーマ色）を実現|
|スタイル統一 | フォント、パディング、背景色などを一括設定|

✅ 実装の工夫：
* MaximumSize と AutoSize を組み合わせて、複数行メッセージにも対応
* Timer により一定時間後に自動で消える仕組みを構築
* GraphicsPath による角丸描画とテーマカラーでの縁取りで、視認性と美しさを両立
* DoubleBuffered = true でちらつきを抑制

💡 利用例：
```C#
toastPanel1.ShowToast("登録に成功しました！", this);
```

## ７．まとめ

本記事では、2025年の技術スタックを活用したWinFormsアプリのモダンUIテンプレートを紹介しました。MVPパターンの適用やカスタムコントロールの導入により、保守性と拡張性の高いアプリケーションの構築を目指してます。

このように、テンプレートにMVPパターンを採用することで、UIとロジックの分離が徹底され、新規画面の追加や保守作業が圧倒的に効率化されました。