Skip to content

Commit e76f883

Browse files
authored
Cache TextPainter plain text value to improve performance (#109841)
1 parent df259c5 commit e76f883

File tree

5 files changed

+120
-57
lines changed

5 files changed

+120
-57
lines changed

packages/flutter/lib/src/painting/text_painter.dart

+21-12
Original file line numberDiff line numberDiff line change
@@ -353,9 +353,7 @@ class TextPainter {
353353
///
354354
/// The [InlineSpan] this provides is in the form of a tree that may contain
355355
/// multiple instances of [TextSpan]s and [WidgetSpan]s. To obtain a plain text
356-
/// representation of the contents of this [TextPainter], use [InlineSpan.toPlainText]
357-
/// to get the full contents of all nodes in the tree. [TextSpan.text] will
358-
/// only provide the contents of the first node in the tree.
356+
/// representation of the contents of this [TextPainter], use [plainText].
359357
InlineSpan? get text => _text;
360358
InlineSpan? _text;
361359
set text(InlineSpan? value) {
@@ -373,6 +371,7 @@ class TextPainter {
373371
: _text?.compareTo(value) ?? RenderComparison.layout;
374372

375373
_text = value;
374+
_cachedPlainText = null;
376375

377376
if (comparison.index >= RenderComparison.layout.index) {
378377
markNeedsLayout();
@@ -384,6 +383,15 @@ class TextPainter {
384383
// Neither relayout or repaint is needed.
385384
}
386385

386+
/// Returns a plain text version of the text to paint.
387+
///
388+
/// This uses [InlineSpan.toPlainText] to get the full contents of all nodes in the tree.
389+
String get plainText {
390+
_cachedPlainText ??= _text?.toPlainText(includeSemanticsLabels: false);
391+
return _cachedPlainText ?? '';
392+
}
393+
String? _cachedPlainText;
394+
387395
/// How the text should be aligned horizontally.
388396
///
389397
/// After this is set, you must call [layout] before the next call to [paint].
@@ -898,11 +906,11 @@ class TextPainter {
898906
// Get the Rect of the cursor (in logical pixels) based off the near edge
899907
// of the character upstream from the given string offset.
900908
Rect? _getRectFromUpstream(int offset, Rect caretPrototype) {
901-
final String flattenedText = _text!.toPlainText(includeSemanticsLabels: false);
902-
final int? prevCodeUnit = _text!.codeUnitAt(max(0, offset - 1));
903-
if (prevCodeUnit == null) {
909+
final int plainTextLength = plainText.length;
910+
if (plainTextLength == 0 || offset > plainTextLength) {
904911
return null;
905912
}
913+
final int prevCodeUnit = plainText.codeUnitAt(max(0, offset - 1));
906914

907915
// If the upstream character is a newline, cursor is at start of next line
908916
const int NEWLINE_CODE_UNIT = 10;
@@ -923,7 +931,7 @@ class TextPainter {
923931
if (!needsSearch && prevCodeUnit == NEWLINE_CODE_UNIT) {
924932
break; // Only perform one iteration if no search is required.
925933
}
926-
if (prevRuneOffset < -flattenedText.length) {
934+
if (prevRuneOffset < -plainTextLength) {
927935
break; // Stop iterating when beyond the max length of the text.
928936
}
929937
// Multiply by two to log(n) time cover the entire text span. This allows
@@ -950,12 +958,13 @@ class TextPainter {
950958
// Get the Rect of the cursor (in logical pixels) based off the near edge
951959
// of the character downstream from the given string offset.
952960
Rect? _getRectFromDownstream(int offset, Rect caretPrototype) {
953-
final String flattenedText = _text!.toPlainText(includeSemanticsLabels: false);
954-
// We cap the offset at the final index of the _text.
955-
final int? nextCodeUnit = _text!.codeUnitAt(min(offset, flattenedText.length - 1));
956-
if (nextCodeUnit == null) {
961+
final int plainTextLength = plainText.length;
962+
if (plainTextLength == 0 || offset < 0) {
957963
return null;
958964
}
965+
// We cap the offset at the final index of plain text.
966+
final int nextCodeUnit = plainText.codeUnitAt(min(offset, plainTextLength - 1));
967+
959968
// Check for multi-code-unit glyphs such as emojis or zero width joiner
960969
final bool needsSearch = _isUtf16Surrogate(nextCodeUnit) || nextCodeUnit == _zwjUtf16 || _isUnicodeDirectionality(nextCodeUnit);
961970
int graphemeClusterLength = needsSearch ? 2 : 1;
@@ -972,7 +981,7 @@ class TextPainter {
972981
if (!needsSearch) {
973982
break; // Only perform one iteration if no search is required.
974983
}
975-
if (nextRuneOffset >= flattenedText.length << 1) {
984+
if (nextRuneOffset >= plainTextLength << 1) {
976985
break; // Stop iterating when beyond the max length of the text.
977986
}
978987
// Multiply by two to log(n) time cover the entire text span. This allows

packages/flutter/lib/src/rendering/editable.dart

+24-25
Original file line numberDiff line numberDiff line change
@@ -687,7 +687,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
687687
final TextRange line = _textPainter.getLineBoundary(position);
688688
// If text is obscured, the entire string should be treated as one line.
689689
if (obscureText) {
690-
return TextSelection(baseOffset: 0, extentOffset: _plainText.length);
690+
return TextSelection(baseOffset: 0, extentOffset: plainText.length);
691691
}
692692
return TextSelection(baseOffset: line.start, extentOffset: line.end);
693693
}
@@ -756,12 +756,12 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
756756

757757
void _setSelection(TextSelection nextSelection, SelectionChangedCause cause) {
758758
if (nextSelection.isValid) {
759-
// The nextSelection is calculated based on _plainText, which can be out
759+
// The nextSelection is calculated based on plainText, which can be out
760760
// of sync with the textSelectionDelegate.textEditingValue by one frame.
761761
// This is due to the render editable and editable text handle pointer
762762
// event separately. If the editable text changes the text during the
763763
// event handler, the render editable will use the outdated text stored in
764-
// the _plainText when handling the pointer event.
764+
// the plainText when handling the pointer event.
765765
//
766766
// If this happens, we need to make sure the new selection is still valid.
767767
final int textLength = textSelectionDelegate.textEditingValue.text.length;
@@ -803,16 +803,16 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
803803
_textLayoutLastMinWidth = null;
804804
}
805805

806-
String? _cachedPlainText;
807-
// Returns a plain text version of the text in the painter.
808-
//
809-
// Returns the obscured text when [obscureText] is true. See
810-
// [obscureText] and [obscuringCharacter].
811-
String get _plainText {
812-
return _cachedPlainText ??= _textPainter.text!.toPlainText(includeSemanticsLabels: false);
813-
}
806+
/// Returns a plain text version of the text in [TextPainter].
807+
///
808+
/// If [obscureText] is true, returns the obscured text. See
809+
/// [obscureText] and [obscuringCharacter].
810+
/// In order to get the styled text as an [InlineSpan] tree, use [text].
811+
String get plainText => _textPainter.plainText;
814812

815-
/// The text to display.
813+
/// The text to paint in the form of a tree of [InlineSpan]s.
814+
///
815+
/// In order to get the plain text representation, use [plainText].
816816
InlineSpan? get text => _textPainter.text;
817817
final TextPainter _textPainter;
818818
AttributedString? _cachedAttributedValue;
@@ -821,9 +821,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
821821
if (_textPainter.text == value) {
822822
return;
823823
}
824-
_cachedPlainText = null;
825824
_cachedLineBreakCount = null;
826-
827825
_textPainter.text = value;
828826
_cachedAttributedValue = null;
829827
_cachedCombinedSemanticsInfos = null;
@@ -1328,7 +1326,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
13281326
}
13291327
if (_cachedAttributedValue == null) {
13301328
if (obscureText) {
1331-
_cachedAttributedValue = AttributedString(obscuringCharacter * _plainText.length);
1329+
_cachedAttributedValue = AttributedString(obscuringCharacter * plainText.length);
13321330
} else {
13331331
final StringBuffer buffer = StringBuffer();
13341332
int offset = 0;
@@ -1855,23 +1853,24 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
18551853
if (maxLines == null) {
18561854
final double estimatedHeight;
18571855
if (width == double.infinity) {
1858-
estimatedHeight = preferredLineHeight * (_countHardLineBreaks(_plainText) + 1);
1856+
estimatedHeight = preferredLineHeight * (_countHardLineBreaks(plainText) + 1);
18591857
} else {
18601858
_layoutText(maxWidth: width);
18611859
estimatedHeight = _textPainter.height;
18621860
}
18631861
return math.max(estimatedHeight, minHeight);
18641862
}
1863+
18651864
// TODO(LongCatIsLooong): this is a workaround for
1866-
// https://github.com/flutter/flutter/issues/112123 .
1865+
// https://github.com/flutter/flutter/issues/112123.
18671866
// Use preferredLineHeight since SkParagraph currently returns an incorrect
18681867
// height.
18691868
final TextHeightBehavior? textHeightBehavior = this.textHeightBehavior;
18701869
final bool usePreferredLineHeightHack = maxLines == 1
1871-
&& text?.codeUnitAt(0) == null
1872-
&& strutStyle != null && strutStyle != StrutStyle.disabled
1873-
&& textHeightBehavior != null
1874-
&& (!textHeightBehavior.applyHeightToFirstAscent || !textHeightBehavior.applyHeightToLastDescent);
1870+
&& text?.codeUnitAt(0) == null
1871+
&& strutStyle != null && strutStyle != StrutStyle.disabled
1872+
&& textHeightBehavior != null
1873+
&& (!textHeightBehavior.applyHeightToFirstAscent || !textHeightBehavior.applyHeightToLastDescent);
18751874

18761875
// Special case maxLines == 1 since it forces the scrollable direction
18771876
// to be horizontal. Report the real height to prevent the text from being
@@ -2142,14 +2141,14 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
21422141
TextSelection _getWordAtOffset(TextPosition position) {
21432142
debugAssertLayoutUpToDate();
21442143
// When long-pressing past the end of the text, we want a collapsed cursor.
2145-
if (position.offset >= _plainText.length) {
2144+
if (position.offset >= plainText.length) {
21462145
return TextSelection.fromPosition(
2147-
TextPosition(offset: _plainText.length, affinity: TextAffinity.upstream)
2146+
TextPosition(offset: plainText.length, affinity: TextAffinity.upstream)
21482147
);
21492148
}
21502149
// If text is obscured, the entire sentence should be treated as one word.
21512150
if (obscureText) {
2152-
return TextSelection(baseOffset: 0, extentOffset: _plainText.length);
2151+
return TextSelection(baseOffset: 0, extentOffset: plainText.length);
21532152
}
21542153
final TextRange word = _textPainter.getWordBoundary(position);
21552154
final int effectiveOffset;
@@ -2170,7 +2169,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
21702169
// If the platform is Android and the text is read only, try to select the
21712170
// previous word if there is one; otherwise, select the single whitespace at
21722171
// the position.
2173-
if (TextLayoutMetrics.isWhitespace(_plainText.codeUnitAt(effectiveOffset))
2172+
if (TextLayoutMetrics.isWhitespace(plainText.codeUnitAt(effectiveOffset))
21742173
&& effectiveOffset > 0) {
21752174
assert(defaultTargetPlatform != null);
21762175
final TextRange? previousWord = _getPreviousWord(word.start);

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

+2-6
Original file line numberDiff line numberDiff line change
@@ -509,8 +509,6 @@ class TextSelectionOverlay {
509509
}
510510

511511
double _getStartGlyphHeight() {
512-
final InlineSpan span = renderObject.text!;
513-
final String prevText = span.toPlainText();
514512
final String currText = selectionDelegate.textEditingValue.text;
515513
final int firstSelectedGraphemeExtent;
516514
Rect? startHandleRect;
@@ -521,7 +519,7 @@ class TextSelectionOverlay {
521519
// widget.renderObject.getRectForComposingRange might fail. In cases where
522520
// the current frame is different from the previous we fall back to
523521
// renderObject.preferredLineHeight.
524-
if (prevText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) {
522+
if (renderObject.plainText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) {
525523
final String selectedGraphemes = _selection.textInside(currText);
526524
firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length;
527525
startHandleRect = renderObject.getRectForComposingRange(TextRange(start: _selection.start, end: _selection.start + firstSelectedGraphemeExtent));
@@ -530,13 +528,11 @@ class TextSelectionOverlay {
530528
}
531529

532530
double _getEndGlyphHeight() {
533-
final InlineSpan span = renderObject.text!;
534-
final String prevText = span.toPlainText();
535531
final String currText = selectionDelegate.textEditingValue.text;
536532
final int lastSelectedGraphemeExtent;
537533
Rect? endHandleRect;
538534
// See the explanation in _getStartGlyphHeight.
539-
if (prevText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) {
535+
if (renderObject.plainText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) {
540536
final String selectedGraphemes = _selection.textInside(currText);
541537
lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length;
542538
endHandleRect = renderObject.getRectForComposingRange(TextRange(start: _selection.end - lastSelectedGraphemeExtent, end: _selection.end));

packages/flutter/test/painting/text_painter_test.dart

+45-14
Original file line numberDiff line numberDiff line change
@@ -1209,20 +1209,51 @@ void main() {
12091209
painter.dispose();
12101210
});
12111211

1212-
test('TextPainter.getWordBoundary works', (){
1213-
// Regression test for https://github.com/flutter/flutter/issues/93493 .
1214-
const String testCluster = '👨‍👩‍👦👨‍👩‍👦👨‍👩‍👦'; // 8 * 3
1215-
final TextPainter textPainter = TextPainter(
1216-
text: const TextSpan(text: testCluster),
1217-
textDirection: TextDirection.ltr,
1218-
);
1219-
1220-
textPainter.layout();
1221-
expect(
1222-
textPainter.getWordBoundary(const TextPosition(offset: 8)),
1223-
const TextRange(start: 8, end: 16),
1224-
);
1225-
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61017
1212+
test('TextPainter.getWordBoundary works', (){
1213+
// Regression test for https://github.com/flutter/flutter/issues/93493 .
1214+
const String testCluster = '👨‍👩‍👦👨‍👩‍👦👨‍👩‍👦'; // 8 * 3
1215+
final TextPainter textPainter = TextPainter(
1216+
text: const TextSpan(text: testCluster),
1217+
textDirection: TextDirection.ltr,
1218+
);
1219+
1220+
textPainter.layout();
1221+
expect(
1222+
textPainter.getWordBoundary(const TextPosition(offset: 8)),
1223+
const TextRange(start: 8, end: 16),
1224+
);
1225+
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61017
1226+
1227+
test('TextPainter plainText getter', () {
1228+
final TextPainter painter = TextPainter()
1229+
..textDirection = TextDirection.ltr;
1230+
1231+
expect(painter.plainText, '');
1232+
1233+
painter.text = const TextSpan(children: <InlineSpan>[
1234+
TextSpan(text: 'before\n'),
1235+
WidgetSpan(child: Text('widget')),
1236+
TextSpan(text: 'after'),
1237+
]);
1238+
expect(painter.plainText, 'before\n\uFFFCafter');
1239+
1240+
painter.setPlaceholderDimensions(const <PlaceholderDimensions>[
1241+
PlaceholderDimensions(size: Size(50, 30), alignment: ui.PlaceholderAlignment.bottom),
1242+
]);
1243+
painter.layout();
1244+
expect(painter.plainText, 'before\n\uFFFCafter');
1245+
1246+
painter.text = const TextSpan(children: <InlineSpan>[
1247+
TextSpan(text: 'be\nfo\nre\n'),
1248+
WidgetSpan(child: Text('widget')),
1249+
TextSpan(text: 'af\nter'),
1250+
]);
1251+
expect(painter.plainText, 'be\nfo\nre\n\uFFFCaf\nter');
1252+
painter.layout();
1253+
expect(painter.plainText, 'be\nfo\nre\n\uFFFCaf\nter');
1254+
1255+
painter.dispose();
1256+
});
12261257
}
12271258

12281259
class MockCanvas extends Fake implements Canvas {

packages/flutter/test/rendering/editable_test.dart

+28
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,34 @@ void main() {
364364
expect(editable.debugNeedsLayout, isTrue);
365365
});
366366

367+
test('Can read plain text', () {
368+
final TextSelectionDelegate delegate = _FakeEditableTextState();
369+
final RenderEditable editable = RenderEditable(
370+
maxLines: null,
371+
textDirection: TextDirection.ltr,
372+
offset: ViewportOffset.zero(),
373+
textSelectionDelegate: delegate,
374+
startHandleLayerLink: LayerLink(),
375+
endHandleLayerLink: LayerLink(),
376+
);
377+
378+
expect(editable.plainText, '');
379+
380+
editable.text = const TextSpan(text: '123');
381+
expect(editable.plainText, '123');
382+
383+
editable.text = const TextSpan(
384+
children: <TextSpan>[
385+
TextSpan(text: 'abc', style: TextStyle(fontSize: 12, fontFamily: 'Ahem')),
386+
TextSpan(text: 'def', style: TextStyle(fontSize: 10, fontFamily: 'Ahem')),
387+
],
388+
);
389+
expect(editable.plainText, 'abcdef');
390+
391+
editable.layout(const BoxConstraints.tightFor(width: 200));
392+
expect(editable.plainText, 'abcdef');
393+
});
394+
367395
test('Cursor with ideographic script', () {
368396
final TextSelectionDelegate delegate = _FakeEditableTextState();
369397
final ValueNotifier<bool> showCursor = ValueNotifier<bool>(true);

0 commit comments

Comments
 (0)