废话少说,先直接看效果图
之前在做原生安卓时候 实现过类似的功能,地址https://www.jianshu.com/p/5398df1c8f2c?v=1701076000905
近段时间转flutter ,也需要实现类似的需求,百度一下,发现这方面的文章很少,有的demo跑起来完全不是我需要的效果。
折腾了一两天,还是自己手动撸吧
这里仅仅演示了 添加文字块,没有演示删除效果,之前是模拟器 flutter的RawKeyboardListener无法监听到模拟器的按键事件,查了很久发现是 只能监听物理按键,不能监听屏幕按键,后面有小伙伴发现ios也监听不到,果断放弃RawKeyboardListener,换另一种思路来解决,目前在模拟器测试是ok的。
这个@功能整理思路很重要,这里我去分析了安卓版实现的原理,很多功能 flutter都是没法做到和原生一致的,都是换一种思路来实现。
不过flutter实现起来代码量很少,只有三个文件,大部分都是在FTextEditingController中实现,维护比较方便
flutter和原生最大的不同就是设置高亮颜色的方式,
原生是
editable.setSpan(new ForegroundColorSpan(color), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
flutter则是通过TextEditingController的buildTextSpan方法来重新拼接
比如说 “我是@张三啊实打实”,我想让“@张三”高亮
那么需要怎么拼接的?
第一种方式是 ,通过正则来比配 “@张三”,则分成三部分 :“我是”,“@张三”,“啊实打实”
也就是三个TextSpan拼接起来 :
[TextSpan(“我是”).TextSpan(“@张三”),TextSpan(“啊实打实”)]
第二种是indexOf(highlight, start)来寻找@张三的起始位置,因为@张三有可能是多个,
这里使用死循环来寻找,如下:
void highlight(){
int start = 0, end;
int index;
String text = "我是@张三啊实打实";
String highlight = '@张三';
while ((index = text.indexOf(highlight, start)) > -1) {
end = index + highlight.length;
// [index, end ]就是@张三的起始位置
start = end; // 下一次从上次发现的位置开始往后寻找
}
}
我这边使用正则来比配,感觉没有那么绕脑,第二种方式也可以,看个人使用
以下是文件夹结构
选择@人 或者#事件,这里没有使用原生的多态来实现,感觉多态不容易看懂,选择艾特一般就两三个类型,多态感觉用在大型项目比较合适,
// 这里就是模拟页面跳转选择之后,插入文字块
void addAit(){
int id=10;
String name="张三";
// 第一个参数就是拼接成@张三 格式,建议后面加空格,原生安卓版不加会有问题,
// 原因看安卓版有说明https://www.jianshu.com/p/5398df1c8f2c?v=1701076000905
// 第二个参数就是 提交给后端的格式,可以自己自定义
SpanEntity span=SpanEntity("@${name} ","{[@$name,$id]}",color: Colors.blue);
_controller.insertSpan(span);
}
void addEvent(){
int id=9999;
String name="alan";
SpanEntity span=SpanEntity("#${name} ","{[#$name,$id]}",color: Colors.orange);
_controller.insertSpan(span);
}
void add$Span(){
int id=555;
String name="公司";
SpanEntity span=SpanEntity("\$${name} ","{[\$$name,$id]}",color: Colors.orange);
_controller.insertSpan(span);
}
// 如果使用$作为特殊字符开头,在Controller还要做一层处理不然,比配不到
String get rulerText {
return spanList.map((e) => e.text.startsWith('\$') ? '\\${e.text}' : e.text).toSet().toList().join('|');
}
不用多态就直接定义一个类就可以,其实原生也可以直接这样,省事,通俗易懂,只是懒得去修改原生的了
发现了一个bug,如下图所示
只要第一个是文字块,然后又跑到最前面插入文字块,就会导致光标位置错乱
具体原因可以查看代码分析
// 分析:
// 如果第一个文本就是一个文字块,比如span(0,6)
// 此时光标移入0的位置,再次插入文字块span(0,4)
// 就会变成 spanList=[span(0,6),span(0,4)],也就是文字块的起始位置冲突了
// 也就是span(0,6)本来应该往后位移,但是两个位置冲突了,beforeTextChanged方法无法正常执行
之前的判断是,根据文字块的开始位置即start来设置Offset,也就是当我插入文字快,你的start大于当前插入的start就要设置偏移量,很明显spanList=[span(0,6),span(0,4)],这里没法做偏移量,因为start都是0
spanList.forEach((element) {
if (element.start > start && count != 0) {
element.setOffset(count);
}
print('11count: $count, start: ${element.start}, end: ${element.end}');
});
isInsertSpan = false;
解决:
1.给SpanEntity 增加一个id属性,默认按时间戳生成,这样可以在添加之后过滤掉本次添加span
思路:过滤掉本次添加span之后,只要start>=当前插入span的start(element.start >= start),都要做偏移量
过滤完之后,spanList=[span(0,6),span(0,4)]会就变成 spanList=[span(0,6)],那么span(0,6)就可以设置对应的偏移量
修改前
void beforeTextChanged() {
int count = text.length - textSize;
int end = selection.end;
if (end < 0) {
// 没有焦点的情况下直接返回,一般是event编辑的时候会出现
textSize = text.length;
return;
}
int start = end - count;
if (isInsertSpan) {
spanList.forEach((element) {
if (element.start > start && count != 0) {
element.setOffset(count);
}
print('11count: $count, start: ${element.start}, end: ${element.end}');
});
isInsertSpan = false;
} else {
spanList.forEach((element) {
if (element.start >= start && count != 0) {
element.setOffset(count);
}
print('22count: $count, start: ${element.start}, end: ${element.end}');
});
}
// print('count: $count, start: $start, end: $end');
textSize = text.length;
}
修改后
void beforeTextChanged() {
int count = text.length - textSize;
int end = selection.end;
if (end < 0) {
// 没有焦点的情况下直接返回,一般是event编辑的时候会出现
textSize = text.length;
return;
}
int start = end - count;
List<SpanEntity> list = spanList;
if (insertSpanCache!=null) {
// 过滤掉本次插入的文字块
list = list.where((el) => el.id!=insertSpanCache!.id).toList();
insertSpanCache = null;
}
list.forEach((element) {
if (element.start >= start && count != 0) {
element.setOffset(count);
}
});
// print('count: $count, start: $start, end: $end');
textSize = text.length;
}
2024-6-26发现一个可以去掉正则的写法,而且比正则更容易看懂和维护,关键在于spanList,
spanList包含了所有文字快的信息(主要是起始位置信息),那完全没有必要使用正则,
直接使用遍历spanList,然后拼接不是更好?
先看原本的代码
TextSpan buildTextSpan(
{required BuildContext context,
TextStyle? style,
required bool withComposing}) {
RegExp regex = RegExp(rulerText); // RegExp(r'@牛逼|#大事件');
Iterable<RegExpMatch> matches = regex.allMatches(value.text);
int lastEnd = 0;
List<TextSpan> spans = [];
for (RegExpMatch match in matches) {
final matchText = match.group(0);
// 前部分文本
final preMatch = text.substring(lastEnd, match.start);
if (preMatch.isNotEmpty) {
spans.add(TextSpan(text: preMatch));
}
Color highLightColor =spanColor[matchText]?? Colors.red;
// 目标文本
spans.add(
TextSpan(text: matchText, style: TextStyle(color: highLightColor)));
lastEnd = match.end;
}
// 目标后面的文本
final tail = text.substring(lastEnd);
if (tail.isNotEmpty) {
spans.add(TextSpan(text: tail));
}
return TextSpan(style: style, children: spans);
}
修改之后
@override
TextSpan buildTextSpan(
{required BuildContext context,
TextStyle? style,
required bool withComposing}) {
return formatInputText(style);
}
// 这里直接使用spanList来遍历重新组合TextSpan ,省去使用正则 ,而且完全没有必要使用正则
TextSpan formatInputText(TextStyle? style) {
int lastEnd = 0;
List<TextSpan> spans = [];
for (SpanEntity span in spanList) {
if (lastEnd <= text.length && span.start <= text.length) {
final startText = text.substring(lastEnd, span.start);
if (startText.isNotEmpty) {
spans.add(TextSpan(text: startText));
}
String matchText = span.text;
Color highLightColor = span.color ?? Colors.red;
// 目标文本
spans.add(
TextSpan(text: matchText, style: TextStyle(color: highLightColor)));
lastEnd = span.end;
}
}
final endText = text.substring(lastEnd);
if (endText.isNotEmpty) {
spans.add(TextSpan(text: endText));
}
return TextSpan(style: style, children: spans);
}
另外还有一个地方修改
每次插入或者文本变化之后,spanList必须要重新排序,这样才可以使用spanList来遍历,使用正则的好处就是不用排序,不过从最终优化的结果来看,还是排序比较容易看懂
不想看分析,直接拉取最新代码即可
代码传送门 https://gitee.com/Pino_W/flutter_ait