最近做了一个仿飞书的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
特点下的组件无法点击
这是一个官方的问题:
github.com/flutter/flu…
github.com/flutter/flu…
原因是套了一个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~
你们的鼓励是对我最大的必定~