最近做了一个仿飞书的flutter web内容文本高亮相关的需求,费了不少脑细胞,可算研究出比较抱负的效果了,感觉共享出来仍是挺有含义的~

常见产品

飞书内容纠错:

Flutter 仿飞书内容纠错及文本多样式高亮开发实践

需求

web端的文章检查,需求开发一个可以进行内容纠错的组件——文本高亮的组件

和飞书不同的地方是,我们这儿的需求并不是修正框内纠错,而是文本纠错,不过实质都是相同的

要求:

  1. 文章内容指定的文本高亮,比如查找时高亮、纠错高亮、灵敏词高亮等
  2. 希望可以有不同的高亮样式,并且多种样式可以一同出现(包含交叉出现)
  3. 纠错高亮需求准确到内容的某一个词组,不可将内容中其他地方相同的字一同高亮
  4. 出现过错内容的文字可以悬浮直接修正内容

提炼

我们需求有一个组件,支撑:

  1. 多种样式文本高亮,可以传入高亮词组(匹配悉数出现的方位)或高亮的区间(固定方位)
  2. 假如有高亮堆叠的区域,需求闪现多种样式结合后的样式
  3. 可以对某高亮内容做一些工作的添加,如悬浮,点击等

调研

当然是希望可以在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作者的规划,这个库支撑多样式的高亮:

Flutter 仿飞书内容纠错及文本多样式高亮开发实践

  1. 让运用者通过一个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.高亮交叉/高亮叠加(重点)

先来看个效果,这就是我想要结束的成果:

Flutter 仿飞书内容纠错及文本多样式高亮开发实践

上图中的悉数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**的集结:

Flutter 仿飞书内容纠错及文本多样式高亮开发实践

需求

这儿就有一个很显着的诉求,比如我们想在某一个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;

结束

  1. 封装一个实体类,每一个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)),
    ],
)

结束效果

“悉数” 为交叉后我们自定义的蓝色

Flutter 仿飞书内容纠错及文本多样式高亮开发实践

内容纠错

在此组件的加持下,去结束一个仿飞书的内容纠错功用

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

通过传入的TextSpanCorrectItem方针,很容易的可以结束套一个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组件有一个问题:

TooltiprichMessage特点下的组件无法点击

这是一个官方的问题:

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(...);
  }
}

结束效果:

Flutter 仿飞书内容纠错及文本多样式高亮开发实践

总结

直接运用开源库是本钱最低的办法,但是当社区没有某些功用的时分,需求静下心来渐渐结束~

仍是希望在开发中能多注重一些细节,为产品供应更大的支撑力~

其实每一个难题都能拆解成一个个的小问题~

我大约主要结束了如下功用:

  1. 一个文本中可以展现多种样式
  2. 多种样式可以堆叠交叉闪现
  3. 可以一同通过给定的词组或方位来展现特定的样式
  4. 可以自定义装饰多样式的文本集结
  5. 给出简略的内容闪现和内容纠错demo

项目地址

github: github.com/pengboboer/…

pub: pub.dev/packages/mu…

其他

这个功用2个月前就写好了,工作太忙一向没有时间写文章~

总算在中秋节前能把它宣告去了~

假如能帮到你们,希望给个点赞和star~

你们的鼓励是对我最大的必定~