likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 41

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

常见产品

飞书内容纠错:

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

需求

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

和飞书不同的地方是,我们这里的需求并不是编辑框内纠错,而是文本纠错,不过本质都是一样的

要求:

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

提炼

我们需要有一个组件,支持:

  1. 多种样式文本高亮,可以传入高亮词组(匹配所有出现的位置)或高亮的区间(固定位置)
  2. 如果有高亮重叠的区域,需要显示多种样式结合后的样式
  3. 可以对某高亮内容做一些事件的添加,如悬浮,点击等

调研

当然是希望可以在pub或者github找到成熟的开源方案,然后直接拿来用一下子~

开源库likespopularity特点
highlight_text9195%支持词组高亮,且支持不同词组不同样式
substring_highlight17398%仅支持词组单一样式高亮
flutter_highlight17398%这个貌似是代码高亮组件。。。
extended_text20897%支持很多不错的功能

最接近需求的库是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~

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

转载自:https://juejin.cn/post/7283749823428214821
评论
请登录