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