mirror of
https://github.com/flutter/flutter
synced 2024-10-12 11:12:54 +00:00
[text_input] introduce TextInputControl (#76072)
Allows the creation of virtual in-app keyboards written in Flutter. Especially useful for embedded environments.
This commit is contained in:
parent
208056f63e
commit
2fdfa29e08
1
AUTHORS
1
AUTHORS
|
@ -77,6 +77,7 @@ Hidenori Matsubayashi <Hidenori.Matsubayashi@sony.com>
|
|||
Perqin Xie <perqinxie@gmail.com>
|
||||
Seongyun Kim <helloworld@cau.ac.kr>
|
||||
Ludwik Trammer <ludwik@gmail.com>
|
||||
J-P Nurmi <jpnurmi@gmail.com>
|
||||
Marian Triebe <m.triebe@live.de>
|
||||
Alexis Rouillard <contact@arouillard.fr>
|
||||
Mirko Mucaria <skogsfrae@gmail.com>
|
||||
|
|
167
examples/api/lib/services/text_input/text_input_control.0.dart
Normal file
167
examples/api/lib/services/text_input/text_input_control.0.dart
Normal file
|
@ -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<MyStatefulWidget> {
|
||||
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<MyVirtualKeyboard> {
|
||||
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<bool>(
|
||||
valueListenable: _inputControl.visible,
|
||||
builder: (_, bool visible, __) {
|
||||
return Visibility(
|
||||
visible: visible,
|
||||
child: FocusScope(
|
||||
canRequestFocus: false,
|
||||
child: TextFieldTapRegion(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
for (final String key in <String>['A', 'B', 'C'])
|
||||
ElevatedButton(
|
||||
child: Text(key),
|
||||
onPressed: () => _handleKeyPress(key),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyTextInputControl with TextInputControl {
|
||||
TextEditingValue _editingState = TextEditingValue.empty;
|
||||
final ValueNotifier<bool> _visible = ValueNotifier<bool>(false);
|
||||
|
||||
/// The input control's visibility state for updating the visual presentation.
|
||||
ValueListenable<bool> 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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(
|
||||
<String, dynamic>{
|
||||
'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(
|
||||
<String, dynamic>{
|
||||
'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(
|
||||
<String, dynamic>{
|
||||
'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<SelectionRect> selectionRects) {
|
||||
if (!listEquals(_cachedSelectionRects, selectionRects)) {
|
||||
_cachedSelectionRects = selectionRects;
|
||||
TextInput._instance._setSelectionRects(selectionRects.map((SelectionRect rect) {
|
||||
return <num>[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(
|
||||
<String, dynamic>{
|
||||
'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<TextInputControl> _inputControls = <TextInputControl>{
|
||||
_PlatformTextInputControl.instance,
|
||||
};
|
||||
|
||||
static const List<TextInputAction> _androidSupportedInputActions = <TextInputAction>[
|
||||
TextInputAction.none,
|
||||
TextInputAction.unspecified,
|
||||
|
@ -1661,15 +1706,9 @@ class TextInput {
|
|||
assert(connection._client != null);
|
||||
assert(configuration != null);
|
||||
assert(_debugEnsureInputActionWorksOnPlatform(configuration.inputAction));
|
||||
_channel.invokeMethod<void>(
|
||||
'TextInput.setClient',
|
||||
<Object>[
|
||||
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<String, dynamic>));
|
||||
final TextEditingValue value = TextEditingValue.fromJSON(args[1] as Map<String, dynamic>);
|
||||
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<void>('TextInput.hide');
|
||||
_hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _setClient(TextInputClient client, TextInputConfiguration configuration) {
|
||||
for (final TextInputControl control in _inputControls) {
|
||||
control.attach(client, configuration);
|
||||
}
|
||||
}
|
||||
|
||||
void _clearClient() {
|
||||
_channel.invokeMethod<void>('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<void>(
|
||||
'TextInput.updateConfig',
|
||||
configuration.toJson(),
|
||||
);
|
||||
for (final TextInputControl control in _inputControls) {
|
||||
control.updateConfig(configuration);
|
||||
}
|
||||
}
|
||||
|
||||
void _setEditingState(TextEditingValue value) {
|
||||
assert(value != null);
|
||||
_channel.invokeMethod<void>(
|
||||
'TextInput.setEditingState',
|
||||
value.toJSON(),
|
||||
);
|
||||
for (final TextInputControl control in _inputControls) {
|
||||
control.setEditingState(value);
|
||||
}
|
||||
}
|
||||
|
||||
void _show() {
|
||||
_channel.invokeMethod<void>('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<SelectionRect> 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<void>('TextInput.requestAutofill');
|
||||
for (final TextInputControl control in _inputControls) {
|
||||
control.requestAutofill();
|
||||
}
|
||||
}
|
||||
|
||||
void _setEditableSizeAndTransform(Map<String, dynamic> args) {
|
||||
_channel.invokeMethod<void>(
|
||||
'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<String, dynamic> args) {
|
||||
_channel.invokeMethod<void>(
|
||||
'TextInput.setMarkedTextRect',
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
void _setCaretRect(Map<String, dynamic> args) {
|
||||
_channel.invokeMethod<void>(
|
||||
'TextInput.setCaretRect',
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
void _setSelectionRects(List<List<num>> args) {
|
||||
_channel.invokeMethod<void>(
|
||||
'TextInput.setSelectionRects',
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
void _setStyle(Map<String, dynamic> args) {
|
||||
_channel.invokeMethod<void>(
|
||||
'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<void>(
|
||||
'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<SelectionRect> 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<String, dynamic> _configurationToJson(TextInputConfiguration configuration) {
|
||||
final Map<String, dynamic> 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<void>(
|
||||
'TextInput.setClient',
|
||||
<Object>[
|
||||
TextInput._instance._currentConnection!._id,
|
||||
_configurationToJson(configuration),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void detach(TextInputClient client) {
|
||||
_channel.invokeMethod<void>('TextInput.clearClient');
|
||||
}
|
||||
|
||||
@override
|
||||
void updateConfig(TextInputConfiguration configuration) {
|
||||
_channel.invokeMethod<void>(
|
||||
'TextInput.updateConfig',
|
||||
_configurationToJson(configuration),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void setEditingState(TextEditingValue value) {
|
||||
_channel.invokeMethod<void>(
|
||||
'TextInput.setEditingState',
|
||||
value.toJSON(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void show() {
|
||||
_channel.invokeMethod<void>('TextInput.show');
|
||||
}
|
||||
|
||||
@override
|
||||
void hide() {
|
||||
_channel.invokeMethod<void>('TextInput.hide');
|
||||
}
|
||||
|
||||
@override
|
||||
void setEditableSizeAndTransform(Size editableBoxSize, Matrix4 transform) {
|
||||
_channel.invokeMethod<void>(
|
||||
'TextInput.setEditableSizeAndTransform',
|
||||
<String, dynamic>{
|
||||
'width': editableBoxSize.width,
|
||||
'height': editableBoxSize.height,
|
||||
'transform': transform.storage,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void setComposingRect(Rect rect) {
|
||||
_channel.invokeMethod<void>(
|
||||
'TextInput.setMarkedTextRect',
|
||||
<String, dynamic>{
|
||||
'width': rect.width,
|
||||
'height': rect.height,
|
||||
'x': rect.left,
|
||||
'y': rect.top,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void setCaretRect(Rect rect) {
|
||||
_channel.invokeMethod<void>(
|
||||
'TextInput.setCaretRect',
|
||||
<String, dynamic>{
|
||||
'width': rect.width,
|
||||
'height': rect.height,
|
||||
'x': rect.left,
|
||||
'y': rect.top,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void setSelectionRects(List<SelectionRect> selectionRects) {
|
||||
_channel.invokeMethod<void>(
|
||||
'TextInput.setSelectionRects',
|
||||
selectionRects.map((SelectionRect rect) {
|
||||
return <num>[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<void>(
|
||||
'TextInput.setStyle',
|
||||
<String, dynamic>{
|
||||
'fontFamily': fontFamily,
|
||||
'fontSize': fontSize,
|
||||
'fontWeightIndex': fontWeight?.index,
|
||||
'textAlignIndex': textAlign.index,
|
||||
'textDirectionIndex': textDirection.index,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void requestAutofill() {
|
||||
_channel.invokeMethod<void>('TextInput.requestAutofill');
|
||||
}
|
||||
|
||||
@override
|
||||
void finishAutofillContext({bool shouldSave = true}) {
|
||||
_channel.invokeMethod<void>(
|
||||
'TextInput.finishAutofillContext',
|
||||
shouldSave,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2678,6 +2678,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||
}
|
||||
|
||||
|
||||
@override
|
||||
void didChangeInputControl(TextInputControl? oldControl, TextInputControl? newControl) {
|
||||
if (_hasFocus && _hasInputConnection) {
|
||||
oldControl?.hide();
|
||||
newControl?.show();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void connectionClosed() {
|
||||
if (_hasInputConnection) {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -292,4 +292,9 @@ class FakeDeltaTextInputClient implements DeltaTextInputClient {
|
|||
}
|
||||
|
||||
TextInputConfiguration get configuration => const TextInputConfiguration(enableDeltaModel: true);
|
||||
|
||||
@override
|
||||
void didChangeInputControl(TextInputControl? oldControl, TextInputControl? newControl) {
|
||||
latestMethodCall = 'didChangeInputControl';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<String> expectedMethodCalls = <String>['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<String> expectedMethodCalls = <String>[];
|
||||
|
||||
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', <dynamic>[1, TextEditingValue.empty.toJSON()]));
|
||||
|
||||
expect(client.latestMethodCall, 'updateEditingValue');
|
||||
expect(control.methodCalls, <String>['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<String> expectedMethodCalls = <String>['attach'];
|
||||
expect(control.methodCalls, expectedMethodCalls);
|
||||
expect(control.inputType, TextInputType.text);
|
||||
fakeTextChannel.validateOutgoingMethodCalls(<MethodCall>[
|
||||
// When there's a custom text input control installed, the platform text
|
||||
// input control receives TextInputType.none
|
||||
MethodCall('TextInput.setClient', <dynamic>[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(<MethodCall>[
|
||||
// When there's a custom text input control installed, the platform text
|
||||
// input control receives TextInputType.none
|
||||
MethodCall('TextInput.setClient', <dynamic>[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>[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<String> methodCalls = <String>[];
|
||||
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<SelectionRect> 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');
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue