如何在Flutter上实现一个具有自定义截断符的富文本

1,960 阅读12分钟

如何在Flutter上实现一个具有自定义截断符的富文本

本文通过自定义RenderObject,带大家一起看看文本的自定义截断符是如何实现的。通过本文,你将学习到画布,段落,手势传递和渲染相关的知识。

一、背景

用过TextRichText的同学一定知道,如果我们要实现一个文本尾部截断的功能,可以通过系统提供的枚举类型TextOverflow,选择不同的截断方式。这些截断方式都是文本组件内部设置好的,一般使用的是clipellipsis,前者在到达最大宽度时直接截断,后者则显示。但是这都不足以满足我们产品千奇百怪的需求。

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,
}

比如,产品要求我们在文本达到最大行数并且显示不下时,尾部显示“…全文**”**。这是一个很常见的需求,基本在所有的图文排布列表上都会遇到。而一旦出现这种需求,仅靠TextRichText就满足不了我们的需求了,当然,你可以打开Github上搜索一番,肯定也能找到有类似功能的组件,但是本文不向你推荐任何组件,而是带你一步步去实现这么一个自定义的截断符。

截屏2022-04-07_上午11.35.46.png

二、RenderBox

平常我们在开发的时候,一般都是组合各种Widget来搭建出功能丰富的组件,它们往往能满足我们的需求且工作良好,并且很多系统组件也是这么做的,典型的如Container组件。

但这次我们需要从底层绘制对象开始实现,因此我们需要用到RenderObject,而要用到RenderObject,那么就需要用到RenderObjectWidget,我们通过图来看看他们之间的关系(图片来自raywenderlich)。

下面这张图是Widget的子类图。

widget_types-650x244.png

RenderObjectWidget提供了RenderObject的配置信息,这个类会检测碰撞和绘制UI。

下面这张图是RenderObject的子类图。

renderobject_types-650x244.png

我们接下来会用到RenderBox,它定义了屏幕上用于绘制的矩形区域。RenderParagraphFlutter提供的用来绘制文本的类,它用在RichText中,而Text只是对RichText的包装。

1、自定义文本Widget

我们先定义一个继承于LeafRenderObjectWidget的类RichLabel,作为我们对外的一个组件。因为我们文本不需要子节点,所以这里用LeafRenderObjectWidget最合适。

RichLabel需要对外提供几个属性,输入文本TextSpan text,截断符类型RichTextOverflow overflow,自定义截断符TextSpan overflowSpan,以及最大行数int maxLines

class RichLabel extends LeafRenderObjectWidget {
  final TextSpan text;

  /// `overflow``custom`时生效
  final TextSpan? overflowSpan;

	/// 截断符类型
  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);
	
/// 初始化的时候调用
  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderRichLabel(
        text: text, overflowSpan: overflowSpan, maxLines: maxLines, overflow: overflow);
  }

	/// hot reload的时候触发
  @override
  void updateRenderObject(BuildContext context, RenderRichLabel renderObject) {
    renderObject.text = text;
    renderObject.maxLines = maxLines;
    renderObject.overflow = overflow;
    renderObject.overflowSpan = overflowSpan;
  }
}

2、自定义绘制类

LeafRenderObjectWidget继承自RenderObject,它要求我们实现createRenderObject方法,并返回一个RenderObject对象。于是,我们定义了一个RenderRichLabel类。

class RenderRichLabel extends RenderBox {
  RenderRichLabel(
      {required TextSpan text,
      TextSpan? overflowSpan,
      int maxLines = 0,
      RichTextOverflow overflow = RichTextOverflow.clip})
      : _textPainter = RichTextPainter(text, maxLines, overflowSpan, overflow);
// 自定义文本绘制类
  final RichTextPainter _textPainter;

// 处理手势,决议哪个InlineSpan响应手势
@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;
  }

// 布局,并计算这个RenderBox的宽高
@override
  void performLayout() {
    _layoutTextWithConstraints(constraints);
    final Size textSize = _textPainter.size;
    size = constraints.constrain(textSize);
  }

// 绘制
  @override
  void paint(PaintingContext context, Offset offset) {
    _textPainter.paint(context.canvas, offset);
  }

2.1、手势决议

上面的代码中,我贴出了几处关键代码,也就是必须要实现的代码。hitTestChildren方法是底层RenderBox在接收到手势的触摸点事件时触发的。这个触摸事件的源头在hooks.dart文件的_dispatchPointerDataPacket函数中,经由GestureBinding接收触摸事件,然后传递给最底部的RenderView处理,通过调用bool hitTest(HitTestResult result, { required Offset position })方法判断自己和自己的子节点是否能影响这个触摸点,沿着节点树向下查找,最终找到最顶层的也就是我们自定义的RenderRichLabelhitTestChildren这。

r1.png

r2.png

r3.png

r4.png

