Unverified Commit 57e577a5 authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Clean up `_updateSelectionRects` (#113425)

parent c84897c6
...@@ -1779,6 +1779,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1779,6 +1779,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier(); final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
TextInputConnection? _textInputConnection; TextInputConnection? _textInputConnection;
bool get _hasInputConnection => _textInputConnection?.attached ?? false;
TextSelectionOverlay? _selectionOverlay; TextSelectionOverlay? _selectionOverlay;
ScrollController? _internalScrollController; ScrollController? _internalScrollController;
...@@ -2037,7 +2039,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2037,7 +2039,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_clipboardStatus?.addListener(_onChangedClipboardStatus); _clipboardStatus?.addListener(_onChangedClipboardStatus);
widget.controller.addListener(_didChangeTextEditingValue); widget.controller.addListener(_didChangeTextEditingValue);
widget.focusNode.addListener(_handleFocusChanged); widget.focusNode.addListener(_handleFocusChanged);
_scrollController.addListener(_updateSelectionOverlayForScroll); _scrollController.addListener(_onEditableScroll);
_cursorVisibilityNotifier.value = widget.showCursor; _cursorVisibilityNotifier.value = widget.showCursor;
_spellCheckConfiguration = _inferSpellCheckConfiguration(widget.spellCheckConfiguration); _spellCheckConfiguration = _inferSpellCheckConfiguration(widget.spellCheckConfiguration);
} }
...@@ -2125,8 +2127,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2125,8 +2127,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
} }
if (widget.scrollController != oldWidget.scrollController) { if (widget.scrollController != oldWidget.scrollController) {
(oldWidget.scrollController ?? _internalScrollController)?.removeListener(_updateSelectionOverlayForScroll); (oldWidget.scrollController ?? _internalScrollController)?.removeListener(_onEditableScroll);
_scrollController.addListener(_updateSelectionOverlayForScroll); _scrollController.addListener(_onEditableScroll);
} }
if (!_shouldCreateInputConnection) { if (!_shouldCreateInputConnection) {
...@@ -2567,7 +2569,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2567,7 +2569,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
return RevealedOffset(rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset); return RevealedOffset(rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset);
} }
bool get _hasInputConnection => _textInputConnection?.attached ?? false;
/// Whether to send the autofill information to the autofill service. True by /// Whether to send the autofill information to the autofill service. True by
/// default. /// default.
bool get _needsAutofill => _effectiveAutofillClient.textInputConfiguration.autofillConfiguration.enabled; bool get _needsAutofill => _effectiveAutofillClient.textInputConfiguration.autofillConfiguration.enabled;
...@@ -2718,8 +2719,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2718,8 +2719,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
} }
} }
void _updateSelectionOverlayForScroll() { void _onEditableScroll() {
_selectionOverlay?.updateForScroll(); _selectionOverlay?.updateForScroll();
_scribbleCacheKey = null;
} }
void _createSelectionOverlay() { void _createSelectionOverlay() {
...@@ -3115,11 +3117,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3115,11 +3117,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// Place cursor at the end if the selection is invalid when we receive focus. // Place cursor at the end if the selection is invalid when we receive focus.
_handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), null); _handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), null);
} }
_cachedText = '';
_cachedFirstRect = null;
_cachedSize = Size.zero;
_cachedPlaceholder = -1;
} else { } else {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
setState(() { _currentPromptRectRange = null; }); setState(() { _currentPromptRectRange = null; });
...@@ -3127,74 +3124,66 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3127,74 +3124,66 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
updateKeepAlive(); updateKeepAlive();
} }
String _cachedText = ''; _ScribbleCacheKey? _scribbleCacheKey;
Rect? _cachedFirstRect;
Size _cachedSize = Size.zero;
int _cachedPlaceholder = -1;
TextStyle? _cachedTextStyle;
void _updateSelectionRects({bool force = false}) { void _updateSelectionRects({bool force = false}) {
if (!widget.scribbleEnabled) { if (!widget.scribbleEnabled || defaultTargetPlatform != TargetPlatform.iOS) {
return; return;
} }
if (defaultTargetPlatform != TargetPlatform.iOS) {
final ScrollDirection scrollDirection = _scrollController.position.userScrollDirection;
if (scrollDirection != ScrollDirection.idle) {
return; return;
} }
final String text = renderEditable.text?.toPlainText(includeSemanticsLabels: false) ?? ''; final InlineSpan inlineSpan = renderEditable.text!;
final List<Rect> firstSelectionBoxes = renderEditable.getBoxesForSelection(const TextSelection(baseOffset: 0, extentOffset: 1)); final _ScribbleCacheKey newCacheKey = _ScribbleCacheKey(
final Rect? firstRect = firstSelectionBoxes.isNotEmpty ? firstSelectionBoxes.first : null; inlineSpan: inlineSpan,
final ScrollDirection scrollDirection = _scrollController.position.userScrollDirection; textAlign: widget.textAlign,
final Size size = renderEditable.size; textDirection: _textDirection,
final bool textChanged = text != _cachedText; textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context),
final bool textStyleChanged = _cachedTextStyle != widget.style; textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context),
final bool firstRectChanged = _cachedFirstRect != firstRect; locale: widget.locale,
final bool sizeChanged = _cachedSize != size; structStyle: widget.strutStyle,
final bool placeholderChanged = _cachedPlaceholder != _placeholderLocation; placeholder: _placeholderLocation,
if (scrollDirection == ScrollDirection.idle && (force || textChanged || textStyleChanged || firstRectChanged || sizeChanged || placeholderChanged)) { size: renderEditable.size,
_cachedText = text; );
_cachedFirstRect = firstRect;
_cachedTextStyle = widget.style;
_cachedSize = size;
_cachedPlaceholder = _placeholderLocation;
bool belowRenderEditableBottom = false;
final List<SelectionRect> rects = List<SelectionRect?>.generate(
_cachedText.characters.length,
(int i) {
if (belowRenderEditableBottom) {
return null;
}
final int offset = _cachedText.characters.getRange(0, i).string.length; final RenderComparison comparison = force
final List<Rect> boxes = renderEditable.getBoxesForSelection(TextSelection(baseOffset: offset, extentOffset: offset + _cachedText.characters.characterAt(i).string.length)); ? RenderComparison.layout
if (boxes.isEmpty) { : _scribbleCacheKey?.compare(newCacheKey) ?? RenderComparison.layout;
return null; if (comparison.index < RenderComparison.layout.index) {
} return;
}
_scribbleCacheKey = newCacheKey;
final List<SelectionRect> rects = <SelectionRect>[];
int graphemeStart = 0;
// Can't use _value.text here: the controller value could change between
// frames.
final String plainText = inlineSpan.toPlainText(includeSemanticsLabels: false);
final CharacterRange characterRange = CharacterRange(plainText);
while (characterRange.moveNext()) {
final int graphemeEnd = graphemeStart + characterRange.current.length;
final List<Rect> boxes = renderEditable.getBoxesForSelection(
TextSelection(baseOffset: graphemeStart, extentOffset: graphemeEnd),
);
final SelectionRect selectionRect = SelectionRect( final Rect? box = boxes.isEmpty ? null : boxes.first;
bounds: boxes.first, if (box != null) {
position: offset, final Rect paintBounds = renderEditable.paintBounds;
); // Stop early when characters are already below the bottom edge of the
if (renderEditable.paintBounds.bottom < selectionRect.bounds.top) { // RenderEditable, regardless of its clipBehavior.
belowRenderEditableBottom = true; if (paintBounds.bottom <= box.top) {
return null; break;
}
return selectionRect;
},
).where((SelectionRect? selectionRect) {
if (selectionRect == null) {
return false;
}
if (renderEditable.paintBounds.right < selectionRect.bounds.left || selectionRect.bounds.right < renderEditable.paintBounds.left) {
return false;
} }
if (renderEditable.paintBounds.bottom < selectionRect.bounds.top || selectionRect.bounds.bottom < renderEditable.paintBounds.top) { if (paintBounds.contains(box.topLeft) || paintBounds.contains(box.bottomRight)) {
return false; rects.add(SelectionRect(position: graphemeStart, bounds: box));
} }
return true; }
}).map<SelectionRect>((SelectionRect? selectionRect) => selectionRect!).toList(); graphemeStart = graphemeEnd;
_textInputConnection!.setSelectionRects(rects);
} }
_textInputConnection!.setSelectionRects(rects);
} }
void _updateSizeAndTransform() { void _updateSizeAndTransform() {
...@@ -4103,6 +4092,46 @@ class _Editable extends MultiChildRenderObjectWidget { ...@@ -4103,6 +4092,46 @@ class _Editable extends MultiChildRenderObjectWidget {
} }
} }
@immutable
class _ScribbleCacheKey {
const _ScribbleCacheKey({
required this.inlineSpan,
required this.textAlign,
required this.textDirection,
required this.textScaleFactor,
required this.textHeightBehavior,
required this.locale,
required this.structStyle,
required this.placeholder,
required this.size,
});
final TextAlign textAlign;
final TextDirection textDirection;
final double textScaleFactor;
final TextHeightBehavior? textHeightBehavior;
final Locale? locale;
final StrutStyle structStyle;
final int placeholder;
final Size size;
final InlineSpan inlineSpan;
RenderComparison compare(_ScribbleCacheKey other) {
if (identical(other, this)) {
return RenderComparison.identical;
}
final bool needsLayout = textAlign != other.textAlign
|| textDirection != other.textDirection
|| textScaleFactor != other.textScaleFactor
|| (textHeightBehavior ?? const TextHeightBehavior()) != (other.textHeightBehavior ?? const TextHeightBehavior())
|| locale != other.locale
|| structStyle != other.structStyle
|| placeholder != other.placeholder
|| size != other.size;
return needsLayout ? RenderComparison.layout : inlineSpan.compareTo(other.inlineSpan);
}
}
class _ScribbleFocusable extends StatefulWidget { class _ScribbleFocusable extends StatefulWidget {
const _ScribbleFocusable({ const _ScribbleFocusable({
required this.child, required this.child,
......
...@@ -4628,41 +4628,136 @@ void main() { ...@@ -4628,41 +4628,136 @@ void main() {
// Ensure selection rects are sent on iPhone (using SE 3rd gen size) // Ensure selection rects are sent on iPhone (using SE 3rd gen size)
tester.binding.window.physicalSizeTestValue = const Size(750.0, 1334.0); tester.binding.window.physicalSizeTestValue = const Size(750.0, 1334.0);
final List<MethodCall> log = <MethodCall>[]; final List<List<SelectionRect>> log = <List<SelectionRect>>[];
SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async {
log.add(methodCall); if (methodCall.method == 'TextInput.setSelectionRects') {
final List<dynamic> args = methodCall.arguments as List<dynamic>;
final List<SelectionRect> selectionRects = <SelectionRect>[];
for (final dynamic rect in args) {
selectionRects.add(SelectionRect(
position: (rect as List<dynamic>)[4] as int,
bounds: Rect.fromLTWH(rect[0] as double, rect[1] as double, rect[2] as double, rect[3] as double),
));
}
log.add(selectionRects);
}
}); });
final TextEditingController controller = TextEditingController(); final TextEditingController controller = TextEditingController();
final ScrollController scrollController = ScrollController();
controller.text = 'Text1'; controller.text = 'Text1';
await tester.pumpWidget( Future<void> pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async {
MediaQuery( await tester.pumpWidget(
data: const MediaQueryData(), MediaQuery(
child: Directionality( data: const MediaQueryData(),
textDirection: TextDirection.ltr, child: Directionality(
child: Column( textDirection: TextDirection.ltr,
crossAxisAlignment: CrossAxisAlignment.start, child: Center(
children: <Widget>[ child: SizedBox(
EditableText( width: width,
key: ValueKey<String>(controller.text), height: height,
controller: controller, child: EditableText(
focusNode: FocusNode(), controller: controller,
style: Typography.material2018().black.titleMedium!, textAlign: textAlign,
cursorColor: Colors.blue, scrollController: scrollController,
backgroundCursorColor: Colors.grey, maxLines: null,
focusNode: focusNode,
cursorWidth: 0,
style: Typography.material2018().black.titleMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
),
), ),
], ),
), ),
), ),
), );
); }
await tester.showKeyboard(find.byKey(ValueKey<String>(controller.text)));
// There should be a new platform message updating the selection rects. await pumpEditableText();
final MethodCall methodCall = log.firstWhere((MethodCall m) => m.method == 'TextInput.setSelectionRects'); expect(log, isEmpty);
expect(methodCall.method, 'TextInput.setSelectionRects');
expect((methodCall.arguments as List<dynamic>).length, 5); await tester.showKeyboard(find.byType(EditableText));
// First update.
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(14.0, 0.0, 28.0, 14.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(28.0, 0.0, 42.0, 14.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(42.0, 0.0, 56.0, 14.0)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(56.0, 0.0, 70.0, 14.0))
]);
log.clear();
await tester.pumpAndSettle();
expect(log, isEmpty);
await pumpEditableText();
expect(log, isEmpty);
// Change the width such that each character occupies a line.
await pumpEditableText(width: 20);
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(0.0, 56.0, 14.0, 70.0))
]);
log.clear();
await tester.enterText(find.byType(EditableText), 'Text1👨‍👩‍👦');
await tester.pump();
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(0.0, 56.0, 14.0, 70.0)),
SelectionRect(position: 5, bounds: Rect.fromLTRB(0.0, 70.0, 42.0, 84.0)),
]);
log.clear();
// The 4th line will be partially visible.
await pumpEditableText(width: 20, height: 45);
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)),
]);
log.clear();
await pumpEditableText(width: 20, height: 45, textAlign: TextAlign.right);
// This is 1px off from being completely right-aligned. The 1px width is
// reserved for caret.
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(5.0, 0.0, 19.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(5.0, 14.0, 19.0, 28.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(5.0, 28.0, 19.0, 42.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(5.0, 42.0, 19.0, 56.0)),
// These 2 lines will be out of bounds.
// SelectionRect(position: 4, bounds: Rect.fromLTRB(5.0, 56.0, 19.0, 70.0)),
// SelectionRect(position: 5, bounds: Rect.fromLTRB(-23.0, 70.0, 19.0, 84.0)),
]);
log.clear();
expect(scrollController.offset, 0);
// Scrolling also triggers update.
scrollController.jumpTo(14);
await tester.pumpAndSettle();
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(5.0, -14.0, 19.0, 0.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(5.0, 0.0, 19.0, 14.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(5.0, 14.0, 19.0, 28.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(5.0, 28.0, 19.0, 42.0)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(5.0, 42.0, 19.0, 56.0)),
// This line is skipped because it's below the bottom edge of the render
// object.
// SelectionRect(position: 5, bounds: Rect.fromLTRB(5.0, 56.0, 47.0, 70.0)),
]);
log.clear();
// On web, we should rely on the browser's implementation of Scribble, so we will not send selection rects. // On web, we should rely on the browser's implementation of Scribble, so we will not send selection rects.
}, skip: kIsWeb, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS })); // [intended] }, skip: kIsWeb, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS })); // [intended]
......
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