form_validation

Dart/Flutter向けの包括的なフォームバリデーションライブラリ。動的なバリデーション、カスタムバリデータ、国際化対応を提供

概要

form_validationは、Dart/Flutter アプリケーション向けの強力で柔軟なフォームバリデーションライブラリです。シンプルなAPIを提供し、文字列、数値、メールアドレスなど、さまざまなデータ型の検証をサポートします。

主な特徴

  • 🔍 包括的なバリデータセット - メール、電話番号、URL、クレジットカードなど
  • 🎨 Flutterフォームとの統合 - TextFormFieldとシームレスに連携
  • 🔧 カスタムバリデータ - 独自のバリデーションロジックを簡単に実装
  • 🌍 国際化対応 - 多言語エラーメッセージのサポート
  • 高パフォーマンス - 軽量で高速な検証処理
  • 📱 クロスプラットフォーム - iOS、Android、Web、デスクトップに対応

インストール

pubspec.yamlに追加

dependencies:
  form_validation: ^1.0.0

パッケージのインストール

flutter pub get

基本的な使い方

シンプルなフォームバリデーション

import 'package:flutter/material.dart';
import 'package:form_validation/form_validation.dart';

class LoginForm extends StatefulWidget {
  @override
  _LoginFormState createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          // メールアドレスフィールド
          TextFormField(
            controller: _emailController,
            decoration: InputDecoration(
              labelText: 'メールアドレス',
              hintText: '[email protected]',
            ),
            validator: ValidationBuilder()
                .required('メールアドレスを入力してください')
                .email('有効なメールアドレスを入力してください')
                .build(),
          ),
          
          // パスワードフィールド
          TextFormField(
            controller: _passwordController,
            obscureText: true,
            decoration: InputDecoration(
              labelText: 'パスワード',
            ),
            validator: ValidationBuilder()
                .required('パスワードを入力してください')
                .minLength(8, 'パスワードは8文字以上必要です')
                .maxLength(20, 'パスワードは20文字以内にしてください')
                .build(),
          ),
          
          // 送信ボタン
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                // バリデーション成功時の処理
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('ログイン処理を開始します')),
                );
              }
            },
            child: Text('ログイン'),
          ),
        ],
      ),
    );
  }
}

高度な使い方

カスタムバリデータの作成

// カスタムバリデータクラス
class CustomValidators {
  // 日本の郵便番号バリデータ
  static String? japanesePostalCode(String? value) {
    if (value == null || value.isEmpty) {
      return null;
    }
    
    final postalCodeRegex = RegExp(r'^\d{3}-?\d{4}$');
    if (!postalCodeRegex.hasMatch(value)) {
      return '有効な郵便番号を入力してください(例:123-4567)';
    }
    
    return null;
  }
  
  // 日本の電話番号バリデータ
  static String? japanesePhoneNumber(String? value) {
    if (value == null || value.isEmpty) {
      return null;
    }
    
    final phoneRegex = RegExp(r'^0\d{1,4}-?\d{1,4}-?\d{4}$');
    if (!phoneRegex.hasMatch(value)) {
      return '有効な電話番号を入力してください';
    }
    
    return null;
  }
}

// 使用例
TextFormField(
  decoration: InputDecoration(labelText: '郵便番号'),
  validator: (value) {
    // 複数のバリデータを組み合わせる
    final required = ValidationBuilder()
        .required('郵便番号を入力してください')
        .build()(value);
    
    if (required != null) return required;
    
    return CustomValidators.japanesePostalCode(value);
  },
)

ValidationBuilderの拡張

// ValidationBuilderの拡張
extension ValidationExtensions on ValidationBuilder {
  ValidationBuilder japanesePostalCode([String? message]) {
    return add((value) {
      if (value == null || value.isEmpty) return null;
      
      final regex = RegExp(r'^\d{3}-?\d{4}$');
      if (!regex.hasMatch(value)) {
        return message ?? '有効な郵便番号を入力してください';
      }
      
      return null;
    });
  }
  
