Skip to content

Commit d3bdbed

Browse files
[EditableText] preserve selection/composition range on unfocus (#86796)
1 parent 497eb13 commit d3bdbed

9 files changed

Lines changed: 110 additions & 84 deletions

File tree

‎packages/flutter/lib/src/cupertino/text_field.dart‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,11 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
884884
_controller!.dispose();
885885
_controller = null;
886886
}
887+
888+
if (widget.focusNode != oldWidget.focusNode) {
889+
(oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged);
890+
(widget.focusNode ?? _focusNode)?.addListener(_handleFocusChanged);
891+
}
887892
_effectiveFocusNode.canRequestFocus = widget.enabled ?? true;
888893
}
889894

@@ -915,6 +920,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
915920

916921
@override
917922
void dispose() {
923+
_effectiveFocusNode.removeListener(_handleFocusChanged);
918924
_focusNode?.dispose();
919925
_controller?.dispose();
920926
super.dispose();
@@ -926,6 +932,13 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
926932
_editableText.requestKeyboard();
927933
}
928934

935+
void _handleFocusChanged() {
936+
setState(() {
937+
// Rebuild the widget on focus change to show/hide the text selection
938+
// highlight.
939+
});
940+
}
941+
929942
bool _shouldShowSelectionHandles(SelectionChangedCause? cause) {
930943
// When the text field is activated by something that doesn't trigger the
931944
// selection overlay, we shouldn't show the handles either.
@@ -1202,7 +1215,8 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
12021215
maxLines: widget.maxLines,
12031216
minLines: widget.minLines,
12041217
expands: widget.expands,
1205-
selectionColor: selectionColor,
1218+
// Only show the selection highlight when the text field is focused.
1219+
selectionColor: _effectiveFocusNode.hasFocus ? selectionColor : null,
12061220
selectionControls: widget.selectionEnabled
12071221
? textSelectionControls : null,
12081222
onChanged: widget.onChanged,

‎packages/flutter/lib/src/material/text_field.dart‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
985985
_createLocalController();
986986
}
987987
_effectiveFocusNode.canRequestFocus = _isEnabled;
988+
_effectiveFocusNode.addListener(_handleFocusChanged);
988989
}
989990

990991
bool get _canRequestFocus {
@@ -1013,7 +1014,14 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
10131014
_controller!.dispose();
10141015
_controller = null;
10151016
}
1017+
1018+
if (widget.focusNode != oldWidget.focusNode) {
1019+
(oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged);
1020+
(widget.focusNode ?? _focusNode)?.addListener(_handleFocusChanged);
1021+
}
1022+
10161023
_effectiveFocusNode.canRequestFocus = _canRequestFocus;
1024+
10171025
if (_effectiveFocusNode.hasFocus && widget.readOnly != oldWidget.readOnly && _isEnabled) {
10181026
if(_effectiveController.selection.isCollapsed) {
10191027
_showSelectionHandles = !widget.readOnly;
@@ -1048,6 +1056,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
10481056

10491057
@override
10501058
void dispose() {
1059+
_effectiveFocusNode.removeListener(_handleFocusChanged);
10511060
_focusNode?.dispose();
10521061
_controller?.dispose();
10531062
super.dispose();
@@ -1083,6 +1092,13 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
10831092
return false;
10841093
}
10851094

1095+
void _handleFocusChanged() {
1096+
setState(() {
1097+
// Rebuild the widget on focus change to show/hide the text selection
1098+
// highlight.
1099+
});
1100+
}
1101+
10861102
void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) {
10871103
final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause);
10881104
if (willShowSelectionHandles != _showSelectionHandles) {
@@ -1239,7 +1255,8 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
12391255
maxLines: widget.maxLines,
12401256
minLines: widget.minLines,
12411257
expands: widget.expands,
1242-
selectionColor: selectionColor,
1258+
// Only show the selection highlight when the text field is focused.
1259+
selectionColor: focusNode.hasFocus ? selectionColor : null,
12431260
selectionControls: widget.selectionEnabled ? textSelectionControls : null,
12441261
onChanged: widget.onChanged,
12451262
onSelectionChanged: _handleSelectionChanged,

‎packages/flutter/lib/src/widgets/editable_text.dart‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2454,9 +2454,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
24542454
}
24552455
} else {
24562456
WidgetsBinding.instance!.removeObserver(this);
2457-
// Clear the selection and composition state if this widget lost focus.
2458-
_value = TextEditingValue(text: _value.text);
2459-
_currentPromptRectRange = null;
2457+
setState(() { _currentPromptRectRange = null; });
24602458
}
24612459
updateKeepAlive();
24622460
}
@@ -2754,7 +2752,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
27542752
return widget.controller.buildTextSpan(
27552753
context: context,
27562754
style: widget.style,
2757-
withComposing: !widget.readOnly,
2755+
withComposing: !widget.readOnly && _hasFocus,
27582756
);
27592757
}
27602758
}

