diff --git a/packages/flutter/lib/src/material/text_form_field.dart b/packages/flutter/lib/src/material/text_form_field.dart index efb75f05cc..51c57b2b04 100644 --- a/packages/flutter/lib/src/material/text_form_field.dart +++ b/packages/flutter/lib/src/material/text_form_field.dart @@ -195,6 +195,7 @@ class TextFormField extends FormField { Iterable? autofillHints, AutovalidateMode? autovalidateMode, ScrollController? scrollController, + String? restorationId, }) : assert(initialValue == null || controller == null), assert(textAlign != null), assert(autofocus != null), @@ -230,73 +231,78 @@ class TextFormField extends FormField { assert(maxLength == null || maxLength > 0), assert(enableInteractiveSelection != null), super( - key: key, - initialValue: controller != null ? controller.text : (initialValue ?? ''), - onSaved: onSaved, - validator: validator, - enabled: enabled ?? decoration?.enabled ?? true, - autovalidateMode: autovalidate - ? AutovalidateMode.always - : (autovalidateMode ?? AutovalidateMode.disabled), - builder: (FormFieldState field) { - final _TextFormFieldState state = field as _TextFormFieldState; - final InputDecoration effectiveDecoration = (decoration ?? const InputDecoration()) - .applyDefaults(Theme.of(field.context).inputDecorationTheme); - void onChangedHandler(String value) { - field.didChange(value); - if (onChanged != null) { - onChanged(value); + key: key, + restorationId: restorationId, + initialValue: controller != null ? controller.text : (initialValue ?? ''), + onSaved: onSaved, + validator: validator, + enabled: enabled ?? decoration?.enabled ?? true, + autovalidateMode: autovalidate + ? AutovalidateMode.always + : (autovalidateMode ?? AutovalidateMode.disabled), + builder: (FormFieldState field) { + final _TextFormFieldState state = field as _TextFormFieldState; + final InputDecoration effectiveDecoration = (decoration ?? const InputDecoration()) + .applyDefaults(Theme.of(field.context).inputDecorationTheme); + void onChangedHandler(String value) { + field.didChange(value); + if (onChanged != null) { + onChanged(value); + } } - } - return TextField( - controller: state._effectiveController, - focusNode: focusNode, - decoration: effectiveDecoration.copyWith(errorText: field.errorText), - keyboardType: keyboardType, - textInputAction: textInputAction, - style: style, - strutStyle: strutStyle, - textAlign: textAlign, - textAlignVertical: textAlignVertical, - textDirection: textDirection, - textCapitalization: textCapitalization, - autofocus: autofocus, - toolbarOptions: toolbarOptions, - readOnly: readOnly, - showCursor: showCursor, - obscuringCharacter: obscuringCharacter, - obscureText: obscureText, - autocorrect: autocorrect, - smartDashesType: smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), - smartQuotesType: smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), - enableSuggestions: enableSuggestions, - maxLengthEnforced: maxLengthEnforced, - maxLengthEnforcement: maxLengthEnforcement, - maxLines: maxLines, - minLines: minLines, - expands: expands, - maxLength: maxLength, - onChanged: onChangedHandler, - onTap: onTap, - onEditingComplete: onEditingComplete, - onSubmitted: onFieldSubmitted, - inputFormatters: inputFormatters, - enabled: enabled ?? decoration?.enabled ?? true, - cursorWidth: cursorWidth, - cursorHeight: cursorHeight, - cursorRadius: cursorRadius, - cursorColor: cursorColor, - scrollPadding: scrollPadding, - scrollPhysics: scrollPhysics, - keyboardAppearance: keyboardAppearance, - enableInteractiveSelection: enableInteractiveSelection, - selectionControls: selectionControls, - buildCounter: buildCounter, - autofillHints: autofillHints, - scrollController: scrollController, - ); - }, - ); + return UnmanagedRestorationScope( + bucket: field.bucket, + child: TextField( + restorationId: restorationId, + controller: state._effectiveController, + focusNode: focusNode, + decoration: effectiveDecoration.copyWith(errorText: field.errorText), + keyboardType: keyboardType, + textInputAction: textInputAction, + style: style, + strutStyle: strutStyle, + textAlign: textAlign, + textAlignVertical: textAlignVertical, + textDirection: textDirection, + textCapitalization: textCapitalization, + autofocus: autofocus, + toolbarOptions: toolbarOptions, + readOnly: readOnly, + showCursor: showCursor, + obscuringCharacter: obscuringCharacter, + obscureText: obscureText, + autocorrect: autocorrect, + smartDashesType: smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), + smartQuotesType: smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), + enableSuggestions: enableSuggestions, + maxLengthEnforced: maxLengthEnforced, + maxLengthEnforcement: maxLengthEnforcement, + maxLines: maxLines, + minLines: minLines, + expands: expands, + maxLength: maxLength, + onChanged: onChangedHandler, + onTap: onTap, + onEditingComplete: onEditingComplete, + onSubmitted: onFieldSubmitted, + inputFormatters: inputFormatters, + enabled: enabled ?? decoration?.enabled ?? true, + cursorWidth: cursorWidth, + cursorHeight: cursorHeight, + cursorRadius: cursorRadius, + cursorColor: cursorColor, + scrollPadding: scrollPadding, + scrollPhysics: scrollPhysics, + keyboardAppearance: keyboardAppearance, + enableInteractiveSelection: enableInteractiveSelection, + selectionControls: selectionControls, + buildCounter: buildCounter, + autofillHints: autofillHints, + scrollController: scrollController, + ), + ); + }, + ); /// Controls the text being edited. /// @@ -309,18 +315,44 @@ class TextFormField extends FormField { } class _TextFormFieldState extends FormFieldState { - TextEditingController? _controller; + RestorableTextEditingController? _controller; - TextEditingController? get _effectiveController => widget.controller ?? _controller; + TextEditingController get _effectiveController => widget.controller ?? _controller!.value; @override TextFormField get widget => super.widget as TextFormField; + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + super.restoreState(oldBucket, initialRestore); + if (_controller != null) { + _registerController(); + } + // Make sure to update the internal [FormFieldState] value to sync up with + // text editing controller value. + setValue(_effectiveController.text); + } + + void _registerController() { + assert(_controller != null); + registerForRestoration(_controller!, 'controller'); + } + + void _createLocalController([TextEditingValue? value]) { + assert(_controller == null); + _controller = value == null + ? RestorableTextEditingController() + : RestorableTextEditingController.fromValue(value); + if (!restorePending) { + _registerController(); + } + } + @override void initState() { super.initState(); if (widget.controller == null) { - _controller = TextEditingController(text: widget.initialValue); + _createLocalController(widget.initialValue != null ? TextEditingValue(text: widget.initialValue!) : null); } else { widget.controller!.addListener(_handleControllerChanged); } @@ -333,12 +365,17 @@ class _TextFormFieldState extends FormFieldState { oldWidget.controller?.removeListener(_handleControllerChanged); widget.controller?.addListener(_handleControllerChanged); - if (oldWidget.controller != null && widget.controller == null) - _controller = TextEditingController.fromValue(oldWidget.controller!.value); + if (oldWidget.controller != null && widget.controller == null) { + _createLocalController(oldWidget.controller!.value); + } + if (widget.controller != null) { setValue(widget.controller!.text); - if (oldWidget.controller == null) + if (oldWidget.controller == null) { + unregisterFromRestoration(_controller!); + _controller!.dispose(); _controller = null; + } } } } @@ -346,6 +383,7 @@ class _TextFormFieldState extends FormFieldState { @override void dispose() { widget.controller?.removeListener(_handleControllerChanged); + _controller?.dispose(); super.dispose(); } @@ -353,15 +391,15 @@ class _TextFormFieldState extends FormFieldState { void didChange(String? value) { super.didChange(value); - if (_effectiveController!.text != value) - _effectiveController!.text = value ?? ''; + if (_effectiveController.text != value) + _effectiveController.text = value ?? ''; } @override void reset() { // setState will be called in the superclass, so even though state is being // manipulated, no setState call is needed here. - _effectiveController!.text = widget.initialValue ?? ''; + _effectiveController.text = widget.initialValue ?? ''; super.reset(); } @@ -373,7 +411,7 @@ class _TextFormFieldState extends FormFieldState { // notifications for changes originating from within this class -- for // example, the reset() method. In such cases, the FormField value will // already have been set. - if (_effectiveController!.text != value) - didChange(_effectiveController!.text); + if (_effectiveController.text != value) + didChange(_effectiveController.text); } } diff --git a/packages/flutter/lib/src/widgets/form.dart b/packages/flutter/lib/src/widgets/form.dart index 089a10294c..90fd941b46 100644 --- a/packages/flutter/lib/src/widgets/form.dart +++ b/packages/flutter/lib/src/widgets/form.dart @@ -4,6 +4,8 @@ import 'framework.dart'; import 'navigator.dart'; +import 'restoration.dart'; +import 'restoration_properties.dart'; import 'will_pop_scope.dart'; /// An optional container for grouping together multiple form field widgets @@ -170,7 +172,7 @@ class FormState extends State
{ widget.onChanged?.call(); _hasInteractedByUser = _fields - .any((FormFieldState field) => field._hasInteractedByUser); + .any((FormFieldState field) => field._hasInteractedByUser.value); _forceRebuild(); } @@ -331,14 +333,15 @@ class FormField extends StatefulWidget { this.autovalidate = false, this.enabled = true, AutovalidateMode? autovalidateMode, + this.restorationId, }) : assert(builder != null), assert( autovalidate == false || autovalidate == true && autovalidateMode == null, 'autovalidate and autovalidateMode should not be used together.', ), - autovalidateMode = autovalidateMode ?? - (autovalidate ? AutovalidateMode.always : AutovalidateMode.disabled), + autovalidateMode = autovalidateMode ?? + (autovalidate ? AutovalidateMode.always : AutovalidateMode.disabled), super(key: key); /// An optional method to call with the final value when the form is saved via @@ -399,16 +402,30 @@ class FormField extends StatefulWidget { ) final bool autovalidate; + /// Restoration ID to save and restore the state of the form field. + /// + /// Setting the restoration ID to a non-null value results in whether or not + /// the form field validation persists. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + final String? restorationId; + @override FormFieldState createState() => FormFieldState(); } /// The current state of a [FormField]. Passed to the [FormFieldBuilder] method /// for use in constructing the form field's widget. -class FormFieldState extends State> { - T? _value; - String? _errorText; - bool _hasInteractedByUser = false; +class FormFieldState extends State> with RestorationMixin { + late T? _value = widget.initialValue; + final RestorableStringN _errorText = RestorableStringN(null); + final RestorableBool _hasInteractedByUser = RestorableBool(false); /// The current value of the form field. T? get value => _value; @@ -416,10 +433,10 @@ class FormFieldState extends State> { /// The current validation error returned by the [FormField.validator] /// callback, or null if no errors have been triggered. This only updates when /// [validate] is called. - String? get errorText => _errorText; + String? get errorText => _errorText.value; /// True if this field has any validation errors. - bool get hasError => _errorText != null; + bool get hasError => _errorText.value != null; /// True if the current value is valid. /// @@ -440,8 +457,8 @@ class FormFieldState extends State> { void reset() { setState(() { _value = widget.initialValue; - _hasInteractedByUser = false; - _errorText = null; + _hasInteractedByUser.value = false; + _errorText.value = null; }); Form.of(context)?._fieldDidChange(); } @@ -462,7 +479,7 @@ class FormFieldState extends State> { void _validate() { if (widget.validator != null) - _errorText = widget.validator!(_value); + _errorText.value = widget.validator!(_value); } /// Updates this field's state to the new value. Useful for responding to @@ -474,7 +491,7 @@ class FormFieldState extends State> { void didChange(T? value) { setState(() { _value = value; - _hasInteractedByUser = true; + _hasInteractedByUser.value = true; }); Form.of(context)?._fieldDidChange(); } @@ -492,9 +509,12 @@ class FormFieldState extends State> { } @override - void initState() { - super.initState(); - _value = widget.initialValue; + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_errorText, 'error_text'); + registerForRestoration(_hasInteractedByUser, 'has_interacted_by_user'); } @override @@ -511,7 +531,7 @@ class FormFieldState extends State> { _validate(); break; case AutovalidateMode.onUserInteraction: - if (_hasInteractedByUser) { + if (_hasInteractedByUser.value) { _validate(); } break; diff --git a/packages/flutter/test/material/text_form_field_restoration_test.dart b/packages/flutter/test/material/text_form_field_restoration_test.dart new file mode 100644 index 0000000000..dd4fc54c68 --- /dev/null +++ b/packages/flutter/test/material/text_form_field_restoration_test.dart @@ -0,0 +1,251 @@ +// 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_test/flutter_test.dart'; + +const String text = 'Hello World! How are you? Life is good!'; +const String alternativeText = 'Everything is awesome!!'; + +void main() { + testWidgets('TextField restoration', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + restorationScopeId: 'app', + home: TestWidget(), + ), + ); + + await restoreAndVerify(tester); + }); + + testWidgets('TextField restoration with external controller', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + restorationScopeId: 'root', + home: TestWidget( + useExternal: true, + ), + ), + ); + + await restoreAndVerify(tester); + }); + + testWidgets('State restoration (No Form ancestor) - onUserInteraction error text validation', (WidgetTester tester) async { + String? errorText(String? value) => '$value/error'; + late GlobalKey> formState; + + Widget builder() { + return MaterialApp( + restorationScopeId: 'app', + home: MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter state) { + formState = GlobalKey>(); + return Material( + child: TextFormField( + key: formState, + autovalidateMode: AutovalidateMode.onUserInteraction, + restorationId: 'text_form_field', + initialValue: 'foo', + validator: errorText, + ), + ); + }, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(builder()); + + // No error text is visible yet. + expect(find.text(errorText('foo')!), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'bar'); + await tester.pumpAndSettle(); + expect(find.text(errorText('bar')!), findsOneWidget); + + final TestRestorationData data = await tester.getRestorationData(); + await tester.restartAndRestore(); + // Error text should be present after restart and restore. + expect(find.text(errorText('bar')!), findsOneWidget); + + // Resetting the form state should remove the error text. + formState.currentState!.reset(); + await tester.pumpAndSettle(); + expect(find.text(errorText('bar')!), findsNothing); + await tester.restartAndRestore(); + // Error text should still be removed after restart and restore. + expect(find.text(errorText('bar')!), findsNothing); + + await tester.restoreFrom(data); + expect(find.text(errorText('bar')!), findsOneWidget); + }); + + testWidgets('State Restoration (No Form ancestor) - validator sets the error text only when validate is called', (WidgetTester tester) async { + String? errorText(String? value) => '$value/error'; + late GlobalKey> formState; + + Widget builder(AutovalidateMode mode) { + return MaterialApp( + restorationScopeId: 'app', + home: MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter state) { + formState = GlobalKey>(); + return Material( + child: TextFormField( + key: formState, + restorationId: 'form_field', + autovalidateMode: mode, + initialValue: 'foo', + validator: errorText, + ), + ); + }, + ), + ), + ), + ), + ); + } + + // Start off not autovalidating. + await tester.pumpWidget(builder(AutovalidateMode.disabled)); + + Future checkErrorText(String testValue) async { + formState.currentState!.reset(); + await tester.pumpWidget(builder(AutovalidateMode.disabled)); + await tester.enterText(find.byType(TextFormField), testValue); + await tester.pump(); + + // We have to manually validate if we're not autovalidating. + expect(find.text(errorText(testValue)!), findsNothing); + formState.currentState!.validate(); + await tester.pump(); + expect(find.text(errorText(testValue)!), findsOneWidget); + final TestRestorationData data = await tester.getRestorationData(); + await tester.restartAndRestore(); + // Error text should be present after restart and restore. + expect(find.text(errorText(testValue)!), findsOneWidget); + + formState.currentState!.reset(); + await tester.pumpAndSettle(); + expect(find.text(errorText(testValue)!), findsNothing); + + await tester.restoreFrom(data); + expect(find.text(errorText(testValue)!), findsOneWidget); + + // Try again with autovalidation. Should validate immediately. + formState.currentState!.reset(); + await tester.pumpWidget(builder(AutovalidateMode.always)); + await tester.enterText(find.byType(TextFormField), testValue); + await tester.pump(); + + expect(find.text(errorText(testValue)!), findsOneWidget); + await tester.restartAndRestore(); + // Error text should be present after restart and restore. + expect(find.text(errorText(testValue)!), findsOneWidget); + } + + await checkErrorText('Test'); + await checkErrorText(''); + }); +} + +Future restoreAndVerify(WidgetTester tester) async { + expect(find.text(text), findsNothing); + expect(tester.state(find.byType(Scrollable)).position.pixels, 0); + + await tester.enterText(find.byType(TextFormField), text); + await skipPastScrollingAnimation(tester); + expect(tester.state(find.byType(Scrollable)).position.pixels, 0); + + await tester.drag(find.byType(Scrollable), const Offset(0, -80)); + await skipPastScrollingAnimation(tester); + + expect(find.text(text), findsOneWidget); + expect(tester.state(find.byType(Scrollable)).position.pixels, 60); + + await tester.restartAndRestore(); + + expect(find.text(text), findsOneWidget); + expect(tester.state(find.byType(Scrollable)).position.pixels, 60); + + final TestRestorationData data = await tester.getRestorationData(); + + await tester.enterText(find.byType(TextFormField), alternativeText); + await skipPastScrollingAnimation(tester); + await tester.drag(find.byType(Scrollable), const Offset(0, 80)); + await skipPastScrollingAnimation(tester); + + expect(find.text(text), findsNothing); + expect(tester.state(find.byType(Scrollable)).position.pixels, isNot(60)); + + await tester.restoreFrom(data); + + expect(find.text(text), findsOneWidget); + expect(tester.state(find.byType(Scrollable)).position.pixels, 60); +} + +class TestWidget extends StatefulWidget { + const TestWidget({Key? key, this.useExternal = false}) : super(key: key); + + final bool useExternal; + + @override + TestWidgetState createState() => TestWidgetState(); +} + +class TestWidgetState extends State with RestorationMixin { + final RestorableTextEditingController controller = RestorableTextEditingController(); + + @override + String get restorationId => 'widget'; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(controller, 'controller'); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + child: Align( + alignment: Alignment.center, + child: SizedBox( + width: 50, + child: TextFormField( + restorationId: 'text', + maxLines: 3, + controller: widget.useExternal ? controller.value : null, + ), + ), + ), + ); + } +} + +Future skipPastScrollingAnimation(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); +}