  ValidationBuilder strongPassword([String? message]) {
    return add((value) {
      if (value == null || value.isEmpty) return null;
      
      // 大文字、小文字、数字、特殊文字を含む
      final hasUpperCase = value.contains(RegExp(r'[A-Z]'));
      final hasLowerCase = value.contains(RegExp(r'[a-z]'));
      final hasDigits = value.contains(RegExp(r'[0-9]'));
      final hasSpecialCharacters = value.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'));
      
      if (!(hasUpperCase && hasLowerCase && hasDigits && hasSpecialCharacters)) {
        return message ?? 'パスワードは大文字、小文字、数字、特殊文字を含む必要があります';
      }
      
      return null;
    });
  }
}

// 使用例
TextFormField(
  validator: ValidationBuilder()
      .required()
      .minLength(8)
      .strongPassword()
      .build(),
)

動的バリデーション

class DynamicValidationForm extends StatefulWidget {
  @override
  _DynamicValidationFormState createState() => _DynamicValidationFormState();
}

class _DynamicValidationFormState extends State<DynamicValidationForm> {
  bool _requirePhoneNumber = false;
  final _formKey = GlobalKey<FormState>();
  
  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          // チェックボックス
          CheckboxListTile(
            title: Text('電話番号を入力する'),
            value: _requirePhoneNumber,
            onChanged: (value) {
              setState(() {
                _requirePhoneNumber = value ?? false;
                // フォームを再検証
                _formKey.currentState?.validate();
              });
            },
          ),
          
          // 電話番号フィールド(条件付きバリデーション)
          TextFormField(
            decoration: InputDecoration(
              labelText: '電話番号',
              enabled: _requirePhoneNumber,
            ),
            validator: (value) {
              if (!_requirePhoneNumber) {
                return null; // バリデーションをスキップ
              }
              
              return ValidationBuilder()
                  .required('電話番号を入力してください')
                  .phone('有効な電話番号を入力してください')
                  .build()(value);
            },
          ),
        ],
      ),
    );
  }
}

非同期バリデーション

class AsyncValidationExample extends StatelessWidget {
  // メールアドレスの重複チェック(サーバーAPI呼び出し)
  Future<String?> checkEmailAvailability(String email) async {
    // 実際のAPIリクエストをシミュレート
    await Future.delayed(Duration(seconds: 1));
    
    // デモ用:特定のメールアドレスが既に使用されている場合
    final usedEmails = ['[email protected]', '[email protected]'];
    
    if (usedEmails.contains(email)) {
      return 'このメールアドレスは既に使用されています';
    }
    
    return null;
  }
  
  @override
  Widget build(BuildContext context) {
    return TextFormField(
      decoration: InputDecoration(
        labelText: 'メールアドレス',
        suffixIcon: _isChecking
            ? SizedBox(
                width: 20,
                height: 20,
                child: CircularProgressIndicator(strokeWidth: 2),
              )
            : null,
      ),
      validator: (value) {
        // 同期的なバリデーション
        final syncError = ValidationBuilder()
            .required()
            .email()
            .build()(value);
        
        if (syncError != null) {
          return syncError;
        }
        
        // 非同期バリデーションはonChangedで処理
        return _asyncError;
      },
      onChanged: (value) async {
        if (value.isNotEmpty && EmailValidator.validate(value)) {
          setState(() => _isChecking = true);
          
          final error = await checkEmailAvailability(value);
          
          setState(() {
            _isChecking = false;
            _asyncError = error;
          });
        }
      },
    );
  }
}

Flutter統合

フォームフィールドのラッパー

class ValidatedTextFormField extends StatelessWidget {
  final String label;
  final TextEditingController? controller;
  final List<Validator> validators;
  final TextInputType? keyboardType;
  final bool obscureText;
  
