3
4

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 入力チェックはデータモデルに任せよう

Last updated at Posted at 2025-05-25

入力チェックはデータモデルに任せよう!

データアノテーションに連動させて、テキストボックスの入力チェックコードをもう一度書くことをやめる話

注意:本記事での用語

本記事での表現 他の呼び方
データモデル 業務モデル/モデルクラス
データアノテーション モデルに定義されたバリデーション属性(DataAnnotations属性)
Model.cs

public class 顧客Model
{
    // --- 以下の2行がデータアノテーション -----------------------------
    [Required(ErrorMessage = "氏名漢字は必須です")]    
    [MaxLength(10)]
    // --------------------------------------------------------------
    public string 氏名漢字 { get; set; } = string.Empty;

1.はじめに

WinForms で業務アプリを開発していると毎回やってくる「入力チェック」処理。
せっかくデータモデルに記述したデータアノテーションによる Required(必須)、MaxLength(最大長)、書式チェックなど。

チェック処理をModelとForm側の2カ所に書くのはもうやめませんか?

モデルの要件に連動させたカスタムコントロールでバリデーションを実現しました。


2.やりたいこと

以下のような要件を、カスタムTextBoxコントロール側に実装します:

✅ データチェック要件

1) モデルのデータアノテーションを読み取って自動バリデーション

image.png

Model.cs

[Range(0, 999999999999, ErrorMessage = "0〜999999999999 の範囲で入力してください。")]
public decimal 年収 { get; set; }
Form.cs

NVTextBox年収.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.年収));

2) 追加で個別のチェック関数(Validator)も設定できる

データアノテーションには書き切れないチェック処理は、個別に追加可能

Form.cs

VTextBox氏名カナ.InputValidator = s =>
{
    foreach (char c in s)
    {
        if (!((c >= '\u30A0' && c <= '\u30FF') || c == '\u3000'))
            return (false, "全角カタカナまたは全角スペースのみを入力してください。");
    }
    return (true, string.Empty);
};

✅ 業務アプリで TextBox へ良く求められる要件も併せて実装

1) Enterで次の入力欄へ移動(ビープ音なし)

2) フォーカスを受けたことを強調(外枠が青く太くなる)

image.png

3) フォーカスを外すと外枠がグレーに戻る

image.png

4) エラーなら外枠が赤くなる(エラープロバイダーの❌マークも自動表示)

image.png

5) エラープロバイダー❌をマウスオーバーでエラーメッセージを表示

image.png

3.前提条件

  • Visual studio 2022 Version 17.14.1
  • .Net 9

なお、すべてのソースコードを公開しています。

4.実装ポイント

  • バリデーションは「モデルと連携」+「個別Validator」

image.png

Model.cs

public class 顧客Model
{
    [Required(ErrorMessage = "氏名漢字は必須です")]
    [MaxLength(10)]
    public string 氏名漢字 { get; set; } = string.Empty;

    [Required(ErrorMessage = "氏名カナは必須です")]
    [MaxLength(10)]
    public string 氏名カナ { get; set; } = string.Empty;

    [Required(ErrorMessage = "メールアドレスは必須です")]
    [EmailAddress(ErrorMessage = "メールアドレスの形式が正しくありません")]
    [MaxLength(100)]
    public string メールアドレス { get; set; } = string.Empty;

    [Required(ErrorMessage = "生年月日は必須です")]
    [Range(typeof(DateTime), "1900-01-01", "2025-12-31", ErrorMessage = "生年月日は1900年~2025年の間で指定してください")]
    public DateTime 生年月日 { get; set; }

    [Range(0, 999999999999, ErrorMessage = "0〜999999999999 の範囲で入力してください。")]
    public decimal 年収 { get; set; }

}

Form側のコードを見ると、ほとんどモデルのアノテーションだけでチェック処理が終わっていることがわかります

Form.cs

private void SetValidators()
{
    // 氏名(漢字)
    VTextBox氏名漢字.ErrorProvider = this.ErrorProvider;
    VTextBox氏名漢字.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.氏名漢字));

    // 氏名(カナ)
    VTextBox氏名カナ.ErrorProvider = this.ErrorProvider;
    VTextBox氏名カナ.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.氏名カナ));

    VTextBox氏名カナ.InputValidator = s =>
    {
        foreach (char c in s)
        {
            if (!((c >= '\u30A0' && c <= '\u30FF') || c == '\u3000'))
                return (false, "全角カタカナまたは全角スペースのみを入力してください。");
        }
        return (true, string.Empty);
    };

    // メールアドレス
    VTextBoxメールアドレス.ErrorProvider = this.ErrorProvider;
    VTextBoxメールアドレス.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.メールアドレス));

    // 生年月日
    VDateTimePicker生年月日.ErrorProvider = this.ErrorProvider;
    VDateTimePicker生年月日.Format = DateTimePickerFormat.Custom;
    VDateTimePicker生年月日.CustomFormat = "yyyy/MM/dd";
    VDateTimePicker生年月日.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.生年月日));

    // 年収
    NVTextBox年収.ErrorProvider = this.ErrorProvider;
    NVTextBox年収.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.年収));
    NVTextBox年収.NumberFormat = "#,##0";
}