‎packages/flutter/lib/src/widgets/focus_manager.dart‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1868,6 +1868,7 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
18681868
_primaryFocus = _markedForFocus;
18691869
_markedForFocus = null;
18701870
}
1871+
assert(_markedForFocus == null);
18711872
if (previousFocus != _primaryFocus) {
18721873
assert(_focusDebug('Updating focus from $previousFocus to $_primaryFocus'));
18731874
if (previousFocus != null) {

‎packages/flutter/test/material/text_field_focus_test.dart‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ void main() {
120120
await tester.idle();
121121

122122
expect(tester.testTextInput.isVisible, isTrue);
123+
// Prevent the gesture recognizer from recognizing the next tap as a
124+
// double-tap.
125+
await tester.pump(const Duration(seconds: 1));
123126

124127
tester.testTextInput.hide();
125128
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));

‎packages/flutter/test/material/text_field_test.dart‎

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3948,37 +3948,6 @@ void main() {
39483948
feedback.dispose();
39493949
});
39503950

3951-
testWidgets('Text field drops selection when losing focus', (WidgetTester tester) async {
3952-
final Key key1 = UniqueKey();
3953-
final TextEditingController controller1 = TextEditingController();
3954-
final Key key2 = UniqueKey();
3955-
3956-
await tester.pumpWidget(
3957-
overlay(
3958-
child: Column(
3959-
children: <Widget>[
3960-
TextField(
3961-
key: key1,
3962-
controller: controller1,
3963-
),
3964-
TextField(key: key2),
3965-
],
3966-
),
3967-
),
3968-
);
3969-
3970-
await tester.tap(find.byKey(key1));
3971-
await tester.enterText(find.byKey(key1), 'abcd');
3972-
await tester.pump();
3973-
controller1.selection = const TextSelection(baseOffset: 0, extentOffset: 3);
3974-
await tester.pump();
3975-
expect(controller1.selection, isNot(equals(TextRange.empty)));
3976-
3977-
await tester.tap(find.byKey(key2));
3978-
await tester.pump();
3979-
expect(controller1.selection, equals(TextRange.empty));
3980-
});
3981-
39823951
testWidgets('Selection is consistent with text length', (WidgetTester tester) async {
39833952
final TextEditingController controller = TextEditingController();
39843953

@@ -5509,7 +5478,7 @@ void main() {
55095478
}
55105479
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
55115480

5512-
expect(c1.selection.extentOffset - c1.selection.baseOffset, 0);
5481+
expect(c1.selection.extentOffset - c1.selection.baseOffset, -5);
55135482
expect(c2.selection.extentOffset - c2.selection.baseOffset, -5);
55145483
},
55155484
skip: areKeyEventsHandledByPlatform, // [intended] only applies to platforms where we handle key events.

‎packages/flutter/test/material/text_selection_theme_test.dart‎

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,23 +54,29 @@ void main() {
5454
});
5555

