法de空间 2024-12-16 08:30 重庆
点击关注公众号,“技术干货” 及时达!
前言
ExtendedText 的主要功能是支持自定义文本溢出效果。ExtendedText 作为 5 年前在 Flutter 平台发布的组件库,可以说是陪伴了一代代 Flutterer 的成长。前段时间,ExtendedText 也增加了鸿蒙纯血 Next 系统的原生支持。
这里再说下什么是 自定义文本溢出效果 ?
溢出效果的自定义,即希望溢出的效果不是单调的 ...,而且可以指定任何组件。
对比其他平台,该系果的支持情况如下:
| 平台 | ellipsis 自定义 |
|---|---|
| android | 不支持 |
| Ios | 不支持 |
| web | 不支持 |
| flutter | 不支持(ExtendedText 支持) |
| 鸿蒙 Next | ellipsis (ExtendedText 支持) |
溢出效果的位置,即在开头,中间,还是结尾。
对比其他平台,该效果的支持情况如下:
| 平台 | 开头 | 中间 | 结尾 |
|---|---|---|---|
| android | android:ellipsize = "start" | android:ellipsize = "middle" | android:ellipsize = "end" |
| Ios | NSLineBreakByTruncatingHead | NSLineBreakByTruncatingMiddle | NSLineBreakByTruncatingTail |
| web | text-overflow: ellipsis clip | 不支持 | text-overflow: clip ellipsis |
| flutter | 不支持(ExtendedText 支持) | 不支持(ExtendedText 支持) | TextOverflow.ellipsis |
| 鸿蒙 Next | EllipsisMode.START (ExtendedText 支持) | EllipsisMode.MIDDLE (ExtendedText 支持) | EllipsisMode.END |
但是需求往往在不经意间就会出现,有用户在评论区问, 支持高亮关键字加两端省略吗? 并且附上了一张图片。
就是手机短信里面的搜索功能,我自己也看了一下,总结下要求:
搜索的词语,高亮,即保证它要出现在文本之中
根据高亮文本的位置,来设置文本溢出效果。如果高亮文字在中间,那么开头和结尾都显示溢出效果;如果高亮文字在前面可以完全显示,那么最后结尾溢出效果;如果高亮文字在文字后面,那么开头显示溢出效果。
由于溢出效果的位置完全是依据高亮文本的位置而定的,当前 ExtendedText 的功能并不能支持,所以新增了新的溢出模式 TextOverflowPosition.auto。
export enum TextOverflowPosition {/// 开头start,/// 中间middle,/// 结尾end,/// 确保 keepVisible Span 可见/// 自动调整溢出位置auto,}
最终实现效果如下图,也支持多行显示。
实现
打开冰箱
放进大象
关上冰箱
跟将一个大象放进冰箱一样简单,做出文本溢出效果只需要下面 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();letdidOverflowHeight =size.height < textSize.height || textDidExceedMaxLines;letdidOverflowWidth = 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 flowif (_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);// zmtzawqlpif (_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
这里行数是有 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 的总长度的一半, 注意左右边界,超出的部分反补给另外一端。
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% !
❞
使用
安装
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 {@overrideRegExp get regExp => RegExp("<Highlight color=['\"](.*?)['\"]>(.*?)</Highlight>",);static String getHighlightString(String content) {return '<Highlight color="#FF2196F3">' + content + '</Highlight>';}@overrideInlineSpan 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 {@overrideList<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,),);
结语
至此,我们在全平台(Web,Android,Ios,Windows, Mac, Linux, HarmonyOS,HyperOS, ColorOS,OriginOS,MagicOS,Chrome OS,FuchsiaOS)支持了丰富的文本溢出效果。
点击关注公众号,“技术干货” 及时达!
