Add FormField.errorBuilder (#162255)

## Description

This PR adds the `TextFormField.errorBuilder`property which makes it
possible to customize the widget used to display the error message.

Implementation based on
https://github.com/flutter/flutter/pull/156275#issuecomment-2521651828

## Related Issue

Fixes [Unable to use validator along with error widget in
TextFormField](https://github.com/flutter/flutter/issues/133629)
Fixes https://github.com/flutter/flutter/issues/135292

## Tests

Adds 1 tests.
This commit is contained in:
Bruno Leroux 2025-01-30 21:17:56 +01:00 committed by GitHub
parent 9d7d36cfba
commit 3b7c4aa2a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 100 additions and 15 deletions

View File

@ -1722,6 +1722,7 @@ class DropdownButtonFormField<T> extends FormField<T> {
InputDecoration? decoration,
super.onSaved,
super.validator,
super.errorBuilder,
AutovalidateMode? autovalidateMode,
double? menuMaxHeight,
bool? enableFeedback,
@ -1747,10 +1748,8 @@ class DropdownButtonFormField<T> extends FormField<T> {
autovalidateMode: autovalidateMode ?? AutovalidateMode.disabled,
builder: (FormFieldState<T> field) {
final _DropdownButtonFormFieldState<T> state = field as _DropdownButtonFormFieldState<T>;
final InputDecoration decorationArg = decoration ?? const InputDecoration();
final InputDecoration effectiveDecoration = decorationArg.applyDefaults(
Theme.of(field.context).inputDecorationTheme,
);
InputDecoration effectiveDecoration = (decoration ?? const InputDecoration())
.applyDefaults(Theme.of(field.context).inputDecorationTheme);
final bool showSelectedItem =
items != null &&
@ -1767,6 +1766,22 @@ class DropdownButtonFormField<T> extends FormField<T> {
: effectiveHint != null || effectiveDisabledHint != null;
final bool isEmpty = !showSelectedItem && !isHintOrDisabledHintAvailable;
if (field.errorText != null || effectiveDecoration.hintText != null) {
final Widget? error =
field.errorText != null && errorBuilder != null
? errorBuilder(state.context, field.errorText!)
: null;
final String? errorText = error == null ? field.errorText : null;
// Clear the decoration hintText because DropdownButton has its own hint logic.
final String? hintText = effectiveDecoration.hintText != null ? '' : null;
effectiveDecoration = effectiveDecoration.copyWith(
error: error,
errorText: errorText,
hintText: hintText,
);
}
// An unfocusable Focus widget so that this widget can detect if its
// descendants have focus or not.
return Focus(
@ -1800,11 +1815,7 @@ class DropdownButtonFormField<T> extends FormField<T> {
enableFeedback: enableFeedback,
alignment: alignment,
borderRadius: borderRadius,
// Clear the decoration hintText because DropdownButton has its own hint logic.
inputDecoration: effectiveDecoration.copyWith(
errorText: field.errorText,
hintText: effectiveDecoration.hintText != null ? '' : null,
),
inputDecoration: effectiveDecoration,
isEmpty: isEmpty,
padding: padding,
),

View File

@ -150,6 +150,7 @@ class TextFormField extends FormField<String> {
ValueChanged<String>? onFieldSubmitted,
super.onSaved,
super.validator,
super.errorBuilder,
List<TextInputFormatter>? inputFormatters,
bool? enabled,
bool? ignorePointers,
@ -209,8 +210,17 @@ class TextFormField extends FormField<String> {
autovalidateMode: autovalidateMode ?? AutovalidateMode.disabled,
builder: (FormFieldState<String> field) {
final _TextFormFieldState state = field as _TextFormFieldState;
final InputDecoration effectiveDecoration = (decoration ?? const InputDecoration())
InputDecoration effectiveDecoration = (decoration ?? const InputDecoration())
.applyDefaults(Theme.of(field.context).inputDecorationTheme);
final String? errorText = field.errorText;
if (errorText != null) {
effectiveDecoration =
errorBuilder != null
? effectiveDecoration.copyWith(error: errorBuilder(state.context, errorText))
: effectiveDecoration.copyWith(errorText: errorText);
}
void onChangedHandler(String value) {
field.didChange(value);
onChanged?.call(value);
@ -223,7 +233,7 @@ class TextFormField extends FormField<String> {
restorationId: restorationId,
controller: state._effectiveController,
focusNode: focusNode,
decoration: effectiveDecoration.copyWith(errorText: field.errorText),
decoration: effectiveDecoration,
keyboardType: keyboardType,
textInputAction: textInputAction,
style: style,

View File

@ -421,6 +421,14 @@ class _FormScope extends InheritedWidget {
/// Used by [FormField.validator].
typedef FormFieldValidator<T> = String? Function(T? value);
/// Signature for a callback that builds an error widget.
///
/// See also:
///
/// * [FormField.errorBuilder], which is of this type, and passes the result error
/// given by [TextFormField.validator].
typedef FormFieldErrorBuilder = Widget Function(BuildContext context, String errorText);
/// Signature for being notified when a form field changes value.
///
/// Used by [FormField.onSaved].
@ -460,12 +468,19 @@ class FormField<T> extends StatefulWidget {
this.onSaved,
this.forceErrorText,
this.validator,
this.errorBuilder,
this.initialValue,
this.enabled = true,
AutovalidateMode? autovalidateMode,
this.restorationId,
}) : autovalidateMode = autovalidateMode ?? AutovalidateMode.disabled;
/// Function that returns the widget representing this form field.
///
/// It is passed the form field state as input, containing the current value
/// and validation state of this field.
final FormFieldBuilder<T> builder;
/// An optional method to call with the final value when the form is saved via
/// [FormState.save].
final FormFieldSetter<T>? onSaved;
@ -503,10 +518,14 @@ class FormField<T> extends StatefulWidget {
/// parameter to a space.
final FormFieldValidator<T>? validator;
/// Function that returns the widget representing this form field. It is
/// passed the form field state as input, containing the current value and
/// validation state of this field.
final FormFieldBuilder<T> builder;
/// Function that returns the widget representing the error to display.
///
/// It is passed the form field validator error string as input.
/// The resulting widget is passed to [InputDecoration.error].
///
/// If null, the validator error string is passed to
/// [InputDecoration.errorText].
final FormFieldErrorBuilder? errorBuilder;
/// An optional value to initialize the form field to, or null otherwise.
///

View File

@ -1229,4 +1229,28 @@ void main() {
expect(inputDecorator.isFocused, true);
expect(inputDecorator.decoration.errorText, 'Required');
});
// Regression test for https://github.com/flutter/flutter/issues/135292.
testWidgets('Widget returned by errorBuilder is shown', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Material(
child: DropdownButtonFormField<String>(
items:
menuItems.map((String value) {
return DropdownMenuItem<String>(value: value, child: Text(value));
}).toList(),
onChanged: onChanged,
autovalidateMode: AutovalidateMode.always,
validator: (String? v) => 'Required',
errorBuilder: (BuildContext context, String errorText) => Text('**$errorText**'),
),
),
),
);
await tester.pump();
expect(find.text('**Required**'), findsOneWidget);
});
}

View File

@ -1663,4 +1663,25 @@ void main() {
expect(find.text(forceErrorText), findsOne);
expect(find.text(decorationErrorText), findsNothing);
});
// Regression test for https://github.com/flutter/flutter/issues/135292.
testWidgets('Widget returned by errorBuilder is shown', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextFormField(
autovalidateMode: AutovalidateMode.always,
validator: (String? value) => 'validation error',
errorBuilder: (BuildContext context, String errorText) => Text('**$errorText**'),
),
),
),
),
);
await tester.pump();
expect(find.text('**validation error**'), findsOneWidget);
});
}