forked from firka/flutter
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:
138
examples/api/lib/material/text_form_field/text_form_field.2.dart
Normal file
138
examples/api/lib/material/text_form_field/text_form_field.2.dart
Normal 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';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user