The Breakdown: Flutter Text
Explore the internal working of the Flutter Text Widget
Why this series?
While there is quite a lot of content on usage and customisations for Flutter Widgets, there is relatively little content about how the Widget actually achieves the functionality it is designed for. Even a fairly simple Widget has a lot of simplification under the hood even excluding its main purpose. This series breaks down various Widgets to their fundamental level which usually goes down to the canvas level.
Understanding the function of Text
Text(
"Hello World!",
style: TextStyle(
fontSize: 24.0,
),
)
The Text
Widget is probably the simplest Widget in Flutter to explain to a beginner - it is primarily to render some text. However, the simple act of painting the text on the screen is only a fraction of what the Widget does. Primarily, Text
isn't just a UI component, it's a Flutter Widget. What I am implying here is that - not only does using Text
create text on the screen, but it also integrates the app preferences into the Widget such as text direction, text styles, semantics, and more.
But here's the catch: the Text
Widget is not responsible for rendering text, it is responsible for integrating aspects of the Flutter app into the text displayed. This is the reason that the Text
Widget does not expect a TextStyle
or other parameters to be provided. It inherits the default text style from the BuildContext
and builds the text accordingly. As you go deeper into how the Widget paints the text on the screen, you will find more components that are more and more pure - meaning they do not inherit any information from the Flutter context and rather need all the information to be passed down to them.
Here is the simplified code for the Text
Widget build method. As you can see, most of the method incorporates existing styles, adds a mouse region, and adds semantics. The rest of the method delegates the rendering to a RichText
Widget.
// The build() method of the Text Widget
Widget build(BuildContext context) {
// Set the effective text style
final defaultTextStyle = DefaultTextStyle.of(context);
TextStyle? effectiveTextStyle = style;
if (style == null || style!.inherit) {
effectiveTextStyle = defaultTextStyle.style.merge(style);
}
if (MediaQuery.boldTextOverride(context)) {
effectiveTextStyle = effectiveTextStyle!.merge(const TextStyle(fontWeight: FontWeight.bold));
}
final registrar = SelectionContainer.maybeOf(context);
// Parameters passed down to RichText
Widget result = RichText(...);
// Create a mouse region
if (registrar != null) {
result = MouseRegion(...);
}
// Set any semantics for the text
if (semanticsLabel != null) {
result = Semantics(...);
}
return result;
}
Underneath the Text Widget itself is another Widget: RichText
.
Under the hood: RichText
The RichText
Widget is used to display formatted text with varying styles within a single paragraph or sentence. It allows developers to create complex text layouts with different fonts, sizes, colours, and styles.
The RichText
Widget accepts a TextSpan
object to define the text and styles that are displayed on the screen. The TextSpan
class allows developers to set various text formatting options such as font family, size, weight, style, colour, and background colour. It also allows for text decorations such as underlining and strikethrough. Additionally, TextSpan
can also contain child TextSpan
objects that allow nested text with different styles and formatting for each span.
Here's an example of how to use the RichText
Widget to display some simple text with different styles:
RichText(
text: TextSpan(
text: 'Hello ',
style: TextStyle(
fontSize: 20.0,
fontWeight: FontWeight.bold,
color: Colors.black,
),
children: <TextSpan>[
TextSpan(
text: 'World',
style: TextStyle(
fontSize: 24.0,
fontWeight: FontWeight.normal,
color: Colors.blue,
),
),
TextSpan(
text: '!',
style: TextStyle(
fontSize: 20.0,
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
],
),
),
The Widget is made by extending MultiChildRenderObjectWidget
with the multiple children here being the text spans provided to it. The RichText
Widget gets most of its properties from the parameters and gets far fewer properties from Flutter. The only ones it inherits are text direction and locale.
Here is a simplified version of the createRenderObject()
method from RichText
:
@override
RenderParagraph createRenderObject(BuildContext context) {
return RenderParagraph(text,
textAlign: textAlign,
// Defaults to inherited directionality
textDirection: textDirection ?? Directionality.of(context),
softWrap: softWrap,
overflow: overflow,
textScaleFactor: textScaleFactor,
maxLines: maxLines,
strutStyle: strutStyle,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
// Defaults to inherited locale
locale: locale ?? Localizations.maybeLocaleOf(context),
registrar: selectionRegistrar,
selectionColor: selectionColor,
);
}
The RichText
Widget creates a RenderObject
named RenderParagraph
which gets us closer to finally rendering the text on the screen.
Diving deeper: RenderParagraph
The RenderParagraph
class is a RenderObject
responsible for rendering a paragraph of text on the screen. It handles text layout, line breaking, and text selection features. It is used as the rendering component behind the Text
and TextField
Widget in Flutter. RenderParagraph
provides text selection features, allowing the user to select a range of text with a mouse or touch gesture.
RenderParagraph
is focused on rendering text, meaning it does not inherit any Flutter information and needs all parameters defined - either by users or as defaults. Since it is a RenderObject
, it deals with layout of the text. However, while usual RenderObjects
would only deal with the box size, the RenderParagraph
also has to deal with the internal layout of the text to be displayed - taking into account the line height, text direction, wrapping, and more.
There are various methods that deal with this layout procedure. The following are some methods that deal with sizing the RenderBox
:
@override
double computeMinIntrinsicWidth(double height) {...}
@override
double computeMaxIntrinsicWidth(double height) {...}
@override
double computeMinIntrinsicHeight(double width) {...}
@override
double computeMaxIntrinsicHeight(double width) {...}
@override
double computeDistanceToActualBaseline(TextBaseline baseline) {...}
void _computeChildrenWidthWithMaxIntrinsics(double height) {...}
void _computeChildrenWidthWithMinIntrinsics(double height) {...}
void _computeChildrenHeightWithMinIntrinsics(double width) {...}
For the text layout, an important method is the performLayout()
method which handles text overflow and clipping.
Here is a simplified version of the method:
@override
void performLayout() {
// Calculate size of placeholders
final BoxConstraints constraints = this.constraints;
_placeholderDimensions = _layoutChildren(constraints);
_layoutTextWithConstraints(constraints);
_setParentData();
final Size textSize = _textPainter.size;
final bool textDidExceedMaxLines = _textPainter.didExceedMaxLines;
size = constraints.constrain(textSize);
// Check if text overflows any boundary
final bool didOverflowHeight = size.height < textSize.height || textDidExceedMaxLines;
final bool didOverflowWidth = size.width < textSize.width;
final bool hasVisualOverflow = didOverflowWidth || didOverflowHeight;
if (hasVisualOverflow) {
switch (_overflow) {
case TextOverflow.visible:
_needsClipping = false;
_overflowShader = null;
break;
case TextOverflow.clip:
case TextOverflow.ellipsis:
_needsClipping = true;
_overflowShader = null;
break;
case TextOverflow.fade:
_needsClipping = true;
final fadeSizePainter = TextPainter(...)..layout();
if (didOverflowWidth) {
double fadeEnd, fadeStart;
switch (textDirection) {
... // Set the direction of fade
}
_overflowShader = ui.Gradient.linear(...);
} else {
final double fadeEnd = size.height;
final double fadeStart = fadeEnd - fadeSizePainter.height / 2.0;
_overflowShader = ui.Gradient.linear(...);
}
break;
}
} else {
... // No overflow
}
}
The performLayout()
method calculates if the text overflows based on a dry run and also handles overflow appropriately - like by creating a fade painter to fade text going to the end.
However, this still doesn't answer what actually paints the text on the screen.
So far, we've:
1) Collected everything required for the text: styles, direction, semantics, selections, etc.
2) Created text spans based on the data.
3) Performed layout for the text and added considerations for overflow.
Since Flutter apps are just giant paintings on a canvas, the final bit is the TextPainter
class that does the text painting for us.
Bedrock: Canvas and TextPainter
To paint the text on the screen, there are a few steps to follow.
First, we pass the tree of TextSpans
created by the Widgets before to the TextPainter
. The RenderParagraph
also creates an instance of TextPainter
inside it.
Then, we call the layout()
method inside the TextPainter
class which sizes the text height and width, and also clears any caret cache (since the class is also used for the TextField
Widget).
Here is the simplified code for the layout()
method:
void layout({ double minWidth = 0.0, double maxWidth = double.infinity }) {
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 and line metrics as well.
_lineMetricsCache = null;
_previousCaretPosition = null;
_previousCaretPrototype = null;
_layoutParagraph(minWidth, maxWidth);
_inlinePlaceholderBoxes = _paragraph!.getBoxesForPlaceholders();
}
Finally, to actually actually paint text on the screen, we call the paint()
method of the TextPainter
and pass along the canvas from the RenderParagraph
. This calls the canvas.drawParagraph()
which paints the text on the screen:
void paint(Canvas canvas, Offset offset) {
final double? minWidth = _lastMinWidth;
final double? maxWidth = _lastMaxWidth;
if (_rebuildParagraphForPaint) {
_createParagraph();
_layoutParagraph(minWidth, maxWidth);
}
canvas.drawParagraph(_paragraph!, offset);
}
If you wanted to do this yourself, you can also use a CustomPaint
with a CustomPainter
and draw a paragraph to the canvas yourself. That process will make you realise the number of arguments that really go into making a simple Text
widget.
Conclusion
It's very easy to say "It's just a Text Widget" but there is amazing customisation and complexity hidden behind even the simplest of Widgets. Text rendering in particular has always been a complex thing to handle and I hope this article helps you appreciate the complexity that is hidden away from you to allow you to create your best ideas with the least effort.