データアノテーションに連動させて、テキストボックスの入力チェックコードをもう一度書くことをやめる話
注意:本記事での用語
本記事での表現 | 他の呼び方 |
---|---|
データモデル | 業務モデル/モデルクラス |
データアノテーション | モデルに定義されたバリデーション属性(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) モデルのデータアノテーションを読み取って自動バリデーション
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) フォーカスを受けたことを強調(外枠が青く太くなる)
3) フォーカスを外すと外枠がグレーに戻る
4) エラーなら外枠が赤くなる(エラープロバイダーの❌マークも自動表示)
5) エラープロバイダー❌をマウスオーバーでエラーメッセージを表示
3.前提条件
- Visual studio 2022 Version 17.14.1
- .Net 9
なお、すべてのソースコードを公開しています。
4.実装ポイント
- バリデーションは「モデルと連携」+「個別Validator」
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 = "氏名漢字は必須です")]
2) 文字列長
データアノテーションの MaxLength が自動で反映されます。
MaxLength を自動的に反映し、指定の文字列長までしか入力できません
3) 値の範囲
[Range(typeof(DateTime), "1900-01-01", "2025-12-31", ErrorMessage = "生年月日は1900年~2025年の間で指定してください")]
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
{
///
/// 基本仕様:
/// 1.フォーカスを受けたときボーダーラインを強調し青い太枠へ変える
/// 2.フォーカスを失った時にボーダーラインを元に戻す
/// 3.フォーカスを受けたとき、文字が入力されていたら全選択する
/// 4.エンターキーで次のタブインデックスへ遷移する
/// 5.Modelのプロパティのデータアノテーションから、チェック内容を受け取れる。
/// 6.そのテキストボックスの入力値のチェックメソッドを受け取れる。また、文字列の最大長をプロパティに持つ。
/// 7.チェックメソッドや文字列長に違反した場合、フォーカスを失った時にボーダーラインを太い赤色にする
///
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 Funcstring, (bool isValid, string errorMessage)>? _modelValidator;
// 個別に追加指定するValidator
private Funcstring, (bool isValid, string errorMessage)>? _customValidator;
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public Funcstring, (bool isValid, string errorMessage)>? InputValidator
{
get => _combinedValidator;
set
{
_customValidator = value;
UpdateCombinedValidator();
}
}
// モデルからのValidatorと、個別追加Validator 2つを合成した最終的なValidator
private Funcstring, (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.GetCustomAttributeRequiredAttribute>();
this.Required = _requiredAttr != null;
// 最大長
var maxLengthAttr = prop.GetCustomAttributeMaxLengthAttribute>();
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 ListValidationResult>();
bool isValid = System.ComponentModel.DataAnnotations.Validator.TryValidateProperty(typedValue, validationContext, results);
if (isValid)
return (true, string.Empty);
else
return (false, results[0].ErrorMessage ?? "無効な入力です");
};
}
// モデルからの属性チェックと個別チェックを組み合わせる
// (両方のチェックを有効にする)
private Funcstring, (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;
}
}
}
Views: 2