  const ValidatedTextFormField({
    Key? key,
    required this.label,
    this.controller,
    this.validators = const [],
    this.keyboardType,
    this.obscureText = false,
  }) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: controller,
      keyboardType: keyboardType,
      obscureText: obscureText,
      decoration: InputDecoration(
        labelText: label,
        border: OutlineInputBorder(),
        filled: true,
        fillColor: Colors.grey[100],
      ),
      validator: (value) {
        for (final validator in validators) {
          final error = validator(value);
          if (error != null) {
            return error;
          }
        }
        return null;
      },
    );
  }
}

// 使用例
ValidatedTextFormField(
  label: 'ユーザー名',
  validators: [
    RequiredValidator(errorText: '必須項目です'),
    MinLengthValidator(3, errorText: '3文字以上入力してください'),
    PatternValidator(r'^[a-zA-Z0-9]+$', errorText: '英数字のみ使用可能です'),
  ],
)

フォーム状態の管理

class FormStateManager extends ChangeNotifier {
  final Map<String, String?> _errors = {};
  final Map<String, dynamic> _values = {};
  
  void setValue(String field, dynamic value) {
    _values[field] = value;
    notifyListeners();
  }
  
  void setError(String field, String? error) {
    _errors[field] = error;
    notifyListeners();
  }
  
  String? getError(String field) => _errors[field];
  dynamic getValue(String field) => _values[field];
  
  bool get hasErrors => _errors.values.any((error) => error != null);
  
  void validateField(String field, List<Validator> validators) {
    final value = _values[field];
    String? error;
    
    for (final validator in validators) {
      error = validator(value?.toString());
      if (error != null) break;
    }
    
    setError(field, error);
  }
  
  void validateAll(Map<String, List<Validator>> fieldValidators) {
    fieldValidators.forEach((field, validators) {
      validateField(field, validators);
    });
  }
  
  void reset() {
    _errors.clear();
    _values.clear();
    notifyListeners();
  }
}

エラーメッセージのカスタマイズ

多言語対応

class ValidationMessages {
  static Map<String, Map<String, String>> _messages = {
    'ja': {
      'required': '必須項目です',
      'email': '有効なメールアドレスを入力してください',
      'minLength': '最低{min}文字入力してください',
      'maxLength': '最大{max}文字までです',
      'pattern': '正しい形式で入力してください',
      'numeric': '数値を入力してください',
    },
    'en': {
      'required': 'This field is required',
      'email': 'Please enter a valid email address',
      'minLength': 'Must be at least {min} characters',
      'maxLength': 'Must be no more than {max} characters',
      'pattern': 'Please enter in the correct format',
      'numeric': 'Please enter a numeric value',
    },
  };
  
  static String get(String key, String locale, [Map<String, dynamic>? params]) {
    final message = _messages[locale]?[key] ?? _messages['en']?[key] ?? key;
    
    if (params != null) {
      return message.replaceAllMapped(
        RegExp(r'{(\w+)}'),
        (match) => params[match.group(1)!]?.toString() ?? match.group(0)!,
      );
    }
    
    return message;
  }
}

// カスタムバリデータでの使用
class LocalizedValidators {
  final String locale;
  
  LocalizedValidators(this.locale);
  
  String? required(String? value) {
    if (value == null || value.isEmpty) {
      return ValidationMessages.get('required', locale);
    }
    return null;
  }
  
  String? minLength(String? value, int min) {
    if (value != null && value.length < min) {
      return ValidationMessages.get('minLength', locale, {'min': min});
    }
    return null;
  }
}

ベストプラクティス

1. バリデーションロジックの分離

// validators/user_validators.dart
class UserValidators {
  static final username = ValidationBuilder()
      .required('ユーザー名を入力してください')
      .minLength(3, 'ユーザー名は3文字以上必要です')
      .maxLength(20, 'ユーザー名は20文字以内にしてください')
      .regExp(r'^[a-zA-Z0-9_]+$', '英数字とアンダースコアのみ使用可能です')
      .build();
  
  static final email = ValidationBuilder()
      .required('メールアドレスを入力してください')
      .email('有効なメールアドレスを入力してください')
      .build();
  
  static final password = ValidationBuilder()
      .required('パスワードを入力してください')
      .minLength(8, 'パスワードは8文字以上必要です')
      .add((value) {
        // カスタムバリデーション
        if (!RegExp(r'^(?=.*[A-Za-z])(?=.*\d)').hasMatch(value!)) {
          return 'パスワードは英字と数字を含む必要があります';
        }
        return null;
      })
      .build();
}

2. エラー表示のカスタマイズ

class CustomErrorText extends StatelessWidget {
  final String? errorText;
  
  const CustomErrorText({Key? key, this.errorText}) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    if (errorText == null || errorText!.isEmpty) {
      return SizedBox.shrink();
    }
    
    return Padding(
      padding: const EdgeInsets.only(top: 8.0),
      child: Row(
        children: [
          Icon(Icons.error_outline, color: Colors.red, size: 16),
          SizedBox(width: 4),
          Expanded(
            child: Text(
              errorText!,
              style: TextStyle(color: Colors.red, fontSize: 12),
            ),
          ),
        ],
      ),
    );
  }
}

3. リアルタイムバリデーション

class RealtimeValidationField extends StatefulWidget {
  final String label;
  final List<Validator> validators;
  final ValueChanged<String>? onValid;
  
  const RealtimeValidationField({
    Key? key,
    required this.label,
    required this.validators,
    this.onValid,
  }) : super(key: key);
  
  @override
  _RealtimeValidationFieldState createState() => _RealtimeValidationFieldState();
}

class _RealtimeValidationFieldState extends State<RealtimeValidationField> {
  final _controller = TextEditingController();
  String? _error;
  bool _touched = false;
  
  void _validate(String value) {
    if (!_touched && value.isEmpty) return;
    
    _touched = true;
    String? error;
    
    for (final validator in widget.validators) {
      error = validator(value);
      if (error != null) break;
    }
    
    setState(() => _error = error);
    
    if (error == null && widget.onValid != null) {
      widget.onValid!(value);
    }
  }
  
  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        TextField(
          controller: _controller,
          onChanged: _validate,
          decoration: InputDecoration(
            labelText: widget.label,
            errorText: _error,
            suffixIcon: _touched && _error == null
                ? Icon(Icons.check_circle, color: Colors.green)
                : null,
          ),
        ),
      ],
    );
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

トラブルシューティング

よくある問題と解決方法

  1. バリデーションが機能しない

    // 問題: FormStateが正しく初期化されていない
    // 解決方法:
    final _formKey = GlobalKey<FormState>(); // これを忘れずに
    
    Form(
      key: _formKey, // keyを設定
      // ...
    )
    
  2. カスタムバリデータが呼ばれない

    // 問題: nullチェックが適切でない
    // 解決方法:
    String? customValidator(String? value) {
      if (value == null || value.isEmpty) {
        return null; // 空の場合はnullを返す(requiredバリデータに任せる)
      }
      // カスタムロジック
    }
    
  3. 非同期バリデーションのタイミング

    // 問題: 入力のたびにAPIが呼ばれる
    // 解決方法: デバウンスを使用
    Timer? _debounce;
    
    void _onChanged(String value) {
      if (_debounce?.isActive ?? false) _debounce!.cancel();
      _debounce = Timer(const Duration(milliseconds: 500), () {
        // バリデーション実行
      });
    }
    

パフォーマンスの最適化

// メモ化されたバリデータ
class MemoizedValidator {
  final Map<String, String?> _cache = {};
  final String? Function(String?) validator;
  
  MemoizedValidator(this.validator);
  
  String? call(String? value) {
    final key = value ?? '';
    if (_cache.containsKey(key)) {
      return _cache[key];
    }
    
    final result = validator(value);
    _cache[key] = result;
    return result;
  }
  
  void clear() => _cache.clear();
}

関連リソース