Unverified Commit e76f8831 authored by Tomasz Gucio's avatar Tomasz Gucio Committed by GitHub

Cache TextPainter plain text value to improve performance (#109841)

parent df259c58
......@@ -353,9 +353,7 @@ class TextPainter {
///
/// The [InlineSpan] this provides is in the form of a tree that may contain
/// multiple instances of [TextSpan]s and [WidgetSpan]s. To obtain a plain text
/// representation of the contents of this [TextPainter], use [InlineSpan.toPlainText]
/// to get the full contents of all nodes in the tree. [TextSpan.text] will
/// only provide the contents of the first node in the tree.
/// representation of the contents of this [TextPainter], use [plainText].
InlineSpan? get text => _text;
InlineSpan? _text;
set text(InlineSpan? value) {
......@@ -373,6 +371,7 @@ class TextPainter {
: _text?.compareTo(value) ?? RenderComparison.layout;
_text = value;
_cachedPlainText = null;
if (comparison.index >= RenderComparison.layout.index) {
markNeedsLayout();
......@@ -384,6 +383,15 @@ class TextPainter {
// Neither relayout or repaint is needed.
}
/// Returns a plain text version of the text to paint.
///
/// This uses [InlineSpan.toPlainText] to get the full contents of all nodes in the tree.
String get plainText {
_cachedPlainText ??= _text?.toPlainText(includeSemanticsLabels: false);
return _cachedPlainText ?? '';
}
String? _cachedPlainText;
/// How the text should be aligned horizontally.
///
/// After this is set, you must call [layout] before the next call to [paint].
......@@ -898,11 +906,11 @@ class TextPainter {
// Get the Rect of the cursor (in logical pixels) based off the near edge
// of the character upstream from the given string offset.
Rect? _getRectFromUpstream(int offset, Rect caretPrototype) {
final String flattenedText = _text!.toPlainText(includeSemanticsLabels: false);
final int? prevCodeUnit = _text!.codeUnitAt(max(0, offset - 1));
if (prevCodeUnit == null) {
final int plainTextLength = plainText.length;
if (plainTextLength == 0 || offset > plainTextLength) {
return null;
}
final int prevCodeUnit = plainText.codeUnitAt(max(0, offset - 1));
// If the upstream character is a newline, cursor is at start of next line
const int NEWLINE_CODE_UNIT = 10;
......@@ -923,7 +931,7 @@ class TextPainter {
if (!needsSearch && prevCodeUnit == NEWLINE_CODE_UNIT) {
break; // Only perform one iteration if no search is required.
}
if (prevRuneOffset < -flattenedText.length) {
if (prevRuneOffset < -plainTextLength) {
break; // Stop iterating when beyond the max length of the text.
}
// Multiply by two to log(n) time cover the entire text span. This allows
......@@ -950,12 +958,13 @@ class TextPainter {
// Get the Rect of the cursor (in logical pixels) based off the near edge
// of the character downstream from the given string offset.
Rect? _getRectFromDownstream(int offset, Rect caretPrototype) {
final String flattenedText = _text!.toPlainText(includeSemanticsLabels: false);
// We cap the offset at the final index of the _text.
final int? nextCodeUnit = _text!.codeUnitAt(min(offset, flattenedText.length - 1));
if (nextCodeUnit == null) {
final int plainTextLength = plainText.length;
if (plainTextLength == 0 || offset < 0) {
return null;
}
// We cap the offset at the final index of plain text.
final int nextCodeUnit = plainText.codeUnitAt(min(offset, plainTextLength - 1));
// Check for multi-code-unit glyphs such as emojis or zero width joiner
final bool needsSearch = _isUtf16Surrogate(nextCodeUnit) || nextCodeUnit == _zwjUtf16 || _isUnicodeDirectionality(nextCodeUnit);
int graphemeClusterLength = needsSearch ? 2 : 1;
......@@ -972,7 +981,7 @@ class TextPainter {
if (!needsSearch) {
break; // Only perform one iteration if no search is required.
}
if (nextRuneOffset >= flattenedText.length << 1) {
if (nextRuneOffset >= plainTextLength << 1) {
break; // Stop iterating when beyond the max length of the text.
}
// Multiply by two to log(n) time cover the entire text span. This allows
......
......@@ -687,7 +687,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
final TextRange line = _textPainter.getLineBoundary(position);
// If text is obscured, the entire string should be treated as one line.
if (obscureText) {
return TextSelection(baseOffset: 0, extentOffset: _plainText.length);
return TextSelection(baseOffset: 0, extentOffset: plainText.length);
}
return TextSelection(baseOffset: line.start, extentOffset: line.end);
}
......@@ -756,12 +756,12 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
void _setSelection(TextSelection nextSelection, SelectionChangedCause cause) {
if (nextSelection.isValid) {
// The nextSelection is calculated based on _plainText, which can be out
// The nextSelection is calculated based on plainText, which can be out
// of sync with the textSelectionDelegate.textEditingValue by one frame.
// This is due to the render editable and editable text handle pointer
// event separately. If the editable text changes the text during the
// event handler, the render editable will use the outdated text stored in
// the _plainText when handling the pointer event.
// the plainText when handling the pointer event.
//
// If this happens, we need to make sure the new selection is still valid.
final int textLength = textSelectionDelegate.textEditingValue.text.length;
......@@ -803,16 +803,16 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
_textLayoutLastMinWidth = null;
}
String? _cachedPlainText;
// Returns a plain text version of the text in the painter.
//
// Returns the obscured text when [obscureText] is true. See
// [obscureText] and [obscuringCharacter].
String get _plainText {
return _cachedPlainText ??= _textPainter.text!.toPlainText(includeSemanticsLabels: false);
}
/// Returns a plain text version of the text in [TextPainter].
///
/// If [obscureText] is true, returns the obscured text. See
/// [obscureText] and [obscuringCharacter].
/// In order to get the styled text as an [InlineSpan] tree, use [text].
String get plainText => _textPainter.plainText;
/// The text to display.
/// The text to paint in the form of a tree of [InlineSpan]s.
///
/// In order to get the plain text representation, use [plainText].
InlineSpan? get text => _textPainter.text;
final TextPainter _textPainter;
AttributedString? _cachedAttributedValue;
......@@ -821,9 +821,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
if (_textPainter.text == value) {
return;
}
_cachedPlainText = null;
_cachedLineBreakCount = null;
_textPainter.text = value;
_cachedAttributedValue = null;
_cachedCombinedSemanticsInfos = null;
......@@ -1328,7 +1326,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
}
if (_cachedAttributedValue == null) {
if (obscureText) {
_cachedAttributedValue = AttributedString(obscuringCharacter * _plainText.length);
_cachedAttributedValue = AttributedString(obscuringCharacter * plainText.length);
} else {
final StringBuffer buffer = StringBuffer();
int offset = 0;
......@@ -1855,23 +1853,24 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
if (maxLines == null) {
final double estimatedHeight;
if (width == double.infinity) {
estimatedHeight = preferredLineHeight * (_countHardLineBreaks(_plainText) + 1);
estimatedHeight = preferredLineHeight * (_countHardLineBreaks(plainText) + 1);
} else {
_layoutText(maxWidth: width);
estimatedHeight = _textPainter.height;
}
return math.max(estimatedHeight, minHeight);
}
// TODO(LongCatIsLooong): this is a workaround for
// https://github.com/flutter/flutter/issues/112123 .
// https://github.com/flutter/flutter/issues/112123.
// Use preferredLineHeight since SkParagraph currently returns an incorrect
// height.
final TextHeightBehavior? textHeightBehavior = this.textHeightBehavior;
final bool usePreferredLineHeightHack = maxLines == 1
&& text?.codeUnitAt(0) == null
&& strutStyle != null && strutStyle != StrutStyle.disabled
&& textHeightBehavior != null
&& (!textHeightBehavior.applyHeightToFirstAscent || !textHeightBehavior.applyHeightToLastDescent);
&& text?.codeUnitAt(0) == null
&& strutStyle != null && strutStyle != StrutStyle.disabled
&& textHeightBehavior != null
&& (!textHeightBehavior.applyHeightToFirstAscent || !textHeightBehavior.applyHeightToLastDescent);
// Special case maxLines == 1 since it forces the scrollable direction
// to be horizontal. Report the real height to prevent the text from being
......@@ -2142,14 +2141,14 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
TextSelection _getWordAtOffset(TextPosition position) {
debugAssertLayoutUpToDate();
// When long-pressing past the end of the text, we want a collapsed cursor.
if (position.offset >= _plainText.length) {
if (position.offset >= plainText.length) {
return TextSelection.fromPosition(
TextPosition(offset: _plainText.length, affinity: TextAffinity.upstream)
TextPosition(offset: plainText.length, affinity: TextAffinity.upstream)
);
}
// If text is obscured, the entire sentence should be treated as one word.
if (obscureText) {
return TextSelection(baseOffset: 0, extentOffset: _plainText.length);
return TextSelection(baseOffset: 0, extentOffset: plainText.length);
}
final TextRange word = _textPainter.getWordBoundary(position);
final int effectiveOffset;
......@@ -2170,7 +2169,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
// If the platform is Android and the text is read only, try to select the
// previous word if there is one; otherwise, select the single whitespace at
// the position.
if (TextLayoutMetrics.isWhitespace(_plainText.codeUnitAt(effectiveOffset))
if (TextLayoutMetrics.isWhitespace(plainText.codeUnitAt(effectiveOffset))
&& effectiveOffset > 0) {
assert(defaultTargetPlatform != null);
final TextRange? previousWord = _getPreviousWord(word.start);
......
......@@ -509,8 +509,6 @@ class TextSelectionOverlay {
}
double _getStartGlyphHeight() {
final InlineSpan span = renderObject.text!;
final String prevText = span.toPlainText();
final String currText = selectionDelegate.textEditingValue.text;
final int firstSelectedGraphemeExtent;
Rect? startHandleRect;
......@@ -521,7 +519,7 @@ class TextSelectionOverlay {
// widget.renderObject.getRectForComposingRange might fail. In cases where
// the current frame is different from the previous we fall back to
// renderObject.preferredLineHeight.
if (prevText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) {
if (renderObject.plainText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) {
final String selectedGraphemes = _selection.textInside(currText);
firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length;
startHandleRect = renderObject.getRectForComposingRange(TextRange(start: _selection.start, end: _selection.start + firstSelectedGraphemeExtent));
......@@ -530,13 +528,11 @@ class TextSelectionOverlay {
}
double _getEndGlyphHeight() {
final InlineSpan span = renderObject.text!;
final String prevText = span.toPlainText();
final String currText = selectionDelegate.textEditingValue.text;
final int lastSelectedGraphemeExtent;
Rect? endHandleRect;
// See the explanation in _getStartGlyphHeight.
if (prevText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) {
if (renderObject.plainText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) {
final String selectedGraphemes = _selection.textInside(currText);
lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length;
endHandleRect = renderObject.getRectForComposingRange(TextRange(start: _selection.end - lastSelectedGraphemeExtent, end: _selection.end));
......
......@@ -1209,20 +1209,51 @@ void main() {
painter.dispose();
});
test('TextPainter.getWordBoundary works', (){
// Regression test for https://github.com/flutter/flutter/issues/93493 .
const String testCluster = '👨‍👩‍👦👨‍👩‍👦👨‍👩‍👦'; // 8 * 3
final TextPainter textPainter = TextPainter(
text: const TextSpan(text: testCluster),
textDirection: TextDirection.ltr,
);
textPainter.layout();
expect(
textPainter.getWordBoundary(const TextPosition(offset: 8)),
const TextRange(start: 8, end: 16),
);
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61017
test('TextPainter.getWordBoundary works', (){
// Regression test for https://github.com/flutter/flutter/issues/93493 .
const String testCluster = '👨‍👩‍👦👨‍👩‍👦👨‍👩‍👦'; // 8 * 3
final TextPainter textPainter = TextPainter(
text: const TextSpan(text: testCluster),
textDirection: TextDirection.ltr,
);
textPainter.layout();
expect(
textPainter.getWordBoundary(const TextPosition(offset: 8)),
const TextRange(start: 8, end: 16),
);
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61017
test('TextPainter plainText getter', () {
final TextPainter painter = TextPainter()
..textDirection = TextDirection.ltr;
expect(painter.plainText, '');
painter.text = const TextSpan(children: <InlineSpan>[
TextSpan(text: 'before\n'),
WidgetSpan(child: Text('widget')),
TextSpan(text: 'after'),
]);
expect(painter.plainText, 'before\n\uFFFCafter');
painter.setPlaceholderDimensions(const <PlaceholderDimensions>[
PlaceholderDimensions(size: Size(50, 30), alignment: ui.PlaceholderAlignment.bottom),
]);
painter.layout();
expect(painter.plainText, 'before\n\uFFFCafter');
painter.text = const TextSpan(children: <InlineSpan>[
TextSpan(text: 'be\nfo\nre\n'),
WidgetSpan(child: Text('widget')),
TextSpan(text: 'af\nter'),
]);
expect(painter.plainText, 'be\nfo\nre\n\uFFFCaf\nter');
painter.layout();
expect(painter.plainText, 'be\nfo\nre\n\uFFFCaf\nter');
painter.dispose();
});
}
class MockCanvas extends Fake implements Canvas {
......
......@@ -364,6 +364,34 @@ void main() {
expect(editable.debugNeedsLayout, isTrue);
});
test('Can read plain text', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final RenderEditable editable = RenderEditable(
maxLines: null,
textDirection: TextDirection.ltr,
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
);
expect(editable.plainText, '');
editable.text = const TextSpan(text: '123');
expect(editable.plainText, '123');
editable.text = const TextSpan(
children: <TextSpan>[
TextSpan(text: 'abc', style: TextStyle(fontSize: 12, fontFamily: 'Ahem')),
TextSpan(text: 'def', style: TextStyle(fontSize: 10, fontFamily: 'Ahem')),
],
);
expect(editable.plainText, 'abcdef');
editable.layout(const BoxConstraints.tightFor(width: 200));
expect(editable.plainText, 'abcdef');
});
test('Cursor with ideographic script', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final ValueNotifier<bool> showCursor = ValueNotifier<bool>(true);
......
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