forked from flutter/flutter
-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add forceErrorText to FormField & TextFormField. (flutter#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#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 flutter#9688 & flutter#56414. Happy to hear your thoughts on this.
- Loading branch information
1 parent
6c06abb
commit f54dfcd
Showing
6 changed files
with
520 additions
and
13 deletions.
There are no files selected for viewing
138 changes: 138 additions & 0 deletions
138
examples/api/lib/material/text_form_field/text_form_field.2.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'; | ||
} |
82 changes: 82 additions & 0 deletions
82
examples/api/test/material/text_form_field/text_form_field.2_test.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}); | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.