Skip to content

Commit

Permalink
Add viewId to TextInputConfiguration (flutter#145708)
Browse files Browse the repository at this point in the history
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: flutter/engine#51099
Fixes flutter#137344
  • Loading branch information
mdebbar authored and philipfranchi committed Mar 28, 2024
1 parent 029fb39 commit 7f2c567
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 12 deletions.
27 changes: 15 additions & 12 deletions packages/flutter/lib/src/services/autofill.dart
Original file line number Diff line number Diff line change
Expand Up @@ -801,18 +801,21 @@ class _AutofillScopeTextInputConfiguration extends TextInputConfiguration {
_AutofillScopeTextInputConfiguration({
required this.allConfigurations,
required TextInputConfiguration currentClientConfiguration,
}) : super(inputType: currentClientConfiguration.inputType,
obscureText: currentClientConfiguration.obscureText,
autocorrect: currentClientConfiguration.autocorrect,
smartDashesType: currentClientConfiguration.smartDashesType,
smartQuotesType: currentClientConfiguration.smartQuotesType,
enableSuggestions: currentClientConfiguration.enableSuggestions,
inputAction: currentClientConfiguration.inputAction,
textCapitalization: currentClientConfiguration.textCapitalization,
keyboardAppearance: currentClientConfiguration.keyboardAppearance,
actionLabel: currentClientConfiguration.actionLabel,
autofillConfiguration: currentClientConfiguration.autofillConfiguration,
);
}) : super(
viewId: currentClientConfiguration.viewId,
inputType: currentClientConfiguration.inputType,
obscureText: currentClientConfiguration.obscureText,
autocorrect: currentClientConfiguration.autocorrect,
smartDashesType: currentClientConfiguration.smartDashesType,
smartQuotesType: currentClientConfiguration.smartQuotesType,
enableSuggestions: currentClientConfiguration.enableSuggestions,
inputAction: currentClientConfiguration.inputAction,
textCapitalization: currentClientConfiguration.textCapitalization,
keyboardAppearance: currentClientConfiguration.keyboardAppearance,
actionLabel: currentClientConfiguration.actionLabel,
autofillConfiguration:
currentClientConfiguration.autofillConfiguration,
);

final Iterable<TextInputConfiguration> allConfigurations;

Expand Down
13 changes: 13 additions & 0 deletions packages/flutter/lib/src/services/text_input.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'dart:ui' show
FlutterView,
FontWeight,
Offset,
Rect,
Expand Down Expand Up @@ -464,6 +465,7 @@ class TextInputConfiguration {
/// All arguments have default values, except [actionLabel]. Only
/// [actionLabel] may be null.
const TextInputConfiguration({
this.viewId,
this.inputType = TextInputType.text,
this.readOnly = false,
this.obscureText = false,
Expand All @@ -483,6 +485,14 @@ class TextInputConfiguration {
}) : smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.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.
final TextInputType inputType;

Expand Down Expand Up @@ -626,6 +636,7 @@ class TextInputConfiguration {
/// Creates a copy of this [TextInputConfiguration] with the given fields
/// replaced with new values.
TextInputConfiguration copyWith({
int? viewId,
TextInputType? inputType,
bool? readOnly,
bool? obscureText,
Expand All @@ -644,6 +655,7 @@ class TextInputConfiguration {
bool? enableDeltaModel,
}) {
return TextInputConfiguration(
viewId: viewId ?? this.viewId,
inputType: inputType ?? this.inputType,
readOnly: readOnly ?? this.readOnly,
obscureText: obscureText ?? this.obscureText,
Expand Down Expand Up @@ -691,6 +703,7 @@ class TextInputConfiguration {
Map<String, dynamic> toJson() {
final Map<String, dynamic>? autofill = autofillConfiguration.toJson();
return <String, dynamic>{
'viewId': viewId,
'inputType': inputType.toJson(),
'readOnly': readOnly,
'obscureText': obscureText,
Expand Down
12 changes: 12 additions & 0 deletions packages/flutter/lib/src/widgets/editable_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
return;
}
Expand Down Expand Up @@ -4727,6 +4735,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override
String get autofillId => 'EditableText-$hashCode';

int? _viewId;

@override
TextInputConfiguration get textInputConfiguration {
final List<String>? autofillHints = widget.autofillHints?.toList(growable: false);
Expand All @@ -4738,7 +4748,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
)
: AutofillConfiguration.disabled;

_viewId = View.of(context).viewId;
return TextInputConfiguration(
viewId: _viewId,
inputType: widget.keyboardType,
readOnly: widget.readOnly,
obscureText: widget.obscureText,
Expand Down
117 changes: 117 additions & 0 deletions packages/flutter/test/widgets/editable_text_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,64 @@ void main() {
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 {
const TextEditingValue value = TextEditingValue(
text: 'test test',
Expand Down Expand Up @@ -3289,6 +3347,53 @@ void main() {
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 {
late String changedValue;
final Widget widget = MaterialApp(
Expand Down Expand Up @@ -17510,3 +17615,15 @@ class _TestScrollController extends ScrollController {
}

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;
}

0 comments on commit 7f2c567

Please sign in to comment.