稀土掘金技术社区

史上最强大的文本溢出效果,强得飞起 !

前言

ExtendedText 的主要功能是支持自定义文本溢出效果。ExtendedText 作为 5 年前在 Flutter 平台发布的组件库,可以说是陪伴了一代代 Flutterer 的成长。前段时间,ExtendedText 也增加了鸿蒙纯血 Next 系统的原生支持。

这里再说下什么是 自定义文本溢出效果 ?

  • 溢出效果的自定义,即希望溢出的效果不是单调的 ...,而且可以指定任何组件。

对比其他平台,该系果的支持情况如下:

平台ellipsis 自定义
android不支持
Ios不支持
web不支持
flutter不支持(ExtendedText 支持)
鸿蒙 Nextellipsis (ExtendedText 支持)
  • 溢出效果的位置,即在开头,中间,还是结尾。

对比其他平台,该效果的支持情况如下:

平台开头中间结尾
androidandroid:ellipsize = "start"android:ellipsize = "middle"android:ellipsize = "end"
IosNSLineBreakByTruncatingHeadNSLineBreakByTruncatingMiddleNSLineBreakByTruncatingTail
webtext-overflow: ellipsis clip不支持text-overflow: clip ellipsis
flutter不支持(ExtendedText 支持)不支持(ExtendedText 支持)TextOverflow.ellipsis
鸿蒙 NextEllipsisMode.START (ExtendedText 支持)EllipsisMode.MIDDLE (ExtendedText 支持)EllipsisMode.END

但是需求往往在不经意间就会出现,有用户在评论区问, 支持高亮关键字加两端省略吗? 并且附上了一张图片。

Image

1026b7b3b3804e60bdd2cb43ac4d8ce1~tplv-k3u1fbpfcp-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5p2o5rC45a6J_q75_副本.webp

就是手机短信里面的搜索功能,我自己也看了一下,总结下要求:

  • 搜索的词语,高亮,即保证它要出现在文本之中
  • 根据高亮文本的位置,来设置文本溢出效果。如果高亮文字在中间,那么开头和结尾都显示溢出效果;如果高亮文字在前面可以完全显示,那么最后结尾溢出效果;如果高亮文字在文字后面,那么开头显示溢出效果。

由于溢出效果的位置完全是依据高亮文本的位置而定的,当前 ExtendedText 的功能并不能支持,所以新增了新的溢出模式 TextOverflowPosition.auto。

export enum TextOverflowPosition {  /// 开头  start,  /// 中间  middle,  /// 结尾  end,  /// 确保 keepVisible Span 可见  /// 自动调整溢出位置  auto,}

最终实现效果如下图,也支持多行显示。

Image

Image

实现

  • 打开冰箱
  • 放进大象
  • 关上冰箱

跟将一个大象放进冰箱一样简单,做出文本溢出效果只需要下面 4 步。

  • 裁剪文本
  • 计算文本不溢出的情况
  • 绘制溢出效果,并且遮蔽下层的文字

计算文本不溢出的情况

  • Flutter 端我们可以在 performLayout 方法中通过不断尝试裁剪 TextPainter 的 InlineSpan,通过以下方法,判断文本是否溢出。
  bool _didVisualOverflow({TextPainter? textPainter}) {    final Size textSize = (textPainter ?? _textPainter).size;    final bool textDidExceedMaxLines =        (textPainter ?? _textPainter).didExceedMaxLines;    final bool didOverflowHeight =        size.height < textSize.height || textDidExceedMaxLines;    final bool didOverflowWidth = size.width < textSize.width;
if (size.height < textSize.height) { size = constraints.constrain(textSize); }
return didOverflowWidth || didOverflowHeight; }
  • 在鸿蒙端,我们可以在 onMeasureSize 方法中通过不断调整裁剪 ParagraphBuilder 的内容,通过以下方法,判断文本是否溢出。
_didVisualOverflow(paragraph: text.Paragraph, constraint: ConstraintSizeOptions): boolean {  let textSize: SizeResult = {    width: px2vp(paragraph.getMaxWidth()),    height: px2vp(paragraph.getHeight()),  };  let size: SizeResult = {    width: constraint.maxWidth! as number,    height: constraint.maxHeight! as number,  }  let textDidExceedMaxLines =    paragraph.didExceedMaxLines();  let    didOverflowHeight =      size.height < textSize.height || textDidExceedMaxLines;  let    didOverflowWidth = size.width < textSize.width;  let hasVisualOverflow = didOverflowWidth || didOverflowHeight;  return hasVisualOverflow;}

裁剪文本

通过上面一步,我们可以计算出一个临界值,考虑到有复制选择功能,裁剪掉的文本不能直接丢弃,这里利用到 SpecialTextSpan。

❝

你见到的并不是真实的

❞
 SpecialTextSpan(   'abef',   actualText: 'abcdef',  );

比如 abcdef, 我们找到的 Range 为 [2,3] ,即最终显示 ab...ef。考虑支持选择复制,所以我们这里不能简单丢掉 cd。

maxIndex 为文本的长度,找到文本 不溢出的 和 溢出 临界点 index。根据溢出位置可以分为下面 4 种情况。

start

[0,offset] 区域的文本都需要被裁剪掉,即 [0,index] 舍弃, [index,maxIndex] 显示。

middle

[m,index] 区域的文本都需要被裁剪掉,其中 m 为溢出效果区域左侧的索引位置。

[0,m] 显示; [m,index] 舍弃(这里绘制溢出效果); [index,maxIndex]显示。

end

无需更多计算

auto

