forked from firka/flutter
Cache TextPainter plain text value to improve performance (#109841)
This commit is contained in:
@@ -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,
|
||||
);
|
||||
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
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user