当我们将最终能够响应触摸事件的InlineSpan(这里就是TextSpan)选出来之后,将它加入到触摸测试结果类中BoxHitTestResult,也就是这行代码:result.add(HitTestEntry(span as HitTestTarget)); 我们的关于手势事件的决议这块的工作就完成了。而后面关于手势事件的处理则由GestureBinding类来完成,源码也比较简单,我们一起看下(为了表达清晰,代码已经删除了多余的逻辑判断):

//事件传递,也就是完成我上面贴的那些图片的事情
void _handlePointerEventImmediately(PointerEvent event) {
			HitTestResult? hitTestResult;
      hitTestResult = HitTestResult();
      hitTest(hitTestResult, event.position);
			dispatchEvent(event, hitTestResult);
}

// 事件处理
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._();

  // 子类实现事件处理
  void handleEvent(PointerEvent event, HitTestEntry entry);
}

// 我们看TextSpan类,实现了 `HitTestTarget`
class TextSpan extends InlineSpan implements HitTestTarget {
// 专递给手势识别器内部处理。recognizer就是GestureRecognizer
		@override
  void handleEvent(PointerEvent event, HitTestEntry entry) {
    if (event is PointerDownEvent)
      recognizer?.addPointer(event);
  }
}

2.2、布局

我们还要实现RenderBox的布局方法void performLayout(),来告诉Flutter我们的文本对象到底占用多少尺寸。因此在函数_layoutTextWithConstraints(constraints)中,我们传入了父节点传递过来的约束大小constraints,并通过文本内容计算出最终的尺寸大小size。具体计算在后面会讲到。

2.3、绘制

我们通过自定义的RichTextPainter类,来绘制文本。

三、自定义文本绘制

RichTextPainter是我们定义的用于绘制和测量文本的类,功能类似于系统提供的TextPainter。我们需要对外提供布局和绘制相关的接口,并实现相应功能。整个类结构如下所示,已删掉多余的代码。

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);
  }

  Size get 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);
}

void paint(Canvas canvas, Offset offset) {
    _paragraph?.draw(canvas, offset);
  }

RichTextPainter 将数据简单处理后传递给RichTextParagraph,而RichTextParagraph才是真正测量和实现绘制逻辑的地方。

1、计算和测量文本

一段文本的宽高是由内部行高➕行间距决定的,而对于一行来说,行高由其中最大的字形决定。这个很好理解,天塌下来由高个子顶着。我们看下面那张图,红框中的就是一行文字,可以看到这段文字的高度是由最大字形决定的。

截屏2022-04-07_下午11.06.33.png

1.1、RichTextRun

为了计算文字的宽高,好确定是否需要显示自定义的截断符。我们定义一个新的类,RichTextRun,

每个RichTextRun对象都代表一个文字,那么文字的尺寸信息怎么计算。我们引出一个系统类:Paragraph。这个类提供了一段文字的计算能力,通过它我们可以获取到段落的宽高,以及一个有关行的数组List<LineMetrics>LineMetrics类很重要,里面有返回文字的上行间距、下行间距以及基线距离。

说到这里,我们先理解一个概念,也就是字形。什么是字形,字形是字体的一个具体表现。比如PingFangSC是一种字体,这套字体里的某一个具体文字就是字形。而字形里面有几个重要概念,上行(Ascent)、下行(Descent)、原点(Origin)和基线(Baseline)。

glyphterms_2x.png

但是我们看到的实际效果是文字上下还有留白(看上面带有蓝色背景色的文字),这个其实是系统的字体度量行高,它可能高于字体高度或低于字体高度。Flutter官方对这一块有个说明:

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):

text_height_diagram.png

简单说就是只有在hight设置为1.0时字的高度才是fontSize。我们可以先忽略这块知识,回到自定义文字类RichTextRun,直接看下如何计算单个文字的宽高。

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);

  /// 是否是换行符
  bool get isTurn => text == '\n';

  /// 是否是制表符
  bool get isTab => text == '\t';

  /// 是否是回车
  bool get isReturn => text == '\r';

  /// 归属于哪个TextSpan
  final TextSpan textSpan;

  Offset offset;

  bool _drawed;

  /// 是否被绘制
  bool get drawed => _drawed;

  /// 标记需要重新绘制
  void setNeedsDraw() => _drawed = false;

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

每一个RichTextRun持有一个Paragraph实例paragraph,通过paragraph获取单个文字的宽高和行信息LineMetrics,再通过行信息line获取上行、下行以及基线。

最后还提供了一个绘制接口draw,用于调用Canvas的绘制段落文字接口。

1.2、RichTextLine

通过组合多个RichTextRun,就能获取到行。我们再定一个新的类RichTextLine来表示行信息。这个类保存了该行的尺寸,最大的上行,最大的下行以及最大的基线等信息。其中上行和下行用于在绘制时决定单个文字的布局坐标。并且还提供一个绘制接口,通过遍历这一行的所有run来绘制每一个需要绘制的run。

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;
    // 记录除去截断符后,所能到达的最大行宽
    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 {
          // 需要绘制截断符
          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;
    }
  }
}