  • 如果高亮文本在 [offset,max] 区域能显示,这种情况就相当于 start 的情况;

  • 如果高亮文本在 [0,offset] 区域能显示,这种情况就相当于 end 的情况;

  • 如果上面 2 种情况都不满足,即前面需要裁剪,后面也需要裁剪,那么我们裁剪的区域左边一部分和右边一部分,中间的部分要保证高亮文本可见。

要使用该功能,首先需要将高亮文本(可见)对于的 Span 的 keepVisible 设置成 true 。后续在计算中,我们就可以找到它,然后确定高亮文本(可见)的范围,进行进一步的处理。

鸿蒙 端寻找高亮文本(可见)的代码如下:

let keepVisibleSpan: InlineSpan | null = null;this.text.visitChildren((span) => {  if (span.keepVisible === true) {    keepVisibleSpan = span;    return false;  }  return true;})

Flutter 端寻找高亮文本(可见)的代码如下:

  SpecialInlineSpanBase? keepVisibleSpan;  text.visitChildren((InlineSpan span) {    if (span is SpecialInlineSpanBase &&        (span as SpecialInlineSpanBase).keepVisible == true) {      keepVisibleSpan = span as SpecialInlineSpanBase;      return false;    }    return true;  });

绘制溢出效果,并且消除下层的文字

start

绘制在第一行的最左边。

middle

如果总行数是奇数的话,绘制在中间的一行的正中间;如果总行数是偶数的话,绘制在(总行数除以 2)+ 1 行的最左边。

end

绘制在最后一行的最右边。

auto

分为三种情况。绘制在第一行的最左边;绘制在最后一行的最右边;或者绘制在第一行的最左边以及最后一行的最右边。

消除下层文字

除了绘制溢出效果,我们还要注意一点,那就是将溢出效果下面的文字可以消除掉。具体方式为

Flutter 端通过 canvas 的 clipRect 方法,在绘制文字之前裁剪掉那部分的区域。

   // zmtzawqlp    // clip rect of over flow    if (_overflowRects != null) {      context.canvas.saveLayer(offset & size, Paint());      if (overflowWidget?.clearType == TextOverflowClearType.clipRect) {        if (_overflowClipTextRects != null) {          for (final Rect rect in _overflowClipTextRects!) {            context.canvas.clipRect(              rect.shift(offset),              clipOp: ui.ClipOp.difference,            );          }        }
if (_overflowRects != null) { for (final Rect rect in _overflowRects!) { context.canvas.clipRect( rect.shift(offset), clipOp: ui.ClipOp.difference, ); } } } }
_textPainter.paint(context.canvas, offset);
paintInlineChildren(context, offset);
// zmtzawqlp if (_overflowRects != null) { context.canvas.restore(); } // zmtzawqlp _paintTextOverflow(context, offset);

鸿蒙 端通过 canvas 的 clipRect 方法,在绘制文字之前裁剪掉那部分的区域。

if (this.overflowClipRects.length != 0) {  context.canvas.saveLayer();
for (let index = 0; index < this.overflowClipRects.length; index++) { const overflowClipRect = this.overflowClipRects[index]; context.canvas.clipRect(overflowClipRect, drawing.ClipOp.DIFFERENCE); }}this.paragraph.paint(context.canvas, 0, 0);
if (this.overflowClipRects.length != 0) { context.canvas.restore();}

性能再突破

之前计算文本不溢出的情况,是以溢出效果的所在区域获取初始的范围,然后利用通过二分查找。实际上,这种算法会造成更多的尝试次数。

我们可以得到一个单行的 TextPainter/Paragraph 配合当前 TextPainter/Paragraph, 用来计算粗略的范围。