5656
testWidgets('Empty textSelectionTheme will use defaults', (WidgetTester tester) async {
57-
const Color defaultCursorColor = Color(0x002196f3);
57+
const Color defaultCursorColor = Color(0xff2196f3);
5858
const Color defaultSelectionColor = Color(0x662196f3);
5959
const Color defaultSelectionHandleColor = Color(0xff2196f3);
6060

61+
EditableText.debugDeterministicCursor = true;
62+
addTearDown(() {
63+
EditableText.debugDeterministicCursor = false;
64+
});
6165
// Test TextField's cursor & selection color.
6266
await tester.pumpWidget(
6367
const MaterialApp(
6468
home: Material(
65-
child: TextField(),
69+
child: TextField(autofocus: true),
6670
),
6771
),
6872
);
73+
await tester.pump();
6974
await tester.pumpAndSettle();
75+
7076
final EditableTextState editableTextState = tester.firstState(find.byType(EditableText));
7177
final RenderEditable renderEditable = editableTextState.renderEditable;
7278
expect(renderEditable.cursorColor, defaultCursorColor);
73-
expect(Color(renderEditable.selectionColor!.value), defaultSelectionColor);
79+
expect(renderEditable.selectionColor?.value, defaultSelectionColor.value);
7480

7581
// Test the selection handle color.
7682
await tester.pumpWidget(
@@ -104,19 +110,25 @@ void main() {
104110
textSelectionTheme: textSelectionTheme,
105111
);
106112

113+
EditableText.debugDeterministicCursor = true;
114+
addTearDown(() {
115+
EditableText.debugDeterministicCursor = false;
116+
});
117+
107118
// Test TextField's cursor & selection color.
108119
await tester.pumpWidget(
109120
MaterialApp(
110121
theme: theme,
111122
home: const Material(
112-
child: TextField(),
123+
child: TextField(autofocus: true),
113124
),
114125
),
115126
);
116-
await tester.pumpAndSettle();
127+
await tester.pump();
128+
117129
final EditableTextState editableTextState = tester.firstState(find.byType(EditableText));
118130
final RenderEditable renderEditable = editableTextState.renderEditable;
119-
expect(renderEditable.cursorColor, textSelectionTheme.cursorColor!.withAlpha(0));
131+
expect(renderEditable.cursorColor, textSelectionTheme.cursorColor);
120132
expect(renderEditable.selectionColor, textSelectionTheme.selectionColor);
121133

122134
// Test the selection handle color.
@@ -157,22 +169,26 @@ void main() {
157169
selectionHandleColor: Color(0x00ffeedd),
158170
);
159171

172+
EditableText.debugDeterministicCursor = true;
173+
addTearDown(() {
174+
EditableText.debugDeterministicCursor = false;
175+
});
160176
// Test TextField's cursor & selection color.
161177
await tester.pumpWidget(
162178
MaterialApp(
163179
theme: theme,
164180
home: const Material(
165181
child: TextSelectionTheme(
166182
data: widgetTextSelectionTheme,
167-
child: TextField(),
183+
child: TextField(autofocus: true),
168184
),
169185
),
170186
),
171187
);
172-
await tester.pumpAndSettle();
188+
await tester.pump();
173189
final EditableTextState editableTextState = tester.firstState(find.byType(EditableText));
174190
final RenderEditable renderEditable = editableTextState.renderEditable;
175-
expect(renderEditable.cursorColor, widgetTextSelectionTheme.cursorColor!.withAlpha(0));
191+
expect(renderEditable.cursorColor, widgetTextSelectionTheme.cursorColor);
176192
expect(renderEditable.selectionColor, widgetTextSelectionTheme.selectionColor);
177193

178194
// Test the selection handle color.

‎packages/flutter/test/widgets/editable_text_test.dart‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,45 @@ void main() {
516516
expect(tester.testTextInput.setClientArgs!['inputAction'], equals('TextInputAction.newline'));
517517
});
518518

519+
testWidgets('selection persists when unfocused', (WidgetTester tester) async {
520+
const TextEditingValue value = TextEditingValue(
521+
text: 'test test',
522+
selection: TextSelection(affinity: TextAffinity.upstream, baseOffset: 5, extentOffset: 7),
523+
);
524+
controller.value = value;
525+
await tester.pumpWidget(
526+
MediaQuery(
527+
data: const MediaQueryData(devicePixelRatio: 1.0),
528+
child: Directionality(
529+
textDirection: TextDirection.ltr,
530+
child: EditableText(
531+
controller: controller,
532+
backgroundCursorColor: Colors.grey,
533+
focusNode: focusNode,
534+
keyboardType: TextInputType.multiline,
535+
style: textStyle,
536+
cursorColor: cursorColor,
537+
),
538+
),
539+
),
540+
);
541+
542+
expect(controller.value, value);
543+
expect(focusNode.hasFocus, isFalse);
544+
545+
focusNode.requestFocus();
546+
await tester.pump();
547+
548+
expect(controller.value, value);
549+
expect(focusNode.hasFocus, isTrue);
550+
551+
focusNode.unfocus();
552+
await tester.pump();
553+
554+
expect(controller.value, value);
555+
expect(focusNode.hasFocus, isFalse);
556+
});
557+
519558
testWidgets('visiblePassword keyboard is requested when set explicitly', (WidgetTester tester) async {
520559
await tester.pumpWidget(
521560
MediaQuery(
@@ -3906,6 +3945,8 @@ void main() {
39063945
));
39073946

39083947
assert(focusNode.hasFocus);
3948+
// Autofocus has a one frame delay.
3949+
await tester.pump();
39093950

39103951
final RenderEditable renderEditable = findRenderEditable(tester);
39113952
// The actual text span is split into 3 parts with the middle part underlined.
@@ -3915,6 +3956,8 @@ void main() {
39153956
expect(textSpan.style!.decoration, TextDecoration.underline);
39163957

39173958
focusNode.unfocus();
3959+
// Drain microtasks.
3960+
await tester.idle();
39183961
await tester.pump();
39193962

39203963
expect((renderEditable.text! as TextSpan).children, isNull);

‎packages/flutter/test/widgets/selectable_text_test.dart‎

Lines changed: 2 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,42 +1388,7 @@ void main() {
13881388
expect(topLeft.dx, equals(399.0));
13891389
});
13901390

1391-
testWidgets('Selectable text drops selection when losing focus', (WidgetTester tester) async {
1392-
final Key key1 = UniqueKey();
1393-
final Key key2 = UniqueKey();
1394-
1395-
await tester.pumpWidget(
1396-
overlay(
1397-
child: Column(
1398-
children: <Widget>[
1399-
SelectableText(
1400-
'text 1',
1401-
key: key1,
1402-
),
1403-
SelectableText(
1404-
'text 2',
1405-
key: key2,
1406-
),
1407-
],
1408-
),
1409-
),
1410-
);
1411-
1412-
await tester.tap(find.byKey(key1));
1413-
await tester.pump();
1414-
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
1415-
final TextEditingController controller = editableTextWidget.controller;
1416-
controller.selection = const TextSelection(baseOffset: 0, extentOffset: 3);
1417-
await tester.pump();
1418-
expect(controller.selection, isNot(equals(TextRange.empty)));
1419-
1420-
await tester.tap(find.byKey(key2));
1421-
await tester.pump();
1422-
expect(controller.selection, equals(TextRange.empty));
1423-
});
1424-
1425-
testWidgets('Selectable text is skipped during focus traversal',
1426-
(WidgetTester tester) async {
1391+
testWidgets('Selectable text is skipped during focus traversal', (WidgetTester tester) async {
14271392
final FocusNode firstFieldFocus = FocusNode();
14281393
final FocusNode lastFieldFocus = FocusNode();
14291394

@@ -2028,7 +1993,7 @@ void main() {
20281993
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
20291994
await tester.pumpAndSettle();
20301995

2031-
expect(c1.selection.extentOffset - c1.selection.baseOffset, 0);
1996+
expect(c1.selection.extentOffset - c1.selection.baseOffset, -5);
20321997
expect(c2.selection.extentOffset - c2.selection.baseOffset, -5);
20331998
}, variant: KeySimulatorTransitModeVariant.all());
20341999

0 commit comments

Comments
 (0)