## はじめに
Flutterで標準のTextFormFieldウィジットを用いると以下のようなUIが実装できます。
サンプルUI
通常時 | バリデーションエラー時 |
---|---|
コードの状態
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
String? _validateEmail(String? value) {
if (value == null || value.isEmpty) {
return 'メールアドレスを入力してください。';
}
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
return '正しいメールアドレス形式で入力してください。';
}
return null;
}
String? _validatePassword(String? value) {
if (value == null || value.isEmpty) {
return 'パスワードを入力してください。';
}
if (value.length < 6) {
return 'パスワードは6文字以上で入力してください。';
}
return null;
}
void _onSubmit() {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('バリデーションに成功しました!'))
);
}
}
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.circular(12);
return Scaffold(
appBar: AppBar(
title: const Text('ログインフォーム'),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Container(
decoration: BoxDecoration(
borderRadius: borderRadius,
color: Theme.of(context).colorScheme.surface,
),
child: Form(
key: _formKey,
child: ListView(
// ListViewを使うことでキーボード表示時のスクロールにも対応しやすい
padding: const EdgeInsets.symmetric(vertical: 16.0),
children: [
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: 'メールアドレス',
hintText: 'example@example.com',
border: OutlineInputBorder(
borderRadius: borderRadius,
),
enabledBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
),
validator: _validateEmail,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: 'パスワード',
border: OutlineInputBorder(
borderRadius: borderRadius,
),
enabledBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
),
validator: _validatePassword,
),
const SizedBox(height: 32),
FilledButton(
onPressed: _onSubmit,
child: const Text('送信'),
),
],
),
),
),
),
);
}
}
課題:バリデーションUIのカスタム方法
例えば、以下のようにバリデーションメッセージをカスタムしたい場合、
もしくは成功時にも何か表示させたいといった場合の紹介です。
コード:メールアドレスフィールドの例
先に結論だけ記述すると、FormFieldウィジェットを用います。
/// メールアドレスの部分
FormField<String>(
initialValue: '',
validator: _validateEmail,
builder: (field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
onChanged: field.didChange,
decoration: InputDecoration(
labelText: 'メールアドレス',
hintText: 'example@example.com',
border:
OutlineInputBorder(borderRadius: borderRadius),
enabledBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
),
),
if (field.hasError)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
const Icon(Icons.close,
color: Colors.red, size: 20),
const SizedBox(width: 8),
Text(
field.errorText ?? '',
style: const TextStyle(color: Colors.red),
),
],
),
),
],
);
},
),
builder: (field) {
のfield
はFormFieldStateクラスで以下のようになっています
/// The current state of a [FormField]. Passed to the [FormFieldBuilder] method
/// for use in constructing the form field's widget.
class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
late T? _value = widget.initialValue;
// Marking it as late, so it can be registered
// with the value provided by [forceErrorText].
late final RestorableStringN _errorText;
final RestorableBool _hasInteractedByUser = RestorableBool(false);
final FocusNode _focusNode = FocusNode();
/// The current value of the form field.
T? get value => _value;
/// The current validation error returned by the [FormField.validator]
/// callback, or the manually provided error message using the
/// [FormField.forceErrorText] property.
///
/// This property is automatically updated when [validate] is called and the
/// [FormField.validator] callback is invoked, or If [FormField.forceErrorText] is set
/// directly to a non-null value.
String? get errorText => _errorText.value;
/// True if this field has any validation errors.
bool get hasError => _errorText.value != null;
/// Returns true if the user has modified the value of this field.
///
/// This only updates to true once [didChange] has been called and resets to
/// false when [reset] is called.
bool get hasInteractedByUser => _hasInteractedByUser.value;
/// True if the current value is valid.
///
/// This will not set [errorText] or [hasError] and it will not update
/// error display.
///
/// See also:
///
/// * [validate], which may update [errorText] and [hasError].
///
/// * [FormField.forceErrorText], which also may update [errorText] and [hasError].
bool get isValid => widget.forceErrorText == null && widget.validator?.call(_value) == null;
/// Calls the [FormField]'s onSaved method with the current value.
void save() {
widget.onSaved?.call(value);
}
/// Resets the field to its initial value.
void reset() {
setState(() {
_value = widget.initialValue;
_hasInteractedByUser.value = false;
_errorText.value = null;
});
Form.maybeOf(context)?._fieldDidChange();
}
/// Calls [FormField.validator] to set the [errorText] only if [FormField.forceErrorText] is null.
/// When [FormField.forceErrorText] is not null, [FormField.validator] will not be called.
///
/// Returns true if there were no errors.
/// See also:
///
/// * [isValid], which passively gets the validity without setting
/// [errorText] or [hasError].
bool validate() {
setState(() {
_validate();
});
return !hasError;
}
void _validate() {
if (widget.forceErrorText != null) {
_errorText.value = widget.forceErrorText;
// Skip validating if error is forced.
return;
}
if (widget.validator != null) {
_errorText.value = widget.validator!(_value);
} else {
_errorText.value = null;
}
}
/// Updates this field's state to the new value. Useful for responding to
/// child widget changes, e.g. [Slider]'s [Slider.onChanged] argument.
///
/// Triggers the [Form.onChanged] callback and, if [Form.autovalidateMode] is
/// [AutovalidateMode.always] or [AutovalidateMode.onUserInteraction],
/// revalidates all the fields of the form.
void didChange(T? value) {
setState(() {
_value = value;
_hasInteractedByUser.value = true;
});
Form.maybeOf(context)?._fieldDidChange();
}
/// Sets the value associated with this form field.
///
/// This method should only be called by subclasses that need to update
/// the form field value due to state changes identified during the widget
/// build phase, when calling `setState` is prohibited. In all other cases,
/// the value should be set by a call to [didChange], which ensures that
/// `setState` is called.
@protected
// ignore: use_setters_to_change_properties, (API predates enforcing the lint)
void setValue(T? value) {
_value = value;
}
@override
String? get restorationId => widget.restorationId;
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_errorText, 'error_text');
registerForRestoration(_hasInteractedByUser, 'has_interacted_by_user');
}
@override
void deactivate() {
Form.maybeOf(context)?._unregister(this);
super.deactivate();
}
@override
void initState() {
super.initState();
_errorText = RestorableStringN(widget.forceErrorText);
}
@override
void didUpdateWidget(FormField<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.forceErrorText != oldWidget.forceErrorText) {
_errorText.value = widget.forceErrorText;
}
}
@override
void dispose() {
_errorText.dispose();
_focusNode.dispose();
_hasInteractedByUser.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.enabled) {
switch (widget.autovalidateMode) {
case AutovalidateMode.always:
_validate();
case AutovalidateMode.onUserInteraction:
if (_hasInteractedByUser.value) {
_validate();
}
case AutovalidateMode.onUnfocus:
case AutovalidateMode.disabled:
break;
}
}
Form.maybeOf(context)?._register(this);
if (Form.maybeOf(context)?.widget.autovalidateMode == AutovalidateMode.onUnfocus && widget.autovalidateMode != AutovalidateMode.always ||
widget.autovalidateMode == AutovalidateMode.onUnfocus) {
return Focus(
canRequestFocus: false,
skipTraversal: true,
onFocusChange: (bool value) {
if (!value) {
setState(() {
_validate();
});
}
},
focusNode: _focusNode,
child: widget.builder(this),
);
}
return widget.builder(this);
}
}
ハンドリングに必要なものは一式揃っていますが、中でもhasInteractedByUser
はユーザーが編集をしたかわかるフラグになっているので、編集画面でpopするときに「戻ってもよろしいですか?」などのアラートを出したい時にも使えそうですね。
コード全体
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Custom Validation with FormField',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const LoginPage(),
);
}
}
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _formKey = GlobalKey<FormState>();
String? _validateEmail(String? value) {
if (value == null || value.isEmpty) {
return 'メールアドレスを入力してください。';
}
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
return '正しいメールアドレス形式で入力してください。';
}
return null;
}
String? _validatePassword(String? value) {
if (value == null || value.isEmpty) {
return 'パスワードを入力してください。';
}
if (value.length < 6) {
return 'パスワードは6文字以上で入力してください。';
}
return null;
}
void _onSubmit() {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('バリデーションに成功しました!')));
}
}
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.circular(12);
return Scaffold(
appBar: AppBar(
title: const Text('ログインフォーム'),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Form(
key: _formKey,
child: ListView(
children: [
FormField<String>(
initialValue: '',
validator: _validateEmail,
builder: (field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
onChanged: field.didChange,
decoration: InputDecoration(
labelText: 'メールアドレス',
hintText: 'example@example.com',
border:
OutlineInputBorder(borderRadius: borderRadius),
enabledBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
),
),
if (field.hasError)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
const Icon(Icons.close,
color: Colors.red, size: 20),
const SizedBox(width: 8),
Text(
field.errorText ?? '',
style: const TextStyle(color: Colors.red),
),
],
),
),
],
);
},
),
const SizedBox(height: 16),
FormField<String>(
initialValue: '',
validator: _validatePassword,
builder: (field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
onChanged: field.didChange,
obscureText: true,
decoration: InputDecoration(
labelText: 'パスワード',
border:
OutlineInputBorder(borderRadius: borderRadius),
enabledBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
),
),
if (field.hasError)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
const Icon(Icons.close,
color: Colors.red, size: 20),
const SizedBox(width: 8),
Text(
field.errorText ?? '',
style: const TextStyle(color: Colors.red),
),
],
),
),
],
);
},
),
const SizedBox(height: 32),
FilledButton(
onPressed: _onSubmit,
child: const Text('送信'),
),
],
),
),
),
);
}
}
キャプチャ |
---|