Unverified Commit 86c79a83 authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

[TextPainter] Don't invalidate layout cache for paint only changes (#89515)

parent e0b56dbf
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:math' show min, max; import 'dart:math' show min, max;
import 'dart:ui' as ui show Paragraph, ParagraphBuilder, ParagraphConstraints, ParagraphStyle, PlaceholderAlignment, LineMetrics, TextHeightBehavior, BoxHeightStyle, BoxWidthStyle; import 'dart:ui' as ui show Paragraph, ParagraphBuilder, ParagraphConstraints, ParagraphStyle, PlaceholderAlignment, LineMetrics, TextHeightBehavior, TextStyle, BoxHeightStyle, BoxWidthStyle;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
...@@ -185,8 +185,18 @@ class TextPainter { ...@@ -185,8 +185,18 @@ class TextPainter {
_textWidthBasis = textWidthBasis, _textWidthBasis = textWidthBasis,
_textHeightBehavior = textHeightBehavior; _textHeightBehavior = textHeightBehavior;
// _paragraph being null means the text needs layout because of style changes.
// Setting _paragraph to null invalidates all the layout cache.
//
// The TextPainter class should not aggressively invalidate the layout as long
// as `markNeedsLayout` is not called (i.e., the layout cache is still valid).
// See: https://github.com/flutter/flutter/issues/85108
ui.Paragraph? _paragraph; ui.Paragraph? _paragraph;
bool _needsLayout = true; // Whether _paragraph contains outdated paint information and needs to be
// rebuilt before painting.
bool _rebuildParagraphForPaint = true;
bool get _debugNeedsLayout => _paragraph == null;
/// Marks this text painter's layout information as dirty and removes cached /// Marks this text painter's layout information as dirty and removes cached
/// information. /// information.
...@@ -196,7 +206,6 @@ class TextPainter { ...@@ -196,7 +206,6 @@ class TextPainter {
/// in framework will automatically invoke this method. /// in framework will automatically invoke this method.
void markNeedsLayout() { void markNeedsLayout() {
_paragraph = null; _paragraph = null;
_needsLayout = true;
_previousCaretPosition = null; _previousCaretPosition = null;
_previousCaretPrototype = null; _previousCaretPrototype = null;
} }
...@@ -219,8 +228,21 @@ class TextPainter { ...@@ -219,8 +228,21 @@ class TextPainter {
return; return;
if (_text?.style != value?.style) if (_text?.style != value?.style)
_layoutTemplate = null; _layoutTemplate = null;
final RenderComparison comparison = value == null
? RenderComparison.layout
: _text?.compareTo(value) ?? RenderComparison.layout;
_text = value; _text = value;
if (comparison.index >= RenderComparison.layout.index) {
markNeedsLayout(); markNeedsLayout();
} else if (comparison.index >= RenderComparison.paint.index) {
// Don't clear the _paragraph instance variable just yet. It still
// contains valid layout information.
_rebuildParagraphForPaint = true;
}
// Neither relayout or repaint is needed.
} }
/// How the text should be aligned horizontally. /// How the text should be aligned horizontally.
...@@ -378,8 +400,6 @@ class TextPainter { ...@@ -378,8 +400,6 @@ class TextPainter {
markNeedsLayout(); markNeedsLayout();
} }
ui.Paragraph? _layoutTemplate;
/// An ordered list of [TextBox]es that bound the positions of the placeholders /// An ordered list of [TextBox]es that bound the positions of the placeholders
/// in the paragraph. /// in the paragraph.
/// ///
...@@ -454,6 +474,18 @@ class TextPainter { ...@@ -454,6 +474,18 @@ class TextPainter {
); );
} }
ui.Paragraph? _layoutTemplate;
ui.Paragraph _createLayoutTemplate() {
final ui.ParagraphBuilder builder = ui.ParagraphBuilder(
_createParagraphStyle(TextDirection.rtl),
); // direction doesn't matter, text is just a space
final ui.TextStyle? textStyle = text?.style?.getTextStyle(textScaleFactor: textScaleFactor);
if (textStyle != null)
builder.pushStyle(textStyle);
builder.addText(' ');
return builder.build()
..layout(const ui.ParagraphConstraints(width: double.infinity));
}
/// The height of a space in [text] in logical pixels. /// The height of a space in [text] in logical pixels.
/// ///
/// Not every line of text in [text] will have this height, but this height /// Not every line of text in [text] will have this height, but this height
...@@ -466,19 +498,7 @@ class TextPainter { ...@@ -466,19 +498,7 @@ class TextPainter {
/// that contribute to the [preferredLineHeight]. If [text] is null or if it /// that contribute to the [preferredLineHeight]. If [text] is null or if it
/// specifies no styles, the default [TextStyle] values are used (a 10 pixel /// specifies no styles, the default [TextStyle] values are used (a 10 pixel
/// sans-serif font). /// sans-serif font).
double get preferredLineHeight { double get preferredLineHeight => (_layoutTemplate ??= _createLayoutTemplate()).height;
if (_layoutTemplate == null) {
final ui.ParagraphBuilder builder = ui.ParagraphBuilder(
_createParagraphStyle(TextDirection.rtl),
); // direction doesn't matter, text is just a space
if (text?.style != null)
builder.pushStyle(text!.style!.getTextStyle(textScaleFactor: textScaleFactor));
builder.addText(' ');
_layoutTemplate = builder.build()
..layout(const ui.ParagraphConstraints(width: double.infinity));
}
return _layoutTemplate!.height;
}
// Unfortunately, using full precision floating point here causes bad layouts // Unfortunately, using full precision floating point here causes bad layouts
// because floating point math isn't associative. If we add and subtract // because floating point math isn't associative. If we add and subtract
...@@ -496,7 +516,7 @@ class TextPainter { ...@@ -496,7 +516,7 @@ class TextPainter {
/// ///
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
double get minIntrinsicWidth { double get minIntrinsicWidth {
assert(!_needsLayout); assert(!_debugNeedsLayout);
return _applyFloatingPointHack(_paragraph!.minIntrinsicWidth); return _applyFloatingPointHack(_paragraph!.minIntrinsicWidth);
} }
...@@ -504,7 +524,7 @@ class TextPainter { ...@@ -504,7 +524,7 @@ class TextPainter {
/// ///
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
double get maxIntrinsicWidth { double get maxIntrinsicWidth {
assert(!_needsLayout); assert(!_debugNeedsLayout);
return _applyFloatingPointHack(_paragraph!.maxIntrinsicWidth); return _applyFloatingPointHack(_paragraph!.maxIntrinsicWidth);
} }
...@@ -512,7 +532,7 @@ class TextPainter { ...@@ -512,7 +532,7 @@ class TextPainter {
/// ///
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
double get width { double get width {
assert(!_needsLayout); assert(!_debugNeedsLayout);
return _applyFloatingPointHack( return _applyFloatingPointHack(
textWidthBasis == TextWidthBasis.longestLine ? _paragraph!.longestLine : _paragraph!.width, textWidthBasis == TextWidthBasis.longestLine ? _paragraph!.longestLine : _paragraph!.width,
); );
...@@ -522,7 +542,7 @@ class TextPainter { ...@@ -522,7 +542,7 @@ class TextPainter {
/// ///
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
double get height { double get height {
assert(!_needsLayout); assert(!_debugNeedsLayout);
return _applyFloatingPointHack(_paragraph!.height); return _applyFloatingPointHack(_paragraph!.height);
} }
...@@ -530,7 +550,7 @@ class TextPainter { ...@@ -530,7 +550,7 @@ class TextPainter {
/// ///
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
Size get size { Size get size {
assert(!_needsLayout); assert(!_debugNeedsLayout);
return Size(width, height); return Size(width, height);
} }
...@@ -539,7 +559,7 @@ class TextPainter { ...@@ -539,7 +559,7 @@ class TextPainter {
/// ///
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
double computeDistanceToActualBaseline(TextBaseline baseline) { double computeDistanceToActualBaseline(TextBaseline baseline) {
assert(!_needsLayout); assert(!_debugNeedsLayout);
assert(baseline != null); assert(baseline != null);
switch (baseline) { switch (baseline) {
case TextBaseline.alphabetic: case TextBaseline.alphabetic:
...@@ -561,38 +581,29 @@ class TextPainter { ...@@ -561,38 +581,29 @@ class TextPainter {
/// ///
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
bool get didExceedMaxLines { bool get didExceedMaxLines {
assert(!_needsLayout); assert(!_debugNeedsLayout);
return _paragraph!.didExceedMaxLines; return _paragraph!.didExceedMaxLines;
} }
double? _lastMinWidth; double? _lastMinWidth;
double? _lastMaxWidth; double? _lastMaxWidth;
/// Computes the visual position of the glyphs for painting the text. // Creates a ui.Paragraph using the current configurations in this class and
/// // assign it to _paragraph.
/// The text will layout with a width that's as close to its max intrinsic void _createParagraph() {
/// width as possible while still being greater than or equal to `minWidth` and assert(_paragraph == null || _rebuildParagraphForPaint);
/// less than or equal to `maxWidth`. final InlineSpan? text = this.text;
/// if (text == null) {
/// The [text] and [textDirection] properties must be non-null before this is throw StateError('TextPainter.text must be set to a non-null value before using the TextPainter.');
/// called. }
void layout({ double minWidth = 0.0, double maxWidth = double.infinity }) {
assert(text != null, 'TextPainter.text must be set to a non-null value before using the TextPainter.');
assert(textDirection != null, 'TextPainter.textDirection must be set to a non-null value before using the TextPainter.');
if (!_needsLayout && minWidth == _lastMinWidth && maxWidth == _lastMaxWidth)
return;
_needsLayout = false;
if (_paragraph == null) {
final ui.ParagraphBuilder builder = ui.ParagraphBuilder(_createParagraphStyle()); final ui.ParagraphBuilder builder = ui.ParagraphBuilder(_createParagraphStyle());
_text!.build(builder, textScaleFactor: textScaleFactor, dimensions: _placeholderDimensions); text.build(builder, textScaleFactor: textScaleFactor, dimensions: _placeholderDimensions);
_inlinePlaceholderScales = builder.placeholderScales; _inlinePlaceholderScales = builder.placeholderScales;
_paragraph = builder.build(); _paragraph = builder.build();
_rebuildParagraphForPaint = false;
} }
_lastMinWidth = minWidth;
_lastMaxWidth = maxWidth; void _layoutParagraph(double minWidth, double maxWidth) {
// A change in layout invalidates the cached caret metrics as well.
_previousCaretPosition = null;
_previousCaretPrototype = null;
_paragraph!.layout(ui.ParagraphConstraints(width: maxWidth)); _paragraph!.layout(ui.ParagraphConstraints(width: maxWidth));
if (minWidth != maxWidth) { if (minWidth != maxWidth) {
double newWidth; double newWidth;
...@@ -614,6 +625,32 @@ class TextPainter { ...@@ -614,6 +625,32 @@ class TextPainter {
_paragraph!.layout(ui.ParagraphConstraints(width: newWidth)); _paragraph!.layout(ui.ParagraphConstraints(width: newWidth));
} }
} }
}
/// Computes the visual position of the glyphs for painting the text.
///
/// The text will layout with a width that's as close to its max intrinsic
/// width as possible while still being greater than or equal to `minWidth` and
/// less than or equal to `maxWidth`.
///
/// The [text] and [textDirection] properties must be non-null before this is
/// called.
void layout({ double minWidth = 0.0, double maxWidth = double.infinity }) {
assert(text != null, 'TextPainter.text must be set to a non-null value before using the TextPainter.');
assert(textDirection != null, 'TextPainter.textDirection must be set to a non-null value before using the TextPainter.');
// Return early if the current layout information is not outdated, even if
// _needsPaint is true (in which case _paragraph will be rebuilt in paint).
if (_paragraph != null && minWidth == _lastMinWidth && maxWidth == _lastMaxWidth)
return;
if (_rebuildParagraphForPaint || _paragraph == null)
_createParagraph();
_lastMinWidth = minWidth;
_lastMaxWidth = maxWidth;
// A change in layout invalidates the cached caret metrics as well.
_previousCaretPosition = null;
_previousCaretPrototype = null;
_layoutParagraph(minWidth, maxWidth);
_inlinePlaceholderBoxes = _paragraph!.getBoxesForPlaceholders(); _inlinePlaceholderBoxes = _paragraph!.getBoxesForPlaceholders();
} }
...@@ -630,15 +667,30 @@ class TextPainter { ...@@ -630,15 +667,30 @@ class TextPainter {
/// To set the text style, specify a [TextStyle] when creating the [TextSpan] /// To set the text style, specify a [TextStyle] when creating the [TextSpan]
/// that you pass to the [TextPainter] constructor or to the [text] property. /// that you pass to the [TextPainter] constructor or to the [text] property.
void paint(Canvas canvas, Offset offset) { void paint(Canvas canvas, Offset offset) {
assert(() { final double? minWidth = _lastMinWidth;
if (_needsLayout) { final double? maxWidth = _lastMaxWidth;
throw FlutterError( if (_paragraph == null || minWidth == null || maxWidth == null) {
throw StateError(
'TextPainter.paint called when text geometry was not yet calculated.\n' 'TextPainter.paint called when text geometry was not yet calculated.\n'
'Please call layout() before paint() to position the text before painting it.', 'Please call layout() before paint() to position the text before painting it.',
); );
} }
if (_rebuildParagraphForPaint) {
Size? debugSize;
assert(() {
debugSize = size;
return true; return true;
}()); }());
_createParagraph();
// Unfortunately we have to redo the layout using the same constraints,
// since we've created a new ui.Paragraph. But there's no extra work being
// done: if _needsPaint is true and _paragraph is not null, the previous
// `layout` call didn't invoke _layoutParagraph.
_layoutParagraph(minWidth, maxWidth);
assert(debugSize == size);
}
canvas.drawParagraph(_paragraph!, offset); canvas.drawParagraph(_paragraph!, offset);
} }
...@@ -775,7 +827,7 @@ class TextPainter { ...@@ -775,7 +827,7 @@ class TextPainter {
} }
Offset get _emptyOffset { Offset get _emptyOffset {
assert(!_needsLayout); // implies textDirection is non-null assert(!_debugNeedsLayout); // implies textDirection is non-null
assert(textAlign != null); assert(textAlign != null);
switch (textAlign) { switch (textAlign) {
case TextAlign.left: case TextAlign.left:
...@@ -836,7 +888,7 @@ class TextPainter { ...@@ -836,7 +888,7 @@ class TextPainter {
// Checks if the [position] and [caretPrototype] have changed from the cached // Checks if the [position] and [caretPrototype] have changed from the cached
// version and recomputes the metrics required to position the caret. // version and recomputes the metrics required to position the caret.
void _computeCaretMetrics(TextPosition position, Rect caretPrototype) { void _computeCaretMetrics(TextPosition position, Rect caretPrototype) {
assert(!_needsLayout); assert(!_debugNeedsLayout);
if (position == _previousCaretPosition && caretPrototype == _previousCaretPrototype) if (position == _previousCaretPosition && caretPrototype == _previousCaretPrototype)
return; return;
final int offset = position.offset; final int offset = position.offset;
...@@ -884,7 +936,7 @@ class TextPainter { ...@@ -884,7 +936,7 @@ class TextPainter {
ui.BoxHeightStyle boxHeightStyle = ui.BoxHeightStyle.tight, ui.BoxHeightStyle boxHeightStyle = ui.BoxHeightStyle.tight,
ui.BoxWidthStyle boxWidthStyle = ui.BoxWidthStyle.tight, ui.BoxWidthStyle boxWidthStyle = ui.BoxWidthStyle.tight,
}) { }) {
assert(!_needsLayout); assert(!_debugNeedsLayout);
assert(boxHeightStyle != null); assert(boxHeightStyle != null);
assert(boxWidthStyle != null); assert(boxWidthStyle != null);
return _paragraph!.getBoxesForRange( return _paragraph!.getBoxesForRange(
...@@ -897,7 +949,7 @@ class TextPainter { ...@@ -897,7 +949,7 @@ class TextPainter {
/// Returns the position within the text for the given pixel offset. /// Returns the position within the text for the given pixel offset.
TextPosition getPositionForOffset(Offset offset) { TextPosition getPositionForOffset(Offset offset) {
assert(!_needsLayout); assert(!_debugNeedsLayout);
return _paragraph!.getPositionForOffset(offset); return _paragraph!.getPositionForOffset(offset);
} }
...@@ -911,7 +963,7 @@ class TextPainter { ...@@ -911,7 +963,7 @@ class TextPainter {
/// <http://www.unicode.org/reports/tr29/#Word_Boundaries>. /// <http://www.unicode.org/reports/tr29/#Word_Boundaries>.
/// {@endtemplate} /// {@endtemplate}
TextRange getWordBoundary(TextPosition position) { TextRange getWordBoundary(TextPosition position) {
assert(!_needsLayout); assert(!_debugNeedsLayout);
return _paragraph!.getWordBoundary(position); return _paragraph!.getWordBoundary(position);
} }
...@@ -919,7 +971,7 @@ class TextPainter { ...@@ -919,7 +971,7 @@ class TextPainter {
/// ///
/// The newline (if any) is not returned as part of the range. /// The newline (if any) is not returned as part of the range.
TextRange getLineBoundary(TextPosition position) { TextRange getLineBoundary(TextPosition position) {
assert(!_needsLayout); assert(!_debugNeedsLayout);
return _paragraph!.getLineBoundary(position); return _paragraph!.getLineBoundary(position);
} }
...@@ -939,7 +991,7 @@ class TextPainter { ...@@ -939,7 +991,7 @@ class TextPainter {
/// to repeatedly call this. Instead, cache the results. The cached results /// to repeatedly call this. Instead, cache the results. The cached results
/// should be invalidated upon the next successful [layout]. /// should be invalidated upon the next successful [layout].
List<ui.LineMetrics> computeLineMetrics() { List<ui.LineMetrics> computeLineMetrics() {
assert(!_needsLayout); assert(!_debugNeedsLayout);
return _paragraph!.computeLineMetrics(); return _paragraph!.computeLineMetrics();
} }
} }
...@@ -152,7 +152,16 @@ void main() { ...@@ -152,7 +152,16 @@ void main() {
test('TextPainter error test', () { test('TextPainter error test', () {
final TextPainter painter = TextPainter(textDirection: TextDirection.ltr); final TextPainter painter = TextPainter(textDirection: TextDirection.ltr);
expect(() { painter.paint(MockCanvas(), Offset.zero); }, anyOf(throwsFlutterError, throwsAssertionError)); Object? e;
try {
painter.paint(MockCanvas(), Offset.zero);
} catch (exception) {
e = exception;
}
expect(
e.toString(),
contains('TextPainter.paint called when text geometry was not yet calculated'),
);
}); });
test('TextPainter requires textDirection', () { test('TextPainter requires textDirection', () {
......
...@@ -1261,7 +1261,7 @@ void main() { ...@@ -1261,7 +1261,7 @@ void main() {
testWidgets('Text uses TextStyle.overflow', (WidgetTester tester) async { testWidgets('Text uses TextStyle.overflow', (WidgetTester tester) async {
const TextOverflow overflow = TextOverflow.fade; const TextOverflow overflow = TextOverflow.fade;
await tester.pumpWidget( const Text( await tester.pumpWidget(const Text(
'Hello World', 'Hello World',
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
style: TextStyle(overflow: overflow), style: TextStyle(overflow: overflow),
...@@ -1272,6 +1272,42 @@ void main() { ...@@ -1272,6 +1272,42 @@ void main() {
expect(richText.overflow, overflow); expect(richText.overflow, overflow);
expect(richText.text.style!.overflow, overflow); expect(richText.text.style!.overflow, overflow);
}); });
testWidgets(
'Text can be hit-tested without layout or paint being called in a frame',
(WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/85108.
await tester.pumpWidget(
const Opacity(
opacity: 1.0,
child: Text(
'Hello World',
textDirection: TextDirection.ltr,
style: TextStyle(color: Color(0xFF123456)),
),
),
);
// The color changed and the opacity is set to 0:
// * 0 opacity will prevent RenderParagraph.paint from being called.
// * Only changing the color will prevent RenderParagraph.performLayout
// from being called.
// The underlying TextPainter should not evict its layout cache in this
// case, for hit-testing.
await tester.pumpWidget(
const Opacity(
opacity: 0.0,
child: Text(
'Hello World',
textDirection: TextDirection.ltr,
style: TextStyle(color: Color(0x87654321)),
),
),
);
await tester.tap(find.text('Hello World'));
expect(tester.takeException(), isNull);
});
} }
Future<void> _pumpTextWidget({ Future<void> _pumpTextWidget({
......
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