diff --git a/packages/flutter/lib/src/widgets/form.dart b/packages/flutter/lib/src/widgets/form.dart index cb8b5d9c6e8b..657bb04280bc 100644 --- a/packages/flutter/lib/src/widgets/form.dart +++ b/packages/flutter/lib/src/widgets/form.dart @@ -291,18 +291,45 @@ class FormState extends State
{ /// returns true if there are no errors. /// /// The form will rebuild to report the results. + /// + /// See also: + /// * [validateGranularly], which also validates descendant [FormField]s, + /// but instead returns a [Set] of fields with errors. bool validate() { _hasInteractedByUser = true; _forceRebuild(); return _validate(); } - bool _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. + /// + /// This method can be useful to highlight field(s) with errors. + /// + /// The form will rebuild to report the results. + /// + /// See also: + /// * [validate], which also validates descendant [FormField]s, + /// and return true if there are no errors. + Set> validateGranularly() { + final Set> invalidFields = >{}; + _hasInteractedByUser = true; + _forceRebuild(); + _validate(invalidFields); + return invalidFields; + } + + bool _validate([Set>? invalidFields]) { bool hasError = false; String errorMessage = ''; for (final FormFieldState field in _fields) { - hasError = !field.validate() || hasError; + final bool isFieldValid = field.validate(); + hasError = !isFieldValid || hasError; errorMessage += field.errorText ?? ''; + if (invalidFields != null && !isFieldValid) { + invalidFields.add(field); + } } if (errorMessage.isNotEmpty) { diff --git a/packages/flutter/test/widgets/form_test.dart b/packages/flutter/test/widgets/form_test.dart index 2ff305900d6b..fa31bbadd713 100644 --- a/packages/flutter/test/widgets/form_test.dart +++ b/packages/flutter/test/widgets/form_test.dart @@ -272,6 +272,121 @@ void main() { }, ); + testWidgets( + 'validateGranularly returns a set containing all, and only, invalid fields', + (WidgetTester tester) async { + final GlobalKey formKey = GlobalKey(); + final UniqueKey validFieldsKey = UniqueKey(); + final UniqueKey invalidFieldsKey = UniqueKey(); + + const String validString = 'Valid string'; + const String invalidString = 'Invalid string'; + String? validator(String? s) => s == validString ? null : 'Error text'; + + Widget builder() { + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + key: formKey, + child: ListView( + children: [ + TextFormField( + key: validFieldsKey, + initialValue: validString, + validator: validator, + autovalidateMode: AutovalidateMode.disabled, + ), + TextFormField( + key: invalidFieldsKey, + initialValue: invalidString, + validator: validator, + autovalidateMode: AutovalidateMode.disabled, + ), + TextFormField( + key: invalidFieldsKey, + initialValue: invalidString, + validator: validator, + autovalidateMode: AutovalidateMode.disabled, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(builder()); + + final Set> validationResult = formKey.currentState!.validateGranularly(); + + expect(validationResult.length, equals(2)); + expect(validationResult.where((FormFieldState field) => field.widget.key == invalidFieldsKey).length, equals(2)); + expect(validationResult.where((FormFieldState field) => field.widget.key == validFieldsKey).length, equals(0)); + }, + ); + + testWidgets( + 'Should announce error text when validateGranularly is called', + (WidgetTester tester) async { + final GlobalKey formKey = GlobalKey(); + const String validString = 'Valid string'; + String? validator(String? s) => s == validString ? null : 'error'; + + Widget builder() { + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + key: formKey, + child: ListView( + children: [ + TextFormField( + initialValue: validString, + validator: validator, + autovalidateMode: AutovalidateMode.disabled, + ), + TextFormField( + initialValue: '', + validator: validator, + autovalidateMode: AutovalidateMode.disabled, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(builder()); + expect(find.text('error'), findsNothing); + + formKey.currentState!.validateGranularly(); + + await tester.pump(); + expect(find.text('error'), findsOneWidget); + + final CapturedAccessibilityAnnouncement announcement = tester.takeAnnouncements().single; + expect(announcement.message, 'error'); + expect(announcement.textDirection, TextDirection.ltr); + expect(announcement.assertiveness, Assertiveness.assertive); + }, + ); + testWidgets('Multiple TextFormFields communicate', (WidgetTester tester) async { final GlobalKey formKey = GlobalKey(); final GlobalKey> fieldKey = GlobalKey>();