-
Notifications
You must be signed in to change notification settings - Fork 29.7k
Closed
Labels
P1High-priority issues at the top of the work listHigh-priority issues at the top of the work lista: text inputEntering text in a text field or keyboard related problemsEntering text in a text field or keyboard related problemsc: parityWorks on one platform but not anotherWorks on one platform but not anotherc: regressionIt was better in the past than it is nowIt was better in the past than it is nowengineflutter/engine related. See also e: labels.flutter/engine related. See also e: labels.found in release: 3.32Found to occur in 3.32Found to occur in 3.32found in release: 3.33Found to occur in 3.33Found to occur in 3.33frameworkflutter/packages/flutter repository. See also f: labels.flutter/packages/flutter repository. See also f: labels.has reproducible stepsThe issue has been confirmed reproducible and is ready to work onThe issue has been confirmed reproducible and is ready to work onplatform-iosiOS applications specificallyiOS applications specificallyr: fixedIssue is closed as already fixed in a newer versionIssue is closed as already fixed in a newer versionteam-iosOwned by iOS platform teamOwned by iOS platform teamtriaged-iosTriaged by iOS platform teamTriaged by iOS platform team
Description
Steps to reproduce
- Open a text input connection and give the IME some text content and a collapsed selection (keyboard should be visible)
- Hide (don't close) the keyboard using
SystemChannels.textInput.invokeListMethod("TextInput.hide") - Open the keyboard back up with
TextInput._instance._show() - Type a single character on the software keyboard
Expected results
The newly typed character is inserted into the previous IME content.
Actual results
The previous IME content is erased and replaced by the newly typed character.
Error happens on iOS, but doesn't happen on Android.
Code sample
Code sample
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:super_text_layout/super_text_layout.dart';
/// Demo that displays a very limited text field, constructed from
/// the ground up, and using [TextInput] for user interaction instead
/// of a [KeyboardListener] or similar.
class BasicTextInputClientDemo extends StatefulWidget {
@override
State<BasicTextInputClientDemo> createState() => _BasicTextInputClientDemoState();
}
class _BasicTextInputClientDemoState extends State<BasicTextInputClientDemo> {
final _screenFocusNode = FocusNode();
final _textInputConnectionHolder = ValueNotifier<TextInputConnection?>(null);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
print('Removing textfield focus');
_screenFocusNode.requestFocus();
},
behavior: HitTestBehavior.translucent,
child: Column(
children: [
Expanded(
child: Focus(
focusNode: _screenFocusNode,
child: Center(
child: _BareBonesTextFieldWithInputClient(
textInputConnectionHolder: _textInputConnectionHolder,
),
),
),
),
Container(
width: double.infinity,
color: Colors.grey,
child: Row(
children: [
IconButton(
onPressed: () => SystemChannels.textInput.invokeListMethod("TextInput.hide"),
icon: Icon(Icons.arrow_circle_down),
),
IconButton(
onPressed: () => _textInputConnectionHolder.value?.show(),
icon: Icon(Icons.arrow_circle_up),
),
IconButton(
onPressed: () => _textInputConnectionHolder.value?.close(),
icon: Icon(Icons.close),
),
],
),
),
],
),
);
}
}
class _BareBonesTextFieldWithInputClient extends StatefulWidget {
const _BareBonesTextFieldWithInputClient({
required this.textInputConnectionHolder,
});
final ValueNotifier<TextInputConnection?> textInputConnectionHolder;
@override
_BareBonesTextFieldWithInputClientState createState() => _BareBonesTextFieldWithInputClientState();
}
class _BareBonesTextFieldWithInputClientState extends State<_BareBonesTextFieldWithInputClient> with TextInputClient {
final _textKey = GlobalKey<ProseTextState>();
late FocusNode _focusNode;
String _currentText = 'This is a barebones textfield implemented with SuperSelectableText and TextInputClient.';
TextSelection _currentSelection = const TextSelection.collapsed(offset: -1);
TextInputConnection? _textInputConnection;
Offset? _floatingCursorStartOffset;
Offset? _floatingCursorCurrentOffset;
@override
void initState() {
super.initState();
_focusNode = FocusNode()
..unfocus()
..addListener(_onFocusChange);
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
ProseTextLayout get _textLayout => _textKey.currentState!.textLayout;
TextPosition _getTextPositionAtOffset(Offset localOffset) {
return _textLayout.getPositionAtOffset(localOffset)!;
}
Offset _getOffsetAtTextPosition(TextPosition position) {
return _textLayout.getOffsetAtPosition(position);
}
void _onTextFieldTapUp(TapUpDetails details) {
print('Tapped on text field at ${details.localPosition}');
// Calculate the position in the text where the user tapped.
//
// We show placeholder text when there is no text content. We don't want
// to place the caret in the placeholder text, so when _currentText is
// empty, explicitly set the text position to an offset of -1.
final tapTextPosition =
_currentText.isNotEmpty ? _getTextPositionAtOffset(details.localPosition) : const TextPosition(offset: -1);
setState(() {
print('Tap text position: $tapTextPosition');
_currentSelection = TextSelection.collapsed(offset: tapTextPosition.offset);
if (_textInputConnection != null) {
_textInputConnection!.setEditingState(currentTextEditingValue!);
}
});
_focusNode.requestFocus();
}
void _onPanStart(DragStartDetails details) {
if (_textInputConnection == null) {
print('WARNING: Tried to start a drag behavior with no text input connection');
return;
}
_currentSelection = TextSelection.collapsed(
offset: _getTextPositionAtOffset(details.localPosition).offset,
);
_textInputConnection!.setEditingState(currentTextEditingValue!);
}
void _onPanUpdate(DragUpdateDetails details) {
if (_textInputConnection == null) {
print('WARNING: Tried to update a drag behavior with no text input connection');
return;
}
setState(() {
_currentSelection = _currentSelection.copyWith(
extentOffset: _getTextPositionAtOffset(details.localPosition).offset,
);
});
_textInputConnection!.setEditingState(currentTextEditingValue!);
}
void _onFocusChange() {
print('Textfield focus change - has focus: ${_focusNode.hasFocus}');
if (_focusNode.hasFocus) {
// ignore: prefer_conditional_assignment
if (_textInputConnection == null) {
print('Attaching TextInputClient to TextInput');
setState(() {
_textInputConnection = TextInput.attach(
this,
TextInputConfiguration(
viewId: View.of(context).viewId,
),
);
_textInputConnection!
..show()
..setEditingState(currentTextEditingValue!);
widget.textInputConnectionHolder.value = _textInputConnection;
});
}
} else {
print('Detaching TextInputClient from TextInput');
setState(() {
_textInputConnection?.close();
_textInputConnection = null;
widget.textInputConnectionHolder.value = null;
});
}
}
@override
AutofillScope? get currentAutofillScope => null;
@override
TextEditingValue? get currentTextEditingValue => TextEditingValue(
text: _currentText,
selection: _currentSelection,
);
@override
void performAction(TextInputAction action) {
print('My TextInputClient: performAction(): $action');
// performAction() is called when the "done" button is pressed in
// various "text configurations". For example, sometimes the "done"
// button says "Call" or "Next", depending on the current text input
// configuration. We don't need to worry about this for a barebones
// implementation.
}
@override
void performSelector(String selectorName) {
// TODO: implement this method starting with Flutter 3.3.4
}
@override
void performPrivateCommand(String action, Map<String, dynamic> data) {
print('My TextInputClient: performPrivateCommand() - action: $action, data: $data');
// performPrivateCommand() provides a representation for unofficial
// input commands to be executed. This appears to be an extension point
// or an escape hatch for input functionality that an app needs to support,
// but which does not exist at the OS/platform level.
}
@override
void showAutocorrectionPromptRect(int start, int end) {
print('My TextInputClient: showAutocorrectionPromptRect() - start: $start, end: $end');
// I'm not sure why iOS wants to show an "autocorrection" rectangle
// when we already have a selection visible.
}
@override
void updateEditingValue(TextEditingValue value) {
print('My TextInputClient: updateEditingValue(): $value');
setState(() {
_currentText = value.text;
_currentSelection = value.selection;
});
}
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {
print('My TextInputClient: updateFloatingCursor(): ${point.state}, offset: ${point.offset}');
switch (point.state) {
case FloatingCursorDragState.Start:
_floatingCursorStartOffset = _getOffsetAtTextPosition(_currentSelection.extent);
break;
case FloatingCursorDragState.Update:
_floatingCursorCurrentOffset = _floatingCursorStartOffset! + point.offset!;
_currentSelection = TextSelection.collapsed(
// Note: push the offset down by a few pixels so that we look up
// the text position based on the vertical center of the line, not
// the top of the line. TODO: calculate exactly half the line height.
offset: _getTextPositionAtOffset(_floatingCursorCurrentOffset! + const Offset(0, 10)).offset,
);
_textInputConnection!.setEditingState(currentTextEditingValue!);
break;
case FloatingCursorDragState.End:
_floatingCursorStartOffset = null;
_floatingCursorCurrentOffset = null;
break;
}
}
@override
void connectionClosed() {
print('My TextInputClient: connectionClosed()');
_textInputConnection = null;
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _focusNode,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 48),
decoration: BoxDecoration(
border: Border.all(color: Colors.black),
),
child: GestureDetector(
onTapUp: _onTextFieldTapUp,
onPanStart: _focusNode.hasFocus ? _onPanStart : null,
onPanUpdate: _focusNode.hasFocus ? _onPanUpdate : null,
child: Stack(
children: [
SuperTextWithSelection.single(
key: _textKey,
richText: _currentText.isNotEmpty
? TextSpan(
text: _currentText,
style: TextStyle(
color: _currentText.isNotEmpty ? Colors.black : Colors.grey,
fontSize: 18,
height: 1.4,
),
)
: const TextSpan(
text: 'enter text',
style: TextStyle(
color: Colors.grey,
fontSize: 18,
height: 1.4,
),
),
userSelection: UserSelection(
selection: _currentSelection,
hasCaret: true,
),
),
_buildFloatingCaret(),
],
),
),
),
);
}
Widget _buildFloatingCaret() {
if (_floatingCursorCurrentOffset == null) {
return const SizedBox();
}
return Positioned(
left: _floatingCursorCurrentOffset!.dx,
top: _floatingCursorCurrentOffset!.dy,
child: Container(
width: 2,
height: 20,
color: Colors.red,
),
);
}
}Screenshots or Video
Screenshots / Video demonstration
The following video is a product capture of why this matters:
Screen.Recording.2025-07-16.at.12.51.57.PM.mov
The following video shows a minimal reproduction based on a combination of a text layout and direct communication with Flutter's TextInputConnection:
Screen.Recording.2025-07-16.at.12.36.50.PM.mov
Logs
Logs
Flutter Doctor output
Note: I've verified that this problem exists on v3.32.0, v3.32.2, and v3.32.6.
Doctor version output
Flutter 3.32.2 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 8defaa71a7 (6 weeks ago) • 2025-06-04 11:02:51 -0700
Engine • revision 1091508939 (7 weeks ago) • 2025-05-30 12:17:36 -0700
Tools • Dart 3.8.1 • DevTools 2.45.1BazinC, orestesgaolin, suragch and cheymos
Metadata
Metadata
Assignees
Labels
P1High-priority issues at the top of the work listHigh-priority issues at the top of the work lista: text inputEntering text in a text field or keyboard related problemsEntering text in a text field or keyboard related problemsc: parityWorks on one platform but not anotherWorks on one platform but not anotherc: regressionIt was better in the past than it is nowIt was better in the past than it is nowengineflutter/engine related. See also e: labels.flutter/engine related. See also e: labels.found in release: 3.32Found to occur in 3.32Found to occur in 3.32found in release: 3.33Found to occur in 3.33Found to occur in 3.33frameworkflutter/packages/flutter repository. See also f: labels.flutter/packages/flutter repository. See also f: labels.has reproducible stepsThe issue has been confirmed reproducible and is ready to work onThe issue has been confirmed reproducible and is ready to work onplatform-iosiOS applications specificallyiOS applications specificallyr: fixedIssue is closed as already fixed in a newer versionIssue is closed as already fixed in a newer versionteam-iosOwned by iOS platform teamOwned by iOS platform teamtriaged-iosTriaged by iOS platform teamTriaged by iOS platform team