2、布局

布局的真正实现是在RichTextParagraph类的void layout(double maxWidth, double maxHeight)函数中,maxWidthmaxHeight是父组件传递给我们文本的一个最大约束条件,我们需要在这个约束条件下完成文字大小的测量和文本的尺寸计算。

整个计算过程主要分为4个步骤:

  1. 遍历所有字符串,将每个文字都构造成一个RichTextRun,并存入到数组_runs中。
  2. 遍历_runs数组,在满足最大约束size=(maxWidth, maxHeight)的条件下,完成每一行文字的计算,生成RichTextLine,并存入到数组_lines中。
  3. 遍历_lines,累加行高,从而计算出整个文本的高度height
  4. 遍历_lines,获取最大行宽,从而得出整个文本的宽度width

至此,我们的文本所占的实际尺寸就计算好了。

3、绘制

我们在第三步中已经拿到了所有文字的信息,行的信息。这些信息都将在最后的绘制中被使用到。

3.1、行绘制

绘制在RichTextParagraphvoid draw(Canvas canvas, Offset offset)函数下,我们通过遍历所有的行来进行行绘制。当遍历到最后一行时,判断下是否需要展示截断符,因为最后一行可能因为容器的尺寸限制被截断,也可能因为设置了最大行数maxLines而被截断,并非一定是所有文字都显示完了。因此在绘制最后一行的时候我们需要判断下是否需要展示截断符,如需要则传入自定义截断符_overflowSpan

void draw(Canvas canvas, Offset offset) {
    canvas.save();
		// 设置绘制的原点
    canvas.translate(offset.dx, offset.dy);

    for (int i = 0; i < _lines.length; i++) {
      var line = _lines[i];
// 最后一行设置截断符号
      if (i == _lines.length - 1) {
        line.draw(canvas, overflow: _showOverflow ? _overflowSpan : null);
      } else {
        line.draw(canvas);
      }
// 绘制完一行后,我们需要将下一行的绘制点重置到左下角
      canvas.translate(0, line.bounds.height);
    }

    canvas.restore();
  }

3.2、字绘制

行绘制内部会遍历该行保存的所有run,并逐一调用rundraw函数,也就是进入了字绘制,最终调用系统的canvas.drawParagraph(paragraph, offset)实现文字的绘制。

void draw(Canvas canvas, {RichTextOverflowSpan? overflow}) {
    double dx = 0;
    // 记录除去截断符后,所能到达的最大行宽
    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 {
          // 需要绘制截断符
          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;
    }
  }

这里的重点是字于字之间的对齐问题。我们先来看下Flutter提供的RichText对于不同大小的字体是如何对齐的,以及中文、英文和数字之间的对齐规律。

截屏2022-04-08_上午11.27.50.png

从图中可以很清楚的看到,不同字体大小的文字并不是居中对齐的。那么它们对齐的依据是什么?答案是基线对齐。其实文字的对齐一般都是基于基线baseline的。我们打开FlutterWidget Inspector工具,选中show baselines,会看见屏幕的文字上多了根绿色的细线,这个就是基线。

截屏2022-04-08_上午11.30.10.png

知道系统的对齐规则后,接下来就好做了。我们的run中保存着这个文字的所有信息,上行ascent,下行descent,基线baseline。而这一行的行高是由最大的那个文字决定的,那么以最大文字的基线➖当前文字的基线就是当前文字相对行的y偏移量。

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

四、最终效果

flutter_rich_text.gif

Widget label = RichLabel(
        maxLines: fold ? 2 : 0,
        overflowSpan: TextSpan(
            text: '展示全部',
            recognizer: TapGestureRecognizer()
              ..onTap = () {
                print('点击展开全部');
                setState(() {
                  fold = !fold;
                });
              },
            style: const TextStyle(
              color: Colors.black,
              fontSize: 20,
            )),
        overflow: RichTextOverflow.custom,
        text: TextSpan(
            text: '#我是标签#',
            recognizer: TapGestureRecognizer()
              ..onTap = () {
                print("点击标签");
              },
            style: const TextStyle(color: Colors.redAccent),
            children: [
              TextSpan(
                  text: '中文你好呀+数字123456+英文kuhasfjkg组合起来就非常好看了===',
                  recognizer: TapGestureRecognizer()
                    ..onTap = () {
                      print('点击111');
                    },
                  style: const TextStyle(
                      fontSize: 40,
                      color: Colors.black26,
                      backgroundColor: Colors.lightBlue)),
              const TextSpan(
                  text: '中文你好呀+数字123456+英文kuhasfjkg组合起来就非常好看了',
                  style: TextStyle(fontSize: 20, color: Colors.red)),
            ]));
    return label;

👉👉👉源码在这里

参考文章

带你深入理解Flutter中的字体冷知识

Flutter官方TextStyle文档

Flutter是如何绘制文本的