diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index b232a5aad1..308f893c28 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -273,6 +273,7 @@ class CupertinoTextField extends StatefulWidget { this.scrollController, this.scrollPhysics, this.autofillHints, + this.restorationId, }) : assert(textAlign != null), assert(readOnly != null), assert(autofocus != null), @@ -600,6 +601,9 @@ class CupertinoTextField extends StatefulWidget { /// {@macro flutter.services.autofill.autofillHints} final Iterable autofillHints; + /// {@macro flutter.material.textfield.restorationId} + final String restorationId; + @override _CupertinoTextFieldState createState() => _CupertinoTextFieldState(); @@ -641,11 +645,11 @@ class CupertinoTextField extends StatefulWidget { } } -class _CupertinoTextFieldState extends State with AutomaticKeepAliveClientMixin implements TextSelectionGestureDetectorBuilderDelegate { +class _CupertinoTextFieldState extends State with RestorationMixin, AutomaticKeepAliveClientMixin implements TextSelectionGestureDetectorBuilderDelegate { final GlobalKey _clearGlobalKey = GlobalKey(); - TextEditingController _controller; - TextEditingController get _effectiveController => widget.controller ?? _controller; + RestorableTextEditingController _controller; + TextEditingController get _effectiveController => widget.controller ?? _controller.value; FocusNode _focusNode; FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode()); @@ -670,8 +674,7 @@ class _CupertinoTextFieldState extends State with AutomaticK super.initState(); _selectionGestureDetectorBuilder = _CupertinoTextFieldSelectionGestureDetectorBuilder(state: this); if (widget.controller == null) { - _controller = TextEditingController(); - _controller.addListener(updateKeepAlive); + _createLocalController(); } } @@ -679,9 +682,10 @@ class _CupertinoTextFieldState extends State with AutomaticK void didUpdateWidget(CupertinoTextField oldWidget) { super.didUpdateWidget(oldWidget); if (widget.controller == null && oldWidget.controller != null) { - _controller = TextEditingController.fromValue(oldWidget.controller.value); - _controller.addListener(updateKeepAlive); + _createLocalController(oldWidget.controller.value); } else if (widget.controller != null && oldWidget.controller == null) { + unregisterFromRestoration(_controller); + _controller.dispose(); _controller = null; } final bool isEnabled = widget.enabled ?? true; @@ -691,10 +695,36 @@ class _CupertinoTextFieldState extends State with AutomaticK } } + @override + void restoreState(RestorationBucket oldBucket, bool initialRestore) { + if (_controller != null) { + _registerController(); + } + } + + void _registerController() { + assert(_controller != null); + registerForRestoration(_controller, 'controller'); + _controller.value.addListener(updateKeepAlive); + } + + void _createLocalController([TextEditingValue value]) { + assert(_controller == null); + _controller = value == null + ? RestorableTextEditingController() + : RestorableTextEditingController.fromValue(value); + if (!restorePending) { + _registerController(); + } + } + + @override + String get restorationId => widget.restorationId; + @override void dispose() { _focusNode?.dispose(); - _controller?.removeListener(updateKeepAlive); + _controller?.dispose(); super.dispose(); } @@ -736,7 +766,7 @@ class _CupertinoTextFieldState extends State with AutomaticK } @override - bool get wantKeepAlive => _controller?.text?.isNotEmpty == true; + bool get wantKeepAlive => _controller?.value?.text?.isNotEmpty == true; bool _shouldShowAttachment({ OverlayVisibilityMode attachment, @@ -927,57 +957,61 @@ class _CupertinoTextFieldState extends State with AutomaticK final Widget paddedEditable = Padding( padding: widget.padding, child: RepaintBoundary( - child: EditableText( - key: editableTextKey, - controller: controller, - readOnly: widget.readOnly, - toolbarOptions: widget.toolbarOptions, - showCursor: widget.showCursor, - showSelectionHandles: _showSelectionHandles, - focusNode: _effectiveFocusNode, - keyboardType: widget.keyboardType, - textInputAction: widget.textInputAction, - textCapitalization: widget.textCapitalization, - style: textStyle, - strutStyle: widget.strutStyle, - textAlign: widget.textAlign, - autofocus: widget.autofocus, - obscuringCharacter: widget.obscuringCharacter, - obscureText: widget.obscureText, - autocorrect: widget.autocorrect, - smartDashesType: widget.smartDashesType, - smartQuotesType: widget.smartQuotesType, - enableSuggestions: widget.enableSuggestions, - maxLines: widget.maxLines, - minLines: widget.minLines, - expands: widget.expands, - selectionColor: selectionColor, - selectionControls: widget.selectionEnabled - ? cupertinoTextSelectionControls : null, - onChanged: widget.onChanged, - onSelectionChanged: _handleSelectionChanged, - onEditingComplete: widget.onEditingComplete, - onSubmitted: widget.onSubmitted, - inputFormatters: formatters, - rendererIgnoresPointer: true, - cursorWidth: widget.cursorWidth, - cursorHeight: widget.cursorHeight, - cursorRadius: widget.cursorRadius, - cursorColor: cursorColor, - cursorOpacityAnimates: true, - cursorOffset: cursorOffset, - paintCursorAboveText: true, - autocorrectionTextRectColor: selectionColor, - backgroundCursorColor: CupertinoDynamicColor.resolve(CupertinoColors.inactiveGray, context), - selectionHeightStyle: widget.selectionHeightStyle, - selectionWidthStyle: widget.selectionWidthStyle, - scrollPadding: widget.scrollPadding, - keyboardAppearance: keyboardAppearance, - dragStartBehavior: widget.dragStartBehavior, - scrollController: widget.scrollController, - scrollPhysics: widget.scrollPhysics, - enableInteractiveSelection: widget.enableInteractiveSelection, - autofillHints: widget.autofillHints, + child: UnmanagedRestorationScope( + bucket: bucket, + child: EditableText( + key: editableTextKey, + controller: controller, + readOnly: widget.readOnly, + toolbarOptions: widget.toolbarOptions, + showCursor: widget.showCursor, + showSelectionHandles: _showSelectionHandles, + focusNode: _effectiveFocusNode, + keyboardType: widget.keyboardType, + textInputAction: widget.textInputAction, + textCapitalization: widget.textCapitalization, + style: textStyle, + strutStyle: widget.strutStyle, + textAlign: widget.textAlign, + autofocus: widget.autofocus, + obscuringCharacter: widget.obscuringCharacter, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + enableSuggestions: widget.enableSuggestions, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + selectionColor: selectionColor, + selectionControls: widget.selectionEnabled + ? cupertinoTextSelectionControls : null, + onChanged: widget.onChanged, + onSelectionChanged: _handleSelectionChanged, + onEditingComplete: widget.onEditingComplete, + onSubmitted: widget.onSubmitted, + inputFormatters: formatters, + rendererIgnoresPointer: true, + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: widget.cursorRadius, + cursorColor: cursorColor, + cursorOpacityAnimates: true, + cursorOffset: cursorOffset, + paintCursorAboveText: true, + autocorrectionTextRectColor: selectionColor, + backgroundCursorColor: CupertinoDynamicColor.resolve(CupertinoColors.inactiveGray, context), + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + scrollPadding: widget.scrollPadding, + keyboardAppearance: keyboardAppearance, + dragStartBehavior: widget.dragStartBehavior, + scrollController: widget.scrollController, + scrollPhysics: widget.scrollPhysics, + enableInteractiveSelection: widget.enableInteractiveSelection, + autofillHints: widget.autofillHints, + restorationId: 'editable', + ), ), ), ); diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index e3f338bf5c..f50f24a3b6 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -372,6 +372,7 @@ class TextField extends StatefulWidget { this.scrollController, this.scrollPhysics, this.autofillHints, + this.restorationId, }) : assert(textAlign != null), assert(readOnly != null), assert(autofocus != null), @@ -787,6 +788,25 @@ class TextField extends StatefulWidget { /// {@macro flutter.services.autofill.autofillHints} final Iterable autofillHints; + /// {@template flutter.material.textfield.restorationId} + /// Restoration ID to save and restore the state of the text field. + /// + /// If non-null, the text field will persist and restore its current scroll + /// offset and - if no [controller] has been provided - the content of the + /// text field. If a [controller] has been provided, it is the responsibility + /// of the owner of that controller to persist and restore it, e.g. by using + /// a [RestorableTextEditingController]. + /// + /// 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. + /// {@endtemplate} + final String restorationId; + @override _TextFieldState createState() => _TextFieldState(); @@ -828,9 +848,9 @@ class TextField extends StatefulWidget { } } -class _TextFieldState extends State implements TextSelectionGestureDetectorBuilderDelegate { - TextEditingController _controller; - TextEditingController get _effectiveController => widget.controller ?? _controller; +class _TextFieldState extends State with RestorationMixin implements TextSelectionGestureDetectorBuilderDelegate { + RestorableTextEditingController _controller; + TextEditingController get _effectiveController => widget.controller ?? _controller.value; FocusNode _focusNode; FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode()); @@ -937,7 +957,7 @@ class _TextFieldState extends State implements TextSelectionGestureDe super.initState(); _selectionGestureDetectorBuilder = _TextFieldSelectionGestureDetectorBuilder(state: this); if (widget.controller == null) { - _controller = TextEditingController(); + _createLocalController(); } _effectiveFocusNode.canRequestFocus = _isEnabled; } @@ -963,10 +983,13 @@ class _TextFieldState extends State implements TextSelectionGestureDe @override void didUpdateWidget(TextField oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.controller == null && oldWidget.controller != null) - _controller = TextEditingController.fromValue(oldWidget.controller.value); - else if (widget.controller != null && oldWidget.controller == null) + if (widget.controller == null && oldWidget.controller != null) { + _createLocalController(oldWidget.controller.value); + } else if (widget.controller != null && oldWidget.controller == null) { + unregisterFromRestoration(_controller); + _controller.dispose(); _controller = null; + } _effectiveFocusNode.canRequestFocus = _canRequestFocus; if (_effectiveFocusNode.hasFocus && widget.readOnly != oldWidget.readOnly && _isEnabled) { if(_effectiveController.selection.isCollapsed) { @@ -975,9 +998,35 @@ class _TextFieldState extends State implements TextSelectionGestureDe } } + @override + void restoreState(RestorationBucket oldBucket, bool initialRestore) { + if (_controller != null) { + _registerController(); + } + } + + 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 + String get restorationId => widget.restorationId; + @override void dispose() { _focusNode?.dispose(); + _controller?.dispose(); super.dispose(); } @@ -1122,60 +1171,64 @@ class _TextFieldState extends State implements TextSelectionGestureDe } Widget child = RepaintBoundary( - child: EditableText( - key: editableTextKey, - readOnly: widget.readOnly || !_isEnabled, - toolbarOptions: widget.toolbarOptions, - showCursor: widget.showCursor, - showSelectionHandles: _showSelectionHandles, - controller: controller, - focusNode: focusNode, - keyboardType: widget.keyboardType, - textInputAction: widget.textInputAction, - textCapitalization: widget.textCapitalization, - style: style, - strutStyle: widget.strutStyle, - textAlign: widget.textAlign, - textDirection: widget.textDirection, - autofocus: widget.autofocus, - obscuringCharacter: widget.obscuringCharacter, - obscureText: widget.obscureText, - autocorrect: widget.autocorrect, - smartDashesType: widget.smartDashesType, - smartQuotesType: widget.smartQuotesType, - enableSuggestions: widget.enableSuggestions, - maxLines: widget.maxLines, - minLines: widget.minLines, - expands: widget.expands, - selectionColor: selectionColor, - selectionControls: widget.selectionEnabled ? textSelectionControls : null, - onChanged: widget.onChanged, - onSelectionChanged: _handleSelectionChanged, - onEditingComplete: widget.onEditingComplete, - onSubmitted: widget.onSubmitted, - onAppPrivateCommand: widget.onAppPrivateCommand, - onSelectionHandleTapped: _handleSelectionHandleTapped, - inputFormatters: formatters, - rendererIgnoresPointer: true, - mouseCursor: MouseCursor.defer, // TextField will handle the cursor - cursorWidth: widget.cursorWidth, - cursorHeight: widget.cursorHeight, - cursorRadius: cursorRadius, - cursorColor: cursorColor, - selectionHeightStyle: widget.selectionHeightStyle, - selectionWidthStyle: widget.selectionWidthStyle, - cursorOpacityAnimates: cursorOpacityAnimates, - cursorOffset: cursorOffset, - paintCursorAboveText: paintCursorAboveText, - backgroundCursorColor: CupertinoColors.inactiveGray, - scrollPadding: widget.scrollPadding, - keyboardAppearance: keyboardAppearance, - enableInteractiveSelection: widget.enableInteractiveSelection, - dragStartBehavior: widget.dragStartBehavior, - scrollController: widget.scrollController, - scrollPhysics: widget.scrollPhysics, - autofillHints: widget.autofillHints, - autocorrectionTextRectColor: autocorrectionTextRectColor, + child: UnmanagedRestorationScope( + bucket: bucket, + child: EditableText( + key: editableTextKey, + readOnly: widget.readOnly || !_isEnabled, + toolbarOptions: widget.toolbarOptions, + showCursor: widget.showCursor, + showSelectionHandles: _showSelectionHandles, + controller: controller, + focusNode: focusNode, + keyboardType: widget.keyboardType, + textInputAction: widget.textInputAction, + textCapitalization: widget.textCapitalization, + style: style, + strutStyle: widget.strutStyle, + textAlign: widget.textAlign, + textDirection: widget.textDirection, + autofocus: widget.autofocus, + obscuringCharacter: widget.obscuringCharacter, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + enableSuggestions: widget.enableSuggestions, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + selectionColor: selectionColor, + selectionControls: widget.selectionEnabled ? textSelectionControls : null, + onChanged: widget.onChanged, + onSelectionChanged: _handleSelectionChanged, + onEditingComplete: widget.onEditingComplete, + onSubmitted: widget.onSubmitted, + onAppPrivateCommand: widget.onAppPrivateCommand, + onSelectionHandleTapped: _handleSelectionHandleTapped, + inputFormatters: formatters, + rendererIgnoresPointer: true, + mouseCursor: MouseCursor.defer, // TextField will handle the cursor + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: cursorRadius, + cursorColor: cursorColor, + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + cursorOpacityAnimates: cursorOpacityAnimates, + cursorOffset: cursorOffset, + paintCursorAboveText: paintCursorAboveText, + backgroundCursorColor: CupertinoColors.inactiveGray, + scrollPadding: widget.scrollPadding, + keyboardAppearance: keyboardAppearance, + enableInteractiveSelection: widget.enableInteractiveSelection, + dragStartBehavior: widget.dragStartBehavior, + scrollController: widget.scrollController, + scrollPhysics: widget.scrollPhysics, + autofillHints: widget.autofillHints, + autocorrectionTextRectColor: autocorrectionTextRectColor, + restorationId: 'editable', + ), ), ); diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index c5c6018c39..42fde38af0 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -445,6 +445,7 @@ class EditableText extends StatefulWidget { ), this.autofillHints, this.clipBehavior = Clip.hardEdge, + this.restorationId, }) : assert(controller != null), assert(focusNode != null), assert(obscuringCharacter != null && obscuringCharacter.length == 1), @@ -1218,6 +1219,25 @@ class EditableText extends StatefulWidget { /// Defaults to [Clip.hardEdge]. final Clip clipBehavior; + /// Restoration ID to save and restore the scroll offset of the + /// [EditableText]. + /// + /// If a restoration id is provided, the [EditableText] will persist its + /// current scroll offset and restore it during state restoration. + /// + /// The scroll offset is persisted in a [RestorationBucket] claimed from + /// the surrounding [RestorationScope] using the provided restoration ID. + /// + /// Persisting and restoring the content of the [EditableText] is the + /// responsibilility of the owner of the [controller], who may use a + /// [RestorableTextEditingController] for that purpose. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + final String restorationId; + // Infer the keyboard type of an `EditableText` if it's not specified. static TextInputType _inferKeyboardType({ @required Iterable autofillHints, @@ -2323,6 +2343,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien controller: _scrollController, physics: widget.scrollPhysics, dragStartBehavior: widget.dragStartBehavior, + restorationId: widget.restorationId, viewportBuilder: (BuildContext context, ViewportOffset offset) { return CompositedTransformTarget( link: _toolbarLayerLink, diff --git a/packages/flutter/lib/src/widgets/restoration_properties.dart b/packages/flutter/lib/src/widgets/restoration_properties.dart index 90a37dcb2e..3ac8e985ac 100644 --- a/packages/flutter/lib/src/widgets/restoration_properties.dart +++ b/packages/flutter/lib/src/widgets/restoration_properties.dart @@ -4,6 +4,8 @@ // @dart = 2.8 +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -257,11 +259,26 @@ class RestorableTextEditingController extends RestorableListenable restoreAndVerify(WidgetTester tester) async { + expect(find.text(text), findsNothing); + expect(tester.state(find.byType(Scrollable)).position.pixels, 0); + + await tester.enterText(find.byType(CupertinoTextField), 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(CupertinoTextField), 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 MaterialApp( + home: Material( + child: Align( + alignment: Alignment.center, + child: SizedBox( + width: 50, + child: CupertinoTextField( + 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)); +} diff --git a/packages/flutter/test/material/text_field_restoration_test.dart b/packages/flutter/test/material/text_field_restoration_test.dart new file mode 100644 index 0000000000..bf161c0dd2 --- /dev/null +++ b/packages/flutter/test/material/text_field_restoration_test.dart @@ -0,0 +1,125 @@ +// 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. + +// @dart = 2.8 + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/widgets.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 RootRestorationScope( + child: TestWidget(), + restorationId: 'root', + ), + ); + + await restoreAndVerify(tester); + }); + + testWidgets('TextField restoration with external controller', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope( + child: TestWidget( + useExternal: true, + ), + restorationId: 'root', + ), + ); + + await restoreAndVerify(tester); + }); +} + +Future restoreAndVerify(WidgetTester tester) async { + expect(find.text(text), findsNothing); + expect(tester.state(find.byType(Scrollable)).position.pixels, 0); + + await tester.enterText(find.byType(TextField), 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(TextField), 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 MaterialApp( + home: Material( + child: Align( + alignment: Alignment.center, + child: SizedBox( + width: 50, + child: TextField( + 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)); +}