稀土掘金技术社区 2024年12月16日
史上最强大的文本溢出效果,强得飞起 !
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

ExtendedText 是一款 Flutter 平台上的组件库,最近新增了鸿蒙系统的原生支持,主要功能是支持自定义文本溢出效果。文章详细介绍了如何实现文本溢出的自定义,包括溢出位置(开头、中间、结尾)的设置,以及新增的自动溢出模式,该模式可以根据高亮文本的位置来自动调整溢出效果。文章还深入探讨了在 Flutter 和鸿蒙 Next 系统中,如何计算文本溢出、裁剪文本、绘制溢出效果以及消除下层文字。此外,还介绍了通过单行 TextPainter/Paragraph 优化性能的方法,减少了计算文本不溢出情况的尝试次数。

✨ExtendedText 组件库在 Flutter 平台上支持自定义文本溢出效果,现在也支持鸿蒙 Next 系统,允许开发者自定义溢出样式,不再局限于单调的省略号。

🔍新增的 TextOverflowPosition.auto 模式能根据高亮文本的位置自动调整溢出效果。若高亮文本在中间,则开头和结尾都显示溢出;若高亮文本在开头,则结尾显示溢出;若高亮文本在末尾,则开头显示溢出。

✂️文章详细介绍了在 Flutter 和鸿蒙 Next 系统中,如何通过裁剪文本、计算文本不溢出的情况以及绘制溢出效果来实现自定义文本溢出。其中,利用 SpecialTextSpan 来保留被裁剪的文本,以便支持复制和选择功能。

🚀文章还探讨了如何优化性能,通过单行的 TextPainter/Paragraph 计算粗略的范围,减少了二分查找的尝试次数,从而提高文本溢出效果的计算效率。

🎨在绘制溢出效果时,文章详细讲解了在不同溢出位置(开头、中间、结尾、自动)的绘制逻辑,并介绍了如何通过 canvas 的 clipRect 方法消除下层文字,确保溢出效果的清晰呈现。

法de空间 2024-12-16 08:30 重庆

点击关注公众号,“技术干货” 及时达!

前言

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

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

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

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

实现

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

计算文本不溢出的情况

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

要使用该功能,首先需要将高亮文本(可见)对于的 SpankeepVisible 设置成 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 端通过 canvasclipRect 方法,在绘制文字之前裁剪掉那部分的区域。

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

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

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/Paragraph3 行,宽度是 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

这里行数是有 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/Paragraph3 行,宽度是 100,溢出效果宽度是 20 。 我们以 [x1,x2] 为范围,左右增加当前 TextPainter/Paragraph 的总长度的一半, 注意左右边界,超出的部分反补给另外一端。


        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% !

47709FAA7B6829A8DC13EBF43E59EBE1_副本.png

使用

安装

Flutter 端执行 flutter pub add extended_text

鸿蒙 端执行 ohpm install @candies/extended_text

设置可见 Span

根据自身的情况,将想要高亮(可见) 的 SpankeepVisible 属性设置成 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(), ]; }}

设置溢出位置模式

TextOverflowWidgetposition 设置成 TextOverflowPosition.auto 即可。

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

结语

至此,我们在全平台(WebAndroidIosWindows, Mac, Linux, HarmonyOSHyperOS, ColorOSOriginOSMagicOSChrome OS,‌FuchsiaOS)支持了丰富的文本溢出效果。

点击关注公众号,“技术干货” 及时达!

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

ExtendedText 文本溢出 Flutter 鸿蒙 自定义UI
相关文章