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
final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
TextInputConnection? _textInputConnection;
bool get _hasInputConnection => _textInputConnection?.attached ?? false;
TextSelectionOverlay? _selectionOverlay;
ScrollController? _internalScrollController;
......@@ -2037,7 +2039,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_clipboardStatus?.addListener(_onChangedClipboardStatus);
widget.controller.addListener(_didChangeTextEditingValue);
widget.focusNode.addListener(_handleFocusChanged);
_scrollController.addListener(_updateSelectionOverlayForScroll);
_scrollController.addListener(_onEditableScroll);
_cursorVisibilityNotifier.value = widget.showCursor;
_spellCheckConfiguration = _inferSpellCheckConfiguration(widget.spellCheckConfiguration);
}
......@@ -2125,8 +2127,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}
if (widget.scrollController != oldWidget.scrollController) {
(oldWidget.scrollController ?? _internalScrollController)?.removeListener(_updateSelectionOverlayForScroll);
_scrollController.addListener(_updateSelectionOverlayForScroll);
(oldWidget.scrollController ?? _internalScrollController)?.removeListener(_onEditableScroll);
_scrollController.addListener(_onEditableScroll);
}
if (!_shouldCreateInputConnection) {
......@@ -2567,7 +2569,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
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
/// default.
bool get _needsAutofill => _effectiveAutofillClient.textInputConfiguration.autofillConfiguration.enabled;
......@@ -2718,8 +2719,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}
}
void _updateSelectionOverlayForScroll() {
void _onEditableScroll() {
_selectionOverlay?.updateForScroll();
_scribbleCacheKey = null;
}
void _createSelectionOverlay() {
......@@ -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.
_handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), null);
}
_cachedText = '';
_cachedFirstRect = null;
_cachedSize = Size.zero;
_cachedPlaceholder = -1;
} else {
WidgetsBinding.instance.removeObserver(this);
setState(() { _currentPromptRectRange = null; });
......@@ -3127,74 +3124,66 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
updateKeepAlive();
}
String _cachedText = '';
Rect? _cachedFirstRect;
Size _cachedSize = Size.zero;
int _cachedPlaceholder = -1;
TextStyle? _cachedTextStyle;
_ScribbleCacheKey? _scribbleCacheKey;
void _updateSelectionRects({bool force = false}) {
if (!widget.scribbleEnabled) {
if (!widget.scribbleEnabled || defaultTargetPlatform != TargetPlatform.iOS) {
return;
}
if (defaultTargetPlatform != TargetPlatform.iOS) {
final ScrollDirection scrollDirection = _scrollController.position.userScrollDirection;
if (scrollDirection != ScrollDirection.idle) {
return;
}
final String text = renderEditable.text?.toPlainText(includeSemanticsLabels: false) ?? '';
final List<Rect> firstSelectionBoxes = renderEditable.getBoxesForSelection(const TextSelection(baseOffset: 0, extentOffset: 1));
final Rect? firstRect = firstSelectionBoxes.isNotEmpty ? firstSelectionBoxes.first : null;
final ScrollDirection scrollDirection = _scrollController.position.userScrollDirection;
final Size size = renderEditable.size;
final bool textChanged = text != _cachedText;
final bool textStyleChanged = _cachedTextStyle != widget.style;
final bool firstRectChanged = _cachedFirstRect != firstRect;
final bool sizeChanged = _cachedSize != size;
final bool placeholderChanged = _cachedPlaceholder != _placeholderLocation;
if (scrollDirection == ScrollDirection.idle && (force || textChanged || textStyleChanged || firstRectChanged || sizeChanged || placeholderChanged)) {
_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 InlineSpan inlineSpan = renderEditable.text!;
final _ScribbleCacheKey newCacheKey = _ScribbleCacheKey(
inlineSpan: inlineSpan,
textAlign: widget.textAlign,
textDirection: _textDirection,
textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context),
textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context),
locale: widget.locale,
structStyle: widget.strutStyle,
placeholder: _placeholderLocation,
size: renderEditable.size,
);
final int offset = _cachedText.characters.getRange(0, i).string.length;
final List<Rect> boxes = renderEditable.getBoxesForSelection(TextSelection(baseOffset: offset, extentOffset: offset + _cachedText.characters.characterAt(i).string.length));
if (boxes.isEmpty) {
return null;
}
final RenderComparison comparison = force
? RenderComparison.layout
: _scribbleCacheKey?.compare(newCacheKey) ?? RenderComparison.layout;
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(
bounds: boxes.first,
position: offset,
);
if (renderEditable.paintBounds.bottom < selectionRect.bounds.top) {
belowRenderEditableBottom = true;
return null;
}
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;
final Rect? box = boxes.isEmpty ? null : boxes.first;
if (box != null) {
final Rect paintBounds = renderEditable.paintBounds;
// Stop early when characters are already below the bottom edge of the
// RenderEditable, regardless of its clipBehavior.
if (paintBounds.bottom <= box.top) {
break;
}
if (renderEditable.paintBounds.bottom < selectionRect.bounds.top || selectionRect.bounds.bottom < renderEditable.paintBounds.top) {
return false;
if (paintBounds.contains(box.topLeft) || paintBounds.contains(box.bottomRight)) {
rects.add(SelectionRect(position: graphemeStart, bounds: box));
}
return true;
}).map<SelectionRect>((SelectionRect? selectionRect) => selectionRect!).toList();
_textInputConnection!.setSelectionRects(rects);
}
graphemeStart = graphemeEnd;
}
_textInputConnection!.setSelectionRects(rects);
}
void _updateSizeAndTransform() {
......@@ -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 {
const _ScribbleFocusable({
required this.child,
......
......@@ -4628,41 +4628,136 @@ void main() {
// Ensure selection rects are sent on iPhone (using SE 3rd gen size)
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 {
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 ScrollController scrollController = ScrollController();
controller.text = 'Text1';
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
EditableText(
key: ValueKey<String>(controller.text),
controller: controller,
focusNode: FocusNode(),
style: Typography.material2018().black.titleMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
Future<void> pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(
width: width,
height: height,
child: EditableText(
controller: controller,
textAlign: textAlign,
scrollController: scrollController,
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.
final MethodCall methodCall = log.firstWhere((MethodCall m) => m.method == 'TextInput.setSelectionRects');
expect(methodCall.method, 'TextInput.setSelectionRects');
expect((methodCall.arguments as List<dynamic>).length, 5);
await pumpEditableText();
expect(log, isEmpty);
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.
}, 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