如何在Flutter上实现一个具有自定义截断符的富文本
本文通过自定义RenderObject,带大家一起看看文本的自定义截断符是如何实现的。通过本文,你将学习到画布,段落,手势传递和渲染相关的知识。
一、背景
用过Text或RichText的同学一定知道,如果我们要实现一个文本尾部截断的功能,可以通过系统提供的枚举类型TextOverflow,选择不同的截断方式。这些截断方式都是文本组件内部设置好的,一般使用的是clip和ellipsis,前者在到达最大宽度时直接截断,后者则显示…。但是这都不足以满足我们产品千奇百怪的需求。
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,
}
比如,产品要求我们在文本达到最大行数并且显示不下时,尾部显示“…全文**”**。这是一个很常见的需求,基本在所有的图文排布列表上都会遇到。而一旦出现这种需求,仅靠Text或RichText就满足不了我们的需求了,当然,你可以打开Github上搜索一番,肯定也能找到有类似功能的组件,但是本文不向你推荐任何组件,而是带你一步步去实现这么一个自定义的截断符。
二、RenderBox
平常我们在开发的时候,一般都是组合各种Widget来搭建出功能丰富的组件,它们往往能满足我们的需求且工作良好,并且很多系统组件也是这么做的,典型的如Container组件。
但这次我们需要从底层绘制对象开始实现,因此我们需要用到RenderObject,而要用到RenderObject,那么就需要用到RenderObjectWidget,我们通过图来看看他们之间的关系(图片来自raywenderlich)。
下面这张图是Widget的子类图。
RenderObjectWidget提供了RenderObject的配置信息,这个类会检测碰撞和绘制UI。
下面这张图是RenderObject的子类图。
我们接下来会用到RenderBox,它定义了屏幕上用于绘制的矩形区域。RenderParagraph是Flutter提供的用来绘制文本的类,它用在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 })方法判断自己和自己的子节点是否能影响这个触摸点,沿着节点树向下查找,最终找到最顶层的也就是我们自定义的RenderRichLabel的hitTestChildren这。
当我们将最终能够响应触摸事件的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、计算和测量文本
一段文本的宽高是由内部行高➕行间距决定的,而对于一行来说,行高由其中最大的字形决定。这个很好理解,天塌下来由高个子顶着。我们看下面那张图,红框中的就是一行文字,可以看到这段文字的高度是由最大字形决定的。
1.1、RichTextRun
为了计算文字的宽高,好确定是否需要显示自定义的截断符。我们定义一个新的类,RichTextRun,
每个RichTextRun对象都代表一个文字,那么文字的尺寸信息怎么计算。我们引出一个系统类:Paragraph。这个类提供了一段文字的计算能力,通过它我们可以获取到段落的宽高,以及一个有关行的数组List<LineMetrics>。LineMetrics类很重要,里面有返回文字的上行间距、下行间距以及基线距离。
说到这里,我们先理解一个概念,也就是字形。什么是字形,字形是字体的一个具体表现。比如PingFangSC是一种字体,这套字体里的某一个具体文字就是字形。而字形里面有几个重要概念,上行(Ascent)、下行(Descent)、原点(Origin)和基线(Baseline)。
但是我们看到的实际效果是文字上下还有留白(看上面带有蓝色背景色的文字),这个其实是系统的字体度量行高,它可能高于字体高度或低于字体高度。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):
简单说就是只有在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)函数中,maxWidth和maxHeight是父组件传递给我们文本的一个最大约束条件,我们需要在这个约束条件下完成文字大小的测量和文本的尺寸计算。
整个计算过程主要分为4个步骤:
- 遍历所有字符串,将每个文字都构造成一个
RichTextRun,并存入到数组_runs中。 - 遍历
_runs数组,在满足最大约束size=(maxWidth, maxHeight)的条件下,完成每一行文字的计算,生成RichTextLine,并存入到数组_lines中。 - 遍历
_lines,累加行高,从而计算出整个文本的高度height。 - 遍历
_lines,获取最大行宽,从而得出整个文本的宽度width。
至此,我们的文本所占的实际尺寸就计算好了。
3、绘制
我们在第三步中已经拿到了所有文字的信息,行的信息。这些信息都将在最后的绘制中被使用到。
3.1、行绘制
绘制在RichTextParagraph的void 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,并逐一调用run的draw函数,也就是进入了字绘制,最终调用系统的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对于不同大小的字体是如何对齐的,以及中文、英文和数字之间的对齐规律。
从图中可以很清楚的看到,不同字体大小的文字并不是居中对齐的。那么它们对齐的依据是什么?答案是基线对齐。其实文字的对齐一般都是基于基线baseline的。我们打开Flutter的Widget Inspector工具,选中show baselines,会看见屏幕的文字上多了根绿色的细线,这个就是基线。
知道系统的对齐规则后,接下来就好做了。我们的run中保存着这个文字的所有信息,上行ascent,下行descent,基线baseline。而这一行的行高是由最大的那个文字决定的,那么以最大文字的基线➖当前文字的基线就是当前文字相对行的y偏移量。
Offset offset = Offset(dx, maxLineBaseline - run.baseline);
run.draw(canvas, offset);
四、最终效果
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;
👉👉👉源码在这里。