Skip to content

Commit 57e577a

Browse files
Clean up _updateSelectionRects (#113425)
1 parent c84897c commit 57e577a

File tree

2 files changed

+215
-91
lines changed

2 files changed

+215
-91
lines changed

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

Lines changed: 95 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,6 +1779,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
17791779
final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
17801780

17811781
TextInputConnection? _textInputConnection;
1782+
bool get _hasInputConnection => _textInputConnection?.attached ?? false;
1783+
17821784
TextSelectionOverlay? _selectionOverlay;
17831785

17841786
ScrollController? _internalScrollController;
@@ -2037,7 +2039,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
20372039
_clipboardStatus?.addListener(_onChangedClipboardStatus);
20382040
widget.controller.addListener(_didChangeTextEditingValue);
20392041
widget.focusNode.addListener(_handleFocusChanged);
2040-
_scrollController.addListener(_updateSelectionOverlayForScroll);
2042+
_scrollController.addListener(_onEditableScroll);
20412043
_cursorVisibilityNotifier.value = widget.showCursor;
20422044
_spellCheckConfiguration = _inferSpellCheckConfiguration(widget.spellCheckConfiguration);
20432045
}
@@ -2125,8 +2127,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
21252127
}
21262128

21272129
if (widget.scrollController != oldWidget.scrollController) {
2128-
(oldWidget.scrollController ?? _internalScrollController)?.removeListener(_updateSelectionOverlayForScroll);
2129-
_scrollController.addListener(_updateSelectionOverlayForScroll);
2130+
(oldWidget.scrollController ?? _internalScrollController)?.removeListener(_onEditableScroll);
2131+
_scrollController.addListener(_onEditableScroll);
21302132
}
21312133

21322134
if (!_shouldCreateInputConnection) {
@@ -2567,7 +2569,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
25672569
return RevealedOffset(rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset);
25682570
}
25692571

2570-
bool get _hasInputConnection => _textInputConnection?.attached ?? false;
25712572
/// Whether to send the autofill information to the autofill service. True by
25722573
/// default.
25732574
bool get _needsAutofill => _effectiveAutofillClient.textInputConfiguration.autofillConfiguration.enabled;
@@ -2718,8 +2719,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
27182719
}
27192720
}
27202721

2721-
void _updateSelectionOverlayForScroll() {
2722+
void _onEditableScroll() {
27222723
_selectionOverlay?.updateForScroll();
2724+
_scribbleCacheKey = null;
27232725
}
27242726

27252727
void _createSelectionOverlay() {
@@ -3115,86 +3117,73 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
31153117
// Place cursor at the end if the selection is invalid when we receive focus.
31163118
_handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), null);
31173119
}
3118-
3119-
_cachedText = '';
3120-
_cachedFirstRect = null;
3121-
_cachedSize = Size.zero;
3122-
_cachedPlaceholder = -1;
31233120
} else {
31243121
WidgetsBinding.instance.removeObserver(this);
31253122
setState(() { _currentPromptRectRange = null; });
31263123
}
31273124
updateKeepAlive();
31283125
}
31293126

3130-
String _cachedText = '';
3131-
Rect? _cachedFirstRect;
3132-
Size _cachedSize = Size.zero;
3133-
int _cachedPlaceholder = -1;
3134-
TextStyle? _cachedTextStyle;
3127+
_ScribbleCacheKey? _scribbleCacheKey;
31353128

