From 2fdfa29e081bb368bf11b6177e4e9bb7c8ad4d34 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 5 Oct 2022 00:23:12 +0200 Subject: [PATCH] [text_input] introduce TextInputControl (#76072) Allows the creation of virtual in-app keyboards written in Flutter. Especially useful for embedded environments. --- AUTHORS | 1 + .../text_input/text_input_control.0.dart | 167 ++++++ .../text_input/text_input_control.0_test.dart | 40 ++ .../flutter/lib/src/services/text_input.dart | 514 +++++++++++++++--- .../lib/src/widgets/editable_text.dart | 8 + .../flutter/test/services/autofill_test.dart | 5 + .../test/services/delta_text_input_test.dart | 5 + .../test/services/text_input_test.dart | 255 +++++++++ 8 files changed, 907 insertions(+), 88 deletions(-) create mode 100644 examples/api/lib/services/text_input/text_input_control.0.dart create mode 100644 examples/api/test/services/text_input/text_input_control.0_test.dart diff --git a/AUTHORS b/AUTHORS index fd9c0c9ce17..ef0b8bee030 100644 --- a/AUTHORS +++ b/AUTHORS @@ -77,6 +77,7 @@ Hidenori Matsubayashi Perqin Xie Seongyun Kim Ludwik Trammer +J-P Nurmi Marian Triebe Alexis Rouillard Mirko Mucaria diff --git a/examples/api/lib/services/text_input/text_input_control.0.dart b/examples/api/lib/services/text_input/text_input_control.0.dart new file mode 100644 index 00000000000..112ba35ba8d --- /dev/null +++ b/examples/api/lib/services/text_input/text_input_control.0.dart @@ -0,0 +1,167 @@ +// 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. + +// Flutter code sample for TextInputControl + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const MyApp()); + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: MyStatefulWidget(), + ); + } +} + +class MyStatefulWidget extends StatefulWidget { + const MyStatefulWidget({super.key}); + + @override + MyStatefulWidgetState createState() => MyStatefulWidgetState(); +} + +class MyStatefulWidgetState extends State { + final TextEditingController _controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + + @override + void dispose() { + super.dispose(); + _controller.dispose(); + _focusNode.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: TextField( + autofocus: true, + controller: _controller, + focusNode: _focusNode, + decoration: InputDecoration( + suffix: IconButton( + icon: const Icon(Icons.clear), + tooltip: 'Clear and unfocus', + onPressed: () { + _controller.clear(); + _focusNode.unfocus(); + }, + ), + ), + ), + ), + bottomSheet: const MyVirtualKeyboard(), + ); + } +} + +class MyVirtualKeyboard extends StatefulWidget { + const MyVirtualKeyboard({super.key}); + + @override + MyVirtualKeyboardState createState() => MyVirtualKeyboardState(); +} + +class MyVirtualKeyboardState extends State { + final MyTextInputControl _inputControl = MyTextInputControl(); + + @override + void initState() { + super.initState(); + _inputControl.register(); + } + + @override + void dispose() { + super.dispose(); + _inputControl.unregister(); + } + + void _handleKeyPress(String key) { + _inputControl.processUserInput(key); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _inputControl.visible, + builder: (_, bool visible, __) { + return Visibility( + visible: visible, + child: FocusScope( + canRequestFocus: false, + child: TextFieldTapRegion( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final String key in ['A', 'B', 'C']) + ElevatedButton( + child: Text(key), + onPressed: () => _handleKeyPress(key), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class MyTextInputControl with TextInputControl { + TextEditingValue _editingState = TextEditingValue.empty; + final ValueNotifier _visible = ValueNotifier(false); + + /// The input control's visibility state for updating the visual presentation. + ValueListenable get visible => _visible; + + /// Register the input control. + void register() => TextInput.setInputControl(this); + + /// Restore the original platform input control. + void unregister() => TextInput.restorePlatformInputControl(); + + @override + void show() => _visible.value = true; + + @override + void hide() => _visible.value = false; + + @override + void setEditingState(TextEditingValue value) => _editingState = value; + + /// Process user input. + /// + /// Updates the internal editing state by inserting the input text, + /// and by replacing the current selection if any. + void processUserInput(String input) { + _editingState = _editingState.copyWith( + text: _insertText(input), + selection: _replaceSelection(input), + ); + + // Request the attached client to update accordingly. + TextInput.updateEditingValue(_editingState); + } + + String _insertText(String input) { + final String text = _editingState.text; + final TextSelection selection = _editingState.selection; + return text.replaceRange(selection.start, selection.end, input); + } + + TextSelection _replaceSelection(String input) { + final TextSelection selection = _editingState.selection; + return TextSelection.collapsed(offset: selection.start + input.length); + } +} diff --git a/examples/api/test/services/text_input/text_input_control.0_test.dart b/examples/api/test/services/text_input/text_input_control.0_test.dart new file mode 100644 index 00000000000..da933c8404c --- /dev/null +++ b/examples/api/test/services/text_input/text_input_control.0_test.dart @@ -0,0 +1,40 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_api_samples/services/text_input/text_input_control.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Enter text using the VKB', (WidgetTester tester) async { + await tester.pumpWidget(const example.MyApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.descendant( + of: find.byType(example.MyVirtualKeyboard), + matching: find.widgetWithText(ElevatedButton, 'A'), + )); + await tester.pumpAndSettle(); + expect(find.widgetWithText(TextField, 'A'), findsOneWidget); + + await tester.tap(find.descendant( + of: find.byType(example.MyVirtualKeyboard), + matching: find.widgetWithText(ElevatedButton, 'B'), + )); + await tester.pumpAndSettle(); + expect(find.widgetWithText(TextField, 'AB'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + await tester.tap(find.descendant( + of: find.byType(example.MyVirtualKeyboard), + matching: find.widgetWithText(ElevatedButton, 'C'), + )); + await tester.pumpAndSettle(); + expect(find.widgetWithText(TextField, 'ACB'), findsOneWidget); + }); +} diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 180ef4bf270..6bfe9d8adc9 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -1150,6 +1150,18 @@ mixin TextInputClient { /// [TextInputClient] should cleanup its connection and finalize editing. void connectionClosed(); + /// The framework calls this method to notify that the text input control has + /// been changed. + /// + /// The [TextInputClient] may switch to the new text input control by hiding + /// the old and showing the new input control. + /// + /// See also: + /// + /// * [TextInputControl.hide], a method to hide the old input control. + /// * [TextInputControl.show], a method to show the new input control. + void didChangeInputControl(TextInputControl? oldControl, TextInputControl? newControl) {} + /// Requests that the client show the editing toolbar, for example when the /// platform changes the selection through a non-flutter method such as /// scribble. @@ -1362,13 +1374,7 @@ class TextInputConnection { if (editableBoxSize != _cachedSize || transform != _cachedTransform) { _cachedSize = editableBoxSize; _cachedTransform = transform; - TextInput._instance._setEditableSizeAndTransform( - { - 'width': editableBoxSize.width, - 'height': editableBoxSize.height, - 'transform': transform.storage, - }, - ); + TextInput._instance._setEditableSizeAndTransform(editableBoxSize, transform); } } @@ -1387,14 +1393,7 @@ class TextInputConnection { } _cachedRect = rect; final Rect validRect = rect.isFinite ? rect : Offset.zero & const Size(-1, -1); - TextInput._instance._setComposingTextRect( - { - 'width': validRect.width, - 'height': validRect.height, - 'x': validRect.left, - 'y': validRect.top, - }, - ); + TextInput._instance._setComposingTextRect(validRect); } /// Sends the coordinates of caret rect. This is used on macOS for positioning @@ -1406,14 +1405,7 @@ class TextInputConnection { } _cachedCaretRect = rect; final Rect validRect = rect.isFinite ? rect : Offset.zero & const Size(-1, -1); - TextInput._instance._setCaretRect( - { - 'width': validRect.width, - 'height': validRect.height, - 'x': validRect.left, - 'y': validRect.top, - }, - ); + TextInput._instance._setCaretRect(validRect); } /// Send the bounding boxes of the current selected glyphs in the client to @@ -1423,9 +1415,7 @@ class TextInputConnection { void setSelectionRects(List selectionRects) { if (!listEquals(_cachedSelectionRects, selectionRects)) { _cachedSelectionRects = selectionRects; - TextInput._instance._setSelectionRects(selectionRects.map((SelectionRect rect) { - return [rect.bounds.left, rect.bounds.top, rect.bounds.width, rect.bounds.height, rect.position]; - }).toList()); + TextInput._instance._setSelectionRects(selectionRects); } } @@ -1444,13 +1434,11 @@ class TextInputConnection { assert(attached); TextInput._instance._setStyle( - { - 'fontFamily': fontFamily, - 'fontSize': fontSize, - 'fontWeightIndex': fontWeight?.index, - 'textAlignIndex': textAlign.index, - 'textDirectionIndex': textDirection.index, - }, + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: fontWeight, + textDirection: textDirection, + textAlign: textAlign, ); } @@ -1603,6 +1591,63 @@ class TextInput { static final TextInput _instance = TextInput._(); + static void _addInputControl(TextInputControl control) { + if (control != _PlatformTextInputControl.instance) { + _instance._inputControls.add(control); + } + } + + static void _removeInputControl(TextInputControl control) { + if (control != _PlatformTextInputControl.instance) { + _instance._inputControls.remove(control); + } + } + + /// Sets the current text input control. + /// + /// The current text input control receives text input state changes and visual + /// text input control requests, such as showing and hiding the input control, + /// from the framework. + /// + /// Setting the current text input control as `null` removes the visual text + /// input control. + /// + /// See also: + /// + /// * [TextInputControl], an interface for implementing text input controls. + /// * [TextInput.restorePlatformInputControl], a method to restore the default + /// platform text input control. + static void setInputControl(TextInputControl? newControl) { + final TextInputControl? oldControl = _instance._currentControl; + if (newControl == oldControl) { + return; + } + if (newControl != null) { + _addInputControl(newControl); + } + if (oldControl != null) { + _removeInputControl(oldControl); + } + _instance._currentControl = newControl; + final TextInputClient? client = _instance._currentConnection?._client; + client?.didChangeInputControl(oldControl, newControl); + } + + /// Restores the default platform text input control. + /// + /// See also: + /// + /// * [TextInput.setInputControl], a method to set a custom input + /// control, or to remove the visual input control. + static void restorePlatformInputControl() { + setInputControl(_PlatformTextInputControl.instance); + } + + TextInputControl? _currentControl = _PlatformTextInputControl.instance; + final Set _inputControls = { + _PlatformTextInputControl.instance, + }; + static const List _androidSupportedInputActions = [ TextInputAction.none, TextInputAction.unspecified, @@ -1661,15 +1706,9 @@ class TextInput { assert(connection._client != null); assert(configuration != null); assert(_debugEnsureInputActionWorksOnPlatform(configuration.inputAction)); - _channel.invokeMethod( - 'TextInput.setClient', - [ - connection._id, - configuration.toJson(), - ], - ); _currentConnection = connection; _currentConfiguration = configuration; + _setClient(connection._client, configuration); } static bool _debugEnsureInputActionWorksOnPlatform(TextInputAction inputAction) { @@ -1811,7 +1850,8 @@ class TextInput { switch (method) { case 'TextInputClient.updateEditingState': - _currentConnection!._client.updateEditingValue(TextEditingValue.fromJSON(args[1] as Map)); + final TextEditingValue value = TextEditingValue.fromJSON(args[1] as Map); + TextInput._instance._updateEditingValue(value, exclude: _PlatformTextInputControl.instance); break; case 'TextInputClient.updateEditingStateWithDeltas': assert(_currentConnection!._client is DeltaTextInputClient, 'You must be using a DeltaTextInputClient if TextInputConfiguration.enableDeltaModel is set to true'); @@ -1880,74 +1920,119 @@ class TextInput { scheduleMicrotask(() { _hidePending = false; if (_currentConnection == null) { - _channel.invokeMethod('TextInput.hide'); + _hide(); } }); } + void _setClient(TextInputClient client, TextInputConfiguration configuration) { + for (final TextInputControl control in _inputControls) { + control.attach(client, configuration); + } + } + void _clearClient() { - _channel.invokeMethod('TextInput.clearClient'); + final TextInputClient client = _currentConnection!._client; + for (final TextInputControl control in _inputControls) { + control.detach(client); + } _currentConnection = null; _scheduleHide(); } void _updateConfig(TextInputConfiguration configuration) { assert(configuration != null); - _channel.invokeMethod( - 'TextInput.updateConfig', - configuration.toJson(), - ); + for (final TextInputControl control in _inputControls) { + control.updateConfig(configuration); + } } void _setEditingState(TextEditingValue value) { assert(value != null); - _channel.invokeMethod( - 'TextInput.setEditingState', - value.toJSON(), - ); + for (final TextInputControl control in _inputControls) { + control.setEditingState(value); + } } void _show() { - _channel.invokeMethod('TextInput.show'); + for (final TextInputControl control in _inputControls) { + control.show(); + } + } + + void _hide() { + for (final TextInputControl control in _inputControls) { + control.hide(); + } + } + + void _setEditableSizeAndTransform(Size editableBoxSize, Matrix4 transform) { + for (final TextInputControl control in _inputControls) { + control.setEditableSizeAndTransform(editableBoxSize, transform); + } + } + + void _setComposingTextRect(Rect rect) { + for (final TextInputControl control in _inputControls) { + control.setComposingRect(rect); + } + } + + void _setCaretRect(Rect rect) { + for (final TextInputControl control in _inputControls) { + control.setCaretRect(rect); + } + } + + void _setSelectionRects(List selectionRects) { + for (final TextInputControl control in _inputControls) { + control.setSelectionRects(selectionRects); + } + } + + void _setStyle({ + required String? fontFamily, + required double? fontSize, + required FontWeight? fontWeight, + required TextDirection textDirection, + required TextAlign textAlign, + }) { + for (final TextInputControl control in _inputControls) { + control.setStyle( + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: fontWeight, + textDirection: textDirection, + textAlign: textAlign, + ); + } } void _requestAutofill() { - _channel.invokeMethod('TextInput.requestAutofill'); + for (final TextInputControl control in _inputControls) { + control.requestAutofill(); + } } - void _setEditableSizeAndTransform(Map args) { - _channel.invokeMethod( - 'TextInput.setEditableSizeAndTransform', - args, - ); + void _updateEditingValue(TextEditingValue value, {TextInputControl? exclude}) { + if (_currentConnection == null) { + return; + } + + for (final TextInputControl control in _instance._inputControls) { + if (control != exclude) { + control.setEditingState(value); + } + } + _instance._currentConnection!._client.updateEditingValue(value); } - void _setComposingTextRect(Map args) { - _channel.invokeMethod( - 'TextInput.setMarkedTextRect', - args, - ); - } - - void _setCaretRect(Map args) { - _channel.invokeMethod( - 'TextInput.setCaretRect', - args, - ); - } - - void _setSelectionRects(List> args) { - _channel.invokeMethod( - 'TextInput.setSelectionRects', - args, - ); - } - - void _setStyle(Map args) { - _channel.invokeMethod( - 'TextInput.setStyle', - args, - ); + /// Updates the editing value of the attached input client. + /// + /// This method should be called by the text input control implementation to + /// send editing value updates to the attached input client. + static void updateEditingValue(TextEditingValue value) { + _instance._updateEditingValue(value, exclude: _instance._currentControl); } /// Finishes the current autofill context, and potentially saves the user @@ -2000,10 +2085,9 @@ class TextInput { /// topmost [AutofillGroup] is getting disposed. static void finishAutofillContext({ bool shouldSave = true }) { assert(shouldSave != null); - TextInput._instance._channel.invokeMethod( - 'TextInput.finishAutofillContext', - shouldSave, - ); + for (final TextInputControl control in TextInput._instance._inputControls) { + control.finishAutofillContext(shouldSave: shouldSave); + } } /// Registers a [ScribbleClient] with [elementIdentifier] that can be focused @@ -2020,3 +2104,257 @@ class TextInput { TextInput._instance._scribbleClients.remove(elementIdentifier); } } + +/// An interface for implementing text input controls that receive text editing +/// state changes and visual input control requests. +/// +/// Editing state changes and input control requests are sent by the framework +/// when the editing state of the attached text input client changes, or it +/// requests the input control to be shown or hidden, for example. +/// +/// The input control can be installed with [TextInput.setInputControl], and the +/// default platform text input control can be restored with +/// [TextInput.restorePlatformInputControl]. +/// +/// The [TextInputControl] class must be extended. [TextInputControl] +/// implementations should call [TextInput.updateEditingValue] to send user +/// input to the attached input client. +/// +/// {@tool dartpad} +/// This example illustrates a basic [TextInputControl] implementation. +/// +/// ** See code in examples/api/lib/services/text_input/text_input_control.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [TextInput.setInputControl], a method to install a custom text input control. +/// * [TextInput.restorePlatformInputControl], a method to restore the default +/// platform text input control. +/// * [TextInput.updateEditingValue], a method to send user input to +/// the framework. +mixin TextInputControl { + /// Requests the text input control to attach to the given input client. + /// + /// This method is called when a text input client is attached. The input + /// control should update its configuration to match the client's configuration. + void attach(TextInputClient client, TextInputConfiguration configuration) {} + + /// Requests the text input control to detach from the given input client. + /// + /// This method is called when a text input client is detached. The input + /// control should release any resources allocated for the client. + void detach(TextInputClient client) {} + + /// Requests that the text input control is shown. + /// + /// This method is called when the input control should become visible. + void show() {} + + /// Requests that the text input control is hidden. + /// + /// This method is called when the input control should hide. + void hide() {} + + /// Informs the text input control about input configuration changes. + /// + /// This method is called when the configuration of the attached input client + /// has changed. + void updateConfig(TextInputConfiguration configuration) {} + + /// Informs the text input control about editing state changes. + /// + /// This method is called when the editing state of the attached input client + /// has changed. + void setEditingState(TextEditingValue value) {} + + /// Informs the text input control about client position changes. + /// + /// This method is called on when the input control should position itself in + /// relation to the attached input client. + void setEditableSizeAndTransform(Size editableBoxSize, Matrix4 transform) {} + + /// Informs the text input control about composing area changes. + /// + /// This method is called when the attached input client's composing area + /// changes. + void setComposingRect(Rect rect) {} + + /// Informs the text input control about caret area changes. + /// + /// This method is called when the attached input client's caret area + /// changes. + void setCaretRect(Rect rect) {} + + /// Informs the text input control about selection area changes. + /// + /// This method is called when the attached input client's selection area + /// changes. + void setSelectionRects(List selectionRects) {} + + /// Informs the text input control about text style changes. + /// + /// This method is called on the when the attached input client's text style + /// changes. + void setStyle({ + required String? fontFamily, + required double? fontSize, + required FontWeight? fontWeight, + required TextDirection textDirection, + required TextAlign textAlign, + }) {} + + /// Requests autofill from the text input control. + /// + /// This method is called when the autofill UI should appear. + void requestAutofill() {} + + /// Requests that the autofill context is finalized. + /// + /// See also: + /// + /// * [TextInput.finishAutofillContext] + void finishAutofillContext({bool shouldSave = true}) {} +} + +/// Provides access to the platform text input control. +class _PlatformTextInputControl with TextInputControl { + _PlatformTextInputControl._(); + + /// The shared instance of [_PlatformTextInputControl]. + static final _PlatformTextInputControl instance = _PlatformTextInputControl._(); + + MethodChannel get _channel => TextInput._instance._channel; + + Map _configurationToJson(TextInputConfiguration configuration) { + final Map json = configuration.toJson(); + if (TextInput._instance._currentControl != _PlatformTextInputControl.instance) { + json['inputType'] = TextInputType.none.toJson(); + } + return json; + } + + @override + void attach(TextInputClient client, TextInputConfiguration configuration) { + _channel.invokeMethod( + 'TextInput.setClient', + [ + TextInput._instance._currentConnection!._id, + _configurationToJson(configuration), + ], + ); + } + + @override + void detach(TextInputClient client) { + _channel.invokeMethod('TextInput.clearClient'); + } + + @override + void updateConfig(TextInputConfiguration configuration) { + _channel.invokeMethod( + 'TextInput.updateConfig', + _configurationToJson(configuration), + ); + } + + @override + void setEditingState(TextEditingValue value) { + _channel.invokeMethod( + 'TextInput.setEditingState', + value.toJSON(), + ); + } + + @override + void show() { + _channel.invokeMethod('TextInput.show'); + } + + @override + void hide() { + _channel.invokeMethod('TextInput.hide'); + } + + @override + void setEditableSizeAndTransform(Size editableBoxSize, Matrix4 transform) { + _channel.invokeMethod( + 'TextInput.setEditableSizeAndTransform', + { + 'width': editableBoxSize.width, + 'height': editableBoxSize.height, + 'transform': transform.storage, + }, + ); + } + + @override + void setComposingRect(Rect rect) { + _channel.invokeMethod( + 'TextInput.setMarkedTextRect', + { + 'width': rect.width, + 'height': rect.height, + 'x': rect.left, + 'y': rect.top, + }, + ); + } + + @override + void setCaretRect(Rect rect) { + _channel.invokeMethod( + 'TextInput.setCaretRect', + { + 'width': rect.width, + 'height': rect.height, + 'x': rect.left, + 'y': rect.top, + }, + ); + } + + @override + void setSelectionRects(List selectionRects) { + _channel.invokeMethod( + 'TextInput.setSelectionRects', + selectionRects.map((SelectionRect rect) { + return [rect.bounds.left, rect.bounds.top, rect.bounds.width, rect.bounds.height, rect.position]; + }).toList(), + ); + } + + + @override + void setStyle({ + required String? fontFamily, + required double? fontSize, + required FontWeight? fontWeight, + required TextDirection textDirection, + required TextAlign textAlign, + }) { + _channel.invokeMethod( + 'TextInput.setStyle', + { + 'fontFamily': fontFamily, + 'fontSize': fontSize, + 'fontWeightIndex': fontWeight?.index, + 'textAlignIndex': textAlign.index, + 'textDirectionIndex': textDirection.index, + }, + ); + } + + @override + void requestAutofill() { + _channel.invokeMethod('TextInput.requestAutofill'); + } + + @override + void finishAutofillContext({bool shouldSave = true}) { + _channel.invokeMethod( + 'TextInput.finishAutofillContext', + shouldSave, + ); + } +} diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index d8e0a629cb1..e8543a54454 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -2678,6 +2678,14 @@ class EditableTextState extends State with AutomaticKeepAliveClien } + @override + void didChangeInputControl(TextInputControl? oldControl, TextInputControl? newControl) { + if (_hasFocus && _hasInputConnection) { + oldControl?.hide(); + newControl?.show(); + } + } + @override void connectionClosed() { if (_hasInputConnection) { diff --git a/packages/flutter/test/services/autofill_test.dart b/packages/flutter/test/services/autofill_test.dart index 4653fa8cdec..dba33c479ff 100644 --- a/packages/flutter/test/services/autofill_test.dart +++ b/packages/flutter/test/services/autofill_test.dart @@ -139,6 +139,11 @@ class FakeAutofillClient implements TextInputClient, AutofillClient { latestMethodCall = 'showAutocorrectionPromptRect'; } + @override + void didChangeInputControl(TextInputControl? oldControl, TextInputControl? newControl) { + latestMethodCall = 'didChangeInputControl'; + } + @override void autofill(TextEditingValue newEditingValue) => updateEditingValue(newEditingValue); diff --git a/packages/flutter/test/services/delta_text_input_test.dart b/packages/flutter/test/services/delta_text_input_test.dart index 0c755140f22..4e98c5cd6b2 100644 --- a/packages/flutter/test/services/delta_text_input_test.dart +++ b/packages/flutter/test/services/delta_text_input_test.dart @@ -292,4 +292,9 @@ class FakeDeltaTextInputClient implements DeltaTextInputClient { } TextInputConfiguration get configuration => const TextInputConfiguration(enableDeltaModel: true); + + @override + void didChangeInputControl(TextInputControl? oldControl, TextInputControl? newControl) { + latestMethodCall = 'didChangeInputControl'; + } } diff --git a/packages/flutter/test/services/text_input_test.dart b/packages/flutter/test/services/text_input_test.dart index 6be2578525b..bd4d9bd67e2 100644 --- a/packages/flutter/test/services/text_input_test.dart +++ b/packages/flutter/test/services/text_input_test.dart @@ -754,6 +754,178 @@ void main() { isTrue, ); }); + + group('TextInputControl', () { + late FakeTextChannel fakeTextChannel; + + setUp(() { + fakeTextChannel = FakeTextChannel((MethodCall call) async {}); + TextInput.setChannel(fakeTextChannel); + }); + + tearDown(() { + TextInput.restorePlatformInputControl(); + TextInputConnection.debugResetId(); + TextInput.setChannel(SystemChannels.textInput); + }); + + test('gets attached and detached', () { + final FakeTextInputControl control = FakeTextInputControl(); + TextInput.setInputControl(control); + + final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty); + final TextInputConnection connection = TextInput.attach(client, const TextInputConfiguration()); + + final List expectedMethodCalls = ['attach']; + expect(control.methodCalls, expectedMethodCalls); + + connection.close(); + expectedMethodCalls.add('detach'); + expect(control.methodCalls, expectedMethodCalls); + }); + + test('receives text input state changes', () { + final FakeTextInputControl control = FakeTextInputControl(); + TextInput.setInputControl(control); + + final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty); + final TextInputConnection connection = TextInput.attach(client, const TextInputConfiguration()); + control.methodCalls.clear(); + + final List expectedMethodCalls = []; + + connection.updateConfig(const TextInputConfiguration()); + expectedMethodCalls.add('updateConfig'); + expect(control.methodCalls, expectedMethodCalls); + + connection.setEditingState(TextEditingValue.empty); + expectedMethodCalls.add('setEditingState'); + expect(control.methodCalls, expectedMethodCalls); + + connection.close(); + expectedMethodCalls.add('detach'); + expect(control.methodCalls, expectedMethodCalls); + }); + + test('does not interfere with platform text input', () { + final FakeTextInputControl control = FakeTextInputControl(); + TextInput.setInputControl(control); + + final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty); + TextInput.attach(client, const TextInputConfiguration()); + + fakeTextChannel.outgoingCalls.clear(); + + fakeTextChannel.incoming!(MethodCall('TextInputClient.updateEditingState', [1, TextEditingValue.empty.toJSON()])); + + expect(client.latestMethodCall, 'updateEditingValue'); + expect(control.methodCalls, ['attach', 'setEditingState']); + expect(fakeTextChannel.outgoingCalls, isEmpty); + }); + + test('both input controls receive requests', () async { + final FakeTextInputControl control = FakeTextInputControl(); + TextInput.setInputControl(control); + + const TextInputConfiguration textConfig = TextInputConfiguration(); + const TextInputConfiguration numberConfig = TextInputConfiguration(inputType: TextInputType.number); + const TextInputConfiguration noneConfig = TextInputConfiguration(inputType: TextInputType.none); + + final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty); + final TextInputConnection connection = TextInput.attach(client, textConfig); + + final List expectedMethodCalls = ['attach']; + expect(control.methodCalls, expectedMethodCalls); + expect(control.inputType, TextInputType.text); + fakeTextChannel.validateOutgoingMethodCalls([ + // When there's a custom text input control installed, the platform text + // input control receives TextInputType.none + MethodCall('TextInput.setClient', [1, noneConfig.toJson()]), + ]); + + connection.show(); + expectedMethodCalls.add('show'); + expect(control.methodCalls, expectedMethodCalls); + expect(fakeTextChannel.outgoingCalls.length, 2); + expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.show'); + + connection.updateConfig(numberConfig); + expectedMethodCalls.add('updateConfig'); + expect(control.methodCalls, expectedMethodCalls); + expect(control.inputType, TextInputType.number); + expect(fakeTextChannel.outgoingCalls.length, 3); + fakeTextChannel.validateOutgoingMethodCalls([ + // When there's a custom text input control installed, the platform text + // input control receives TextInputType.none + MethodCall('TextInput.setClient', [1, noneConfig.toJson()]), + const MethodCall('TextInput.show'), + MethodCall('TextInput.updateConfig', noneConfig.toJson()), + ]); + + connection.setComposingRect(Rect.zero); + expectedMethodCalls.add('setComposingRect'); + expect(control.methodCalls, expectedMethodCalls); + expect(fakeTextChannel.outgoingCalls.length, 4); + expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.setMarkedTextRect'); + + connection.setCaretRect(Rect.zero); + expectedMethodCalls.add('setCaretRect'); + expect(control.methodCalls, expectedMethodCalls); + expect(fakeTextChannel.outgoingCalls.length, 5); + expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.setCaretRect'); + + connection.setEditableSizeAndTransform(Size.zero, Matrix4.identity()); + expectedMethodCalls.add('setEditableSizeAndTransform'); + expect(control.methodCalls, expectedMethodCalls); + expect(fakeTextChannel.outgoingCalls.length, 6); + expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.setEditableSizeAndTransform'); + + connection.setSelectionRects(const [SelectionRect(position: 0, bounds: Rect.zero)]); + expectedMethodCalls.add('setSelectionRects'); + expect(control.methodCalls, expectedMethodCalls); + expect(fakeTextChannel.outgoingCalls.length, 7); + expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.setSelectionRects'); + + connection.setStyle( + fontFamily: null, + fontSize: null, + fontWeight: null, + textDirection: TextDirection.ltr, + textAlign: TextAlign.left, + ); + expectedMethodCalls.add('setStyle'); + expect(control.methodCalls, expectedMethodCalls); + expect(fakeTextChannel.outgoingCalls.length, 8); + expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.setStyle'); + + connection.close(); + expectedMethodCalls.add('detach'); + expect(control.methodCalls, expectedMethodCalls); + expect(fakeTextChannel.outgoingCalls.length, 9); + expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.clearClient'); + + expectedMethodCalls.add('hide'); + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); + await binding.runAsync(() async {}); + await expectLater(control.methodCalls, expectedMethodCalls); + expect(fakeTextChannel.outgoingCalls.length, 10); + expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.hide'); + }); + + test('notifies changes to the attached client', () async { + final FakeTextInputControl control = FakeTextInputControl(); + TextInput.setInputControl(control); + + final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty); + final TextInputConnection connection = TextInput.attach(client, const TextInputConfiguration()); + + TextInput.setInputControl(null); + expect(client.latestMethodCall, 'didChangeInputControl'); + + connection.show(); + expect(client.latestMethodCall, 'didChangeInputControl'); + }); + }); } class FakeTextInputClient with TextInputClient { @@ -805,6 +977,11 @@ class FakeTextInputClient with TextInputClient { TextInputConfiguration get configuration => const TextInputConfiguration(); + @override + void didChangeInputControl(TextInputControl? oldControl, TextInputControl? newControl) { + latestMethodCall = 'didChangeInputControl'; + } + @override void insertTextPlaceholder(Size size) { latestMethodCall = 'insertTextPlaceholder'; @@ -821,3 +998,81 @@ class FakeTextInputClient with TextInputClient { performedSelectors.add(selectorName); } } + +class FakeTextInputControl with TextInputControl { + final List methodCalls = []; + late TextInputType inputType; + + @override + void attach(TextInputClient client, TextInputConfiguration configuration) { + methodCalls.add('attach'); + inputType = configuration.inputType; + } + + @override + void detach(TextInputClient client) { + methodCalls.add('detach'); + } + + @override + void setEditingState(TextEditingValue value) { + methodCalls.add('setEditingState'); + } + + @override + void updateConfig(TextInputConfiguration configuration) { + methodCalls.add('updateConfig'); + inputType = configuration.inputType; + } + + @override + void show() { + methodCalls.add('show'); + } + + @override + void hide() { + methodCalls.add('hide'); + } + + @override + void setComposingRect(Rect rect) { + methodCalls.add('setComposingRect'); + } + + @override + void setCaretRect(Rect rect) { + methodCalls.add('setCaretRect'); + } + + @override + void setEditableSizeAndTransform(Size editableBoxSize, Matrix4 transform) { + methodCalls.add('setEditableSizeAndTransform'); + } + + @override + void setSelectionRects(List selectionRects) { + methodCalls.add('setSelectionRects'); + } + + @override + void setStyle({ + required String? fontFamily, + required double? fontSize, + required FontWeight? fontWeight, + required TextDirection textDirection, + required TextAlign textAlign, + }) { + methodCalls.add('setStyle'); + } + + @override + void finishAutofillContext({bool shouldSave = true}) { + methodCalls.add('finishAutofillContext'); + } + + @override + void requestAutofill() { + methodCalls.add('requestAutofill'); + } +}