氏名カナの「全角カナチェック」のみ追加実装してます

1) 必須入力チェック

データアノテーションの Required が自動で反映されます。

[Required(ErrorMessage = "氏名漢字は必須です")]

image.png

2) 文字列長

データアノテーションの MaxLength が自動で反映されます。

[MaxLength(10)]

image.png

MaxLength を自動的に反映し、指定の文字列長までしか入力できません

3) 値の範囲

[Range(typeof(DateTime), "1900-01-01", "2025-12-31", ErrorMessage = "生年月日は1900年~2025年の間で指定してください")]

image.png

5.TextBox以外のコントロール

ついでに、数値用の NumericValidatingTextBox
日付用の ValidatingDateTimePicker も作りました。

1) 数値入力に対応:NumericValidatingTextBox

  • 最小値/最大値
  • 負の数許可/ゼロ許可
  • 小数点以下の桁数制限
  • 書式指定(例:"#,##0")

2) 日付入力に対応:ValidatingDateTimePicker

  • DateTimePicker を拡張し、RequiredやRangeをモデルから読み取る
  • 枠線制御や Enter移動も統一
    ※ 全選択は不安定なのでオミット(理由もコード内に記載)

6.最後に

業務アプリの現場で、
2回同じことを書くことで発生する 非同期、メンテナンス漏れを 防ぎたくて
このようなコントロールにしてみました。
「各画面で毎回同じようなバリデーションコードを書く」のをやめて
モデルへ任せる設計にすると、コードも綺麗で再利用性も高まりますよね✨

7.参考

GitHubへソースは全てアップしてますが参考までに。

using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Reflection;

namespace CustomTextbox.Contols
{
    /// <summary>
    /// 基本仕様:
    /// 1.フォーカスを受けたときボーダーラインを強調し青い太枠へ変える
    /// 2.フォーカスを失った時にボーダーラインを元に戻す
    /// 3.フォーカスを受けたとき、文字が入力されていたら全選択する
    /// 4.エンターキーで次のタブインデックスへ遷移する
    /// 5.Modelのプロパティのデータアノテーションから、チェック内容を受け取れる。
    /// 6.そのテキストボックスの入力値のチェックメソッドを受け取れる。また、文字列の最大長をプロパティに持つ。
    /// 7.チェックメソッドや文字列長に違反した場合、フォーカスを失った時にボーダーラインを太い赤色にする
    /// </summary>
    public class ValidatingTextBox : TextBox
    {
        [Category("データ")]
        [Description("必須項目はTrueにしてください")]
        [DefaultValue(false)]
        public bool Required { get; set; } = false;
        
        [Category("外観")]
        [Description("外枠の通常色です")]
        [DefaultValue(typeof(Color), "Gray")]
        public Color 通常色 { get; set; } = Color.Gray;

        [Category("外観")]
        [Description("フォーカスを受けた時の外枠の色です")]
        [DefaultValue(typeof(Color), "DeepSkyBlue")]
        public Color フォーカス色 { get; set; } = Color.DeepSkyBlue;

        [Category("外観")]
        [Description("エラー時の外枠の色です")]
        [DefaultValue(typeof(Color), "Red")]
        public Color エラー色 { get; set; } = Color.Red;

