Flutter 仿飞书内容纠错及文本多样式高亮开发实践
最近做了一个仿飞书的flutter web内容文本高亮相关的需求,费了不少脑细胞,可算研究出比较理想的效果了,感觉分享出来还是挺有意义的~
常见产品
飞书内容纠错:
需求
web端的文章查看,需要开发一个可以进行内容纠错的组件——文本高亮的组件
和飞书不同的地方是,我们这里的需求并不是编辑框内纠错,而是文本纠错,不过本质都是一样的
要求:
- 文章内容指定的文本高亮,比如搜索时高亮、纠错高亮、敏感词高亮等
- 希望可以有不同的高亮样式,并且多种样式可以同时出现(包括交叉出现)
- 纠错高亮需要精确到内容的某一个词组,不可将内容中其他地方相同的字一同高亮
- 出现错误内容的文字可以悬浮直接修改内容
提炼
我们需要有一个组件,支持:
- 多种样式文本高亮,可以传入高亮词组(匹配所有出现的位置)或高亮的区间(固定位置)
- 如果有高亮重叠的区域,需要显示多种样式结合后的样式
- 可以对某高亮内容做一些事件的添加,如悬浮,点击等
调研
当然是希望可以在pub或者github找到成熟的开源方案,然后直接拿来用一下子~
开源库 | likes | popularity | 特点 |
---|---|---|---|
highlight_text | 91 | 95% | 支持词组高亮,且支持不同词组不同样式 |
substring_highlight | 173 | 98% | 仅支持词组单一样式高亮 |
flutter_highlight | 173 | 98% | 这个貌似是代码高亮组件。。。 |
extended_text | 208 | 97% | 支持很多不错的功能 |
最接近需求的库是highlight_text,支持多样式文本高亮 但是不支持按固定的位置高亮,也不支持样式叠加,看来得需要自己实现了。。。
思考
想一想其实就是对RichText的一些逻辑上的封装,某些位置显示的样式和其他地方不一样而已
先起个名字:就叫多重高亮文本MultiHighLightText吧,感觉这个名字挺符合这个核心理念的
然后开始做个简单的设计思考
首先得提供一个文本,以及文本的样式:
class MultiHighLightText extends StatelessWidget {
/// The text you want to show
final String text;
/// The normal text style
final TextStyle textStyle;
......
}
然后来分析一下需求,并且考虑一下现在其他的库缺什么
1.高亮功能:支持多种样式,支持词组,也支持具体的位置
期望的表现:
方式 | 高亮表现 |
---|---|
词组 | 匹配整个文本出现该词组的所有位置 |
位置 | 整个文本固定的某个位置 |
词组与位置 | 同时满足以上两种情况 |
highlight_text的设计
先来参考一下highlight_text作者的设计,这个库支持多样式的高亮:
- 让使用者通过一个Map来传递需要高亮的词组
Map<String, HighlightedWord> words = {
"Flutter": HighlightedWord(
onTap: () {
print("Flutter");
},
textStyle: textStyle,
),
"open-source": HighlightedWord(
onTap: () {
print("open-source");
},
textStyle: textStyle,
),
"Android": HighlightedWord(
onTap: () {
print("Android");
},
textStyle: textStyle,
),
};
2. 每一个词组对应的有具体的样式,点击事件,装饰,padding:
class HighlightedWord {
final TextStyle? textStyle;
final VoidCallback? onTap;
final BoxDecoration? decoration;
final EdgeInsetsGeometry? padding;
HighlightedWord({
this.textStyle,
this.onTap,
this.decoration,
this.padding,
});
}
3. 这是最终的用法
TextHighlight(
text: text,
words: words,
textStyle: TextStyle(
fontSize: 20.0,
color: Colors.black,
),
textAlign: TextAlign.justify,
),
我们的设计
因为要同时满足词组和位置的需求,所以考虑了一下,还是选择用一个类来包含具体的信息:
class HighlightItem {
final String? text;
final TextRange? range;
final TextStyle textStyle;
HighlightItem({this.range, this.text, required this.textStyle})
: assert(text != null || range != null,
"requires at least one condition under which highlighting can be determined");
}
那么MultiHighLightText的构造方法就多了一个集合来传递高亮的文本信息
class MultiHighLightText extends StatelessWidget {
/// The text you want to show
final String text;
/// The normal text style
final TextStyle textStyle;
/// List with the word you need to highlight
final List<HighlightItem>? highlights;
......
}
因为我们可以同时支持词组和位置的方式,所以需要把词组都转换为具体的位置来显示:
// item代表某HighlightItem
// 大概的伪代码
if (item.range == null) {
// Match the position where the text appears in the full text
var matches = item.text!.allMatches(text).toList();
for (var match in matches) {
int start = match.start;
int end = start + match.pattern.toString().length;
add(item.copyWith(range: TextRange(start: start, end: end)));
}
continue;
}
效果
那么通过这个设计其实就能做到:多样式高亮、同时支持词组和具体的位置
2.高亮交叉/高亮叠加(重点)
先来看个效果,这就是我想要实现的结果:
上图中的一切和dept是左右两个高亮文本叠加/交叉的地方,用另一种颜色标记
其实这个需求可能是一个很小众的需求,一般的需求没这么复杂,最多来个多种文本样式的高亮,谁能想到还有交叉叠加?
算法
这个问题就像这样一道算法题,题目我大概描述下(可能不太准确):
给定一个从0开始的区间,期间有N个小区间,每个区间对应一种颜色,求这N个区间各自之间的差集,交集,并且给这些差集交集染色,整个区间内没有小区间交错的位置显示黑色。
示例:
输入:
[0, 15]
[1, 7], 黄色
[3, 6], 红色
[4, 10], 蓝色
[5, 6], 绿色
输出:
[0, 1], 黑色
[1, 3], 黄色
[3, 4], 黄红交叉色
[4, 5], 黄红蓝交叉色
[5, 6], 黄红蓝绿交叉色
[6, 7], 黄蓝交叉色
[7, 10], 蓝色
[10, 15], 黑色
思考
可能这个也算是一个中等难度的算法题了吧
有这么几个关键数据:
假设大区间(文本)的长度是M
小区间的集合(高亮的词组集合)长度是N
每个小区间(高亮的词组)的平均长度是L
根据题目下意识的可能会有一些思路
方案一:双重for循环
遍历整个区间的同时,再遍历小区间的集合,看当前index是否命中了某个区间,如果命中了,需要记录并且叠加颜色,然后循环继续往下走,来段伪代码:
// 区间和对应颜色的集合
List list = [];
// ...
for (int i = 0; i < totalLength; i++) {
for (int j = 0; j < intervals.length; j++) {
// todo 判断是否在这个小区间内,做颜色的累加
// 做是否连续的判断,不连续了加入到集合中
}
}
return list;
// 实际实现起来要比这伪代码复杂的多
时间复杂度是:O(M * N)
方案二:通过map来记录 每次看到双重for循环,就意味着时间复杂度可能是平方级别的,那么能不能用空间来换个时间,想到另一个方案 先遍历小区间的集合,把小区间涉及到的位置颜色用map记录下来,然后再遍历整个区间,对map中的数据做一个整合,来段伪代码:
// 区间和对应颜色的集合
List list = [];
Map<int, List<HighlightItem>> map = {};
for (HighlightItem item in list) {
for (int i = item.range!.start; i < item.range!.end; i++) {
map[i] ??= [];
map[i]!.add(item);
}
}
for (int i = 0; i < totalLength; i++) {
// 做颜色的累加和是否连续的判断
}
return list
时间复杂度是:O(M + N*L)
空间复杂度比方案一多用了:O(N*L)
方案总结
根据我们的实际使用情况来说,文本长度M大概率是最大的,可能几百几千甚至更多,高亮词组的个数N和高亮词组的平均长度L可能大概在个位数最多两位数吧
那么综合来看高亮词组的个数N > 1的话,方案二的时间复杂度就会更优秀,N越大越明显,只是多占用了一些空间
那么综合来说方案二感觉更合适一些
3.提供样式(颜色)混合自定义的功能
onMixStyleBuilder:
对于交叉位置的样式结合规则,提供自定义的功能
那么就又多了一个属性,用来返回多个样式结合后的新样式:
typedef MixStyleBuilder = TextStyle Function(TextStyle a, TextStyle b);
/// When multiple words overlap, multiple TextStyle mix the final TextStyle
/// When mixing more than two TextStyles, the third TextStyle will be remixed with the previous mixed TextStyle
final MixStyleBuilder? onMixStyleBuilder;
实现
超过两个样式则会循环一直结合,比如a、b、c结合后的新样式为:a与b结合的样式再与c结合
默认是通过TextStyle.lerp方法进行结合
for (var value in list!) {
style ??= value.textStyle;
if (style != value.textStyle) {
style = onMixStyleBuilder?.call(style, value.textStyle) ?? TextStyle.lerp(style, value.textStyle, 0.5);
}
}
比如,我想让交叉的文本样式在普通文本的基础上颜色变成蓝色,字号变成17:
onMixStyleBuilder: (styleA, styleB) {
return _textStyle.copyWith(color: Colors.blue, fontSize: 17);
},
4.提供对textSpan的装饰功能
RichText
组件最终要传入一个集合List<TextSpan>
,这个集合就是最终具有不同样式的TextSpan集合
就像这样一个长度为6的TextSpan**的集合:
需求
这里就有一个很明显的诉求,比如我们想在某一个TextSpan上去搞一个点击事件,或者说再这上面再套一个widget,那么这个代码不能写死,否则就不通用了,得想办法抽象出来
onDecorateTextSpanBuilder:
typedef DecorateTextSpanBuilder = List<InlineSpan> Function(List<TextSpanStylesConfig> list);
/// For the decoration of List<InlineSpan> after cutting
/// return a new List<InlineSpan>
final DecorateTextSpanBuilder? onDecorateTextSpanBuilder;
实现
- 封装一个实体类,每一个TextSpan可能对应多个样式:
class TextSpanStylesConfig {
/// The textSpan of a certain range after the cut
final InlineSpan textSpan;
/// The List of config for overlapping styles
/// If configs is null, it indicates that the TextSpan does not have a highlighted style
final List<HighlightItem>? configs;
TextSpanStylesConfig({required this.textSpan, this.configs});
}
2. 在构造List<TextSpan>
时对TextSpanStylesConfig
进行记录
List<TextSpanStylesConfig> configTextSpans = [];
...
if (onDecorateTextSpanBuilder != null) {
configTextSpans.add(TextSpanStylesConfig(textSpan: textSpan, configs: indexMap[start]));
}
...
3. 在build方法返回Widget之前进行装饰
List<InlineSpan> _buildTextSpan() {
List<InlineSpan> textSpans = [];
...
...
...
if (onDecorateTextSpanBuilder != null) {
return onDecorateTextSpanBuilder!(configTextSpans);
}
return textSpans;
}
5.总结
至此实现了一个支持多样式文本交叉高亮的组件,并提供了一些属性
Demo使用方式
String chText = "亲爱的,你是我生命中的一切。无论何时何地,我都深深地爱着你。你是我的永恒,我愿陪伴你走过每一个美好的瞬间。";
final TextStyle _textStyle = const TextStyle(fontSize: 14, color: Colors.black);
MultiHighLightText(
text: chText,
textStyle: _textStyle,
onMixStyleBuilder: onMixStyleBuilder: (styleA, styleB) {
return _textStyle.copyWith(color: Colors.blue, fontSize: 17);
},
highlights: [
HighlightItem(text: "你是我生命中的一切", textStyle: _textStyle.copyWith(color: Colors.green)),
HighlightItem(text: "一切。无论何时何地", textStyle: _textStyle.copyWith(color: Colors.yellow)),
HighlightItem(range: const TextRange(start: 0, end: 1), textStyle: _textStyle.copyWith(color: Colors.red)),
],
)
实现效果
“一切” 为交叉后我们自定义的蓝色
内容纠错
在此组件的加持下,去实现一个仿飞书的内容纠错功能
1.定义错别字返回结构
class CorrectItem {
final String errorText;
final String correctText;
final int start;
final int end;
}
2.考虑悬浮点击后的数据传递问题
这里有一个关键的点,点击后要将错误的文字改为正确的,那一定要传递CorrectItem
对象
这里对HighlightItem做了一个扩展,允许它可以携带一个自定义的customConfig
可以把CorrectItem
对象传递进去
/// The highlighted word information you want to show
class HighlightItem {
final String? text;
final TextRange? range;
final TextStyle textStyle;
/// This custom configuration that can be used for information callback
final dynamic customConfig;
}
3.对TextSpan进行装饰
在对List进行装饰的时候,再把对应的CorrectItem传回来,这样整体逻辑非常清晰
List<InlineSpan> _onDecorateBuilder(List<TextSpanStylesConfig> list) {
...
for (...) {
InlineSpan inlineSpan = list[i].textSpan;
CorrectItem? correctItem = ...;
// 存在错别字,使用自定义的WidgetSpan
if (correctItem != null) {
inlineSpan = buildErrorWidgetSpan(inlineSpan, correctItem);
}
...
}
...
}
4.构建悬浮点击的Widget
通过传入的TextSpan
和CorrectItem
对象,很轻易的可以实现套一个Tooltip然后悬浮点击的功能
/// build Tooltip Widget
WidgetSpan buildErrorWidgetSpan(InlineSpan textSpan, CorrectItem correctItem) {
return WidgetSpan(
...,
child: ClickTooltip(
...
richMessage: WidgetSpan(
child: Listener(
onPointerDown: (detail) => onClickCorrectError(correctItem),
child: InkWell(
onTap: () {},
child:...
)
)
),
child: RichText(
text: textSpan,
),
),
);
}
踩一下小坑:
1.这里的点击事件用了InkWell和Listener的组合
单用InkWell的话,Tooltip会直接消失 单用Listener的话,鼠标悬浮没有那个小手,用户体验不好
2.Tooltip组件无法点击
因为这里我们要实现悬浮点击的情况,所以会用到Tooltip
组件
使用的时候发现了Tooltip
组件有一个问题:
Tooltip
的richMessage
属性下的组件无法点击
这是一个官方的问题:
原因是套了一个IgnorePointer
组件,去掉就可以了
2023年5月19日合并的,在此之前的flutter sdk都会有这个问题,实在不行就拉出来手动改一下
总结
至此,内容纠错开发完成
简化的代码:
class CorrectErrorWidget extends StatelessWidget {
final String text;
...
final ValueChanged<CorrectItem> onClickCorrectError;
@override
Widget build(BuildContext context) {
...
return MultiHighLightText(
text: text,
textStyle: _textStyle,
onMixStyleBuilder: _onMixCorrectErrorTextStyleBuilder,
onDecorateTextSpanBuilder: _onDecorateBuilder,
highlights: list,
),
}
/// 自定义高亮重叠的样式
TextStyle _onMixCorrectErrorTextStyleBuilder(TextStyle styleA, TextStyle styleB) {
...
}
/// 给有错别字的文本添加悬浮点击
List<InlineSpan> _onDecorateBuilder(List<TextSpanStylesConfig> list) {
...
for (int i = 0; i < list.length; i++) {
...
if (correctItem != null) {
inlineSpan = buildErrorWidgetSpan(inlineSpan, correctItem);
}
...
}
}
WidgetSpan buildErrorWidgetSpan(InlineSpan textSpan, CorrectItem correctItem) {
return WidgetSpan(...);
}
}
实现效果:
总结
直接使用开源库是成本最低的方式,但是当社区没有某些功能的时候,需要静下心来慢慢实现~
还是希望在开发中能多注重一些细节,为产品提供更大的支撑力~
其实每一个难题都能拆解成一个个的小问题~
我大概主要实现了如下功能:
- 一个文本中可以展示多种样式
- 多种样式可以重叠交叉显示
- 可以同时通过给定的词组或位置来展示特定的样式
- 可以自定义装饰多样式的文本集合
- 给出简单的内容显示和内容纠错demo
项目地址
github: github.com/pengboboer/…
pub: pub.dev/packages/mu…
其他
这个功能2个月前就写好了,工作太忙一直没有时间写文章~
终于在中秋节前能把它发出去了~
如果能帮到你们,希望给个点赞和star~
你们的鼓励是对我最大的肯定~
转载自:https://juejin.cn/post/7283749823428214821