假设当前 TextPainter/Paragraph 有 3 行,宽度是 100。单行的 TextPainter/Paragraph 的宽度是 500 。

start

那么需要裁剪掉的部分即为 500 - 100 * 3 = 200 。

        for (final ui.LineMetrics line in lines) {          oneLineWidth -= line.width;        }
end = ExtendedTextLibraryUtils .convertTextPainterPostionToTextInputPostion( text, oneLineTextPainter.getPositionForOffset(Offset( math.max(oneLineWidth, overflowWidgetSize.width), oneLineTextPainter.height / 2)))! .offset;

即可以得到初始的裁剪范围为 0 到 单行 TextPainter/Paragraph 200 位置的 index 。

middle

Image

这里行数是有 3 行,那么中间一行的 index 就是 1 。那么开始的溢出效果左边 x 的位置。而右边为 500 -100 -w 的位置,从后减去每行的宽度,直到 index  1 行溢出的右边。

然后也要考虑偶数行的情况,比如假设为有 4 行. 那么中间一行的 index 就是为 2,那么开始的溢出效果左边 x 的位置。而右边为 500 -100 -w 的位置,从后减去每行的宽度,直到 index  1 行溢出的右边。

        final int lineNum = (lines.length / 2).floor();        final bool isEven = lines.length.isEven;        final ui.LineMetrics line = lines[lineNum];        double lineTop = 0;
for (int index = 0; index < lineNum; index++) { final ui.LineMetrics line = lines[index]; lineTop += line.height; }
final double lineCenter = lineTop + line.height / 2; ui.Rect overflowRect = Rect.zero; final double textWidth = _textPainter.width; if (isEven) { overflowRect = Rect.fromLTRB( 0, lineCenter - overflowWidgetSize.height / 2, overflowWidgetSize.width, lineCenter + overflowWidgetSize.height / 2, ); } else { overflowRect = Rect.fromLTRB( textWidth / 2 - overflowWidgetSize.width / 2, lineCenter - overflowWidgetSize.height / 2, textWidth / 2 + overflowWidgetSize.width / 2, lineCenter + overflowWidgetSize.height / 2, ); }
start = ExtendedTextLibraryUtils .convertTextPainterPostionToTextInputPostion( text, _textPainter .getPositionForOffset(overflowRect.centerRight))! .offset;
for (int index = lines.length - 1; index > lineNum; index--) { final ui.LineMetrics line = lines[index]; oneLineWidth -= line.width; }
oneLineWidth -= line.width - overflowRect.right;
end = ExtendedTextLibraryUtils .convertTextPainterPostionToTextInputPostion( text, oneLineTextPainter.getPositionForOffset(Offset( math.max(oneLineWidth, overflowWidgetSize.width), oneLineTextPainter.height / 2)))! .offset;

end

不需要计算。

auto

前面我们找到了高亮文本(可见)。

Flutter 端寻找高亮文本(可见)的代码如下:

  SpecialInlineSpanBase? keepVisibleSpan;  text.visitChildren((InlineSpan span) {    if (span is SpecialInlineSpanBase &&        (span as SpecialInlineSpanBase).keepVisible == true) {      keepVisibleSpan = span as SpecialInlineSpanBase;      return false;    }    return true;  });

通过 keepVisibleSpan 得到了范围 [x1, x2],不管后续怎么裁剪,我们都要保证这个范围在需要保留下来。

        _TextRange keepVisibleRange = _TextRange(            keepVisibleSpan!.textRange.start, keepVisibleSpan!.textRange.end);
final List<ui.TextBox> rects = oneLineTextPainter.getBoxesForSelection( ExtendedTextLibraryUtils .convertTextInputSelectionToTextPainterSelection( text, TextSelection( baseOffset: keepVisibleRange.start, extentOffset: keepVisibleRange.end), ));

这样子我们只需要在 [0,x1] 和 [x2,maxOffset] 之中进行文本裁剪。假设当前 TextPainter/Paragraph 有 3 行,宽度是 100,溢出效果宽度是 20 。 我们以 [x1,x2] 为范围,左右增加当前 TextPainter/Paragraph 的总长度的一半, 注意左右边界,超出的部分反补给另外一端。

Image


        final List<ui.TextBox> rects = oneLineTextPainter.getBoxesForSelection(            ExtendedTextLibraryUtils                .convertTextInputSelectionToTextPainterSelection(          text,          TextSelection(              baseOffset: keepVisibleRange.start,              extentOffset: keepVisibleRange.end),        ));
double left = double.infinity; double right = 0; for (int index = 0; index < rects.length; index++) { final ui.TextBox rect = rects[index]; left = math.min(rect.left, left); right = math.max(rect.right, right); }
keepVisibleRange = _TextRange( ExtendedTextLibraryUtils.convertTextPainterPostionToTextInputPostion( text, oneLineTextPainter.getPositionForOffset(Offset( left - overflowWidgetSize.width, oneLineTextPainter.height / 2)))! .offset, ExtendedTextLibraryUtils.convertTextPainterPostionToTextInputPostion( text, oneLineTextPainter.getPositionForOffset(Offset( right + overflowWidgetSize.width, oneLineTextPainter.height / 2)))! .offset, );
final double totalWidth = _textPainter.computeLineMetrics().length * size.width; final double half = math.max( (totalWidth - (right - left)) / 2, overflowWidgetSize.width * 2);
left = left - half; right = right + half;
if (left < 0) { right -= left; left = 0; } final double maxIntrinsicWidth = oneLineTextPainter.width; if (right > maxIntrinsicWidth) { left -= right - maxIntrinsicWidth; right = maxIntrinsicWidth; }
final _TextRange estimatedRange = _TextRange( ExtendedTextLibraryUtils.convertTextPainterPostionToTextInputPostion( text, oneLineTextPainter.getPositionForOffset( Offset(left, oneLineTextPainter.height / 2)))! .offset, ExtendedTextLibraryUtils.convertTextPainterPostionToTextInputPostion( text, oneLineTextPainter.getPositionForOffset( Offset(right, oneLineTextPainter.height / 2)))! .offset, );

性能提升 40% 以上

通过估算大概的范围,来替换 二分法 求解,理论上文本越长,性能提升越高。

❝

整体性能再突破 40% !

❞

Image

47709FAA7B6829A8DC13EBF43E59EBE1_副本.png

使用

安装

Flutter 端执行 flutter pub add extended_text

鸿蒙 端执行 ohpm install @candies/extended_text

设置可见 Span

根据自身的情况,将想要高亮(可见) 的 Span 的 keepVisible 属性设置成 true 。

Flutter 端代码如下:

import 'package:extended_text/extended_text.dart';import 'package:flutter/material.dart';
class HighlightText extends RegExpSpecialText { @override RegExp get regExp => RegExp( "<Highlight color=['\"](.*?)['\"]>(.*?)</Highlight>", );
static String getHighlightString(String content) { return '<Highlight color="#FF2196F3">' + content + '</Highlight>'; }
@override InlineSpan finishText(int start, Match match, {TextStyle? textStyle, SpecialTextGestureTapCallback? onTap}) { final String hexColor = match[1]!;
return SpecialTextSpan( text: match[2]!, actualText: match[0], start: start, style: textStyle?.copyWith( color: Color(int.parse(hexColor.substring(1), radix: 16)), ), keepVisible: true, ); }}
class HighlightTextSpanBuilder extends RegExpSpecialTextSpanBuilder { @override List<RegExpSpecialText> get regExps => <RegExpSpecialText>[ HighlightText(), ];}

鸿蒙 端代码如下:

import * as extended_text from '@candies/extended_text'import { RegExpSpecialTextSpanBuilder, TextSpan } from '@candies/extended_text';import { text } from "@kit.ArkGraphics2D"

export class HighlightText extends extended_text.RegExpSpecialText { get regExp(): RegExp { return new RegExp("<Highlight color=['"](.*?)['"]>(.*?)</Highlight>", "g"); }
static getHighlightString(content: string) { return '<Highlight color="#FF2196F3">' + content + '</Highlight>'; }
finishText(start: number, match: RegExpExecArray, context: Context, textStyle?: text.TextStyle,): extended_text.InlineSpan { let color = match[1]; return new TextSpan({ text: match[2], style: { fontSize: vp2px(18), color: extended_text.ColorUtils.stringTo2DColor(color), }, actualText: match[0], start: start, keepVisible: true, }); }}
export class HighlightTextSpanBuilder extends RegExpSpecialTextSpanBuilder { get regExps() { return [ new HighlightText(), ]; }}

设置溢出位置模式

将 TextOverflowWidget 的 position 设置成 TextOverflowPosition.auto 即可。

    ExtendedText(      searchMessages[index],      specialTextSpanBuilder: HighlightTextSpanBuilder(),      maxLines: searchText.isEmpty ? 3 : 1,      overflowWidget: TextOverflowWidget(        child: const Text('\u2026 '),        position: TextOverflowPosition.auto,      ),    );

结语

Image

Image

Image

Image

至此,我们在全平台(Web,Android,Ios,Windows, Mac, Linux, HarmonyOS,HyperOS, ColorOS,OriginOS,MagicOS,Chrome OS,‌FuchsiaOS)支持了丰富的文本溢出效果。