How to implement a rich text with a custom truncation on Flutter

This article takes a look at how custom truncations of text are implemented by customizing renderObjects. In this article, you will learn about canvas, paragraphs, gesture transfer and rendering.

The background,

For those of you who have used Text or RichText, if you want to implement a Text tail truncation function, you can use the enumeration type TextOverflow provided by the system to select different truncation methods. These truncation methods are set inside the text component. Generally, they are clip and Ellipsis. The former truncates when the maximum width is reached, and the latter displays… . But that’s not enough to meet the odd demand for our product.

enum TextOverflow {
  /// Clip the overflowing text to fix its container.
  clip,

  /// Fade the overflowing text to transparent.
  fade,

  /// Use an ellipsis to indicate that the text has overflowed.
  ellipsis,

  /// Render overflowing text outside of its container.
  visible,
}
Copy the code

For example, the product requires us to display “… The full text of * * * * “. This is a very common requirement, encountered in almost all graphic layout lists. If this happens, Text or RichText alone won’t be enough. Of course, you can search Github and find components with similar functionality, but this article doesn’t recommend any of them. Instead, it takes you through the steps of implementing a custom truncation.

Second, the RenderBox

Typically, we combine widgets to build feature-rich components that meet our needs and work well, and many system components do the same, typically the Container component.

But this time we need to start from the bottom drawing object, so we need to use The RenderObject, and to use the RenderObject, we need to use the RenderObjectWidget, and let’s look at the relationship between them through diagrams (image from Raywenderlich).

The following diagram is a subclass of Widget.

RenderObjectWidget provides configuration information for RenderObject. This class detects collisions and draws the UI.

The diagram below is a RenderObject subclass.

We’ll use RenderBox next, which defines the rectangular area on the screen for drawing. RenderParagraph is a class provided by Flutter to draw Text. It is used in RichText, and Text is just a wrapper around RichText.

1. Custom text widgets

Let’s start by defining a class RichLabel that inherits from LeafRenderObjectWidget as one of our external components. Since we don’t need child nodes for our text, LeafRenderObjectWidget works best here.

The RichLabel needs to provide several attributes: the input text TextSpan text, the truncation type RichTextOverflow Overflow, the custom truncation TextSpan overflowSpan, and the maximum number of lines int maxLines.

class RichLabel extends LeafRenderObjectWidget {
  final TextSpan text;

  /// when`overflow`for`custom`Effective when
  final TextSpan? overflowSpan;

	/// Truncation type
  final RichTextOverflow overflow;

  final int maxLines;

  const RichLabel(
      {Key? key,
      required this.text,
      this.overflowSpan,
      this.maxLines = 0.this.overflow = RichTextOverflow.clip})
      : super(key: key);
	
/// Called when initialized
  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderRichLabel(
        text: text, overflowSpan: overflowSpan, maxLines: maxLines, overflow: overflow);
  }

	/// Triggered on hot Reload
  @override
  voidupdateRenderObject(BuildContext context, RenderRichLabel renderObject) { renderObject.text = text; renderObject.maxLines = maxLines; renderObject.overflow = overflow; renderObject.overflowSpan = overflowSpan; }}Copy the code

2, custom drawing class

LeafRenderObjectWidget inherits from RenderObject, which requires us to implement the createRenderObject method and return a RenderObject object. So, we define a RenderRichLabel class.

class RenderRichLabel extends RenderBox {
  RenderRichLabel(
      {required TextSpan text,
      TextSpan? overflowSpan,
      int maxLines = 0,
      RichTextOverflow overflow = RichTextOverflow.clip})
      : _textPainter = RichTextPainter(text, maxLines, overflowSpan, overflow);
// Custom text drawing class
  final RichTextPainter _textPainter;

// Handle the gesture and decide which InlineSpan responds to the gesture
@override
  bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
    bool hitText = false;
    final InlineSpan? span = _textPainter.getSpanForPosition(position);
    if(span ! =null && span is HitTestTarget) {
      result.add(HitTestEntry(span as HitTestTarget));
      hitText = true;
    }
    return hitText;
  }

// Layout, and calculate the width and height of the RenderBox
@override
  void performLayout() {
    _layoutTextWithConstraints(constraints);
    final Size textSize = _textPainter.size;
    size = constraints.constrain(textSize);
  }

/ / to draw
  @override
  void paint(PaintingContext context, Offset offset) {
    _textPainter.paint(context.canvas, offset);
  }
Copy the code

2.1 gesture resolution

