Unverified Commit 42988d1b authored by Mouad Debbar's avatar Mouad Debbar Committed by GitHub

Add `viewId` to `TextInputConfiguration` (#145708)

In order for text fields to work correctly in multi-view on the web, we need to have the `viewId` information sent to the engine (`TextInput.setClient`). And while the text field is active, if it somehow moves to a new `View`, we need to inform the engine about such change (`TextInput.updateConfig`).

Engine PR: https://github.com/flutter/engine/pull/51099
Fixes https://github.com/flutter/flutter/issues/137344
parent ee5e6fac
...@@ -801,7 +801,9 @@ class _AutofillScopeTextInputConfiguration extends TextInputConfiguration { ...@@ -801,7 +801,9 @@ class _AutofillScopeTextInputConfiguration extends TextInputConfiguration {
_AutofillScopeTextInputConfiguration({ _AutofillScopeTextInputConfiguration({
required this.allConfigurations, required this.allConfigurations,
required TextInputConfiguration currentClientConfiguration, required TextInputConfiguration currentClientConfiguration,
}) : super(inputType: currentClientConfiguration.inputType, }) : super(
viewId: currentClientConfiguration.viewId,
inputType: currentClientConfiguration.inputType,
obscureText: currentClientConfiguration.obscureText, obscureText: currentClientConfiguration.obscureText,
autocorrect: currentClientConfiguration.autocorrect, autocorrect: currentClientConfiguration.autocorrect,
smartDashesType: currentClientConfiguration.smartDashesType, smartDashesType: currentClientConfiguration.smartDashesType,
...@@ -811,7 +813,8 @@ class _AutofillScopeTextInputConfiguration extends TextInputConfiguration { ...@@ -811,7 +813,8 @@ class _AutofillScopeTextInputConfiguration extends TextInputConfiguration {
textCapitalization: currentClientConfiguration.textCapitalization, textCapitalization: currentClientConfiguration.textCapitalization,
keyboardAppearance: currentClientConfiguration.keyboardAppearance, keyboardAppearance: currentClientConfiguration.keyboardAppearance,
actionLabel: currentClientConfiguration.actionLabel, actionLabel: currentClientConfiguration.actionLabel,
autofillConfiguration: currentClientConfiguration.autofillConfiguration, autofillConfiguration:
currentClientConfiguration.autofillConfiguration,
); );
final Iterable<TextInputConfiguration> allConfigurations; final Iterable<TextInputConfiguration> allConfigurations;
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'dart:ui' show import 'dart:ui' show
FlutterView,
FontWeight, FontWeight,
Offset, Offset,
Rect, Rect,
...@@ -464,6 +465,7 @@ class TextInputConfiguration { ...@@ -464,6 +465,7 @@ class TextInputConfiguration {
/// All arguments have default values, except [actionLabel]. Only /// All arguments have default values, except [actionLabel]. Only
/// [actionLabel] may be null. /// [actionLabel] may be null.
const TextInputConfiguration({ const TextInputConfiguration({
this.viewId,
this.inputType = TextInputType.text, this.inputType = TextInputType.text,
this.readOnly = false, this.readOnly = false,
this.obscureText = false, this.obscureText = false,
...@@ -483,6 +485,14 @@ class TextInputConfiguration { ...@@ -483,6 +485,14 @@ class TextInputConfiguration {
}) : smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), }) : smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled),
smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled); smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled);
/// The ID of the view that the text input belongs to.
///
/// See also:
///
/// * [FlutterView], which is the view that the ID points to.
/// * [View], which is a widget that wraps a [FlutterView].
final int? viewId;
/// The type of information for which to optimize the text input control. /// The type of information for which to optimize the text input control.
final TextInputType inputType; final TextInputType inputType;
...@@ -626,6 +636,7 @@ class TextInputConfiguration { ...@@ -626,6 +636,7 @@ class TextInputConfiguration {
/// Creates a copy of this [TextInputConfiguration] with the given fields /// Creates a copy of this [TextInputConfiguration] with the given fields
/// replaced with new values. /// replaced with new values.
TextInputConfiguration copyWith({ TextInputConfiguration copyWith({
int? viewId,
TextInputType? inputType, TextInputType? inputType,
bool? readOnly, bool? readOnly,
bool? obscureText, bool? obscureText,
...@@ -644,6 +655,7 @@ class TextInputConfiguration { ...@@ -644,6 +655,7 @@ class TextInputConfiguration {
bool? enableDeltaModel, bool? enableDeltaModel,
}) { }) {
return TextInputConfiguration( return TextInputConfiguration(
viewId: viewId ?? this.viewId,
inputType: inputType ?? this.inputType, inputType: inputType ?? this.inputType,
readOnly: readOnly ?? this.readOnly, readOnly: readOnly ?? this.readOnly,
obscureText: obscureText ?? this.obscureText, obscureText: obscureText ?? this.obscureText,
...@@ -691,6 +703,7 @@ class TextInputConfiguration { ...@@ -691,6 +703,7 @@ class TextInputConfiguration {
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final Map<String, dynamic>? autofill = autofillConfiguration.toJson(); final Map<String, dynamic>? autofill = autofillConfiguration.toJson();
return <String, dynamic>{ return <String, dynamic>{
'viewId': viewId,
'inputType': inputType.toJson(), 'inputType': inputType.toJson(),
'readOnly': readOnly, 'readOnly': readOnly,
'obscureText': obscureText, 'obscureText': obscureText,
......
...@@ -2982,6 +2982,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2982,6 +2982,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
} }
} }
// Check for changes in viewId.
if (_hasInputConnection) {
final int newViewId = View.of(context).viewId;
if (newViewId != _viewId) {
_textInputConnection!.updateConfig(_effectiveAutofillClient.textInputConfiguration);
}
}
if (defaultTargetPlatform != TargetPlatform.iOS && defaultTargetPlatform != TargetPlatform.android) { if (defaultTargetPlatform != TargetPlatform.iOS && defaultTargetPlatform != TargetPlatform.android) {
return; return;
} }
...@@ -4727,6 +4735,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -4727,6 +4735,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override @override
String get autofillId => 'EditableText-$hashCode'; String get autofillId => 'EditableText-$hashCode';
int? _viewId;
@override @override
TextInputConfiguration get textInputConfiguration { TextInputConfiguration get textInputConfiguration {
final List<String>? autofillHints = widget.autofillHints?.toList(growable: false); final List<String>? autofillHints = widget.autofillHints?.toList(growable: false);
...@@ -4738,7 +4748,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -4738,7 +4748,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
) )
: AutofillConfiguration.disabled; : AutofillConfiguration.disabled;
_viewId = View.of(context).viewId;
return TextInputConfiguration( return TextInputConfiguration(
viewId: _viewId,
inputType: widget.keyboardType, inputType: widget.keyboardType,
readOnly: widget.readOnly, readOnly: widget.readOnly,
obscureText: widget.obscureText, obscureText: widget.obscureText,
......
...@@ -907,6 +907,64 @@ void main() { ...@@ -907,6 +907,64 @@ void main() {
expect(state.textInputConfiguration.enableInteractiveSelection, isFalse); expect(state.textInputConfiguration.enableInteractiveSelection, isFalse);
}); });
testWidgets('EditableText sends viewId to config', (WidgetTester tester) async {
await tester.pumpWidget(
wrapWithView: false,
View(
view: FakeFlutterView(tester.view, viewId: 77),
child: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: FocusScope(
node: focusScopeNode,
autofocus: true,
child: EditableText(
controller: controller,
backgroundCursorColor: Colors.grey,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
),
),
),
),
);
EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
expect(state.textInputConfiguration.viewId, 77);
await tester.pumpWidget(
wrapWithView: false,
View(
view: FakeFlutterView(tester.view, viewId: 88),
child: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: FocusScope(
node: focusScopeNode,
autofocus: true,
child: EditableText(
enableInteractiveSelection: false,
controller: controller,
backgroundCursorColor: Colors.grey,
focusNode: focusNode,
keyboardType: TextInputType.multiline,
style: textStyle,
cursorColor: cursorColor,
),
),
),
),
),
);
state = tester.state<EditableTextState>(find.byType(EditableText));
expect(state.textInputConfiguration.viewId, 88);
});
testWidgets('selection persists when unfocused', (WidgetTester tester) async { testWidgets('selection persists when unfocused', (WidgetTester tester) async {
const TextEditingValue value = TextEditingValue( const TextEditingValue value = TextEditingValue(
text: 'test test', text: 'test test',
...@@ -3289,6 +3347,53 @@ void main() { ...@@ -3289,6 +3347,53 @@ void main() {
expect(tester.testTextInput.setClientArgs!['obscureText'], isFalse); expect(tester.testTextInput.setClientArgs!['obscureText'], isFalse);
}); });
testWidgets('Sends viewId and updates config when it changes', (WidgetTester tester) async {
int viewId = 14;
late StateSetter setState;
final GlobalKey key = GlobalKey();
await tester.pumpWidget(
wrapWithView: false,
StatefulBuilder(
builder: (BuildContext context, StateSetter stateSetter) {
setState = stateSetter;
return View(
view: FakeFlutterView(tester.view, viewId: viewId),
child: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: EditableText(
key: key,
controller: controller,
backgroundCursorColor: Colors.grey,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
),
),
);
},
),
);
// Focus the field to establish the input connection.
focusNode.requestFocus();
await tester.pump();
expect(tester.testTextInput.setClientArgs!['viewId'], 14);
expect(tester.testTextInput.log, contains(matchesMethodCall('TextInput.setClient')));
tester.testTextInput.log.clear();
setState(() { viewId = 15; });
await tester.pump();
expect(tester.testTextInput.setClientArgs!['viewId'], 15);
expect(tester.testTextInput.log, contains(matchesMethodCall('TextInput.updateConfig')));
tester.testTextInput.log.clear();
});
testWidgets('Fires onChanged when text changes via TextSelectionOverlay', (WidgetTester tester) async { testWidgets('Fires onChanged when text changes via TextSelectionOverlay', (WidgetTester tester) async {
late String changedValue; late String changedValue;
final Widget widget = MaterialApp( final Widget widget = MaterialApp(
...@@ -17510,3 +17615,15 @@ class _TestScrollController extends ScrollController { ...@@ -17510,3 +17615,15 @@ class _TestScrollController extends ScrollController {
} }
class FakeSpellCheckService extends DefaultSpellCheckService {} class FakeSpellCheckService extends DefaultSpellCheckService {}
class FakeFlutterView extends TestFlutterView {
FakeFlutterView(TestFlutterView view, {required this.viewId})
: super(
view: view,
display: view.display,
platformDispatcher: view.platformDispatcher,
);
@override
final int viewId;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment