42
47

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-04-16

1.この記事の目的

久しぶりに実務でWindows Formアプリの開発を担当することになりまし。
2025年現在の技術でWindows Formアプリを作る場合の小さなテンプレートを先行開発しましたので公開します。

  • モダンなフラットUI
  • MVP(Model + View + Presenter)パターン対応
  • カスタムコントロール(+ユーザーコントロール)による共通コンポーネント化
    ** 選択専用のDataGridView
    ** DataGridViewのEXCEL出力
    ** トースト通知
    ** ユーザー設定

2.画面イメージ

2.1 一覧の表示

image.png

2.2 追加+修正+削除

image.png

2.3 トースト通知

image.png

2.4 複数業務を1つのForm内でメニュー切り替え

image.png

2.5 EXCEL出力

image.png

2.6 ユーザー設定

image.png

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イメージ図
image.png

✅ 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)

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

image.png

image.png

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とロジックの分離が徹底され、新規画面の追加や保守作業が圧倒的に効率化されました。

42
47
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
42
47

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?