相关阅读:
关注微信公众号
糖果代码铺
,获取更多鸿蒙/Flutter
讯息。
前言
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();
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
这里行数是有 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 {
@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,
),
);
结语
至此,我们在全平台(Web
,Android
,Ios
,Windows
, Mac
, Linux
, HarmonyOS
,HyperOS
, ColorOS
,OriginOS
,MagicOS
,Chrome OS
,FuchsiaOS
)支持了丰富的文本溢出效果。
一直没人模仿, 从未被超越!能超越的
文本组件
的只有糖果
的ExtendedText
!
爱 鸿蒙
,爱糖果
,欢迎加入Harmony Candies,一起生产可爱的鸿蒙小糖果QQ群:981630644
关注微信公众号
糖果代码铺
,获取更多鸿蒙/Flutter
讯息。