[web] RenderParagraph needs paint after a DPR change (#186968)
Currently, there are situation where a `ui.Paragraph` is not repainted,
for example when it's inside a `RepaintBoundary`. When
`devicePixelRatio` changes due to zooming in or out, and the paragraph
isn't repainted, it results in `WebParagraph`s becoming blurry/choppy
because they are painted as images.
The image needs to be regenerated for the new `devicePixelRatio` in
order to become clear.
This PR adds the `devicePixelRatio` property to `RenderParagraph` and,
on the web, a change in this property causes the paragraph to be
repainted.
The cost of this PR on performance should be minimal because:
1. It's limited to web.
2. It only costs an extra paint when the dpr changes, which is not a
common case (on the web, it happens when zooming in/out or when the
window is moved to a different display).
Part of https://github.com/flutter/flutter/issues/172561
diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart
index b45f3d9..5bcc457 100644
--- a/packages/flutter/lib/src/rendering/paragraph.dart
+++ b/packages/flutter/lib/src/rendering/paragraph.dart
@@ -41,8 +41,10 @@
/// Signature for a function that determines the [_TextBoundaryRecord] at the given
/// [TextPosition], for the given [String].
-typedef _TextBoundaryAtPositionInText =
- _TextBoundaryRecord Function(TextPosition position, String text);
+typedef _TextBoundaryAtPositionInText = _TextBoundaryRecord Function(
+ TextPosition position,
+ String text,
+);
const String _kEllipsis = '\u2026';
@@ -353,6 +355,7 @@
List<RenderBox>? children,
Color? selectionColor,
SelectionRegistrar? registrar,
+ double devicePixelRatio = 1.0,
}) : assert(text.debugAssertIsValid()),
assert(maxLines == null || maxLines > 0),
assert(
@@ -361,6 +364,7 @@
),
_softWrap = softWrap,
_overflow = overflow,
+ _devicePixelRatio = devicePixelRatio,
_selectionColor = selectionColor,
_textPainter = TextPainter(
text: text,
@@ -665,6 +669,25 @@
markNeedsLayout();
}
+ /// The number of device pixels for each logical pixel.
+ ///
+ /// This is used by some renderers (like WebParagraph on the web) to
+ /// regenerate the text bitmap when the scale changes.
+ double get devicePixelRatio => _devicePixelRatio;
+ double _devicePixelRatio;
+
+ set devicePixelRatio(double value) {
+ if (_devicePixelRatio == value) {
+ return;
+ }
+ _devicePixelRatio = value;
+ if (kIsWeb) {
+ // The `WebParagraph` implementation renders the paragraph as an image. After a
+ // `devicePixelRatio` change, the image needs to be regenerated or it would look blurry.
+ markNeedsPaint();
+ }
+ }
+
/// An optional maximum number of lines for the text to span, wrapping if
/// necessary. If the text exceeds the given number of lines, it will be
/// truncated according to [overflow] and [softWrap].
@@ -1475,6 +1498,7 @@
);
properties.add(DiagnosticsProperty<Locale>('locale', locale, defaultValue: null));
properties.add(IntProperty('maxLines', maxLines, ifNull: 'unlimited'));
+ properties.add(DoubleProperty('devicePixelRatio', devicePixelRatio, defaultValue: 1.0));
}
}
diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart
index a706eb8..8848b3b 100644
--- a/packages/flutter/lib/src/widgets/basic.dart
+++ b/packages/flutter/lib/src/widgets/basic.dart
@@ -24,6 +24,8 @@
import 'framework.dart';
import 'indexed_stack.dart';
import 'localizations.dart';
+import 'media_query.dart';
+import 'view.dart';
import 'widget_span.dart';
export 'package:flutter/animation.dart';
@@ -6614,6 +6616,9 @@
/// widgets.
final Color? selectionColor;
+ double _getDevicePixelRatio(BuildContext context) =>
+ MediaQuery.maybeDevicePixelRatioOf(context) ?? View.maybeOf(context)?.devicePixelRatio ?? 1.0;
+
@override
RenderParagraph createRenderObject(BuildContext context) {
assert(textDirection != null || debugCheckHasDirectionality(context));
@@ -6631,6 +6636,7 @@
locale: locale ?? Localizations.maybeLocaleOf(context),
registrar: selectionRegistrar,
selectionColor: selectionColor,
+ devicePixelRatio: _getDevicePixelRatio(context),
);
}
@@ -6650,7 +6656,8 @@
..textHeightBehavior = textHeightBehavior
..locale = locale ?? Localizations.maybeLocaleOf(context)
..registrar = selectionRegistrar
- ..selectionColor = selectionColor;
+ ..selectionColor = selectionColor
+ ..devicePixelRatio = _getDevicePixelRatio(context);
}
@override
diff --git a/packages/flutter/test/rendering/paragraph_test.dart b/packages/flutter/test/rendering/paragraph_test.dart
index 66dfff6..2f2bf9e 100644
--- a/packages/flutter/test/rendering/paragraph_test.dart
+++ b/packages/flutter/test/rendering/paragraph_test.dart
@@ -4,6 +4,7 @@
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, ClipOp, Paragraph, TextBox;
+import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
@@ -398,6 +399,57 @@
expect(getRectForA(), const Rect.fromLTWH(90, 0, 10, 10));
});
+ test('RenderParagraph devicePixelRatio control test', () {
+ final paragraph = RenderParagraph(
+ const TextSpan(text: 'Hello'),
+ textDirection: TextDirection.ltr,
+ );
+ layout(paragraph);
+ pumpFrame(phase: EnginePhase.paint);
+
+ expect(paragraph.debugNeedsLayout, isFalse);
+ expect(paragraph.debugNeedsPaint, isFalse);
+ expect(paragraph.devicePixelRatio, 1.0);
+
+ paragraph.devicePixelRatio = 2.0;
+ expect(paragraph.devicePixelRatio, 2.0);
+ expect(paragraph.debugNeedsLayout, isFalse);
+ // On the web, changing devicePixelRatio triggers a repaint.
+ expect(paragraph.debugNeedsPaint, kIsWeb);
+
+ if (kIsWeb) {
+ pumpFrame(phase: EnginePhase.paint);
+ expect(paragraph.debugNeedsPaint, isFalse);
+ paragraph.devicePixelRatio = 2.0;
+ expect(paragraph.debugNeedsPaint, isFalse);
+ }
+ });
+
+ test('RenderParagraph devicePixelRatio constructor test', () {
+ final paragraph = RenderParagraph(
+ const TextSpan(text: 'Hello'),
+ textDirection: TextDirection.ltr,
+ devicePixelRatio: 2.0,
+ );
+ expect(paragraph.devicePixelRatio, 2.0);
+ });
+
+ test('RenderParagraph.debugFillProperties', () {
+ final paragraph = RenderParagraph(
+ const TextSpan(text: 'Hello'),
+ textDirection: TextDirection.ltr,
+ devicePixelRatio: 2.5,
+ );
+ final builder = DiagnosticPropertiesBuilder();
+ paragraph.debugFillProperties(builder);
+
+ final List<DiagnosticsNode> nodes = builder.properties;
+ expect(
+ nodes.any((DiagnosticsNode node) => node.name == 'devicePixelRatio' && node.value == 2.5),
+ isTrue,
+ );
+ });
+
group('didExceedMaxLines', () {
RenderParagraph createRenderParagraph({
int? maxLines,
diff --git a/packages/flutter/test/widgets/rich_text_test.dart b/packages/flutter/test/widgets/rich_text_test.dart
index d61c870..a1cdd4e 100644
--- a/packages/flutter/test/widgets/rich_text_test.dart
+++ b/packages/flutter/test/widgets/rich_text_test.dart
@@ -234,4 +234,75 @@
]),
);
});
+
+ testWidgets('RichText propagates devicePixelRatio', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MediaQuery(
+ data: const MediaQueryData(devicePixelRatio: 3.0),
+ child: Directionality(
+ textDirection: TextDirection.ltr,
+ child: RichText(text: const TextSpan(text: 'Hello')),
+ ),
+ ),
+ );
+
+ RenderParagraph paragraph = tester.renderObject(find.byType(RichText));
+ expect(paragraph.devicePixelRatio, 3.0);
+
+ await tester.pumpWidget(
+ MediaQuery(
+ data: const MediaQueryData(devicePixelRatio: 4.0),
+ child: Directionality(
+ textDirection: TextDirection.ltr,
+ child: RichText(text: const TextSpan(text: 'Hello')),
+ ),
+ ),
+ );
+
+ paragraph = tester.renderObject(find.byType(RichText));
+ expect(paragraph.devicePixelRatio, 4.0);
+ });
+
+ testWidgets('RichText propagates devicePixelRatio from View', (WidgetTester tester) async {
+ tester.view.devicePixelRatio = 3.0;
+ addTearDown(tester.view.resetDevicePixelRatio);
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: RichText(text: const TextSpan(text: 'Hello')),
+ ),
+ );
+
+ RenderParagraph paragraph = tester.renderObject(find.byType(RichText));
+ expect(paragraph.devicePixelRatio, 3.0);
+
+ tester.view.devicePixelRatio = 4.0;
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: RichText(text: const TextSpan(text: 'Hello')),
+ ),
+ );
+
+ paragraph = tester.renderObject(find.byType(RichText));
+ expect(paragraph.devicePixelRatio, 4.0);
+ });
+
+ testWidgets('RichText defaults to 1.0 devicePixelRatio when no View or MediaQuery is present', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ RawView(
+ view: tester.view,
+ child: LookupBoundary(
+ child: Directionality(
+ textDirection: TextDirection.ltr,
+ child: RichText(text: const TextSpan(text: 'Hello')),
+ ),
+ ),
+ ),
+ wrapWithView: false,
+ );
+
+ final RenderParagraph paragraph = tester.renderObject(find.byType(RichText));
+ expect(paragraph.devicePixelRatio, 1.0);
+ });
}
diff --git a/packages/flutter/test/widgets/sliver_fill_viewport_test.dart b/packages/flutter/test/widgets/sliver_fill_viewport_test.dart
index 21e477f..a731932 100644
--- a/packages/flutter/test/widgets/sliver_fill_viewport_test.dart
+++ b/packages/flutter/test/widgets/sliver_fill_viewport_test.dart
@@ -129,6 +129,7 @@
' │ │ softWrap: wrapping at box width\n'
' │ │ overflow: clip\n'
' │ │ maxLines: unlimited\n'
+ ' │ │ devicePixelRatio: 3.0\n'
' │ ╘═╦══ text ═══\n'
' │ ║ TextSpan:\n'
' │ ║ <all styles inherited>\n'
@@ -160,6 +161,7 @@
' │ softWrap: wrapping at box width\n'
' │ overflow: clip\n'
' │ maxLines: unlimited\n'
+ ' │ devicePixelRatio: 3.0\n'
' ╘═╦══ text ═══\n'
' ║ TextSpan:\n'
' ║ <all styles inherited>\n'
diff --git a/packages/flutter/test/widgets/table_test.dart b/packages/flutter/test/widgets/table_test.dart
index 3f5fc10..da74090 100644
--- a/packages/flutter/test/widgets/table_test.dart
+++ b/packages/flutter/test/widgets/table_test.dart
@@ -729,23 +729,23 @@
equalsIgnoringHashCodes(
'Table-[GlobalKey#00000](dependencies: [Directionality, MediaQuery], renderObject: RenderTable#00000)\n'
'├Text("A", dependencies: [MediaQuery])\n'
- '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "A", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+ '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "A", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
'├Text("B", dependencies: [MediaQuery])\n'
- '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "B", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+ '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "B", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
'├Text("C", dependencies: [MediaQuery])\n'
- '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "C", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+ '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "C", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
'├Text("D", dependencies: [MediaQuery])\n'
- '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "D", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+ '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "D", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
'├Text("EEE", dependencies: [MediaQuery])\n'
- '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "EEE", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+ '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "EEE", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
'├Text("F", dependencies: [MediaQuery])\n'
- '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "F", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+ '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "F", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
'├Text("G", dependencies: [MediaQuery])\n'
- '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "G", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+ '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "G", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
'├Text("H", dependencies: [MediaQuery])\n'
- '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "H", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+ '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "H", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
'└Text("III", dependencies: [MediaQuery])\n'
- ' └RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "III", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n',
+ ' └RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "III", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n',
),
);
});