From 67e6cad0cba0cbd33b1f809c321cd155f2513cf0 Mon Sep 17 00:00:00 2001 From: Bruno Leroux Date: Tue, 5 Mar 2024 11:31:04 +0100 Subject: [PATCH] Restorable CupertinoTextFormFieldRow (#144541) ## Description This PR makes `CupertinoTextFormFieldRow` restorable. The implementation is based on https://github.com/flutter/flutter/pull/78835 which made `FormField` and `TextFormField` restorable. ## Related Issue Fixes https://github.com/flutter/flutter/issues/144504. ## Tests Adds 4 tests. --- .../src/cupertino/text_form_field_row.dart | 144 ++++++---- .../text_form_field_row_restoration_test.dart | 251 ++++++++++++++++++ 2 files changed, 339 insertions(+), 56 deletions(-) create mode 100644 packages/flutter/test/cupertino/text_form_field_row_restoration_test.dart diff --git a/packages/flutter/lib/src/cupertino/text_form_field_row.dart b/packages/flutter/lib/src/cupertino/text_form_field_row.dart index 5f9c951ebd4..bde43c299d1 100644 --- a/packages/flutter/lib/src/cupertino/text_form_field_row.dart +++ b/packages/flutter/lib/src/cupertino/text_form_field_row.dart @@ -157,6 +157,7 @@ class CupertinoTextFormFieldRow extends FormField { color: CupertinoColors.placeholderText, ), EditableTextContextMenuBuilder? contextMenuBuilder = _defaultContextMenuBuilder, + super.restorationId, }) : assert(initialValue == null || controller == null), assert(obscuringCharacter.length == 1), assert(maxLines == null || maxLines > 0), @@ -186,50 +187,54 @@ class CupertinoTextFormFieldRow extends FormField { prefix: prefix, padding: padding, error: (field.errorText == null) ? null : Text(field.errorText!), - child: CupertinoTextField.borderless( - controller: state._effectiveController, - focusNode: focusNode, - keyboardType: keyboardType, - decoration: decoration, - textInputAction: textInputAction, - style: style, - strutStyle: strutStyle, - textAlign: textAlign, - textAlignVertical: textAlignVertical, - textCapitalization: textCapitalization, - textDirection: textDirection, - autofocus: autofocus, - toolbarOptions: toolbarOptions, - readOnly: readOnly, - showCursor: showCursor, - obscuringCharacter: obscuringCharacter, - obscureText: obscureText, - autocorrect: autocorrect, - smartDashesType: smartDashesType, - smartQuotesType: smartQuotesType, - enableSuggestions: enableSuggestions, - maxLines: maxLines, - minLines: minLines, - expands: expands, - maxLength: maxLength, - onChanged: onChangedHandler, - onTap: onTap, - onEditingComplete: onEditingComplete, - onSubmitted: onFieldSubmitted, - inputFormatters: inputFormatters, - enabled: enabled ?? true, - cursorWidth: cursorWidth, - cursorHeight: cursorHeight, - cursorColor: cursorColor, - scrollPadding: scrollPadding, - scrollPhysics: scrollPhysics, - keyboardAppearance: keyboardAppearance, - enableInteractiveSelection: enableInteractiveSelection, - selectionControls: selectionControls, - autofillHints: autofillHints, - placeholder: placeholder, - placeholderStyle: placeholderStyle, - contextMenuBuilder: contextMenuBuilder, + child: UnmanagedRestorationScope( + bucket: field.bucket, + child: CupertinoTextField.borderless( + restorationId: restorationId, + controller: state._effectiveController, + focusNode: focusNode, + keyboardType: keyboardType, + decoration: decoration, + textInputAction: textInputAction, + style: style, + strutStyle: strutStyle, + textAlign: textAlign, + textAlignVertical: textAlignVertical, + textCapitalization: textCapitalization, + textDirection: textDirection, + autofocus: autofocus, + toolbarOptions: toolbarOptions, + readOnly: readOnly, + showCursor: showCursor, + obscuringCharacter: obscuringCharacter, + obscureText: obscureText, + autocorrect: autocorrect, + smartDashesType: smartDashesType, + smartQuotesType: smartQuotesType, + enableSuggestions: enableSuggestions, + maxLines: maxLines, + minLines: minLines, + expands: expands, + maxLength: maxLength, + onChanged: onChangedHandler, + onTap: onTap, + onEditingComplete: onEditingComplete, + onSubmitted: onFieldSubmitted, + inputFormatters: inputFormatters, + enabled: enabled ?? true, + cursorWidth: cursorWidth, + cursorHeight: cursorHeight, + cursorColor: cursorColor, + scrollPadding: scrollPadding, + scrollPhysics: scrollPhysics, + keyboardAppearance: keyboardAppearance, + enableInteractiveSelection: enableInteractiveSelection, + selectionControls: selectionControls, + autofillHints: autofillHints, + placeholder: placeholder, + placeholderStyle: placeholderStyle, + contextMenuBuilder: contextMenuBuilder, + ), ), ); }, @@ -272,19 +277,45 @@ class CupertinoTextFormFieldRow extends FormField { } class _CupertinoTextFormFieldRowState extends FormFieldState { - TextEditingController? _controller; + RestorableTextEditingController? _controller; - TextEditingController? get _effectiveController => - _cupertinoTextFormFieldRow.controller ?? _controller; + TextEditingController get _effectiveController => + _cupertinoTextFormFieldRow.controller ?? _controller!.value; CupertinoTextFormFieldRow get _cupertinoTextFormFieldRow => super.widget as CupertinoTextFormFieldRow; + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + super.restoreState(oldBucket, initialRestore); + if (_controller != null) { + _registerController(); + } + // This makes 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 (_cupertinoTextFormFieldRow.controller == null) { - _controller = TextEditingController(text: widget.initialValue); + _createLocalController(widget.initialValue != null ? TextEditingValue(text: widget.initialValue!) : null); } else { _cupertinoTextFormFieldRow.controller!.addListener(_handleControllerChanged); } @@ -298,13 +329,14 @@ class _CupertinoTextFormFieldRowState extends FormFieldState { _cupertinoTextFormFieldRow.controller?.addListener(_handleControllerChanged); if (oldWidget.controller != null && _cupertinoTextFormFieldRow.controller == null) { - _controller = - TextEditingController.fromValue(oldWidget.controller!.value); + _createLocalController(oldWidget.controller!.value); } if (_cupertinoTextFormFieldRow.controller != null) { setValue(_cupertinoTextFormFieldRow.controller!.text); if (oldWidget.controller == null) { + unregisterFromRestoration(_controller!); + _controller!.dispose(); _controller = null; } } @@ -322,8 +354,8 @@ class _CupertinoTextFormFieldRowState extends FormFieldState { void didChange(String? value) { super.didChange(value); - if (value != null && _effectiveController!.text != value) { - _effectiveController!.text = value; + if (value != null && _effectiveController.text != value) { + _effectiveController.text = value; } } @@ -331,9 +363,9 @@ class _CupertinoTextFormFieldRowState extends FormFieldState { void reset() { // Set the controller value before calling super.reset() to let // _handleControllerChanged suppress the change. - _effectiveController!.text = widget.initialValue!; + _effectiveController.text = widget.initialValue!; super.reset(); - _cupertinoTextFormFieldRow.onChanged?.call(_effectiveController!.text); + _cupertinoTextFormFieldRow.onChanged?.call(_effectiveController.text); } void _handleControllerChanged() { @@ -344,8 +376,8 @@ class _CupertinoTextFormFieldRowState 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/test/cupertino/text_form_field_row_restoration_test.dart b/packages/flutter/test/cupertino/text_form_field_row_restoration_test.dart new file mode 100644 index 00000000000..d0a9de12829 --- /dev/null +++ b/packages/flutter/test/cupertino/text_form_field_row_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/cupertino.dart'; +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('CupertinoTextFormFieldRow restoration', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + restorationScopeId: 'app', + home: RestorableTestWidget(), + ), + ); + + await restoreAndVerify(tester); + }); + + testWidgets('CupertinoTextFormFieldRow restoration with external controller', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + restorationScopeId: 'root', + home: RestorableTestWidget( + useExternalController: 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 CupertinoApp( + restorationScopeId: 'app', + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter state) { + formState = GlobalKey>(); + return Material( + child: CupertinoTextFormFieldRow( + 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(CupertinoTextFormFieldRow), '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 CupertinoApp( + restorationScopeId: 'app', + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter state) { + formState = GlobalKey>(); + return Material( + child: CupertinoTextFormFieldRow( + 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(CupertinoTextFormFieldRow), 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(CupertinoTextFormFieldRow), 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(CupertinoTextFormFieldRow), 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(CupertinoTextFormFieldRow), 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 RestorableTestWidget extends StatefulWidget { + const RestorableTestWidget({super.key, this.useExternalController = false}); + + final bool useExternalController; + + @override + RestorableTestWidgetState createState() => RestorableTestWidgetState(); +} + +class RestorableTestWidgetState 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( + child: SizedBox( + width: 50, + child: CupertinoTextFormFieldRow( + restorationId: 'text', + maxLines: 3, + controller: widget.useExternalController ? controller.value : null, + ), + ), + ), + ); + } +} + +Future skipPastScrollingAnimation(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); +}