add forceErrorText to FormField & TextFormField. (#132903)

Introducing the `forceErrorText` property to both `FormField` and `TextFormField`. With this addition, we gain the capability to trigger an error state and provide an error message without invoking the `validator` method.

While the idea of making the `Validator` method work asynchronously may be appealing, it could introduce significant complexity to our current form field implementation. Additionally, this approach might not be suitable for all developers, as discussed by @justinmc  in this [comment](https://github.com/flutter/flutter/issues/56414#issuecomment-624960263).

This PR try to address this issue by adding `forceErrorText` property allowing us to force the error to the `FormField` or `TextFormField` at our own base making it possible to preform some async operations without the need for any hacks while keep the ability to check for errors if we call `formKey.currentState!.validate()`.

Here is an example:

<details> <summary>Code Example</summary>  

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

void main() {
  runApp(
    const MaterialApp(home: MyHomePage()),
  );
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({
    super.key,
  });

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final key = GlobalKey<FormState>();
  String? forcedErrorText;

  Future<void> handleValidation() async {
    // simulate some async work..
    await Future.delayed(const Duration(seconds: 3));

    setState(() {
      forcedErrorText = 'this username is not available.';
    });

    // wait for build to run and then check.
    //
    // this is not required though, as the error would already be showing.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      print(key.currentState!.validate());
    });
  }

  @override
  Widget build(BuildContext context) {
    print('build');
    return Scaffold(
      floatingActionButton: FloatingActionButton(onPressed: handleValidation),
      body: Form(
        key: key,
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 30),
                child: TextFormField(
                  forceErrorText: forcedErrorText,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```
</details>

Related to #9688 & #56414.

Happy to hear your thoughts on this.
This commit is contained in:
Hasan M. Hallak 2024-06-18 20:52:21 +03:00 committed by GitHub
parent 6c06abbb55
commit f54dfcd27d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 520 additions and 13 deletions

View File

@ -0,0 +1,138 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
/// Flutter code sample for [TextFormField].
const Duration kFakeHttpRequestDuration = Duration(seconds: 3);
void main() => runApp(const TextFormFieldExampleApp());
class TextFormFieldExampleApp extends StatelessWidget {
const TextFormFieldExampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: TextFormFieldExample(),
);
}
}
class TextFormFieldExample extends StatefulWidget {
const TextFormFieldExample({super.key});
@override
State<TextFormFieldExample> createState() => _TextFormFieldExampleState();
}
class _TextFormFieldExampleState extends State<TextFormFieldExample> {
final TextEditingController controller = TextEditingController();
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
String? forceErrorText;
bool isLoading = false;
@override
void dispose() {
controller.dispose();
super.dispose();
}
String? validator(String? value) {
if (value == null || value.isEmpty) {
return 'This field is required';
}
if (value.length != value.replaceAll(' ', '').length) {
return 'Username must not contain any spaces';
}
if (int.tryParse(value[0]) != null) {
return 'Username must not start with a number';
}
if (value.length <= 2) {
return 'Username should be at least 3 characters long';
}
return null;
}
void onChanged(String value) {
// Nullify forceErrorText if the input changed.
if (forceErrorText != null) {
setState(() {
forceErrorText = null;
});
}
}
Future<void> onSave() async {
// Providing a default value in case this was called on the
// first frame, the [fromKey.currentState] will be null.
final bool isValid = formKey.currentState?.validate() ?? false;
if (!isValid) {
return;
}
setState(() => isLoading = true);
final String? errorText = await validateUsernameFromServer(controller.text);
if (context.mounted) {
setState(() => isLoading = false);
if (errorText != null) {
setState(() {
forceErrorText = errorText;
});
}
}
}
@override
Widget build(BuildContext context) {
return Material(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Center(
child: Form(
key: formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
TextFormField(
forceErrorText: forceErrorText,
controller: controller,
decoration: const InputDecoration(
hintText: 'Please write a username',
),
validator: validator,
onChanged: onChanged,
),
const SizedBox(height: 40.0),
if (isLoading)
const CircularProgressIndicator()
else
TextButton(
onPressed: onSave,
child: const Text('Save'),
),
],
),
),
),
),
);
}
}
Future<String?> validateUsernameFromServer(String username) async {
final Set<String> takenUsernames = <String>{'jack', 'alex'};
await Future<void>.delayed(kFakeHttpRequestDuration);
final bool isValid = !takenUsernames.contains(username);
if (isValid) {
return null;
}
return 'Username $username is already taken';
}

View File

@ -0,0 +1,82 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/material/text_form_field/text_form_field.2.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
group('TextFormFieldExample2 Widget Tests', () {
testWidgets('Input validation handles empty, incorrect, and short usernames', (WidgetTester tester) async {
await tester.pumpWidget(const example.TextFormFieldExampleApp());
final Finder textFormField = find.byType(TextFormField);
final Finder saveButton = find.byType(TextButton);
await tester.enterText(textFormField, '');
await tester.pump();
await tester.tap(saveButton);
await tester.pump();
expect(find.text('This field is required'), findsOneWidget);
await tester.enterText(textFormField, 'jo hn');
await tester.tap(saveButton);
await tester.pump();
expect(find.text('Username must not contain any spaces'), findsOneWidget);
await tester.enterText(textFormField, 'jo');
await tester.tap(saveButton);
await tester.pump();
expect(find.text('Username should be at least 3 characters long'), findsOneWidget);
await tester.enterText(textFormField, '1jo');
await tester.tap(saveButton);
await tester.pump();
expect(find.text('Username must not start with a number'), findsOneWidget);
});
testWidgets('Async validation feedback is handled correctly', (WidgetTester tester) async {
await tester.pumpWidget(const example.TextFormFieldExampleApp());
final Finder textFormField = find.byType(TextFormField);
final Finder saveButton = find.byType(TextButton);
// Simulate entering a username already taken.
await tester.enterText(textFormField, 'jack');
await tester.pump();
await tester.tap(saveButton);
await tester.pump();
expect(find.text('Username jack is already taken'), findsNothing);
await tester.pump(example.kFakeHttpRequestDuration);
expect(find.text('Username jack is already taken'), findsOneWidget);
await tester.enterText(textFormField, 'alex');
await tester.pump();
await tester.tap(saveButton);
await tester.pump();
expect(find.text('Username alex is already taken'), findsNothing);
await tester.pump(example.kFakeHttpRequestDuration);
expect(find.text('Username alex is already taken'), findsOneWidget);
await tester.enterText(textFormField, 'jack');
await tester.pump();
await tester.tap(saveButton);
await tester.pump();
expect(find.text('Username jack is already taken'), findsNothing);
await tester.pump(example.kFakeHttpRequestDuration);
expect(find.text('Username jack is already taken'), findsOneWidget);
});
testWidgets('Loading spinner displays correctly when saving', (WidgetTester tester) async {
await tester.pumpWidget(const example.TextFormFieldExampleApp());
final Finder textFormField = find.byType(TextFormField);
final Finder saveButton = find.byType(TextButton);
await tester.enterText(textFormField, 'alexander');
await tester.pump();
await tester.tap(saveButton);
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pump(example.kFakeHttpRequestDuration);
expect(find.byType(CircularProgressIndicator), findsNothing);
});
});
}

View File

@ -81,6 +81,13 @@ export 'package:flutter/services.dart' show SmartDashesType, SmartQuotesType;
/// ** See code in examples/api/lib/material/text_form_field/text_form_field.1.dart **
/// {@end-tool}
///
/// {@tool dartpad}
/// This example shows how to force an error text to the field after making
/// an asynchronous call.
///
/// ** See code in examples/api/lib/material/text_form_field/text_form_field.2.dart **
/// {@end-tool}
///
/// See also:
///
/// * <https://material.io/design/components/text-fields.html>
@ -105,6 +112,7 @@ class TextFormField extends FormField<String> {
this.controller,
String? initialValue,
FocusNode? focusNode,
super.forceErrorText,
InputDecoration? decoration = const InputDecoration(),
TextInputType? keyboardType,
TextCapitalization textCapitalization = TextCapitalization.none,

View File

@ -234,8 +234,7 @@ class FormState extends State<Form> {
void _fieldDidChange() {
widget.onChanged?.call();
_hasInteractedByUser = _fields
.any((FormFieldState<dynamic> field) => field._hasInteractedByUser.value);
_hasInteractedByUser = _fields.any((FormFieldState<dynamic> field) => field._hasInteractedByUser.value);
_forceRebuild();
}
@ -337,7 +336,6 @@ class FormState extends State<Form> {
return _validate();
}
/// Validates every [FormField] that is a descendant of this [Form], and
/// returns a [Set] of [FormFieldState] of the invalid field(s) only, if any.
///
@ -393,8 +391,8 @@ class _FormScope extends InheritedWidget {
required super.child,
required FormState formState,
required int generation,
}) : _formState = formState,
_generation = generation;
}) : _formState = formState,
_generation = generation;
final FormState _formState;
@ -454,6 +452,7 @@ class FormField<T> extends StatefulWidget {
super.key,
required this.builder,
this.onSaved,
this.forceErrorText,
this.validator,
this.initialValue,
this.enabled = true,
@ -465,6 +464,24 @@ class FormField<T> extends StatefulWidget {
/// [FormState.save].
final FormFieldSetter<T>? onSaved;
/// An optional property that forces the [FormFieldState] into an error state
/// by directly setting the [FormFieldState.errorText] property without
/// running the validator function.
///
/// When the [forceErrorText] property is provided, the [FormFieldState.errorText]
/// will be set to the provided value, causing the form field to be considered
/// invalid and to display the error message specified.
///
/// When [validator] is provided, [forceErrorText] will override any error that it
/// returns. [validator] will not be called unless [forceErrorText] is null.
///
/// See also:
///
/// * [InputDecoration.errorText], which is used to display error messages in the text
/// field's decoration without effecting the field's state. When [forceErrorText] is
/// not null, it will override [InputDecoration.errorText] value.
final String? forceErrorText;
/// An optional method that validates an input. Returns an error string to
/// display if the input is invalid, or null otherwise.
///
@ -533,7 +550,9 @@ class FormField<T> extends StatefulWidget {
/// for use in constructing the form field's widget.
class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
late T? _value = widget.initialValue;
final RestorableStringN _errorText = RestorableStringN(null);
// 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();
@ -541,8 +560,12 @@ class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
T? get value => _value;
/// The current validation error returned by the [FormField.validator]
/// callback, or null if no errors have been triggered. This only updates when
/// [validate] is called.
/// 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.
@ -562,7 +585,9 @@ class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
/// See also:
///
/// * [validate], which may update [errorText] and [hasError].
bool get isValid => widget.validator?.call(_value) == null;
///
/// * [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() {
@ -579,9 +604,10 @@ class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
Form.maybeOf(context)?._fieldDidChange();
}
/// Calls [FormField.validator] to set the [errorText]. Returns true if there
/// were no errors.
/// 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
@ -594,6 +620,11 @@ class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
}
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 {
@ -643,6 +674,20 @@ class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
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();

View File

@ -1714,4 +1714,84 @@ void main() {
expect(stateKey.currentState!.value,'initialValue');
expect(value, 'initialValue');
});
testWidgets(
'isValid returns false when forceErrorText is set and will change error display',
(WidgetTester tester) async {
final GlobalKey<FormFieldState<String>> fieldKey1 = GlobalKey<FormFieldState<String>>();
final GlobalKey<FormFieldState<String>> fieldKey2 = GlobalKey<FormFieldState<String>>();
const String forceErrorText = 'Forcing error.';
const String validString = 'Valid string';
String? validator(String? s) => s == validString ? null : 'Error text';
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
child: ListView(
children: <Widget>[
TextFormField(
key: fieldKey1,
initialValue: validString,
validator: validator,
autovalidateMode: AutovalidateMode.disabled,
),
TextFormField(
key: fieldKey2,
initialValue: '',
forceErrorText: forceErrorText,
validator: validator,
autovalidateMode: AutovalidateMode.disabled,
),
],
),
),
),
),
),
),
),
);
expect(fieldKey1.currentState!.isValid, isTrue);
expect(fieldKey1.currentState!.hasError, isFalse);
expect(fieldKey2.currentState!.isValid, isFalse);
expect(fieldKey2.currentState!.hasError, isTrue);
});
testWidgets('forceErrorText will override InputDecoration.error when both are provided', (WidgetTester tester) async {
const String forceErrorText = 'Forcing error';
const String decorationErrorText = 'Decoration';
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
child: TextFormField(
forceErrorText: forceErrorText,
decoration: const InputDecoration(
errorText: decorationErrorText,
),
),
),
),
),
),
),
),
);
expect(find.text(forceErrorText), findsOne);
expect(find.text(decorationErrorText), findsNothing);
});
}

View File

@ -1060,8 +1060,162 @@ void main() {
expect(fieldKey.currentState!.hasInteractedByUser, isFalse);
});
testWidgets('Validator is nullified and error text behaves accordingly',
(WidgetTester tester) async {
testWidgets('forceErrorText forces an error state when first init', (WidgetTester tester) async {
const String forceErrorText = 'Forcing error.';
Widget builder(AutovalidateMode autovalidateMode) {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
autovalidateMode: autovalidateMode,
child: TextFormField(
forceErrorText: forceErrorText,
),
),
),
),
),
),
);
}
await tester.pumpWidget(builder(AutovalidateMode.disabled));
expect(find.text(forceErrorText), findsOne);
});
testWidgets(
'Validate returns false when forceErrorText is non-null even when validator returns a null value',
(WidgetTester tester) async {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
const String forceErrorText = 'Forcing error';
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
key: formKey,
child: TextFormField(
forceErrorText: forceErrorText,
validator: (String? value) => null,
),
),
),
),
),
),
),
);
expect(find.text(forceErrorText), findsOne);
final bool isValid = formKey.currentState!.validate();
expect(isValid, isFalse);
await tester.pump();
expect(find.text(forceErrorText), findsOne);
});
testWidgets('forceErrorText forces an error state only after setting it to a non-null value', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
const String errorText = 'Forcing Error Text';
Widget builder(AutovalidateMode autovalidateMode, String? forceErrorText) {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
key: formKey,
autovalidateMode: autovalidateMode,
child: TextFormField(
forceErrorText: forceErrorText,
),
),
),
),
),
),
);
}
await tester.pumpWidget(builder(AutovalidateMode.disabled, null));
final bool isValid = formKey.currentState!.validate();
expect(isValid, true);
expect(find.text(errorText), findsNothing);
await tester.pumpWidget(builder(AutovalidateMode.disabled, errorText));
expect(find.text(errorText), findsOne);
});
testWidgets('Validator will not be called if forceErrorText is provided', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
const String forceErrorText = 'Forcing error.';
const String validatorErrorText = 'this error should not appear as we override it with forceErrorText';
bool didCallValidator = false;
Widget builder(AutovalidateMode autovalidateMode) {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
key: formKey,
autovalidateMode: autovalidateMode,
child: TextFormField(
forceErrorText: forceErrorText,
validator: (String? value) {
didCallValidator = true;
return validatorErrorText;
},
),
),
),
),
),
),
);
}
// Start off not autovalidating.
await tester.pumpWidget(builder(AutovalidateMode.disabled));
expect(find.text(forceErrorText), findsOne);
expect(find.text(validatorErrorText), findsNothing);
formKey.currentState!.reset();
await tester.pump();
expect(find.text(forceErrorText), findsNothing);
expect(find.text(validatorErrorText), findsNothing);
// We have to manually validate if we're not autovalidating.
formKey.currentState!.validate();
await tester.pump();
expect(didCallValidator, isFalse);
expect(find.text(forceErrorText), findsOne);
expect(find.text(validatorErrorText), findsNothing);
// Try again with autovalidation. Should validate immediately.
await tester.pumpWidget(builder(AutovalidateMode.always));
expect(didCallValidator, isFalse);
expect(find.text(forceErrorText), findsOne);
expect(find.text(validatorErrorText), findsNothing);
});
testWidgets('Validator is nullified and error text behaves accordingly', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
bool useValidator = false;
late StateSetter setState;