Skip to content

Commit f54dfcd

Browse files
authored
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](flutter/flutter#56414 (comment)). 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.
1 parent 6c06abb commit f54dfcd

File tree

6 files changed

+520
-13
lines changed

6 files changed

+520
-13
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
/// Flutter code sample for [TextFormField].
8+
9+
const Duration kFakeHttpRequestDuration = Duration(seconds: 3);
10+
11+
void main() => runApp(const TextFormFieldExampleApp());
12+
13+
class TextFormFieldExampleApp extends StatelessWidget {
14+
const TextFormFieldExampleApp({super.key});
15+
16+
@override
17+
Widget build(BuildContext context) {
18+
return const MaterialApp(
19+
home: TextFormFieldExample(),
20+
);
21+
}
22+
}
23+
24+
class TextFormFieldExample extends StatefulWidget {
25+
const TextFormFieldExample({super.key});
26+
27+
@override
28+
State<TextFormFieldExample> createState() => _TextFormFieldExampleState();
29+
}
30+
31+
class _TextFormFieldExampleState extends State<TextFormFieldExample> {
32+
final TextEditingController controller = TextEditingController();
33+
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
34+
String? forceErrorText;
35+
bool isLoading = false;
36+
37+
@override
38+
void dispose() {
39+
controller.dispose();
40+
super.dispose();
41+
}
42+
43+
String? validator(String? value) {
44+
if (value == null || value.isEmpty) {
45+
return 'This field is required';
46+
}
47+
if (value.length != value.replaceAll(' ', '').length) {
48+
return 'Username must not contain any spaces';
49+
}
50+
if (int.tryParse(value[0]) != null) {
51+
return 'Username must not start with a number';
52+
}
53+
if (value.length <= 2) {
54+
return 'Username should be at least 3 characters long';
55+
}
56+
return null;
57+
}
58+
59+
void onChanged(String value) {
60+
// Nullify forceErrorText if the input changed.
61+
if (forceErrorText != null) {
62+
setState(() {
63+
forceErrorText = null;
64+
});
65+
}
66+
}
67+
68+
Future<void> onSave() async {
69+
// Providing a default value in case this was called on the
70+
// first frame, the [fromKey.currentState] will be null.
71+
final bool isValid = formKey.currentState?.validate() ?? false;
72+
if (!isValid) {
73+
return;
74+
}
75+
76+
setState(() => isLoading = true);
77+
final String? errorText = await validateUsernameFromServer(controller.text);
78+
79+
if (context.mounted) {
80+
setState(() => isLoading = false);
81+
82+
if (errorText != null) {
83+
setState(() {
84+
forceErrorText = errorText;
85+
});
86+
}
87+
}
88+
}
89+
90+
@override
91+
Widget build(BuildContext context) {
92+
return Material(
93+
child: Padding(
94+
padding: const EdgeInsets.symmetric(horizontal: 24.0),
95+
child: Center(
96+
child: Form(
97+
key: formKey,
98+
child: Column(
99+
mainAxisAlignment: MainAxisAlignment.center,
100+
children: <Widget>[
101+
TextFormField(
102+
forceErrorText: forceErrorText,
103+
controller: controller,
104+
decoration: const InputDecoration(
105+
hintText: 'Please write a username',
106+
),
107+
validator: validator,
108+
onChanged: onChanged,
109+
),
110+
const SizedBox(height: 40.0),
111+
if (isLoading)
112+
const CircularProgressIndicator()
113+
else
114+
TextButton(
115+
onPressed: onSave,
116+
child: const Text('Save'),
117+
),
118+
],
119+
),
120+
),
121+
),
122+
),
123+
);
124+
}
125+
}
126+
127+
Future<String?> validateUsernameFromServer(String username) async {
128+
final Set<String> takenUsernames = <String>{'jack', 'alex'};
129+
130+
await Future<void>.delayed(kFakeHttpRequestDuration);
131+
132+
final bool isValid = !takenUsernames.contains(username);
133+
if (isValid) {
134+
return null;
135+
}
136+
137+
return 'Username $username is already taken';
138+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter_api_samples/material/text_form_field/text_form_field.2.dart' as example;
7+
import 'package:flutter_test/flutter_test.dart';
8+
9+
void main() {
10+
group('TextFormFieldExample2 Widget Tests', () {
11+
testWidgets('Input validation handles empty, incorrect, and short usernames', (WidgetTester tester) async {
12+
await tester.pumpWidget(const example.TextFormFieldExampleApp());
13+
final Finder textFormField = find.byType(TextFormField);
14+
final Finder saveButton = find.byType(TextButton);
15+
16+
await tester.enterText(textFormField, '');
17+
await tester.pump();
18+
await tester.tap(saveButton);
19+
await tester.pump();
20+
expect(find.text('This field is required'), findsOneWidget);
21+
22+
await tester.enterText(textFormField, 'jo hn');
23+
await tester.tap(saveButton);
24+
await tester.pump();
25+
expect(find.text('Username must not contain any spaces'), findsOneWidget);
26+
27+
await tester.enterText(textFormField, 'jo');
28+
await tester.tap(saveButton);
29+
await tester.pump();
30+
expect(find.text('Username should be at least 3 characters long'), findsOneWidget);
31+
32+
await tester.enterText(textFormField, '1jo');
33+
await tester.tap(saveButton);
34+
await tester.pump();
35+
expect(find.text('Username must not start with a number'), findsOneWidget);
36+
});
37+
38+
testWidgets('Async validation feedback is handled correctly', (WidgetTester tester) async {
39+
await tester.pumpWidget(const example.TextFormFieldExampleApp());
40+
final Finder textFormField = find.byType(TextFormField);
41+
final Finder saveButton = find.byType(TextButton);
42+
43+
// Simulate entering a username already taken.
44+
await tester.enterText(textFormField, 'jack');
45+
await tester.pump();
46+
await tester.tap(saveButton);
47+
await tester.pump();
48+
expect(find.text('Username jack is already taken'), findsNothing);
49+
await tester.pump(example.kFakeHttpRequestDuration);
50+
expect(find.text('Username jack is already taken'), findsOneWidget);
51+
52+
await tester.enterText(textFormField, 'alex');
53+
await tester.pump();
54+
await tester.tap(saveButton);
55+
await tester.pump();
56+
expect(find.text('Username alex is already taken'), findsNothing);
57+
await tester.pump(example.kFakeHttpRequestDuration);
58+
expect(find.text('Username alex is already taken'), findsOneWidget);
59+
60+
await tester.enterText(textFormField, 'jack');
61+
await tester.pump();
62+
await tester.tap(saveButton);
63+
await tester.pump();
64+
expect(find.text('Username jack is already taken'), findsNothing);
65+
await tester.pump(example.kFakeHttpRequestDuration);
66+
expect(find.text('Username jack is already taken'), findsOneWidget);
67+
});
68+
69+
testWidgets('Loading spinner displays correctly when saving', (WidgetTester tester) async {
70+
await tester.pumpWidget(const example.TextFormFieldExampleApp());
71+
final Finder textFormField = find.byType(TextFormField);
72+
final Finder saveButton = find.byType(TextButton);
73+
await tester.enterText(textFormField, 'alexander');
74+
await tester.pump();
75+
await tester.tap(saveButton);
76+
await tester.pump();
77+
expect(find.byType(CircularProgressIndicator), findsOneWidget);
78+
await tester.pump(example.kFakeHttpRequestDuration);
79+
expect(find.byType(CircularProgressIndicator), findsNothing);
80+
});
81+
});
82+
}

packages/flutter/lib/src/material/text_form_field.dart

+8
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ export 'package:flutter/services.dart' show SmartDashesType, SmartQuotesType;
8181
/// ** See code in examples/api/lib/material/text_form_field/text_form_field.1.dart **
8282
/// {@end-tool}
8383
///
84+
/// {@tool dartpad}
85+
/// This example shows how to force an error text to the field after making
86+
/// an asynchronous call.
87+
///
88+
/// ** See code in examples/api/lib/material/text_form_field/text_form_field.2.dart **
89+
/// {@end-tool}
90+
///
8491
/// See also:
8592
///
8693
/// * <https://material.io/design/components/text-fields.html>
@@ -105,6 +112,7 @@ class TextFormField extends FormField<String> {
105112
this.controller,
106113
String? initialValue,
107114
FocusNode? focusNode,
115+
super.forceErrorText,
108116
InputDecoration? decoration = const InputDecoration(),
109117
TextInputType? keyboardType,
110118
TextCapitalization textCapitalization = TextCapitalization.none,

packages/flutter/lib/src/widgets/form.dart

+56-11
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,7 @@ class FormState extends State<Form> {
234234
void _fieldDidChange() {
235235
widget.onChanged?.call();
236236

237-
_hasInteractedByUser = _fields
238-
.any((FormFieldState<dynamic> field) => field._hasInteractedByUser.value);
237+
_hasInteractedByUser = _fields.any((FormFieldState<dynamic> field) => field._hasInteractedByUser.value);
239238
_forceRebuild();
240239
}
241240

@@ -337,7 +336,6 @@ class FormState extends State<Form> {
337336
return _validate();
338337
}
339338

340-
341339
/// Validates every [FormField] that is a descendant of this [Form], and
342340
/// returns a [Set] of [FormFieldState] of the invalid field(s) only, if any.
343341
///
@@ -393,8 +391,8 @@ class _FormScope extends InheritedWidget {
393391
required super.child,
394392
required FormState formState,
395393
required int generation,
396-
}) : _formState = formState,
397-
_generation = generation;
394+
}) : _formState = formState,
395+
_generation = generation;
398396

399397
final FormState _formState;
400398

@@ -454,6 +452,7 @@ class FormField<T> extends StatefulWidget {
454452
super.key,
455453
required this.builder,
456454
this.onSaved,
455+
this.forceErrorText,
457456
this.validator,
458457
this.initialValue,
459458
this.enabled = true,
@@ -465,6 +464,24 @@ class FormField<T> extends StatefulWidget {
465464
/// [FormState.save].
466465
final FormFieldSetter<T>? onSaved;
467466

467+
/// An optional property that forces the [FormFieldState] into an error state
468+
/// by directly setting the [FormFieldState.errorText] property without
469+
/// running the validator function.
470+
///
471+
/// When the [forceErrorText] property is provided, the [FormFieldState.errorText]
472+
/// will be set to the provided value, causing the form field to be considered
473+
/// invalid and to display the error message specified.
474+
///
475+
/// When [validator] is provided, [forceErrorText] will override any error that it
476+
/// returns. [validator] will not be called unless [forceErrorText] is null.
477+
///
478+
/// See also:
479+
///
480+
/// * [InputDecoration.errorText], which is used to display error messages in the text
481+
/// field's decoration without effecting the field's state. When [forceErrorText] is
482+
/// not null, it will override [InputDecoration.errorText] value.
483+
final String? forceErrorText;
484+
468485
/// An optional method that validates an input. Returns an error string to
469486
/// display if the input is invalid, or null otherwise.
470487
///
@@ -533,16 +550,22 @@ class FormField<T> extends StatefulWidget {
533550
/// for use in constructing the form field's widget.
534551
class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
535552
late T? _value = widget.initialValue;
536-
final RestorableStringN _errorText = RestorableStringN(null);
553+
// Marking it as late, so it can be registered
554+
// with the value provided by [forceErrorText].
555+
late final RestorableStringN _errorText;
537556
final RestorableBool _hasInteractedByUser = RestorableBool(false);
538557
final FocusNode _focusNode = FocusNode();
539558

540559
/// The current value of the form field.
541560
T? get value => _value;
542561

543562
/// The current validation error returned by the [FormField.validator]
544-
/// callback, or null if no errors have been triggered. This only updates when
545-
/// [validate] is called.
563+
/// callback, or the manually provided error message using the
564+
/// [FormField.forceErrorText] property.
565+
///
566+
/// This property is automatically updated when [validate] is called and the
567+
/// [FormField.validator] callback is invoked, or If [FormField.forceErrorText] is set
568+
/// directly to a non-null value.
546569
String? get errorText => _errorText.value;
547570

548571
/// True if this field has any validation errors.
@@ -562,7 +585,9 @@ class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
562585
/// See also:
563586
///
564587
/// * [validate], which may update [errorText] and [hasError].
565-
bool get isValid => widget.validator?.call(_value) == null;
588+
///
589+
/// * [FormField.forceErrorText], which also may update [errorText] and [hasError].
590+
bool get isValid => widget.forceErrorText == null && widget.validator?.call(_value) == null;
566591

567592
/// Calls the [FormField]'s onSaved method with the current value.
568593
void save() {
@@ -579,9 +604,10 @@ class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
579604
Form.maybeOf(context)?._fieldDidChange();
580605
}
581606

582-
/// Calls [FormField.validator] to set the [errorText]. Returns true if there
583-
/// were no errors.
607+
/// Calls [FormField.validator] to set the [errorText] only if [FormField.forceErrorText] is null.
608+
/// When [FormField.forceErrorText] is not null, [FormField.validator] will not be called.
584609
///
610+
/// Returns true if there were no errors.
585611
/// See also:
586612
///
587613
/// * [isValid], which passively gets the validity without setting
@@ -594,6 +620,11 @@ class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
594620
}
595621

596622
void _validate() {
623+
if (widget.forceErrorText != null) {
624+
_errorText.value = widget.forceErrorText;
625+
// Skip validating if error is forced.
626+
return;
627+
}
597628
if (widget.validator != null) {
598629
_errorText.value = widget.validator!(_value);
599630
} else {
@@ -643,6 +674,20 @@ class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
643674
super.deactivate();
644675
}
645676

677+
@override
678+
void initState() {
679+
super.initState();
680+
_errorText = RestorableStringN(widget.forceErrorText);
681+
}
682+
683+
@override
684+
void didUpdateWidget(FormField<T> oldWidget) {
685+
super.didUpdateWidget(oldWidget);
686+
if (widget.forceErrorText != oldWidget.forceErrorText) {
687+
_errorText.value = widget.forceErrorText;
688+
}
689+
}
690+
646691
@override
647692
void dispose() {
648693
_errorText.dispose();

0 commit comments

Comments
 (0)