        [Browsable(false)]
        [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
        public ErrorProvider? ErrorProvider { get; set; }

        //バインドするModleのプロパティから自動生成するValidator
        private Func<string, (bool isValid, string errorMessage)>? _modelValidator;
        // 個別に追加指定するValidator
        private Func<string, (bool isValid, string errorMessage)>? _customValidator;
        [Browsable(false)]
        [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
        public Func<string, (bool isValid, string errorMessage)>? InputValidator
        {
            get => _combinedValidator;
            set
            {
                _customValidator = value;
                UpdateCombinedValidator();
            }
        }

        // モデルからのValidatorと、個別追加Validator 2つを合成した最終的なValidator
        private Func<string, (bool isValid, string errorMessage)>? _combinedValidator;
        private void UpdateCombinedValidator()
        {
            _combinedValidator = CombineValidators();
        }


        [Browsable(false)]
        [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
        public bool IsValid => _isValid;

        internal bool _hasFocus = false;
        private bool _isValid = true;
        private RequiredAttribute? _requiredAttr;


        public ValidatingTextBox()
        {
            BorderStyle = BorderStyle.FixedSingle;
        }

        protected override void OnEnter(EventArgs e)
        {
            _hasFocus = true;
            Invalidate();

            if (!string.IsNullOrEmpty(Text))
            {
                // 3.フォーカスを受けたとき、文字が入力されていたら全選択する
                SelectAll(); // 全選択
            }

            base.OnEnter(e);
        }

        protected override void OnLeave(EventArgs e)
        {
            _hasFocus = false;
            _isValid = ValidateInput();
            Invalidate();
            base.OnLeave(e);
        }

        protected override void OnKeyDown(KeyEventArgs e)
        {
            if (e.KeyCode == Keys.Enter)
            {
                // 4.エンターキーで次のタブインデックスへ遷移する
                Parent?.SelectNextControl(this, true, true, true, true);
                e.Handled = true;
                e.SuppressKeyPress = true; // ← ★これでビープ音防止!
            }

            base.OnKeyDown(e);
        }

        // フォーカスあり/なし/エラー状態で外枠の色を変える
        protected override void WndProc(ref Message m)
        {
            base.WndProc(ref m);

            const int WM_PAINT = 0x000F;
            if (m.Msg == WM_PAINT)
            {
                using Graphics g = CreateGraphics();
                // 1.フォーカスを受けたときボーダーラインを強調し青い太枠へ変える
                Color borderColor = 通常色;

                // 2.フォーカスを失った時にボーダーラインを元に戻す
                if (_hasFocus)
                    borderColor = フォーカス色;
                // 7.チェックメソッドや文字列長に違反した場合、フォーカスを失った時にボーダーラインを太い赤色にする
                else if (!_isValid)
                    borderColor = エラー色;

                using Pen pen = new Pen(borderColor, borderColor != 通常色 ? 2 : 1);
                Rectangle rect = new Rectangle(1, 1, this.Width - 3, this.Height - 3);
                g.DrawRectangle(pen, rect);
            }
        }

        // 5.Modelのプロパティのデータアノテーションから、チェック内容を受け取れる。
        public void BindValidationToModelProperty(Type modelType, string propertyName)
        {
            var prop = modelType.GetProperty(propertyName);
            if (prop == null) return;

            // Requiredの有無
            _requiredAttr = prop.GetCustomAttribute<RequiredAttribute>();
            this.Required = _requiredAttr != null;


            // 最大長
            var maxLengthAttr = prop.GetCustomAttribute<MaxLengthAttribute>();
            if (maxLengthAttr != null)
            {
                MaxLength = maxLengthAttr.Length;
            }

            // その他のValidation
            _modelValidator = s =>
            {
                // 空文字は Validator に流さず、Required で判定
                if (string.IsNullOrWhiteSpace(s)) return (true, string.Empty);

                // 型変換チェック
                var prop = modelType.GetProperty(propertyName);
                if (prop == null) return (true, string.Empty);

                var targetType = prop.PropertyType;

                // TryParse 相当の安全な型変換
                object? typedValue;
                try
                {
                    typedValue = TypeDescriptor.GetConverter(targetType).ConvertFromString(s);
                }
                catch (Exception ex)
                {
                    return (false, $"形式が正しくありません: {ex.Message}");
                }


                var dummyInstance = Activator.CreateInstance(modelType) ?? 
                    throw new InvalidOperationException("モデルのインスタンス生成に失敗しました");
                var validationContext = new ValidationContext(dummyInstance) 
                { 
                    MemberName = propertyName 
                };

                var results = new List<ValidationResult>();
                bool isValid = System.ComponentModel.DataAnnotations.Validator.TryValidateProperty(typedValue, validationContext, results);

                if (isValid)
                    return (true, string.Empty);
                else
                    return (false, results[0].ErrorMessage ?? "無効な入力です");
            };
        }

        // モデルからの属性チェックと個別チェックを組み合わせる
        // (両方のチェックを有効にする)
        private Func<string, (bool isValid, string errorMessage)>? CombineValidators()
        {
            if (_customValidator == null && _modelValidator == null)
                return null;

            return s =>
            {
                if (_modelValidator != null)
                {
                    var result = _modelValidator(s);
                    if (!result.isValid) return result;
                }

                if (_customValidator != null)
                {
                    var result = _customValidator(s);
                    if (!result.isValid) return result;
                }

                return (true, string.Empty);
            };
        }

        // Validation実行
        private bool ValidateInput()
        {
            // 6.そのテキストボックスの入力値のチェックメソッドを受け取れる。また、文字列の最大長をプロパティに持つ。
            var errorMessage = string.Empty;

            // 検証ロジック
            if (Required && string.IsNullOrWhiteSpace(Text))
            {
                errorMessage = _requiredAttr?.ErrorMessage ?? "必須入力です。";
                ErrorProvider?.SetError(this, errorMessage);
                return false;
            }

            if (InputValidator != null)
            {
                var result = InputValidator(Text);
                if (!result.isValid)
                {
                    errorMessage = result.errorMessage;
                    ErrorProvider?.SetError(this, errorMessage);
                    return false;
                }
            }

            ErrorProvider?.SetError(this, string.Empty);
            return true;
        }
    }
}
3
4
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
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?