From 4344d62ae61e0e20b4a73dba7c52c44a888e6bc9 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 27 Oct 2020 20:39:50 +0100 Subject: [PATCH] [WIP] just the old PR rebased so far... Closes: #68988 --- .../flutter/lib/src/services/text_input.dart | 122 ++++++++++- .../lib/src/widgets/editable_text.dart | 9 + .../flutter/test/services/autofill_test.dart | 5 + .../test/services/text_input_test.dart | 202 ++++++++++++++++++ 4 files changed, 335 insertions(+), 3 deletions(-) diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 1fabfbba0619a..819bb51db05d4 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -856,6 +856,17 @@ abstract class TextInputClient { /// /// [TextInputClient] should cleanup its connection and finalize editing. void connectionClosed(); + + /// Framework notified of a text input [source] that has been updated. + /// + /// [TextInputClient] should re-attach itself if it wants to show the text + /// input control from the new text input source. + /// + /// See also: + /// + /// * [TextInput.attach], a method used to re-attach the client. + /// * [TextInput.setSource], a method used to change the text input source. + void didUpdateInputSource(TextInputSource source); } /// An interface for interacting with a text input control. @@ -864,12 +875,26 @@ abstract class TextInputClient { /// over the [SystemChannels.textInput] method channel. See [SystemChannels.textInput] /// for more details about the method channel messages. /// +/// ### Connect to a different input source +/// +/// The default [TextInputConnection] can be replaced by setting a custom +/// [TextInputSource] with [TextInput.setSource]. The text input source is used +/// to create [TextInputConnection] instances whenever a [TextInputClient] is +/// attached. +/// +/// A custom implementation of the [TextInputConnection] interface can override +/// the default method channel connection, and delegate text input calls from the +/// framework (i.e. the attached [TextInputClient]), for example, to an in-app +/// virtual keyboard on platforms that don't have one provided by the system. +/// /// See also: /// /// * [TextInput.attach], a method used to establish a [TextInputConnection] /// between the system's text input and a [TextInputClient]. /// * [EditableText], a [TextInputClient] that connects to and interacts with /// the system's text input using a [TextInputConnection]. +/// * [TextInput.setSource], used to register a text input source for creating +/// creating [TextInputConnection] instances. abstract class TextInputConnection { /// Creates a connection for a [TextInputClient]. TextInputConnection(this._client) @@ -1300,11 +1325,33 @@ class TextInput { @visibleForTesting static void setChannel(MethodChannel newChannel) { assert(() { - _TextInputSource.setChannel(newChannel); + _DefaultTextInputSource.setChannel(newChannel); return true; }()); } + /// Sets the [TextInputSource] used to attach and detach text input clients. + /// The text input source is responsible for creating [TextInputConnection] + /// instances that are used to communicate with the currently attached + /// [TextInputClient]. + /// + /// The default text input source can be restored by passing + /// [TextInput.defaultSource]. + /// + /// If there is a [TextInputClient] attached at the time of calling + /// [TextInput.setSource], the current text input client is notified via + /// [TextInputClient.didUpdateInputSource]. + /// + /// See also: + /// + /// * [TextInput.defaultSource], the default text input source instance. + static void setSource(TextInputSource source) { + _instance._currentSource.cleanup(); + _instance._currentSource = source..init(); + final TextInputClient? client = _instance._currentConnection?._client; + client?.didUpdateInputSource(source); + } + static final TextInput _instance = TextInput._(); static const List _androidSupportedInputActions = [ @@ -1384,7 +1431,20 @@ class TextInput { return true; } - final _TextInputSource _currentSource = _TextInputSource(); + /// The default instance of [TextInputSource]. + /// + /// The default text input source creates [TextInputConnection] instances that + /// communicate with the platform text input plugin over the + /// [SystemChannels.textInput] method channel. See [SystemChannels.textInput] + /// for more details about the method channel messages. + /// + /// See also: + /// + /// * [TextInput.setSource], a method to set the current text input source. + static final TextInputSource defaultSource = _DefaultTextInputSource(); + + TextInputSource _currentSource = defaultSource; + TextInputConnection? _currentConnection; late TextInputConfiguration _currentConfiguration; @@ -1501,28 +1561,84 @@ class TextInput { } } -class _TextInputSource { +/// An interface for attaching and detaching [TextInputClient]. +/// +/// [TextInputSource] creates is responsible for creating [TextInputConnection] +/// instances that are used to communicate with the currently attached +/// [TextInputClient]. +/// +/// The default text input source, available via [TextInput.defaultSource], +/// creates [TextInputConnection] instances that communicate with the platform +/// text input plugin over the [SystemChannels.textInput] method channel. See +/// [SystemChannels.textInput] for more details about the text input method +/// channel messages. +/// +/// ### Custom text input sources +/// +/// A custom text input source can delegate text input calls from the framework +/// (i.e. the attached [TextInputClient]), for example, to an in-app virtual +/// keyboard on platforms that don't have one provided by the system. +/// +/// See also: +/// +/// * [TextInput.setSource], a method for setting the desired text input source. +abstract class TextInputSource { + /// TODO(jpnurmi) + void init(); + + /// TODO(jpnurmi) + void cleanup(); + + /// Attaches the current [TextInputClient] and creates a [TextInputConnection] + /// used to interact with the text input control. + /// + /// This method is called by the framework when a [client] should be attached. + /// + /// See also: + /// + /// * [TextInput.attach] + TextInputConnection attach(TextInputClient client); + + /// Detaches the current [TextInputClient] from the text input control. + /// + /// This method is called by the framework when a [client] should be detached. + /// + /// See also: + /// + /// * [TextInput.attach] + void detach(TextInputClient client); + + /// TODO(jpnurmi) + void finishAutofillContext({bool shouldSave = true}); +} + +class _DefaultTextInputSource implements TextInputSource { static MethodChannel? _channel; static void setChannel(MethodChannel newChannel) { _channel = newChannel..setMethodCallHandler(_handleTextInputInvocation); } + @override void init() { _channel ??= SystemChannels.textInput; _channel!.setMethodCallHandler(_handleTextInputInvocation); } + @override void cleanup() { _channel!.setMethodCallHandler((MethodCall methodCall) async {}); } + @override TextInputConnection attach(TextInputClient client) { return _TextInputChannelConnection(client, _channel!); } + @override void detach(TextInputClient client) {} + @override void finishAutofillContext({bool shouldSave = true}) { _channel!.invokeMethod( 'TextInput.finishAutofillContext', diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 4043b15bd6467..be4216deb9aaf 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -2053,6 +2053,15 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } + @override + void didUpdateInputSource(TextInputSource source) { + if (_hasFocus && _hasInputConnection) { + _textInputConnection!.hide(); + _closeInputConnectionIfNeeded(); + _openInputConnection(); + } + } + @override void connectionClosed() { if (_hasInputConnection) { diff --git a/packages/flutter/test/services/autofill_test.dart b/packages/flutter/test/services/autofill_test.dart index 5f34ae3f00cf8..b19f0e1924c6d 100644 --- a/packages/flutter/test/services/autofill_test.dart +++ b/packages/flutter/test/services/autofill_test.dart @@ -136,6 +136,11 @@ class FakeAutofillClient implements TextInputClient, AutofillClient { latestMethodCall = 'connectionClosed'; } + @override + void didUpdateInputSource(TextInputSource source) { + latestMethodCall = 'didUpdateInputSource'; + } + @override void showAutocorrectionPromptRect(int start, int end) { latestMethodCall = 'showAutocorrectionPromptRect'; diff --git a/packages/flutter/test/services/text_input_test.dart b/packages/flutter/test/services/text_input_test.dart index ac47fcb0e2520..83de309b0c1a5 100644 --- a/packages/flutter/test/services/text_input_test.dart +++ b/packages/flutter/test/services/text_input_test.dart @@ -5,9 +5,16 @@ import 'dart:convert' show utf8; import 'dart:convert' show jsonDecode; +import 'dart:ui' show + FontWeight, + Size, + Rect, + TextAlign, + TextDirection; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart' show TestWidgetsFlutterBinding; +import 'package:vector_math/vector_math_64.dart' show Matrix4; import '../flutter_test_alternative.dart'; void main() { @@ -436,6 +443,92 @@ void main() { isTrue, ); }); + + group('TextInputSource', () { + late FakeTextInputClient client; + + setUp(() { + client = FakeTextInputClient(const TextEditingValue(text: 'test1')); + }); + + tearDown(() { + TextInput.detach(client); + TextInput.setSource(TextInput.defaultSource); + TextInput.setChannel(SystemChannels.textInput); + TextInputConnection.debugResetId(); + }); + + test('creates a correct connection instance', () { + TextInput.setSource(FakeTextInputSource()); + final TextInputConnection connection = TextInput.attach(client, client.configuration); + expect(connection is FakeTextInputConnection, isTrue); + }); + + test('can be reset back to default', () { + TextInput.setSource(TextInput.defaultSource); + final TextInputConnection connection = TextInput.attach(client, client.configuration); + expect(connection is! FakeTextInputConnection, isTrue); + }); + + test('calls the excepted methods', () async { + final FakeTextInputSource source = FakeTextInputSource(); + TextInput.setSource(source); + final FakeTextInputConnection connection = TextInput.attach(client, client.configuration) as FakeTextInputConnection; + expect(source.methodCalls, ['init', 'attach']); + expect(connection.methodCalls, ['setClient']); + + TextInput.detach(client); + expect(source.methodCalls, ['init', 'attach', 'detach']); + expect(connection.methodCalls, ['setClient', 'clearClient']); + + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized() as TestWidgetsFlutterBinding; + await binding.runAsync(() async {}); + await expectLater(connection.methodCalls, ['setClient', 'clearClient', 'hide']); + }); + + test('detaches the old client when a new client is attached',() { + final FakeTextInputSource source = FakeTextInputSource(); + TextInput.setSource(source); + + final FakeTextInputConnection connection1 = TextInput.attach(client, client.configuration) as FakeTextInputConnection; + expect(source.methodCalls, ['init', 'attach']); + expect(connection1.methodCalls, ['setClient']); + + final FakeTextInputClient client2 = FakeTextInputClient(const TextEditingValue(text: 'test1')); + + final FakeTextInputConnection connection2 = TextInput.attach(client2, client2.configuration) as FakeTextInputConnection; + expect(source.methodCalls, ['init', 'attach', 'detach', 'attach']); + expect(connection1.methodCalls, ['setClient', 'clearClient']); + expect(connection2.methodCalls, ['setClient']); + }); + + test('cleans up previous source', () { + final FakeTextInputSource source1 = FakeTextInputSource(); + TextInput.setSource(source1); + expect(source1.methodCalls, ['init']); + + final FakeTextInputSource source2 = FakeTextInputSource(); + TextInput.setSource(source2); + expect(source2.methodCalls, ['init']); + expect(source1.methodCalls, ['init', 'cleanup']); + }); + + test('informs the attached client when the source is changed', () { + TextInput.setSource(FakeTextInputSource()); + TextInput.attach(client, client.configuration); + TextInput.setSource(FakeTextInputSource()); + expect(client.latestMethodCall, 'didUpdateInputSource'); + }); + + test('ignores text input method channel', () { + final FakeTextChannel fakeTextChannel = FakeTextChannel((MethodCall call) async {}); + TextInput.setChannel(fakeTextChannel); + TextInput.setSource(FakeTextInputSource()); + TextInput.attach(client, client.configuration); + fakeTextChannel.incoming!(const MethodCall('TextInputClient.requestExistingInputState', null)); + fakeTextChannel.validateOutgoingMethodCalls([]); + }); + }); } class FakeTextInputClient implements TextInputClient { @@ -474,6 +567,11 @@ class FakeTextInputClient implements TextInputClient { latestMethodCall = 'connectionClosed'; } + @override + void didUpdateInputSource(TextInputSource source) { + latestMethodCall = 'didUpdateInputSource'; + } + @override void showAutocorrectionPromptRect(int start, int end) { latestMethodCall = 'showAutocorrectionPromptRect'; @@ -547,3 +645,107 @@ class FakeTextChannel implements MethodChannel { } } } + +class FakeTextInputConnection extends TextInputConnection { + FakeTextInputConnection(TextInputClient client) : super(client); + + final List methodCalls = []; + + @override + void show() { + methodCalls.add('show'); + } + + @override + void hide() { + methodCalls.add('hide'); + } + + @override + void requestAutofill() { + methodCalls.add('requestAutofill'); + } + + @override + void setClient(TextInputConfiguration configuration) { + methodCalls.add('setClient'); + } + + @override + void clearClient() { + methodCalls.add('clearClient'); + } + + @override + void updateConfig(TextInputConfiguration configuration) { + methodCalls.add('updateConfig'); + } + + @override + void setEditingState(TextEditingValue value) { + methodCalls.add('setEditingState'); + } + + @override + void setEditableSizeAndTransform(Size editableBoxSize, Matrix4 transform) { + methodCalls.add('setEditableSizeAndTransform'); + } + + @override + void setComposingRect(Rect rect) { + methodCalls.add('setComposingRect'); + } + + @override + void setStyle({ + required String? fontFamily, + required double? fontSize, + required FontWeight? fontWeight, + required TextDirection textDirection, + required TextAlign textAlign, + }) { + methodCalls.add('setStyle'); + } + + @override + void close() { + methodCalls.add('close'); + } + + @override + void connectionClosedReceived() { + methodCalls.add('connectionClosedReceived'); + } +} + +class FakeTextInputSource extends TextInputSource { + final List methodCalls = []; + late FakeTextInputConnection latestConnection; + + @override + void init() { + methodCalls.add('init'); + } + + @override + void cleanup() { + methodCalls.add('cleanup'); + } + + @override + TextInputConnection attach(TextInputClient client) { + methodCalls.add('attach'); + latestConnection = FakeTextInputConnection(client); + return latestConnection; + } + + @override + void detach(TextInputClient client) { + methodCalls.add('detach'); + } + + @override + void finishAutofillContext({bool shouldSave = true}) { + methodCalls.add('finishAutofillContext'); + } +}