31363129
void _updateSelectionRects({bool force = false}) {
3137-
if (!widget.scribbleEnabled) {
3130+
if (!widget.scribbleEnabled || defaultTargetPlatform != TargetPlatform.iOS) {
31383131
return;
31393132
}
3140-
if (defaultTargetPlatform != TargetPlatform.iOS) {
3133+
3134+
final ScrollDirection scrollDirection = _scrollController.position.userScrollDirection;
3135+
if (scrollDirection != ScrollDirection.idle) {
31413136
return;
31423137
}
31433138

3144-
final String text = renderEditable.text?.toPlainText(includeSemanticsLabels: false) ?? '';
3145-
final List<Rect> firstSelectionBoxes = renderEditable.getBoxesForSelection(const TextSelection(baseOffset: 0, extentOffset: 1));
3146-
final Rect? firstRect = firstSelectionBoxes.isNotEmpty ? firstSelectionBoxes.first : null;
3147-
final ScrollDirection scrollDirection = _scrollController.position.userScrollDirection;
3148-
final Size size = renderEditable.size;
3149-
final bool textChanged = text != _cachedText;
3150-
final bool textStyleChanged = _cachedTextStyle != widget.style;
3151-
final bool firstRectChanged = _cachedFirstRect != firstRect;
3152-
final bool sizeChanged = _cachedSize != size;
3153-
final bool placeholderChanged = _cachedPlaceholder != _placeholderLocation;
3154-
if (scrollDirection == ScrollDirection.idle && (force || textChanged || textStyleChanged || firstRectChanged || sizeChanged || placeholderChanged)) {
3155-
_cachedText = text;
3156-
_cachedFirstRect = firstRect;
3157-
_cachedTextStyle = widget.style;
3158-
_cachedSize = size;
3159-
_cachedPlaceholder = _placeholderLocation;
3160-
bool belowRenderEditableBottom = false;
3161-
final List<SelectionRect> rects = List<SelectionRect?>.generate(
3162-
_cachedText.characters.length,
3163-
(int i) {
3164-
if (belowRenderEditableBottom) {
3165-
return null;
3166-
}
3139+
final InlineSpan inlineSpan = renderEditable.text!;
3140+
final _ScribbleCacheKey newCacheKey = _ScribbleCacheKey(
3141+
inlineSpan: inlineSpan,
3142+
textAlign: widget.textAlign,
3143+
textDirection: _textDirection,
3144+
textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context),
3145+
textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context),
3146+
locale: widget.locale,
3147+
structStyle: widget.strutStyle,
3148+
placeholder: _placeholderLocation,
3149+
size: renderEditable.size,
3150+
);
31673151

3168-
final int offset = _cachedText.characters.getRange(0, i).string.length;
3169-
final List<Rect> boxes = renderEditable.getBoxesForSelection(TextSelection(baseOffset: offset, extentOffset: offset + _cachedText.characters.characterAt(i).string.length));
3170-
if (boxes.isEmpty) {
3171-
return null;
3172-
}
3152+
final RenderComparison comparison = force
3153+
? RenderComparison.layout
3154+
: _scribbleCacheKey?.compare(newCacheKey) ?? RenderComparison.layout;
3155+
if (comparison.index < RenderComparison.layout.index) {
3156+
return;
3157+
}
3158+
_scribbleCacheKey = newCacheKey;
3159+
3160+
final List<SelectionRect> rects = <SelectionRect>[];
3161+
int graphemeStart = 0;
3162+
// Can't use _value.text here: the controller value could change between
3163+
// frames.
3164+
final String plainText = inlineSpan.toPlainText(includeSemanticsLabels: false);
3165+
final CharacterRange characterRange = CharacterRange(plainText);
3166+
while (characterRange.moveNext()) {
3167+
final int graphemeEnd = graphemeStart + characterRange.current.length;
3168+
final List<Rect> boxes = renderEditable.getBoxesForSelection(
3169+
TextSelection(baseOffset: graphemeStart, extentOffset: graphemeEnd),
3170+
);
31733171

3174-
final SelectionRect selectionRect = SelectionRect(
3175-
bounds: boxes.first,
3176-
position: offset,
3177-
);
3178-
if (renderEditable.paintBounds.bottom < selectionRect.bounds.top) {
3179-
belowRenderEditableBottom = true;
3180-
return null;
3181-
}
3182-
return selectionRect;
3183-
},
3184-
).where((SelectionRect? selectionRect) {
3185-
if (selectionRect == null) {
3186-
return false;
3187-
}
3188-
if (renderEditable.paintBounds.right < selectionRect.bounds.left || selectionRect.bounds.right < renderEditable.paintBounds.left) {
3189-
return false;
3172+
final Rect? box = boxes.isEmpty ? null : boxes.first;
3173+
if (box != null) {
3174+
final Rect paintBounds = renderEditable.paintBounds;
3175+
// Stop early when characters are already below the bottom edge of the
3176+
// RenderEditable, regardless of its clipBehavior.
3177+
if (paintBounds.bottom <= box.top) {
3178+
break;
31903179
}
3191-
if (renderEditable.paintBounds.bottom < selectionRect.bounds.top || selectionRect.bounds.bottom < renderEditable.paintBounds.top) {
3192-
return false;
3180+
if (paintBounds.contains(box.topLeft) || paintBounds.contains(box.bottomRight)) {
3181+
rects.add(SelectionRect(position: graphemeStart, bounds: box));
31933182
}
3194-
return true;
3195-
}).map<SelectionRect>((SelectionRect? selectionRect) => selectionRect!).toList();
3196-
_textInputConnection!.setSelectionRects(rects);
3183+
}
3184+
graphemeStart = graphemeEnd;
31973185
}
3186+
_textInputConnection!.setSelectionRects(rects);
31983187
}
31993188

32003189
void _updateSizeAndTransform() {
@@ -4103,6 +4092,46 @@ class _Editable extends MultiChildRenderObjectWidget {
41034092
}
41044093
}
41054094

4095+
@immutable
4096+
class _ScribbleCacheKey {
4097+
const _ScribbleCacheKey({
4098+
required this.inlineSpan,
4099+
required this.textAlign,
4100+
required this.textDirection,
4101+
required this.textScaleFactor,
4102+
required this.textHeightBehavior,
4103+
required this.locale,
4104+
required this.structStyle,
4105+
required this.placeholder,
4106+
required this.size,
4107+
});
4108+
4109+
final TextAlign textAlign;
4110+
final TextDirection textDirection;
4111+
final double textScaleFactor;
4112+
final TextHeightBehavior? textHeightBehavior;
4113+
final Locale? locale;
4114+
final StrutStyle structStyle;
4115+
final int placeholder;
4116+
final Size size;
4117+
final InlineSpan inlineSpan;
4118+
4119+
RenderComparison compare(_ScribbleCacheKey other) {
4120+
if (identical(other, this)) {
4121+
return RenderComparison.identical;
4122+
}
4123+
final bool needsLayout = textAlign != other.textAlign
4124+
|| textDirection != other.textDirection
4125+
|| textScaleFactor != other.textScaleFactor
4126+
|| (textHeightBehavior ?? const TextHeightBehavior()) != (other.textHeightBehavior ?? const TextHeightBehavior())
4127+
|| locale != other.locale
4128+
|| structStyle != other.structStyle
4129+
|| placeholder != other.placeholder
4130+
|| size != other.size;
4131+
return needsLayout ? RenderComparison.layout : inlineSpan.compareTo(other.inlineSpan);
4132+
}
4133+
}
4134+
41064135
class _ScribbleFocusable extends StatefulWidget {
41074136
const _ScribbleFocusable({
41084137
required this.child,

packages/flutter/test/widgets/editable_text_test.dart

Lines changed: 120 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4628,41 +4628,136 @@ void main() {
46284628
// Ensure selection rects are sent on iPhone (using SE 3rd gen size)
46294629
tester.binding.window.physicalSizeTestValue = const Size(750.0, 1334.0);
46304630

4631-
final List<MethodCall> log = <MethodCall>[];
4631+
final List<List<SelectionRect>> log = <List<SelectionRect>>[];
46324632
SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async {
4633-
log.add(methodCall);
4633+
if (methodCall.method == 'TextInput.setSelectionRects') {
4634+
final List<dynamic> args = methodCall.arguments as List<dynamic>;
4635+
final List<SelectionRect> selectionRects = <SelectionRect>[];
4636+
for (final dynamic rect in args) {
4637+
selectionRects.add(SelectionRect(
4638+
position: (rect as List<dynamic>)[4] as int,
4639+
bounds: Rect.fromLTWH(rect[0] as double, rect[1] as double, rect[2] as double, rect[3] as double),
4640+
));
4641+
}
4642+
log.add(selectionRects);
4643+
}
46344644
});
46354645

46364646
final TextEditingController controller = TextEditingController();
4647+
final ScrollController scrollController = ScrollController();
46374648
controller.text = 'Text1';
46384649

4639-
await tester.pumpWidget(
4640-
MediaQuery(
4641-
data: const MediaQueryData(),
4642-
child: Directionality(
4643-
textDirection: TextDirection.ltr,
4644-
child: Column(
4645-
crossAxisAlignment: CrossAxisAlignment.start,
4646-
children: <Widget>[
4647-
EditableText(
4648-
key: ValueKey<String>(controller.text),
4649-
controller: controller,
4650-
focusNode: FocusNode(),
4651-
style: Typography.material2018().black.titleMedium!,
4652-
cursorColor: Colors.blue,
4653-
backgroundCursorColor: Colors.grey,
4650+
Future<void> pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async {
4651+
await tester.pumpWidget(
4652+
MediaQuery(
4653+
data: const MediaQueryData(),
4654+
child: Directionality(
4655+
textDirection: TextDirection.ltr,
4656+
child: Center(
4657+
child: SizedBox(
4658+
width: width,
4659+
height: height,
4660+
child: EditableText(
4661+
controller: controller,
4662+
textAlign: textAlign,
4663+
scrollController: scrollController,
4664+
maxLines: null,
4665+
focusNode: focusNode,
4666+
cursorWidth: 0,
4667+
style: Typography.material2018().black.titleMedium!,
4668+
cursorColor: Colors.blue,
4669+
backgroundCursorColor: Colors.grey,
4670+
),
46544671
),
4655-
],
4672+
),
46564673
),
46574674
),
4658-
),
4659-
);
4660-
await tester.showKeyboard(find.byKey(ValueKey<String>(controller.text)));
4675+
);
4676+
}
46614677

4662-
// There should be a new platform message updating the selection rects.
4663-
final MethodCall methodCall = log.firstWhere((MethodCall m) => m.method == 'TextInput.setSelectionRects');
4664-
expect(methodCall.method, 'TextInput.setSelectionRects');
4665-
expect((methodCall.arguments as List<dynamic>).length, 5);
4678+
await pumpEditableText();
4679+
expect(log, isEmpty);
4680+
4681+
await tester.showKeyboard(find.byType(EditableText));
4682+
// First update.
4683+
expect(log.single, const <SelectionRect>[
4684+
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
4685+
SelectionRect(position: 1, bounds: Rect.fromLTRB(14.0, 0.0, 28.0, 14.0)),
4686+
SelectionRect(position: 2, bounds: Rect.fromLTRB(28.0, 0.0, 42.0, 14.0)),
4687+
SelectionRect(position: 3, bounds: Rect.fromLTRB(42.0, 0.0, 56.0, 14.0)),
4688+
SelectionRect(position: 4, bounds: Rect.fromLTRB(56.0, 0.0, 70.0, 14.0))
4689+
]);
4690+
log.clear();
4691+
4692+
await tester.pumpAndSettle();
4693+
expect(log, isEmpty);
4694+
4695+
await pumpEditableText();
4696+
expect(log, isEmpty);
4697+
4698+
// Change the width such that each character occupies a line.
4699+
await pumpEditableText(width: 20);
4700+
expect(log.single, const <SelectionRect>[
4701+
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
4702+
SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)),
4703+
SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)),
4704+
SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)),
4705+
SelectionRect(position: 4, bounds: Rect.fromLTRB(0.0, 56.0, 14.0, 70.0))
4706+
]);
4707+
log.clear();
4708+
4709+
await tester.enterText(find.byType(EditableText), 'Text1👨‍👩‍👦');
4710+
await tester.pump();
4711+
expect(log.single, const <SelectionRect>[
4712+
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
4713+
SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)),
4714+
SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)),
4715+
SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)),
4716+
SelectionRect(position: 4, bounds: Rect.fromLTRB(0.0, 56.0, 14.0, 70.0)),
4717+
SelectionRect(position: 5, bounds: Rect.fromLTRB(0.0, 70.0, 42.0, 84.0)),
4718+
]);
4719+
log.clear();
4720+
4721+
// The 4th line will be partially visible.
4722+
await pumpEditableText(width: 20, height: 45);
4723+
expect(log.single, const <SelectionRect>[
4724+
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
4725+
SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)),
4726+
SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)),
4727+
SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)),
4728+
]);
4729+
log.clear();
4730+
4731+
await pumpEditableText(width: 20, height: 45, textAlign: TextAlign.right);
4732+
// This is 1px off from being completely right-aligned. The 1px width is
4733+
// reserved for caret.
4734+
expect(log.single, const <SelectionRect>[
4735+
SelectionRect(position: 0, bounds: Rect.fromLTRB(5.0, 0.0, 19.0, 14.0)),
4736+
SelectionRect(position: 1, bounds: Rect.fromLTRB(5.0, 14.0, 19.0, 28.0)),
4737+
SelectionRect(position: 2, bounds: Rect.fromLTRB(5.0, 28.0, 19.0, 42.0)),
4738+
SelectionRect(position: 3, bounds: Rect.fromLTRB(5.0, 42.0, 19.0, 56.0)),
4739+
// These 2 lines will be out of bounds.
4740+
// SelectionRect(position: 4, bounds: Rect.fromLTRB(5.0, 56.0, 19.0, 70.0)),
4741+
// SelectionRect(position: 5, bounds: Rect.fromLTRB(-23.0, 70.0, 19.0, 84.0)),
4742+
]);
4743+
log.clear();
4744+
4745+
expect(scrollController.offset, 0);
4746+
4747+
// Scrolling also triggers update.
4748+
scrollController.jumpTo(14);
4749+
await tester.pumpAndSettle();
4750+
expect(log.single, const <SelectionRect>[
4751+
SelectionRect(position: 0, bounds: Rect.fromLTRB(5.0, -14.0, 19.0, 0.0)),
4752+
SelectionRect(position: 1, bounds: Rect.fromLTRB(5.0, 0.0, 19.0, 14.0)),
4753+
SelectionRect(position: 2, bounds: Rect.fromLTRB(5.0, 14.0, 19.0, 28.0)),
4754+
SelectionRect(position: 3, bounds: Rect.fromLTRB(5.0, 28.0, 19.0, 42.0)),
4755+
SelectionRect(position: 4, bounds: Rect.fromLTRB(5.0, 42.0, 19.0, 56.0)),
4756+
// This line is skipped because it's below the bottom edge of the render
4757+
// object.
4758+
// SelectionRect(position: 5, bounds: Rect.fromLTRB(5.0, 56.0, 47.0, 70.0)),
4759+
]);
4760+
log.clear();
46664761

46674762
// On web, we should rely on the browser's implementation of Scribble, so we will not send selection rects.
46684763
}, skip: kIsWeb, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS })); // [intended]

0 commit comments

Comments
 (0)