From e76f88316aa08d2bbbbb8e8564640f9c9010e33d Mon Sep 17 00:00:00 2001 From: Tomasz Gucio <72562119+tgucio@users.noreply.github.com> Date: Tue, 25 Oct 2022 22:51:35 +0200 Subject: [PATCH] Cache TextPainter plain text value to improve performance (#109841) --- .../lib/src/painting/text_painter.dart | 33 +++++++---- .../flutter/lib/src/rendering/editable.dart | 49 ++++++++-------- .../lib/src/widgets/text_selection.dart | 8 +-- .../test/painting/text_painter_test.dart | 57 ++++++++++++++----- .../flutter/test/rendering/editable_test.dart | 28 +++++++++ 5 files changed, 119 insertions(+), 56 deletions(-) diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index be6be2d377..da314d62f9 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -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 diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 08da752aa6..573ae8be21 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -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); diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 825b8cf9a4..4d60aaa91c 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -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)); diff --git a/packages/flutter/test/painting/text_painter_test.dart b/packages/flutter/test/painting/text_painter_test.dart index 41a6115766..f4831e3e2e 100644 --- a/packages/flutter/test/painting/text_painter_test.dart +++ b/packages/flutter/test/painting/text_painter_test.dart @@ -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: [ + TextSpan(text: 'before\n'), + WidgetSpan(child: Text('widget')), + TextSpan(text: 'after'), + ]); + expect(painter.plainText, 'before\n\uFFFCafter'); + + painter.setPlaceholderDimensions(const [ + PlaceholderDimensions(size: Size(50, 30), alignment: ui.PlaceholderAlignment.bottom), + ]); + painter.layout(); + expect(painter.plainText, 'before\n\uFFFCafter'); + + painter.text = const TextSpan(children: [ + 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 { diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index e7b797fdcc..eb2c0ce1a2 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -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(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 showCursor = ValueNotifier(true);