In the code above, I’ve posted a few key pieces of code that must be implemented. The hitTestChildren method is triggered by the underlying RenderBox when it receives a touch point event for the gesture. The source of the touch events in hooks. The dart files _dispatchPointerDataPacket function, through GestureBinding receive touch events, and then passed to the RenderView processing, at the bottom of the Determine whether you and your children can influence the touch point by calling bool hitTest(HitTestResult result, {required Offset position}) and look down the node tree. And finally we find the top layer which is our custom RenderRichLabel’s hitTestChildren.

After we have selected the InlineSpan (in this case TextSpan) that will finally respond to the touch event, we add it to the touch test result class BoxHitTestResult, which is this line of code: result.add(HitTestEntry(span as HitTestTarget)); That completes our work on the resolution of the gesture event. GestureBinding: GestureBinding: GestureBinding: GestureBinding: GestureBinding: GestureBinding: GestureBinding: GestureBinding

// Event pass, that is, finish the picture I posted above
void _handlePointerEventImmediately(PointerEvent event) {
			HitTestResult? hitTestResult;
      hitTestResult = HitTestResult();
      hitTest(hitTestResult, event.position);
			dispatchEvent(event, hitTestResult);
}

// Event processing
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
			entry.target.handleEvent(event.transformed(entry.transform), entry);
}

abstract class HitTestTarget {
  // This class is intended to be used as an interface, and should not be
  // extended directly; this constructor prevents instantiation and extension.
  HitTestTarget._();

  // Subclasses implement event handling
  void handleEvent(PointerEvent event, HitTestEntry entry);
}

// Let's look at the TextSpan class, which implements' HitTestTarget '
class TextSpan extends InlineSpan implements HitTestTarget {
// Post to the gesture recognizer for internal processing. Recognizer is GestureRecognizer
		@override
  void handleEvent(PointerEvent event, HitTestEntry entry) {
    if (event is PointerDownEvent)
      recognizer?.addPointer(event);
  }
}
Copy the code

2.2, layout,

We also need to implement the RenderBox layout method void performLayout() to tell Flutter how much size our text object occupies. So in function _layoutTextWithConstraints (constraints), we introduced to the parent node passed the constraints of size constraints, and through the final text content calculated size size. And we’ll do that later.

2.3, drawing

We use our custom RichTextPainter class to draw text.

3. Custom text rendering

RichTextPainter is a class we defined to draw and measure text, similar in function to the TextPainter provided by the system. We need to provide interfaces related to layout and drawing externally, and implement corresponding functions. The entire class structure is shown below, with excess code removed.

class RichTextPainter {
  RichTextPainter(TextSpan text, int maxLines,
      TextSpan? overflowSpan, RichTextOverflow overflow)
      : _text = text,
        _maxLines = maxLines,
        _overflowSpan = overflowSpan,
        _overflow = overflow;

  RichTextParagraph? _paragraph;
  bool _needsLayout = true;

double get width {
    return_applyFloatingPointHack(_paragraph? .width); }double get height {
    return_applyFloatingPointHack(_paragraph? .height); } Sizeget size {
    return Size(width, height);
  }

InlineSpan? getSpanForPosition(Offset position) {
    return_paragraph? .getSpanForPosition(position); }void layout(
      {double maxWidth = double.infinity, double maxHeight = double.infinity}) { _paragraph! .layout(maxWidth, maxHeight); }voidpaint(Canvas canvas, Offset offset) { _paragraph? .draw(canvas, offset); }Copy the code

The RichTextPainter simply processes the data and passes it to the RichTextParagraph, which is where the drawing logic is really measured and implemented.

1. Calculate and measure the text

The width and height of a piece of text is determined by the internal line height ➕ line spacing, and for a line the line height is determined by the largest glyph in it. It’s easy to understand. The sky is falling on tall people. If we look at the picture below, the red box is a line of text, you can see that the height of the text is determined by the maximum font size.

1.1, RichTextRun

To calculate the width and height of the text, determine if you need to display custom truncators. Let’s define a new class, RichTextRun,

Each RichTextRun object represents a text, so how to calculate the size of the text information. We introduce a system class: Paragraph. This class provides the computing power for a paragraph, from which we can get the width and height of the paragraph, and an array List of the rows. The LineMetrics class is important in that it has the up-spacing, down-spacing, and baseline distance for the returned text.

Speaking of which, let’s first understand the concept of glyph. What is a font? A font is a specific representation of a font. For example, PingFangSC is a type of font, in which a particular text is a glyph. And the glyph contains several important concepts: Ascent, Descent, Origin and Baseline.

But what we actually see is white space above and below the text (see above the text with a blue background color). This is actually the system’s font measure line height, which may be higher or lower than the font height. The Flutter official has a note for this section:

Line height

By default, text will layout with line height as defined by the font. Font-metrics defined line height may be taller or shorter than the font size. The height property allows manual adjustment of the height of the line as a multiple of fontSize. For most fonts, Setting height to 1.0 is not the same as omitting or setting height to null. The following diagram illustrates the difference between the font-metrics-defined line height and the line height produced with height: 1.0 (also known as the em-Square):

Font height is fontSize only if hight is set to 1.0. We can ignore this for a moment and go back to the custom text class RichTextRun to see how to calculate the width and height of a single text.

class RichTextRun {
  RichTextRun(this.text, this.position, this.paragraph, this.textSpan)
      : _width = paragraph.maxIntrinsicWidth,
        _height = paragraph.height,
        _line = paragraph.computeLineMetrics().first,
        offset = Offset.zero,
        _drawed = false;

