Refactor FormField build wrappers (#185795)
## Summary
This is the refactor-only half of the sliver form field work.
`FormFieldState.build` now delegates its validation semantics wrapper
and `AutovalidateMode.onUnfocus` focus wrapper through protected
`wrapWithSemantics` and `wrapWithFocus` methods. The shared validation
result and `Focus.includeSemantics` value are kept behind small getters
so subclasses can customize only the layout-protocol-specific pieces.
There is no intended behavior change for `FormField` or existing
subclasses. The existing render-box semantics and focus behavior remain
the same.
## Issue
Preparation for #173912. This PR does not fix the issue by itself; the
sliver API can follow in a separate PR after this refactor lands.
## Tests
- `flutter analyze --no-pub packages/flutter/lib/src/widgets/form.dart
packages/flutter/test/widgets/form_test.dart
packages/flutter/test/widgets/semantics_test.dart
packages/flutter/test/material/text_form_field_test.dart
packages/flutter/test/material/text_form_field_restoration_test.dart
packages/flutter/test/material/dropdown_form_field_test.dart
packages/flutter/test/material/dropdown_menu_form_field_test.dart
packages/flutter/test/material/input_date_picker_form_field_test.dart`
- `flutter test packages/flutter/test/widgets/form_test.dart
packages/flutter/test/widgets/semantics_test.dart
packages/flutter/test/material/text_form_field_test.dart
packages/flutter/test/material/text_form_field_restoration_test.dart
packages/flutter/test/material/dropdown_form_field_test.dart
packages/flutter/test/material/dropdown_menu_form_field_test.dart
packages/flutter/test/material/input_date_picker_form_field_test.dart`
## Pre-launch Checklist
- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide].
- [x] I listed at least one issue that this PR relates to in the
description above.
- [x] I added or updated tests where needed for the change I am making.
- [x] All existing and new tests are passing.
[Contributor Guide]:
https://github.com/flutter/flutter/wiki/Tree-hygiene#overview
[Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene
[Flutter Style Guide]:
https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo
---------
Co-authored-by: Victor Sanni <victorsanniay@gmail.com>
diff --git a/packages/flutter/lib/src/widgets/form.dart b/packages/flutter/lib/src/widgets/form.dart
index 7ec722e..190fa79 100644
--- a/packages/flutter/lib/src/widgets/form.dart
+++ b/packages/flutter/lib/src/widgets/form.dart
@@ -635,6 +635,10 @@
/// The current state of a [FormField]. Passed to the [FormFieldBuilder] method
/// for use in constructing the form field's widget.
+///
+/// Subclasses whose [FormField.builder] returns a sliver can override
+/// [wrapWithSemantics], [wrapWithFocus], and [focusIncludesSemantics] to
+/// replace the default render-box wrappers with sliver-compatible ones.
class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
late T? _value = widget.initialValue;
// Marking it as late, so it can be registered
@@ -829,6 +833,78 @@
super.dispose();
}
+ /// The [SemanticsValidationResult] published by [wrapWithSemantics] for this
+ /// field's current state.
+ ///
+ /// See also:
+ ///
+ /// * [wrapWithSemantics], which publishes this value on a [Semantics] node.
+ /// * [hasError], which the default implementation reflects.
+ @protected
+ SemanticsValidationResult get semanticsValidationResult =>
+ hasError ? SemanticsValidationResult.invalid : SemanticsValidationResult.valid;
+
+ /// Whether [wrapWithFocus]'s [Focus] should add its own [Semantics] node.
+ ///
+ /// Subclasses whose [wrapWithSemantics] uses a sliver-typed semantics node
+ /// override this to `false`, since the box-typed semantics node added by
+ /// [Focus] would otherwise crash on a sliver child.
+ ///
+ /// See also:
+ ///
+ /// * [wrapWithFocus], whose [Focus] node this flag configures.
+ /// * [Focus.includeSemantics], which receives this value.
+ @protected
+ bool get focusIncludesSemantics => true;
+
+ /// Wraps the result of [FormField.builder] with a [Semantics] node that
+ /// publishes the field's [SemanticsValidationResult].
+ ///
+ /// Subclasses whose builder returns a sliver override this to wrap with a
+ /// sliver-typed semantics node instead, since [Semantics] is a render-box
+ /// widget and would crash when given a sliver child.
+ ///
+ /// See also:
+ ///
+ /// * [semanticsValidationResult], the value the default implementation
+ /// publishes.
+ /// * [wrapWithFocus], which wraps the result of this method when
+ /// [AutovalidateMode.onUnfocus] validation is in effect.
+ @protected
+ Widget wrapWithSemantics(Widget child) {
+ return Semantics(validationResult: semanticsValidationResult, child: child);
+ }
+
+ /// Wraps [child] with the [Focus] node used to drive
+ /// [AutovalidateMode.onUnfocus] validation.
+ ///
+ /// Subclasses whose builder returns a sliver override [focusIncludesSemantics]
+ /// to disable [Focus]'s own [Semantics] wrapper, which is a render-box widget.
+ ///
+ /// See also:
+ ///
+ /// * [focusIncludesSemantics], which controls whether the [Focus] node adds
+ /// its own [Semantics] node.
+ /// * [wrapWithSemantics], which wraps the result of [FormField.builder]
+ /// before this method runs.
+ @protected
+ Widget wrapWithFocus(Widget child) {
+ return Focus(
+ canRequestFocus: false,
+ skipTraversal: true,
+ includeSemantics: focusIncludesSemantics,
+ onFocusChange: (bool value) {
+ if (!value) {
+ setState(() {
+ _validate();
+ });
+ }
+ },
+ focusNode: _focusNode,
+ child: child,
+ );
+ }
+
@protected
@override
Widget build(BuildContext context) {
@@ -852,29 +928,12 @@
Form.maybeOf(context)?._register(this);
- final Widget child = Semantics(
- validationResult: hasError
- ? SemanticsValidationResult.invalid
- : SemanticsValidationResult.valid,
- child: widget.builder(this),
- );
+ final Widget child = wrapWithSemantics(widget.builder(this));
if (Form.maybeOf(context)?.widget.autovalidateMode == AutovalidateMode.onUnfocus &&
widget.autovalidateMode != AutovalidateMode.always ||
widget.autovalidateMode == AutovalidateMode.onUnfocus) {
- return Focus(
- canRequestFocus: false,
- skipTraversal: true,
- onFocusChange: (bool value) {
- if (!value) {
- setState(() {
- _validate();
- });
- }
- },
- focusNode: _focusNode,
- child: child,
- );
+ return wrapWithFocus(child);
}
return child;
diff --git a/packages/flutter/test/widgets/form_test.dart b/packages/flutter/test/widgets/form_test.dart
index 257ee11..fc59d4e 100644
--- a/packages/flutter/test/widgets/form_test.dart
+++ b/packages/flutter/test/widgets/form_test.dart
@@ -1901,6 +1901,52 @@
);
});
+ testWidgets('FormFieldState.build delegates semantics wrapping to wrapWithSemantics', (
+ WidgetTester tester,
+ ) async {
+ final SemanticsHandle handle = tester.ensureSemantics();
+
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Form(
+ child: _WrappingFormField(semanticsValidationResult: SemanticsValidationResult.invalid),
+ ),
+ ),
+ );
+
+ expect(find.byKey(_semanticsWrapperKey), findsOneWidget);
+ expect(find.byKey(_focusWrapperKey), findsNothing);
+ expect(
+ tester.getSemantics(find.text('field')),
+ containsSemantics(validationResult: SemanticsValidationResult.invalid),
+ );
+
+ handle.dispose();
+ });
+
+ testWidgets('FormFieldState.build delegates onUnfocus wrapping to wrapWithFocus', (
+ WidgetTester tester,
+ ) async {
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Form(
+ autovalidateMode: AutovalidateMode.onUnfocus,
+ child: _WrappingFormField(focusIncludesSemantics: false),
+ ),
+ ),
+ );
+
+ expect(find.byKey(_focusWrapperKey), findsOneWidget);
+ expect(find.byKey(_semanticsWrapperKey), findsOneWidget);
+ final Finder focus = find.descendant(
+ of: find.byKey(_focusWrapperKey),
+ matching: find.byType(Focus),
+ );
+ expect(tester.widget<Focus>(focus).includeSemantics, isFalse);
+ });
+
testWidgets('Form does not crash at zero area', (WidgetTester tester) async {
await tester.pumpWidget(
const Directionality(
@@ -2149,3 +2195,39 @@
final bool supportsAnnounce;
final String testName;
}
+
+const Key _semanticsWrapperKey = Key('semantics wrapper');
+const Key _focusWrapperKey = Key('focus wrapper');
+
+class _WrappingFormField extends FormField<String> {
+ _WrappingFormField({
+ this.semanticsValidationResult = SemanticsValidationResult.valid,
+ this.focusIncludesSemantics = true,
+ }) : super(initialValue: 'value', builder: (FormFieldState<String> state) => const Text('field'));
+
+ final SemanticsValidationResult semanticsValidationResult;
+ final bool focusIncludesSemantics;
+
+ @override
+ FormFieldState<String> createState() => _WrappingFormFieldState();
+}
+
+class _WrappingFormFieldState extends FormFieldState<String> {
+ _WrappingFormField get _widget => widget as _WrappingFormField;
+
+ @override
+ SemanticsValidationResult get semanticsValidationResult => _widget.semanticsValidationResult;
+
+ @override
+ bool get focusIncludesSemantics => _widget.focusIncludesSemantics;
+
+ @override
+ Widget wrapWithSemantics(Widget child) {
+ return KeyedSubtree(key: _semanticsWrapperKey, child: super.wrapWithSemantics(child));
+ }
+
+ @override
+ Widget wrapWithFocus(Widget child) {
+ return KeyedSubtree(key: _focusWrapperKey, child: super.wrapWithFocus(child));
+ }
+}