Flutter Text Rendering

Learn about how Flutter renders Text widgets and see how to make your own custom text widget. By Jonathan Sande.

5 (18) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

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:

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:

  1. These are the indexes of the text run substring, where start is inclusive and end is exclusive.
  2. 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:

  1. You’ll store every word in the string separately.
  2. Add the style and text before building the paragraph.
  3. You must call layout before you can get the measurements. I set the width constraint to infinity 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:

  1. No need to recalculate the runs if it has already been done.
  2. This is the simple line breaker class I included in the util folder. The breaks variable is a list of index locations where breaks could occur, in this case between a space and non-space character.
  3. Create a run from the text between each break.
  4. 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:

Expected runs

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:

Maximum line length

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:

  1. These indexes tell you the range of runs that are included in this line.
  2. 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 simple Rect 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:

  1. The length of this list will be the number of lines.
  2. At this point you haven't rotated anything, so width and height 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:

  1. This method must be called after runs are calculated.
  2. It's OK to relayout the lines with a different constraint.
  3. Loop through each run checking the measurements.
  4. Paragraph also has a width parameter, but it's the constraint width, not the measured width. Since you passed in double.infinity as the constraint, the width is infinity. Using maxIntrinsicWidth or longestLine will give you the measured width of the run. See this link for more.
  5. Find the sum of the widths. If it exceeds the max length, then start a new line.
  6. 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.
  7. 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:

Expected lines