在上节文章中,通过解析拆分text,构建List<InlineSpan>的方式,实现了富文本的效果。而在常用的text功能中,还有一个收缩文本的功能经常会被使用到,无奈Flutter只能默认使用…,而不能使用其他自定义的文案,且无法做到点击再展开的效果。因此这里考虑为SuperText控件加上可定制化的overflow效果。
实现原理
系统Text控件虽然无法做到自定义ellipsis文案,但是其中判定是否需要ellipsize的方法,却是可以直接拿来使用的,其实就是使用TextPainter对文本进行测量,也就能知道在有限的行数内,能否显示下全文。
但光有测量还没有用,我们还得知道,如果出现ellipsize效果了,是在哪里出现的,这个才是本文的重点。
只要知道在哪里被ellipsize了,那么只需要截取0到被ellipsize位置的文本,然后拼接上自定义的ellipsis文案即可。当然过程中还需要部分的修正。
TextPainter
这里简单介绍TextPainter的用法:
TextPainter painter = TextPainter(
text: textSpan,
locale: WidgetsBinding.instance?.window.locale,
textDirection: TextDirection.ltr,
textAlign: widget.textAlign ?? TextAlign.start,
textScaleFactor: 1.0,
maxLines: widget.maxLine,
);
painter.layout(maxWidth: widget.maxWidth);
print('textSize: ${painter.size}');
和Text的参数差不多,主要是调用layout方法后,就能获取到绘制对应文本,所需要的宽高了。
除了最基本的绘制宽高可以获取到之外,还能够通过painter.didExceedMaxLines来获取,文本是否被ellipsize了。
painter.getPositionForOffset
getPositionForOffset方法,是对于我们知晓何处被ellipsize非常重要的方法。它是接收一个Offset参数,返回一个TextPosition对象。这个方法的作用就是传入一个Offset表示的坐标(相对于文字绘制区域),返回在这个坐标上,绘制的文字的下标(需要注意,如果用WidgetSpan替换了一段文字,那么这个WidgetSpan的文字长度只能算1,这里返回的下标,并不是原有文字的下标,而是被WidgetSpan替换过的)。
既然已经有了文字被ellipsize时的绘制区域,又有通过坐标换取文字下标的方法,自然也就能够知道文字被截断的位置。
计算文本截断位置
因为我们需要自定义ellipsis文案,那么最终的文本截断位置,是需要根据这个ellipsis文案来计算的。
所以先使用另一个TextPainter来测量ellipsis文案所需要的宽高:
TextPainter ellipsisPainter = TextPainter(
text: TextSpan(text: ellipsisText, style: widget.ellipsisTextStyle ?? widget.style),
locale: WidgetsBinding.instance?.window.locale,
textDirection: TextDirection.ltr,
textAlign: widget.textAlign ?? TextAlign.start,
textScaleFactor: 1.0,
);
ellipsisPainter.layout();
然后使用getPositionForOffset方法,来获取预留了用于显示ellipsis文案的空间后,文字被截断的位置:
TextPosition pos = painter.getPositionForOffset(Offset(
max(painter.size.width - _ellipsisTextWidth! - ellipsisFontSize, 0),
painter.size.height));
因为文字拼接实际上还需要考虑间距等问题,因此这里再减去一个ellipsisFontSize来做保险。这样就能获取到一个合适的截断位置了。
根据截断位置,裁剪List
在之前的文章中,通过富文本SpanBuilder解析后,我们已经有了一个包含全文的List的textSpan,然后现在又有了截断位置,那么我们只需要裁剪这个textSpan的内容,然后在最后追加上一个显示ellipsis文案的textSpan,就是我们最终要显示的效果。
int c = 0;
//从头开始遍历,查找最终被截断的位置在哪个子span中
for (int i = 0; i < (textSpan.children?.length ?? 0); i++) {
int c2 = c;
InlineSpan span = textSpan.children![i];
if (span is TextSpan) {
c2 += span.text!.length;
} else if (span is WidgetSpan) {
c2 += 1;
}
if (c2 - 1 >= pos.offset && c - 1 <= pos.offset) {
//找到了被截断的文字位置所在的span
//下面就进行裁剪
List<InlineSpan> realSpans = [];
//先加之前从头开始的
realSpans.addAll(textSpan.children!.sublist(0, i));
//检查是否要拆分当前的
if (span is TextSpan) {
if (c2 - 1 > pos.offset) {
//当前的span包含的文字很多,超过了最后的限制
realSpans.add(TextSpan(
text: span.text!.substring(0, pos.offset - c),
style: span.style,
recognizer: span.recognizer,
));
} else {
realSpans.add(textSpan.children![i]);
}
}
//追加ellipsis的TextSpan
TapGestureRecognizer? recognizer;
if (widget.enableExpand) {
recognizer = TapGestureRecognizer();
recognizer.onTap = () {
setState(() {
_mExpanded = true;
_textSpan = _oriTextSpan;
});
};
}
realSpans.add(TextSpan(
text: ellipsisText,
style: widget.ellipsisTextStyle ?? widget.style,
recognizer: recognizer,
));
//返回最终裁剪完的textSpan
return TextSpan(
children: realSpans,
style: widget.style,
recognizer: textSpan.recognizer,
);
}
c = c2;
}
效果预览
下面的Emoji并不是通过unicode实现的,而是使用WidgetSpan替换的。
Container(
width: 160,
child: SuperText(
'[微笑]123456[微笑][微笑][微笑]11[微笑][微笑][微笑]123456[微笑][微笑][微笑][微笑]',
style: const TextStyle(color: Colors.black, fontSize: 22),
maxLine: 3,
spanBuilders: [
EmojiSpanBuilder(),
],
maxWidth: 160,
ellipsis: '...查看',
ellipsisTextStyle:
const TextStyle(color: Colors.blue, fontSize: 18),
overflow: TextOverflow.ellipsis,
),
)
收缩状态下:
展示时: