diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 42fde38af07..232a1c856bf 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -1629,18 +1629,25 @@ class EditableTextState extends State with AutomaticKeepAliveClien // action; The newline is already inserted. Otherwise, finalize // editing. if (!_isMultiline) - _finalizeEditing(true); + _finalizeEditing(action, shouldUnfocus: true); break; case TextInputAction.done: case TextInputAction.go: - case TextInputAction.send: + case TextInputAction.next: + case TextInputAction.previous: case TextInputAction.search: - _finalizeEditing(true); + case TextInputAction.send: + _finalizeEditing(action, shouldUnfocus: true); break; - default: + case TextInputAction.continueAction: + case TextInputAction.emergencyCall: + case TextInputAction.join: + case TextInputAction.none: + case TextInputAction.route: + case TextInputAction.unspecified: // Finalize editing, but don't give up focus because this keyboard // action does not imply the user is done inputting information. - _finalizeEditing(false); + _finalizeEditing(action, shouldUnfocus: false); break; } } @@ -1725,16 +1732,38 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } - void _finalizeEditing(bool shouldUnfocus) { + void _finalizeEditing(TextInputAction action, {@required bool shouldUnfocus}) { // Take any actions necessary now that the user has completed editing. if (widget.onEditingComplete != null) { widget.onEditingComplete(); } else { // Default behavior if the developer did not provide an - // onEditingComplete callback: Finalize editing and remove focus. + // onEditingComplete callback: Finalize editing and remove focus, or move + // it to the next/previous field, depending on the action. widget.controller.clearComposing(); - if (shouldUnfocus) - widget.focusNode.unfocus(); + if (shouldUnfocus) { + switch (action) { + case TextInputAction.none: + case TextInputAction.unspecified: + case TextInputAction.done: + case TextInputAction.go: + case TextInputAction.search: + case TextInputAction.send: + case TextInputAction.continueAction: + case TextInputAction.join: + case TextInputAction.route: + case TextInputAction.emergencyCall: + case TextInputAction.newline: + widget.focusNode.unfocus(); + break; + case TextInputAction.next: + widget.focusNode.nextFocus(); + break; + case TextInputAction.previous: + widget.focusNode.previousFocus(); + break; + } + } } // Invoke optional callback with the user's submitted content. @@ -1883,7 +1912,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien _textInputConnection = null; _lastFormattedUnmodifiedTextEditingValue = null; _receivedRemoteTextEditingValue = null; - _finalizeEditing(true); + _finalizeEditing(TextInputAction.done, shouldUnfocus: true); } } diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 4ca4823f287..9e47443d1cd 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -1336,35 +1336,88 @@ void main() { expect(changedValue, clipboardContent); }); - testWidgets('Does not lose focus by default when "next" action is pressed', (WidgetTester tester) async { - final FocusNode focusNode = FocusNode(); + // The variants to test in the focus handling test. + final ValueVariant focusVariants = ValueVariant< + TextInputAction>( + TextInputAction.values.toSet(), + ); - final Widget widget = MaterialApp( - home: EditableText( - backgroundCursorColor: Colors.grey, - controller: TextEditingController(), - focusNode: focusNode, - style: Typography.material2018(platform: TargetPlatform.android).black.subtitle1, - cursorColor: Colors.blue, - selectionControls: materialTextSelectionControls, - keyboardType: TextInputType.text, - ), - ); - await tester.pumpWidget(widget); + testWidgets('Handles focus correctly when action is invoked', (WidgetTester tester) async { + // The expectations for each of the types of TextInputAction. + const Map actionShouldLoseFocus = { + TextInputAction.none: false, + TextInputAction.unspecified: false, + TextInputAction.done: true, + TextInputAction.go: true, + TextInputAction.search: true, + TextInputAction.send: true, + TextInputAction.continueAction: false, + TextInputAction.join: false, + TextInputAction.route: false, + TextInputAction.emergencyCall: false, + TextInputAction.newline: true, + TextInputAction.next: true, + TextInputAction.previous: true, + }; - // Select EditableText to give it focus. - final Finder textFinder = find.byType(EditableText); - await tester.tap(textFinder); - await tester.pump(); + final TextInputAction action = focusVariants.currentValue; + expect(actionShouldLoseFocus.containsKey(action), isTrue); - assert(focusNode.hasFocus); + Future _ensureCorrectFocusHandlingForAction( + TextInputAction action, { + @required bool shouldLoseFocus, + bool shouldFocusNext = false, + bool shouldFocusPrevious = false, + }) async { + final FocusNode focusNode = FocusNode(); + final GlobalKey previousKey = GlobalKey(); + final GlobalKey nextKey = GlobalKey(); - await tester.testTextInput.receiveAction(TextInputAction.next); - await tester.pump(); + final Widget widget = MaterialApp( + home: Column( + children: [ + TextButton( + child: Text('Previous Widget', key: previousKey), + onPressed: () {}), + EditableText( + backgroundCursorColor: Colors.grey, + controller: TextEditingController(), + focusNode: focusNode, + style: Typography.material2018(platform: TargetPlatform.android).black.subtitle1, + cursorColor: Colors.blue, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + autofocus: true, + ), + TextButton( + child: Text('Next Widget', key: nextKey), onPressed: () {}), + ], + ), + ); + await tester.pumpWidget(widget); - // Still has focus after pressing "next". - expect(focusNode.hasFocus, true); - }); + assert(focusNode.hasFocus); + + await tester.testTextInput.receiveAction(action); + await tester.pump(); + + expect(Focus.of(nextKey.currentContext).hasFocus, equals(shouldFocusNext)); + expect(Focus.of(previousKey.currentContext).hasFocus, equals(shouldFocusPrevious)); + expect(focusNode.hasFocus, equals(!shouldLoseFocus)); + } + + try { + await _ensureCorrectFocusHandlingForAction( + action, + shouldLoseFocus: actionShouldLoseFocus[action], + shouldFocusNext: action == TextInputAction.next, + shouldFocusPrevious: action == TextInputAction.previous, + ); + } on PlatformException { + // on Android, continueAction isn't supported. + expect(action, equals(TextInputAction.continueAction)); + } + }, variant: focusVariants); testWidgets('Does not lose focus by default when "done" action is pressed and onEditingComplete is provided', (WidgetTester tester) async { final FocusNode focusNode = FocusNode(); diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart index 8e8e4fff644..c0cea0839f0 100644 --- a/packages/flutter_test/lib/src/widget_tester.dart +++ b/packages/flutter_test/lib/src/widget_tester.dart @@ -271,6 +271,61 @@ class TargetPlatformVariant extends TestVariant { } } +/// A [TestVariant] that runs separate tests with each of the given values. +/// +/// To use this variant, define it before the test, and then access +/// [currentValue] inside the test. +/// +/// The values are typically enums, but they don't have to be. The `toString` +/// for the given value will be used to describe the variant. Values will have +/// their type name stripped from their `toString` output, so that enum values +/// will only print the value, not the type. +/// +/// {@tool snippet} +/// This example shows how to set up the test to access the [currentValue]. In +/// this example, two tests will be run, one with `value1`, and one with +/// `value2`. The test with `value2` will fail. The names of the tests will be: +/// +/// - `Test handling of TestScenario (value1)` +/// - `Test handling of TestScenario (value2)` +/// +/// ```dart +/// enum TestScenario { +/// value1, +/// value2, +/// value3, +/// } +/// +/// final ValueVariant variants = ValueVariant( +/// {value1, value2}, +/// ); +/// +/// testWidgets('Test handling of TestScenario', (WidgetTester tester) { +/// expect(variants.currentValue, equals(value1)); +/// }, variant: variants); +/// ``` +/// {@end-tool} +class ValueVariant extends TestVariant { + /// Creates a [ValueVariant] that tests the given [values]. + ValueVariant(this.values); + + /// Returns the value currently under test. + T get currentValue => _currentValue; + T _currentValue; + + @override + final Set values; + + @override + String describeValue(T value) => value.toString().replaceFirst('$T.', ''); + + @override + Future setUp(T value) async => _currentValue = value; + + @override + Future tearDown(T value, T memento) async {} +} + /// The warning message to show when a benchmark is performed with assert on. const String kDebugWarning = ''' ┏╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┓