  final String text;
  final int position;
  final Paragraph paragraph;

  final LineMetrics _line;

  double get ascent => _line.ascent;
  double get descent => _line.descent;
  double get baseline => _line.baseline;
  double get unscaledAscent => _line.unscaledAscent;

  final double _width;
  final double _height;

  Size get size => Size(_width, _height);

  /// Is it a newline character
  bool get isTurn => text == '\n';

  /// Is it a TAB character
  bool get isTab => text == '\t';

  /// Is it enter
  bool get isReturn => text == '\r';

  /// Which TextSpan to belong to
  final TextSpan textSpan;

  Offset offset;

  bool _drawed;

  /// Drawn or not
  bool get drawed => _drawed;

  /// Tags need to be redrawn
  void setNeedsDraw() => _drawed = false;

  void draw(Canvas canvas, Offset offset) {
    if (drawed) return;
    _drawed = true;
    this.offset = offset; canvas.drawParagraph(paragraph, offset); }}Copy the code

Each RichTextRun holds an instance of a Paragraph, a Paragraph, which obtains the width and height of a single text and the line information, LineMetrics, and the uplink, downlink, and baseline information from the line information.

Finally, draw interface DRAW is provided, which is used to call the Drawing paragraph text interface of Canvas.

1.2, RichTextLine

By combining multiple RichTextruns, you get the rows. Let’s create a new class RichTextLine to represent the row information. This class holds the line’s size, maximum uplink, maximum downlink, and maximum baseline. The uplink and downlink are used to determine the layout coordinates of individual text at drawing time. It also provides a drawing interface to draw each run that needs to be drawn by iterating through all the runs in the line.

class RichTextLine {
  RichTextLine(this.runs, this.bounds, this.maxWidth)
      : minLineHeight = bounds.height,
        maxLineHeight = bounds.height,
        minLineAscent = 0,
        maxLineAscent = 0,
        minLineDecent = 0,
        maxLineDecent = 0,
        maxLineBaseline = 0;

  final List<RichTextRun> runs;
  final Rect bounds;
  final double maxWidth;
  double minLineHeight;
  double maxLineHeight;
  double minLineAscent;
  double maxLineAscent;
  double minLineDecent;
  double maxLineDecent;
  double maxLineBaseline;

  double get dx => bounds.left;
  double get dy => bounds.top;

  void draw(Canvas canvas, {RichTextOverflowSpan? overflow}) {
    double dx = 0;
    // Records the maximum line width that can be reached after truncation is removed
    double maxOverlowLineWidth = 0;
    for (int j = 0; j < runs.length; j++) {
      final run = runs[j];
      if(overflow ! =null && overflow.hasOverflowSpan) {
        if (run.size.width + maxOverlowLineWidth + overflow.size.width <
            maxWidth) {
          maxOverlowLineWidth += run.size.width;
        } else {
          // We need to draw a truncation
          assert(overflow.paragraph ! =null);
          Offset offset =
              Offset(dx, maxLineBaseline - overflow.baseline);
          overflow.draw(canvas, offset);
          break; } } Offset offset = Offset(dx, maxLineBaseline - run.baseline); run.draw(canvas, offset); dx += run.size.width; }}}Copy the code

2, layout,

The real implementation of the layout is in the Void Layout (double maxWidth, double maxHeight) function of the RichTextParagraph class. MaxWidth and maxHeight are the maximum constraints that the parent component passes to our text, We need to complete the text size measurement and text size calculation under this constraint.

The whole calculation process is mainly divided into four steps:

  1. Iterate through all strings, constructing each literal into oneRichTextRunAnd store it in an array_runsIn the.
  2. traverse_runsArray in order to satisfy the maximum constraintsize=(maxWidth, maxHeight)Under the condition of completing each line of text calculation, generateRichTextLineAnd store it in an array_linesIn the.
  3. traverse_linesTo calculate the height of the entire textheight.
  4. traverse_linesTo get the maximum line width to get the width of the entire textwidth.

At this point, the actual size of our text is calculated.

3, drawing

We’ve got all the text information, line information, in step 3. This information will be used in the final drawing.

3.1. Line drawing

In RichTextParagraph’s void draw(Canvas Canvas, Offset Offset) function, we draw rows by iterating through all the rows. When traversing the last line, determine if you need to show the truncation, because the last line may be truncated due to the size limit of the container, or because maxLines is set to the maximum number of lines, not necessarily all text is displayed. Therefore, when drawing the last line, we need to determine whether we need to display the truncation and pass in the custom truncation _overflowSpan if necessary.

void draw(Canvas canvas, Offset offset) {
    canvas.save();
		// Sets the origin of the draw
    canvas.translate(offset.dx, offset.dy);

    for (int i = 0; i < _lines.length; i++) {
      var line = _lines[i];
// The last line sets the truncation symbol
      if (i == _lines.length - 1) {
        line.draw(canvas, overflow: _showOverflow ? _overflowSpan : null);
      } else {
        line.draw(canvas);
      }
// After drawing a line, we need to reset the drawing point of the next line to the lower left corner
      canvas.translate(0, line.bounds.height);
    }

    canvas.restore();
  }
Copy the code

3.2. Word rendering

Inside the line drawing, all the run saved in the line will be traversed, and the draw function of run will be called one by one, that is, the word drawing will be entered, and finally the canvas.drawParagraph(paragraph, offset) of the system will be called to realize the text drawing.

void draw(Canvas canvas, {RichTextOverflowSpan? overflow}) {
    double dx = 0;
    // Records the maximum line width that can be reached after truncation is removed
    double maxOverlowLineWidth = 0;
    for (int j = 0; j < runs.length; j++) {
      final run = runs[j];
      if(overflow ! =null && overflow.hasOverflowSpan) {
        if (run.size.width + maxOverlowLineWidth + overflow.size.width <
            maxWidth) {
          maxOverlowLineWidth += run.size.width;
        } else {
          // We need to draw a truncation
          assert(overflow.paragraph ! =null);
          Offset offset =
              Offset(dx, maxLineBaseline - overflow.baseline);
          overflow.draw(canvas, offset);
          break; } } Offset offset = Offset(dx, maxLineBaseline - run.baseline); run.draw(canvas, offset); dx += run.size.width; }}Copy the code

The emphasis here is on the alignment of words to words. Let’s take a look at how The RichText provided by Flutter aligns with different font sizes and the alignment patterns between Chinese, English and numbers.

It is clear from the figure that text of different font sizes is not centered. So how do they align? The answer is baseline alignment. Text alignment is usually based on the baseline. Open the Widget Inspector of Flutter and select Show Baselines. The text on the screen contains a thin green line. This line is the baseline.

Now that you know the alignment rules for your system, it’s easy to do. Our run stores all the information for this word: ascent, Descent, baseline, baseline. The height of this line is determined by the largest text, so the baseline of the largest text ➖ is the y offset of the current text relative to the line.

Offset offset = Offset(dx, maxLineBaseline - run.baseline);
run.draw(canvas, offset);
Copy the code

Fourth, the final effect

Widget label = RichLabel(
        maxLines: fold ? 2 : 0,
        overflowSpan: TextSpan(
            text: 'Show it all', recognizer: TapGestureRecognizer() .. onTap = () {print('Click to expand all'); setState(() { fold = ! fold; }); }, style:const TextStyle(
              color: Colors.black,
              fontSize: 20,
            )),
        overflow: RichTextOverflow.custom,
        text: TextSpan(
            text: '# I am hashtag #', recognizer: TapGestureRecognizer() .. onTap = () {print("Click TAB");
              },
            style: const TextStyle(color: Colors.redAccent),
            children: [
              TextSpan(
                  text: 'Chinese hello + number 123456+ English kuhasfjkg ===', recognizer: TapGestureRecognizer() .. onTap = () {print('click on 111');
                    },
                  style: const TextStyle(
                      fontSize: 40,
                      color: Colors.black26,
                      backgroundColor: Colors.lightBlue)),
              const TextSpan(
                  text: 'Chinese hello ah + number 123456+ English kuhasfjkg combination is very nice',
                  style: TextStyle(fontSize: 20, color: Colors.red)),
            ]));
    return label;
Copy the code

👉👉👉 source code here.

Refer to the article

Give you an insight into the font lore of Flutter

The official TextStyle document of Flutter

How does Flutter draw text