From 2a65505e8af78aba79ccbae4f0b2bb83bf3c65d4 Mon Sep 17 00:00:00 2001 From: matthew-carroll Date: Mon, 2 Jul 2018 06:58:35 +0000 Subject: [PATCH] Support all keyboard actions. (#11344) (#18855) * Support all keyboard actions. (#11344) --- .../flutter/lib/src/material/text_field.dart | 8 + .../flutter/lib/src/services/text_input.dart | 254 +++++++++- .../lib/src/widgets/editable_text.dart | 90 +++- .../test/material/text_form_field_test.dart | 2 +- .../test/widgets/editable_text_test.dart | 463 ++++++++++++++++-- .../flutter_test/lib/src/test_text_input.dart | 50 +- .../test/test_text_input_test.dart | 36 ++ .../flutter_test/test/widget_tester_test.dart | 4 +- 8 files changed, 830 insertions(+), 77 deletions(-) create mode 100644 packages/flutter_test/test/test_text_input_test.dart diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index f62f58c8f8..f1f5247e6b 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -102,6 +102,7 @@ class TextField extends StatefulWidget { this.focusNode, this.decoration = const InputDecoration(), TextInputType keyboardType = TextInputType.text, + this.textInputAction = TextInputAction.done, this.style, this.textAlign = TextAlign.start, this.autofocus = false, @@ -115,6 +116,7 @@ class TextField extends StatefulWidget { this.inputFormatters, this.enabled, }) : assert(keyboardType != null), + assert(textInputAction != null), assert(textAlign != null), assert(autofocus != null), assert(obscureText != null), @@ -151,6 +153,11 @@ class TextField extends StatefulWidget { /// [TextInputType.multiline] keyboard type is used. final TextInputType keyboardType; + /// The type of action button to use for the keyboard. + /// + /// Defaults to [TextInputAction.done]. Must not be null. + final TextInputAction textInputAction; + /// The style to use for the text being edited. /// /// This text style is also used as the base style for the [decoration]. @@ -473,6 +480,7 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi controller: controller, focusNode: focusNode, keyboardType: widget.keyboardType, + textInputAction: widget.textInputAction, style: style, textAlign: widget.textAlign, autofocus: widget.autofocus, diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 4a07f4635a..702e4f552b 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:io' show Platform; import 'dart:ui' show TextAffinity, hashValues; import 'package:flutter/foundation.dart'; @@ -134,20 +135,200 @@ class TextInputType { } /// An action the user has requested the text input control to perform. +/// +/// Each action represents a logical meaning, and also configures the soft +/// keyboard to display a certain kind of action button. The visual appearance +/// of the action button might differ between versions of the same OS. +/// +/// Despite the logical meaning of each action, choosing a particular +/// [TextInputAction] does not necessarily cause any specific behavior to +/// happen. It is up to the developer to ensure that the behavior that occurs +/// when an action button is pressed is appropriate for the action button chosen. +/// +/// For example: If the user presses the keyboard action button on iOS when it +/// reads "Emergency Call", the result should not be a focus change to the next +/// TextField. This behavior is not logically appropriate for a button that says +/// "Emergency Call". +/// +/// See [EditableText] for more information about customizing action button +/// behavior. +/// +/// Most [TextInputAction]s are supported equally by both Android and iOS. +/// However, there is not a complete, direct mapping between Android's IME input +/// types and iOS's keyboard return types. Therefore, some [TextInputAction]s +/// are inappropriate for one of the platforms. If a developer chooses an +/// inappropriate [TextInputAction] when running in debug mode, an error will be +/// thrown. If the same thing is done in release mode, then instead of sending +/// the inappropriate value, Android will use "unspecified" on the platform +/// side and iOS will use "default" on the platform side. +/// +/// See also: +/// +/// * [TextInput], which configures the platform's keyboard setup. +/// * [EditableText], which invokes callbacks when the action button is pressed. enum TextInputAction { - /// Complete the text input operation. + /// Logical meaning: There is no relevant input action for the current input + /// source, e.g., [TextField]. + /// + /// Android: Corresponds to Android's "IME_ACTION_NONE". The keyboard setup + /// is decided by the OS. The keyboard will likely show a return key. + /// + /// iOS: iOS does not have a keyboard return type of "none." It is + /// inappropriate to choose this [TextInputAction] when running on iOS. + none, + + /// Logical meaning: Let the OS decide which action is most appropriate. + /// + /// Android: Corresponds to Android's "IME_ACTION_UNSPECIFIED". The OS chooses + /// which keyboard action to display. The decision will likely be a done + /// button or a return key. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyDefault". The title displayed in + /// the action button is "return". + unspecified, + + /// Logical meaning: The user is done providing input to a group of inputs + /// (like a form). Some kind of finalization behavior should now take place. + /// + /// Android: Corresponds to Android's "IME_ACTION_DONE". The OS displays a + /// button that represents completion, e.g., a checkmark button. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyDone". The title displayed in the + /// action button is "Done". done, - /// The action to take when the enter button is pressed in a multi-line - /// text field (which is typically to do nothing). + /// Logical meaning: The user has entered some text that represents a + /// destination, e.g., a restaurant name. The "go" button is intended to take + /// the user to a part of the app that corresponds to this destination. + /// + /// Android: Corresponds to Android's "IME_ACTION_GO". The OS displays a + /// button that represents taking "the user to the target of the text they + /// typed", e.g., a right-facing arrow button. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyGo". The title displayed in the + /// action button is "Go". + go, + + /// Logical meaning: Execute a search query. + /// + /// Android: Corresponds to Android's "IME_ACTION_SEARCH". The OS displays a + /// button that represents a search, e.g., a magnifying glass button. + /// + /// iOS: Corresponds to iOS's "UIReturnKeySearch". The title displayed in the + /// action button is "Search". + search, + + /// Logical meaning: Sends something that the user has composed, e.g., an + /// email or a text message. + /// + /// Android: Corresponds to Android's "IME_ACTION_SEND". The OS displays a + /// button that represents sending something, e.g., a paper plane button. + /// + /// iOS: Corresponds to iOS's "UIReturnKeySend". The title displayed in the + /// action button is "Send". + send, + + /// Logical meaning: The user is done with the current input source and wants + /// to move to the next one. + /// + /// Android: Corresponds to Android's "IME_ACTION_NEXT". The OS displays a + /// button that represents moving forward, e.g., a right-facing arrow button. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyNext". The title displayed in the + /// action button is "Next". + next, + + /// Logical meaning: The user wishes to return to the previous input source + /// in the group, e.g., a form with multiple [TextField]s. + /// + /// Android: Corresponds to Android's "IME_ACTION_PREVIOUS". The OS displays a + /// button that represents moving backward, e.g., a left-facing arrow button. + /// + /// iOS: iOS does not have a keyboard return type of "previous." It is + /// inappropriate to choose this [TextInputAction] when running on iOS. + previous, + + /// Logical meaning: In iOS apps, it is common for a "Back" button and + /// "Continue" button to appear at the top of the screen. However, when the + /// keyboard is open, these buttons are often hidden off-screen. Therefore, + /// the purpose of the "Continue" return key on iOS is to make the "Continue" + /// button available when the user is entering text. + /// + /// Historical context aside, [TextInputAction.continueAction] can be used any + /// time that the term "Continue" seems most appropriate for the given action. + /// + /// Android: Android does not have an IME input type of "continue." It is + /// inappropriate to choose this [TextInputAction] when running on Android. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyContinue". The title displayed in the + /// action button is "Continue". This action is only available on iOS 9.0+. + /// + /// The reason that this value has "Action" post-fixed to it is because + /// "continue" is a reserved word in Dart, as well as many other languages. + continueAction, + + /// Logical meaning: The user wants to join something, e.g., a wireless + /// network. + /// + /// Android: Android does not have an IME input type of "join." It is + /// inappropriate to choose this [TextInputAction] when running on Android. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyJoin". The title displayed in the + /// action button is "Join". + join, + + /// Logical meaning: The user wants routing options, e.g., driving directions. + /// + /// Android: Android does not have an IME input type of "route." It is + /// inappropriate to choose this [TextInputAction] when running on Android. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyRoute". The title displayed in the + /// action button is "Route". + route, + + /// Logical meaning: Initiate a call to emergency services. + /// + /// Android: Android does not have an IME input type of "emergencyCall." It is + /// inappropriate to choose this [TextInputAction] when running on Android. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyEmergencyCall". The title displayed + /// in the action button is "Emergency Call". + emergencyCall, + + /// Logical meaning: Insert a newline character in the focused text input, + /// e.g., [TextField]. + /// + /// Android: Corresponds to Android's "IME_ACTION_NONE". The OS displays a + /// button that represents a new line, e.g., a carriage return button. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyDefault". The title displayed in the + /// action button is "return". + /// + /// The term [TextInputAction.newline] exists in Flutter but not in Android + /// or iOS. The reason for introducing this term is so that developers can + /// achieve the common result of inserting new lines without needing to + /// understand the various IME actions on Android and return keys on iOS. + /// Thus, [TextInputAction.newline] is a convenience term that alleviates the + /// need to understand the underlying platforms to achieve this common behavior. newline, } /// Controls the visual appearance of the text input control. /// +/// Many [TextInputAction]s are common between Android and iOS. However, if an +/// [inputAction] is provided that is not supported by the current +/// platform in debug mode, an error will be thrown when the corresponding +/// text input is attached. For example, providing iOS's "emergencyCall" +/// action when running on an Android device will result in an error when in +/// debug mode. In release mode, incompatible [TextInputAction]s are replaced +/// either with "unspecified" on Android, or "default" on iOS. Appropriate +/// [inputAction]s can be chosen by checking the current platform and then +/// selecting the appropriate action. +/// /// See also: /// /// * [TextInput.attach] +/// * [TextInputAction] @immutable class TextInputConfiguration { /// Creates configuration information for a text input control. @@ -368,6 +549,28 @@ class TextInputConnection { TextInputAction _toTextInputAction(String action) { switch (action) { + case 'TextInputAction.none': + return TextInputAction.none; + case 'TextInputAction.unspecified': + return TextInputAction.unspecified; + case 'TextInputAction.go': + return TextInputAction.go; + case 'TextInputAction.search': + return TextInputAction.search; + case 'TextInputAction.send': + return TextInputAction.send; + case 'TextInputAction.next': + return TextInputAction.next; + case 'TextInputAction.previuos': + return TextInputAction.previous; + case 'TextInputAction.continue_action': + return TextInputAction.continueAction; + case 'TextInputAction.join': + return TextInputAction.join; + case 'TextInputAction.route': + return TextInputAction.route; + case 'TextInputAction.emergencyCall': + return TextInputAction.emergencyCall; case 'TextInputAction.done': return TextInputAction.done; case 'TextInputAction.newline': @@ -426,6 +629,32 @@ final _TextInputClientHandler _clientHandler = new _TextInputClientHandler(); /// An interface to the system's text input control. class TextInput { + static const List _androidSupportedInputActions = [ + TextInputAction.none, + TextInputAction.unspecified, + TextInputAction.done, + TextInputAction.send, + TextInputAction.go, + TextInputAction.search, + TextInputAction.next, + TextInputAction.previous, + TextInputAction.newline, + ]; + + static const List _iOSSupportedInputActions = [ + TextInputAction.unspecified, + TextInputAction.done, + TextInputAction.send, + TextInputAction.go, + TextInputAction.search, + TextInputAction.next, + TextInputAction.newline, + TextInputAction.continueAction, + TextInputAction.join, + TextInputAction.route, + TextInputAction.emergencyCall, + ]; + TextInput._(); /// Begin interacting with the text input control. @@ -441,6 +670,7 @@ class TextInput { static TextInputConnection attach(TextInputClient client, TextInputConfiguration configuration) { assert(client != null); assert(configuration != null); + assert(_debugEnsureInputActionWorksOnPlatform(configuration.inputAction)); final TextInputConnection connection = new TextInputConnection._(client); _clientHandler._currentConnection = connection; SystemChannels.textInput.invokeMethod( @@ -449,4 +679,22 @@ class TextInput { ); return connection; } + + static bool _debugEnsureInputActionWorksOnPlatform(TextInputAction inputAction) { + assert(() { + if (Platform.isIOS) { + assert( + _iOSSupportedInputActions.contains(inputAction), + 'The requested TextInputAction "$inputAction" is not supported on iOS.', + ); + } else if (Platform.isAndroid) { + assert( + _androidSupportedInputActions.contains(inputAction), + 'The requested TextInputAction "$inputAction" is not supported on Android.', + ); + } + return true; + }()); + return true; + } } diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 91fc384a5d..2b484578f4 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -133,6 +133,36 @@ class TextEditingController extends ValueNotifier { /// movement. This widget does not provide any focus management (e.g., /// tap-to-focus). /// +/// ## Input Actions +/// +/// A [TextInputAction] can be provided to customize the appearance of the +/// action button on the soft keyboard for Android and iOS. The default action +/// is [TextInputAction.done]. +/// +/// Many [TextInputAction]s are common between Android and iOS. However, if an +/// [inputAction] is provided that is not supported by the current +/// platform in debug mode, an error will be thrown when the corresponding +/// EditableText receives focus. For example, providing iOS's "emergencyCall" +/// action when running on an Android device will result in an error when in +/// debug mode. In release mode, incompatible [TextInputAction]s are replaced +/// either with "unspecified" on Android, or "default" on iOS. Appropriate +/// [inputAction]s can be chosen by checking the current platform and then +/// selecting the appropriate action. +/// +/// ## Lifecycle +/// +/// Upon completion of editing, like pressing the "done" button on the keyboard, +/// two actions take place: +/// +/// 1st: Editing is finalized. The default behavior of this step includes +/// an invocation of [onChanged]. That default behavior can be overridden. +/// See [onEditingComplete] for details. +/// +/// 2nd: [onSubmitted] is invoked with the user's input value. +/// +/// [onSubmitted] can be used to manually move focus to another input widget +/// when a user finishes with the currently focused input widget. +/// /// Rather than using this widget directly, consider using [TextField], which /// is a full-featured, material-design text input field with placeholder text, /// labels, and [Form] integration. @@ -171,7 +201,9 @@ class EditableText extends StatefulWidget { this.selectionColor, this.selectionControls, TextInputType keyboardType, + this.textInputAction = TextInputAction.done, this.onChanged, + this.onEditingComplete, this.onSubmitted, this.onSelectionChanged, List inputFormatters, @@ -280,9 +312,30 @@ class EditableText extends StatefulWidget { /// The type of keyboard to use for editing the text. final TextInputType keyboardType; + /// The type of action button to use with the soft keyboard. + final TextInputAction textInputAction; + /// Called when the text being edited changes. final ValueChanged onChanged; + /// Called when the user submits editable content (e.g., user presses the "done" + /// button on the keyboard). + /// + /// The default implementation of [onEditingComplete] executes 2 different + /// behaviors based on the situation: + /// + /// - When a completion action is pressed, such as "done", "go", "send", or + /// "search", the user's content is submitted to the [controller] and then + /// focus is given up. + /// + /// - When a non-completion action is pressed, such as "next" or "previous", + /// the user's content is submitted to the [controller], but focus is not + /// given up because developers may want to immediately move focus to + /// another input widget within [onSubmitted]. + /// + /// Providing [onEditingComplete] prevents the aforementioned default behavior. + final VoidCallback onEditingComplete; + /// Called when the user indicates that they are done editing the text in the field. final ValueChanged onSubmitted; @@ -405,14 +458,41 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override void performAction(TextInputAction action) { switch (action) { + case TextInputAction.newline: + // Do nothing for a "newline" action: the newline is already inserted. + break; case TextInputAction.done: - widget.controller.clearComposing(); - widget.focusNode.unfocus(); + case TextInputAction.go: + case TextInputAction.send: + case TextInputAction.search: + // 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. + widget.controller.clearComposing(); + widget.focusNode.unfocus(); + } + + // Invoke optional callback with the user's submitted content. if (widget.onSubmitted != null) widget.onSubmitted(_value.text); break; - case TextInputAction.newline: - // Do nothing for a "newline" action: the newline is already inserted. + default: + if (widget.onEditingComplete != null) { + widget.onEditingComplete(); + } else { + // Default behavior if the developer did not provide an + // onEditingComplete callback: Finalize editing, but don't give up + // focus because this keyboard action does not imply the user is done + // inputting information. + widget.controller.clearComposing(); + } + + // Invoke optional callback with the user's submitted content. + if (widget.onSubmitted != null) + widget.onSubmitted(_value.text); break; } } @@ -467,7 +547,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien autocorrect: widget.autocorrect, inputAction: widget.keyboardType == TextInputType.multiline ? TextInputAction.newline - : TextInputAction.done + : widget.textInputAction, ) )..setEditingState(localValue); } diff --git a/packages/flutter/test/material/text_form_field_test.dart b/packages/flutter/test/material/text_form_field_test.dart index 263c560369..43208bdfec 100644 --- a/packages/flutter/test/material/text_form_field_test.dart +++ b/packages/flutter/test/material/text_form_field_test.dart @@ -45,7 +45,7 @@ void main() { ); await tester.showKeyboard(find.byType(TextField)); - tester.testTextInput.receiveAction(TextInputAction.done); + await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pump(); expect(_called, true); }); diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 9a17829544..2373041f54 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; @@ -22,15 +24,53 @@ void main() { debugResetSemanticsIdCounter(); }); + // Tests that the desired keyboard action button is requested. + // + // More technically, when an EditableText is given a particular [action], Flutter + // requests [serializedActionName] when attaching to the platform's input + // system. + Future _desiredKeyboardActionIsRequested({ + WidgetTester tester, + TextInputAction action, + String serializedActionName, + }) async { + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new FocusScope( + node: focusScopeNode, + autofocus: true, + child: new EditableText( + controller: controller, + focusNode: focusNode, + textInputAction: action, + style: textStyle, + cursorColor: cursorColor, + ), + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.showKeyboard(find.byType(EditableText)); + controller.text = 'test'; + await tester.idle(); + expect(tester.testTextInput.editingState['text'], equals('test')); + expect(tester.testTextInput.setClientArgs['inputAction'], equals(serializedActionName)); + } + testWidgets('has expected defaults', (WidgetTester tester) async { - await tester.pumpWidget(new Directionality( + await tester.pumpWidget( + new Directionality( textDirection: TextDirection.ltr, child: new EditableText( controller: controller, focusNode: focusNode, style: textStyle, cursorColor: cursorColor, - ))); + ), + ), + ); final EditableText editableText = tester.firstWidget(find.byType(EditableText)); @@ -41,17 +81,21 @@ void main() { testWidgets('text keyboard is requested when maxLines is default', (WidgetTester tester) async { - await tester.pumpWidget(new Directionality( + await tester.pumpWidget( + new Directionality( textDirection: TextDirection.ltr, child: new FocusScope( - node: focusScopeNode, - autofocus: true, - child: new EditableText( - controller: controller, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, - )))); + node: focusScopeNode, + autofocus: true, + child: new EditableText( + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), + ), + ), + ); await tester.tap(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText)); controller.text = 'test'; @@ -65,20 +109,141 @@ void main() { equals('TextInputAction.done')); }); + testWidgets('Keyboard is configured for "unspecified" action when explicitly requested', + (WidgetTester tester) async { + await _desiredKeyboardActionIsRequested( + tester: tester, + action: TextInputAction.unspecified, + serializedActionName: 'TextInputAction.unspecified', + ); + }); + + testWidgets('Keyboard is configured for "none" action when explicitly requested', + (WidgetTester tester) async { + await _desiredKeyboardActionIsRequested( + tester: tester, + action: TextInputAction.none, + serializedActionName: 'TextInputAction.none', + ); + }); + + testWidgets('Keyboard is configured for "done" action when explicitly requested', + (WidgetTester tester) async { + await _desiredKeyboardActionIsRequested( + tester: tester, + action: TextInputAction.done, + serializedActionName: 'TextInputAction.done', + ); + }); + + testWidgets('Keyboard is configured for "send" action when explicitly requested', + (WidgetTester tester) async { + await _desiredKeyboardActionIsRequested( + tester: tester, + action: TextInputAction.send, + serializedActionName: 'TextInputAction.send', + ); + }); + + testWidgets('Keyboard is configured for "go" action when explicitly requested', + (WidgetTester tester) async { + await _desiredKeyboardActionIsRequested( + tester: tester, + action: TextInputAction.go, + serializedActionName: 'TextInputAction.go', + ); + }); + + testWidgets('Keyboard is configured for "search" action when explicitly requested', + (WidgetTester tester) async { + await _desiredKeyboardActionIsRequested( + tester: tester, + action: TextInputAction.search, + serializedActionName: 'TextInputAction.search', + ); + }); + + testWidgets('Keyboard is configured for "send" action when explicitly requested', + (WidgetTester tester) async { + await _desiredKeyboardActionIsRequested( + tester: tester, + action: TextInputAction.send, + serializedActionName: 'TextInputAction.send', + ); + }); + + testWidgets('Keyboard is configured for "next" action when explicitly requested', + (WidgetTester tester) async { + await _desiredKeyboardActionIsRequested( + tester: tester, + action: TextInputAction.next, + serializedActionName: 'TextInputAction.next', + ); + }); + + testWidgets('Keyboard is configured for "previous" action when explicitly requested', + (WidgetTester tester) async { + await _desiredKeyboardActionIsRequested( + tester: tester, + action: TextInputAction.previous, + serializedActionName: 'TextInputAction.previous', + ); + }); + + testWidgets('Keyboard is configured for "continue" action when explicitly requested', + (WidgetTester tester) async { + await _desiredKeyboardActionIsRequested( + tester: tester, + action: TextInputAction.continueAction, + serializedActionName: 'TextInputAction.continueAction', + ); + }); + + testWidgets('Keyboard is configured for "join" action when explicitly requested', + (WidgetTester tester) async { + await _desiredKeyboardActionIsRequested( + tester: tester, + action: TextInputAction.join, + serializedActionName: 'TextInputAction.join', + ); + }); + + testWidgets('Keyboard is configured for "route" action when explicitly requested', + (WidgetTester tester) async { + await _desiredKeyboardActionIsRequested( + tester: tester, + action: TextInputAction.route, + serializedActionName: 'TextInputAction.route', + ); + }); + + testWidgets('Keyboard is configured for "emergencyCall" action when explicitly requested', + (WidgetTester tester) async { + await _desiredKeyboardActionIsRequested( + tester: tester, + action: TextInputAction.emergencyCall, + serializedActionName: 'TextInputAction.emergencyCall', + ); + }); + testWidgets('multiline keyboard is requested when set explicitly', (WidgetTester tester) async { - await tester.pumpWidget(new Directionality( + await tester.pumpWidget( + new Directionality( textDirection: TextDirection.ltr, child: new FocusScope( - node: focusScopeNode, - autofocus: true, - child: new EditableText( - controller: controller, - focusNode: focusNode, - keyboardType: TextInputType.multiline, - style: textStyle, - cursorColor: cursorColor, - )))); + node: focusScopeNode, + autofocus: true, + child: new EditableText( + controller: controller, + focusNode: focusNode, + keyboardType: TextInputType.multiline, + style: textStyle, + cursorColor: cursorColor, + ), + ), + ), + ); await tester.tap(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText)); @@ -91,19 +256,23 @@ void main() { testWidgets('Correct keyboard is requested when set explicitly and maxLines > 1', (WidgetTester tester) async { - await tester.pumpWidget(new Directionality( + await tester.pumpWidget( + new Directionality( textDirection: TextDirection.ltr, child: new FocusScope( - node: focusScopeNode, - autofocus: true, - child: new EditableText( - controller: controller, - focusNode: focusNode, - keyboardType: TextInputType.phone, - maxLines: 3, - style: textStyle, - cursorColor: cursorColor, - )))); + node: focusScopeNode, + autofocus: true, + child: new EditableText( + controller: controller, + focusNode: focusNode, + keyboardType: TextInputType.phone, + maxLines: 3, + style: textStyle, + cursorColor: cursorColor, + ), + ), + ), + ); await tester.tap(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText)); @@ -116,18 +285,22 @@ void main() { testWidgets('multiline keyboard is requested when set implicitly', (WidgetTester tester) async { - await tester.pumpWidget(new Directionality( + await tester.pumpWidget( + new Directionality( textDirection: TextDirection.ltr, child: new FocusScope( - node: focusScopeNode, - autofocus: true, - child: new EditableText( - controller: controller, - focusNode: focusNode, - maxLines: 3, // Sets multiline keyboard implicitly. - style: textStyle, - cursorColor: cursorColor, - )))); + node: focusScopeNode, + autofocus: true, + child: new EditableText( + controller: controller, + focusNode: focusNode, + maxLines: 3, // Sets multiline keyboard implicitly. + style: textStyle, + cursorColor: cursorColor, + ), + ), + ), + ); await tester.tap(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText)); @@ -140,18 +313,22 @@ void main() { testWidgets('single line inputs have correct default keyboard', (WidgetTester tester) async { - await tester.pumpWidget(new Directionality( + await tester.pumpWidget( + new Directionality( textDirection: TextDirection.ltr, child: new FocusScope( - node: focusScopeNode, - autofocus: true, - child: new EditableText( - controller: controller, - focusNode: focusNode, - maxLines: 1, // Sets text keyboard implicitly. - style: textStyle, - cursorColor: cursorColor, - )))); + node: focusScopeNode, + autofocus: true, + child: new EditableText( + controller: controller, + focusNode: focusNode, + maxLines: 1, // Sets text keyboard implicitly. + style: textStyle, + cursorColor: cursorColor, + ), + ), + ), + ); await tester.tap(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText)); @@ -201,6 +378,188 @@ void main() { expect(changedValue, clipboardContent); }); + testWidgets('Loses focus by default when "done" action is pressed', (WidgetTester tester) async { + final GlobalKey editableTextKey = new GlobalKey(); + final FocusNode focusNode = new FocusNode(); + + final Widget widget = new MaterialApp( + home: new EditableText( + key: editableTextKey, + controller: new TextEditingController(), + focusNode: focusNode, + style: new Typography(platform: TargetPlatform.android).black.subhead, + cursorColor: Colors.blue, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + ), + ); + await tester.pumpWidget(widget); + + // Select EditableText to give it focus. + final Finder textFinder = find.byKey(editableTextKey); + await tester.tap(textFinder); + await tester.pump(); + + assert(focusNode.hasFocus); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + // Lost focus because "done" was pressed. + expect(focusNode.hasFocus, false); + }); + + testWidgets('Does not lose focus by default when "next" action is pressed', (WidgetTester tester) async { + final GlobalKey editableTextKey = new GlobalKey(); + final FocusNode focusNode = new FocusNode(); + + final Widget widget = new MaterialApp( + home: new EditableText( + key: editableTextKey, + controller: new TextEditingController(), + focusNode: focusNode, + style: new Typography(platform: TargetPlatform.android).black.subhead, + cursorColor: Colors.blue, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + ), + ); + await tester.pumpWidget(widget); + + // Select EditableText to give it focus. + final Finder textFinder = find.byKey(editableTextKey); + await tester.tap(textFinder); + await tester.pump(); + + assert(focusNode.hasFocus); + + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pump(); + + // Still has focus after pressing "next". + expect(focusNode.hasFocus, true); + }); + + testWidgets('Does not lose focus by default when "done" action is pressed and onEditingComplete is provided', + (WidgetTester tester) async { + final GlobalKey editableTextKey = new GlobalKey(); + final FocusNode focusNode = new FocusNode(); + + final Widget widget = new MaterialApp( + home: new EditableText( + key: editableTextKey, + controller: new TextEditingController(), + focusNode: focusNode, + style: new Typography(platform: TargetPlatform.android).black.subhead, + cursorColor: Colors.blue, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + onEditingComplete: () { + // This prevents the default focus change behavior on submission. + }, + ), + ); + await tester.pumpWidget(widget); + + // Select EditableText to give it focus. + final Finder textFinder = find.byKey(editableTextKey); + await tester.tap(textFinder); + await tester.pump(); + + assert(focusNode.hasFocus); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + // Still has focus even though "done" was pressed because onEditingComplete + // was provided and it overrides the default behavior. + expect(focusNode.hasFocus, true); + }); + + testWidgets('When "done" is pressed callbacks are invoked: onEditingComplete > onSubmitted', + (WidgetTester tester) async { + final GlobalKey editableTextKey = new GlobalKey(); + final FocusNode focusNode = new FocusNode(); + + bool onEditingCompleteCalled = false; + bool onSubmittedCalled = false; + + final Widget widget = new MaterialApp( + home: new EditableText( + key: editableTextKey, + controller: new TextEditingController(), + focusNode: focusNode, + style: new Typography(platform: TargetPlatform.android).black.subhead, + cursorColor: Colors.blue, + onEditingComplete: () { + onEditingCompleteCalled = true; + expect(onSubmittedCalled, false); + }, + onSubmitted: (String value) { + onSubmittedCalled = true; + expect(onEditingCompleteCalled, true); + }, + ), + ); + await tester.pumpWidget(widget); + + // Select EditableText to give it focus. + final Finder textFinder = find.byKey(editableTextKey); + await tester.tap(textFinder); + await tester.pump(); + + assert(focusNode.hasFocus); + + // The execution path starting with receiveAction() will trigger the + // onEditingComplete and onSubmission callbacks. + await tester.testTextInput.receiveAction(TextInputAction.done); + + // The expectations we care about are up above in the onEditingComplete + // and onSubmission callbacks. + }); + + testWidgets('When "next" is pressed callbacks are invoked: onEditingComplete > onSubmitted', + (WidgetTester tester) async { + final GlobalKey editableTextKey = new GlobalKey(); + final FocusNode focusNode = new FocusNode(); + + bool onEditingCompleteCalled = false; + bool onSubmittedCalled = false; + + final Widget widget = new MaterialApp( + home: new EditableText( + key: editableTextKey, + controller: new TextEditingController(), + focusNode: focusNode, + style: new Typography(platform: TargetPlatform.android).black.subhead, + cursorColor: Colors.blue, + onEditingComplete: () { + onEditingCompleteCalled = true; + assert(!onSubmittedCalled); + }, + onSubmitted: (String value) { + onSubmittedCalled = true; + assert(onEditingCompleteCalled); + }, + ), + ); + await tester.pumpWidget(widget); + + // Select EditableText to give it focus. + final Finder textFinder = find.byKey(editableTextKey); + await tester.tap(textFinder); + await tester.pump(); + + assert(focusNode.hasFocus); + + // The execution path starting with receiveAction() will trigger the + // onEditingComplete and onSubmission callbacks. + await tester.testTextInput.receiveAction(TextInputAction.done); + + // The expectations we care about are up above in the onEditingComplete + // and onSubmission callbacks. + }); + testWidgets('Changing controller updates EditableText', (WidgetTester tester) async { final GlobalKey editableTextKey = new GlobalKey(); final TextEditingController controller1 = new TextEditingController(text: 'Wibble'); diff --git a/packages/flutter_test/lib/src/test_text_input.dart b/packages/flutter_test/lib/src/test_text_input.dart index e8bebca103..b1075c6a16 100644 --- a/packages/flutter_test/lib/src/test_text_input.dart +++ b/packages/flutter_test/lib/src/test_text_input.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/foundation.dart'; import 'widget_tester.dart'; @@ -126,21 +127,42 @@ class TestTextInput { /// Simulates the user pressing one of the [TextInputAction] buttons. /// Does not check that the [TextInputAction] performed is an acceptable one /// based on the `inputAction` [setClientArgs]. - void receiveAction(TextInputAction action) { - // Not using the `expect` function because in the case of a FlutterDriver - // test this code does not run in a package:test test zone. - if (_client == 0) - throw new TestFailure('Tried to use TestTextInput with no keyboard attached. You must use WidgetTester.showKeyboard() first.'); - BinaryMessages.handlePlatformMessage( - SystemChannels.textInput.name, - SystemChannels.textInput.codec.encodeMethodCall( - new MethodCall( - 'TextInputClient.performAction', - [_client, action.toString()], + Future receiveAction(TextInputAction action) async { + return TestAsyncUtils.guard(() { + // Not using the `expect` function because in the case of a FlutterDriver + // test this code does not run in a package:test test zone. + if (_client == 0) { + throw new TestFailure('Tried to use TestTextInput with no keyboard attached. You must use WidgetTester.showKeyboard() first.'); + } + + final Completer completer = new Completer(); + + BinaryMessages.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + new MethodCall( + 'TextInputClient.performAction', + [_client, action.toString()], + ), ), - ), - (ByteData data) { /* response from framework is discarded */ }, - ); + (ByteData data) { + try { + // Decoding throws a PlatformException if the data represents an + // error, and that's all we care about here. + SystemChannels.textInput.codec.decodeEnvelope(data); + + // No error was found. Complete without issue. + completer.complete(); + } catch (error) { + // An exception occurred as a result of receiveAction()'ing. Report + // that error. + completer.completeError(error); + } + }, + ); + + return completer.future; + }); } /// Simulates the user hiding the onscreen keyboard. diff --git a/packages/flutter_test/test/test_text_input_test.dart b/packages/flutter_test/test/test_text_input_test.dart new file mode 100644 index 0000000000..ce70c43e0a --- /dev/null +++ b/packages/flutter_test/test/test_text_input_test.dart @@ -0,0 +1,36 @@ +// Copyright 2018 The Chromium 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/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('receiveAction() forwards exception when exception occurs during action processing', + (WidgetTester tester) async { + // Setup a widget that can receive focus so that we can open the keyboard. + final Widget widget = new MaterialApp( + home: const Material( + child: const TextField(), + ), + ); + await tester.pumpWidget(widget); + + // Keyboard must be shown for receiveAction() to function. + await tester.showKeyboard(find.byType(TextField)); + + // Register a handler for the text input channel that throws an error. This + // error should be reported within a PlatformException by TestTextInput. + SystemChannels.textInput.setMethodCallHandler((MethodCall call) { + throw new FlutterError('A fake error occurred during action processing.'); + }); + + try { + await tester.testTextInput.receiveAction(TextInputAction.done); + fail('Expected a PlatformException, but it was not thrown.'); + } catch (e) { + expect(e, isInstanceOf()); + } + }); +} \ No newline at end of file diff --git a/packages/flutter_test/test/widget_tester_test.dart b/packages/flutter_test/test/widget_tester_test.dart index 441d59d71a..f0d927df92 100644 --- a/packages/flutter_test/test/widget_tester_test.dart +++ b/packages/flutter_test/test/widget_tester_test.dart @@ -518,10 +518,10 @@ void main() { ), ); await tester.showKeyboard(find.byType(TextField)); - tester.testTextInput.receiveAction(TextInputAction.done); + await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pump(); await tester.showKeyboard(find.byType(TextField)); - tester.testTextInput.receiveAction(TextInputAction.done); + await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pump(); await tester.showKeyboard(find.byType(TextField)); await tester.showKeyboard(find.byType(TextField));