Flutter Text Rendering
Learn about how Flutter renders Text widgets and see how to make your own custom text widget. By Jonathan Sande.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Flutter Text Rendering
30 mins
- Getting Started
- A Journey Through the Framework
- Stepping in: The Text Widget
- Stepping Down: Text Rendering Objects
- Way Down: Flutter’s Text Engine
- Stepping Up Your Game: Building a Custom Text Widget
- Custom Render Object
- Calculating and Measuring Text Runs
- Laying Out Runs in Lines
- Setting the size
- Painting Text to the Canvas
- Where to Go From Here?
Calculating and Measuring Text Runs
The text needs to line wrap. To do that you need to find appropriate places in the string where it’s OK to break lines. As I mentioned above, at the time of this writing Flutter does not expose the Minikin/ICU LineBreaker
class, but an acceptable substitute would be to break between a space and a word.
This is the app welcome string in Unicode:
ᠤᠷᠭᠡᠨ ᠠᠭᠤᠳᠠᠮ ᠲᠠᠯᠠ ᠨᠤᠲᠤᠭ ᠲᠤ ᠮᠢᠨᠢ ᠬᠦᠷᠦᠯᠴᠡᠨ ᠢᠷᠡᠭᠡᠷᠡᠢ
These are the possible break locations:
I’ll call the substrings between breaks “runs” of text. You’ll represent that with a TextRun
class, which you’ll make now.
In the lib/model folder, create a file called text_run.dart, and paste in the following code:
import 'dart:ui' as ui show Paragraph;
class TextRun {
TextRun(this.start, this.end, this.paragraph);
// 1
int start;
int end;
// 2
ui.Paragraph paragraph;
}
Explaining the comments:
- These are the indexes of the text run substring, where
start
is inclusive andend
is exclusive. - You’ll make a “paragraph” for each run so that you can get its measured size.
In dartui/vertical_paragraph.dart add the following code to VerticalParagraph, remembering to import TextRun
:
// 1
List<TextRun> _runs = [];
void _addRun(int start, int end) {
// 2
final builder = ui.ParagraphBuilder(_paragraphStyle)
..pushStyle(_textStyle)
..addText(_text.substring(start, end));
final paragraph = builder.build();
// 3
paragraph.layout(ui.ParagraphConstraints(width: double.infinity));
final run = TextRun(start, end, paragraph);
_runs.add(run);
}
These items are worthy of mention:
- You’ll store every word in the string separately.
- Add the style and text before building the paragraph.
- You must call
layout
before you can get the measurements. I set thewidth
constraint toinfinity
to make sure that this run is only one line.
Then in the _calculateRuns method add the following:
// 1
if (_runs.isNotEmpty) {
return;
}
// 2
final breaker = LineBreaker();
breaker.text = _text;
final int breakCount = breaker.computeBreaks();
final breaks = breaker.breaks;
// 3
int start = 0;
int end;
for (int i = 0; i < breakCount; i++) {
end = breaks[i];
_addRun(start, end);
start = end;
}
// 4
end = _text.length;
if (start < end) {
_addRun(start, end);
}
Explaining each section:
- No need to recalculate the runs if it has already been done.
- This is the simple line breaker class I included in the
util
folder. Thebreaks
variable is a list of index locations where breaks could occur, in this case between a space and non-space character. - Create a run from the text between each break.
- Catch the last word of the string.
Test out what you have done so far. You don't have enough to show anything on the screen yet, but add a print statement at the end of the _layout method.
print("There are ${_runs.length} runs.");
Run the app normally. You should see the following printout in the Run console:
There are 8 runs.
Good. That's what you would expect:
Laying Out Runs in Lines
Now you need to see how many of those runs you can fit per line. Let's say the maximum length of the line can be as long as the green bar in the image below:
You can see that the first three runs will fit, but the fourth needs to go on a new line.
To do this programmatically you need to know how long each run is. Thankfully that information is stored in the paragraph
property of TextRun
.
You're going to make a class to save information about each line. In the lib/model folder, create a file called line_info.dart. Paste in the following code:
import 'dart:ui';
class LineInfo {
LineInfo(this.textRunStart, this.textRunEnd, this.bounds);
// 1
int textRunStart;
int textRunEnd;
// 2
Rect bounds;
}
Commenting on the properties:
- These indexes tell you the range of runs that are included in this line.
- This is the pixel size of this line. You could have used
TextBox
instead, which includes text direction (left-to-right or right-to-left). This app doesn't use bidi text, though, so a simpleRect
will suffice.
Back in dartui/vertical_paragraph.dart, in the VerticalParagraph class, add the following code, remembering to import LineInfo
:
// 1
List<LineInfo> _lines = [];
// 2
void _addLine(int start, int end, double width, double height) {
final bounds = Rect.fromLTRB(0, 0, width, height);
final LineInfo lineInfo = LineInfo(start, end, bounds);
_lines.add(lineInfo);
}
Going over both parts:
- The length of this list will be the number of lines.
- At this point you haven't rotated anything, so
width
andheight
refer to the horizontal orientation.
Then in the _calculateLineBreaks method add the following:
// 1
if (_runs.isEmpty) {
return;
}
// 2
if (_lines.isNotEmpty) {
_lines.clear();
}
// 3
int start = 0;
int end;
double lineWidth = 0;
double lineHeight = 0;
for (int i = 0; i < _runs.length; i++) {
end = i;
final run = _runs[i];
// 4
final runWidth = run.paragraph.maxIntrinsicWidth;
final runHeight = run.paragraph.height;
// 5
if (lineWidth + runWidth > maxLineLength) {
_addLine(start, end, lineWidth, lineHeight);
start = end;
lineWidth = runWidth;
lineHeight = runHeight;
} else {
lineWidth += runWidth;
// 6
lineHeight = math.max(lineHeight, run.paragraph.height);
}
}
// 7
end = _runs.length;
if (start < end) {
_addLine(start, end, lineWidth, lineHeight);
}
Explaining the different parts in order:
- This method must be called after runs are calculated.
- It's OK to relayout the lines with a different constraint.
- Loop through each run checking the measurements.
-
Paragraph
also has awidth
parameter, but it's the constraint width, not the measured width. Since you passed indouble.infinity
as the constraint, the width is infinity. UsingmaxIntrinsicWidth
orlongestLine
will give you the measured width of the run. See this link for more. - Find the sum of the widths. If it exceeds the max length, then start a new line.
- Currently the height is always the same, but in the future if you use different styles for each run, taking the max will allow everything to fit.
- Add any final runs as the last line.
Test out what you have done so far by adding another print statement at the end of the _layout method:
print("There are ${_lines.length} lines.");
Do a hot restart (or restart the app if needed). You should see:
There are 3 lines.
This is what you would expect because in main.dart the VerticalText
widget has a constraint of 300 logical pixels, which is the approximate length of the green bar: