三方库开源地址:https://atomgit.com/nutpi/flutter_ohos_text_span_field

写在前面

请添加图片描述

最近在做一个社交类的项目,产品要求实现类似微博、QQ那种@用户的功能。一开始我想着用普通的 TextField 加正则匹配来做,后来发现坑太多了——用户重名怎么办?删除的时候只删了一个字怎么办?光标定位到@块中间又该怎么处理?

折腾了两天,各种边界情况处理得我头都大了。后来在 GitHub 上翻了半天,发现了 FlutterTextSpanField 这个库。虽然作者说停止维护了,但试了下确实好用,基本上把我遇到的问题都解决了。

今天就来分享一下怎么用它实现@功能,顺便记录下鸿蒙适配的一些细节。毕竟现在鸿蒙生态越来越重要,能跑在鸿蒙上的库才是好库。

这个库能解决什么问题

说实话,@功能看起来简单,实际做起来有几个头疼的点。

问题一:用户重名

比如你@了一个叫"张三"的用户,但系统里可能有100个张三,你怎么知道@的是哪个?

如果只存显示的文本,后端根本没法判断。所以我们需要把用户ID"藏"在显示的文本后面,用户看到的是昵称,但实际传给后端的是ID。

问题二:删除体验

用户在删除的时候,如果@张三被删成了@张,这体验就很奇怪了。我之前用正则匹配的方案,就遇到过这个问题,用户反馈说删除的时候很别扭。

正常的做法应该是整块删除,要么全删,要么不删。就像微信那样,你删@块的时候,整个@块会一起消失。

问题三:光标定位

还有一个容易被忽略的问题:光标定位。

用户点击@张三的"张"和"三"之间,光标应该在哪?如果光标真的定位到中间,用户输入的内容会插到@块里面,这显然不对。

所以需要做限制,光标只能在@块的前面或后面,不能在中间。


FlutterTextSpanField 刚好解决了这三个问题。它支持把自定义的 TextSpan 当成一个"块"来处理,块删除、光标限制这些都帮你做好了。

开始集成

首先在 pubspec.yaml 里加上依赖:

dependencies:
  text_span_field: ^最新版本

这里建议去 pub.dev 上看一下最新版本号,直接复制过来就行。我用的是 1.0.0 版本,目前还挺稳定的。

加完依赖别忘了运行一下:

flutter pub get

等依赖下载完就可以开始写代码了。


创建自定义的 AtTextSpan

接下来要创建一个自定义的 AtTextSpan 类。为什么要自定义呢?因为我们需要在 TextSpan 的基础上加一个 id 字段来存用户ID。

class AtTextSpan extends TextSpan {
  final String id;

  const AtTextSpan({
    required this.id,
    String? text,
    TextStyle? style,
  }) : super(text: text, style: style);
}

这段代码很简单,就是继承了 TextSpan,然后加了一个 id 属性。

为什么要这么做?

因为 Flutter 原生的 TextSpan 只能存显示的文本,没地方存额外的数据。通过继承的方式,我们可以在不改变原有功能的基础上,加上自己需要的字段。

后面我们@用户的时候,显示的是昵称,但实际存的是这个 id。用户看不到 id,但我们的代码能拿到。


初始化 TextSpanBuilder

然后在页面里初始化 TextSpanBuilder:

class _MyPageState extends State<MyPage> {
  TextSpanBuilder _textSpanBuilder = TextSpanBuilder();
}

TextSpanBuilder 是这个库的核心类,所有的操作都要通过它来完成。

它的作用是什么?

你可以把它理解成一个"管理器",负责管理输入框里所有的自定义块。包括:

  • 添加@块
  • 删除@块
  • 获取@块列表
  • 处理光标位置
  • 处理删除逻辑

基本上所有和@功能相关的操作,都是通过这个 builder 来完成的。


创建输入框

创建输入框的时候,把 builder 传进去:

TextSpanField(
  maxLines: null,
  textSpanBuilder: _textSpanBuilder,
  decoration: InputDecoration(
    contentPadding: EdgeInsets.all(20),
    hintText: '分享你的点滴,记录这一刻...',
  ),
)

这里有几个参数需要注意:

maxLines: null 表示输入框可以自动换行,不限制行数。如果你想限制行数,可以设置成具体的数字,比如 maxLines: 5

textSpanBuilder 就是我们刚才创建的 builder,这个参数是必须的。

decoration 和普通的 TextField 一样,可以设置边框、提示文字、内边距这些。基本上 TextField 支持的样式,这里都支持。

所以如果你之前用过 TextField,上手这个组件基本没什么成本。

实现@用户的核心逻辑

重点来了。当用户点击@按钮(或者从用户列表选择了一个用户)时,我们需要把@内容插入到输入框。

插入到光标位置

_atUser(String name, String id) {
  _textSpanBuilder.appendToCursor(AtTextSpan(
    id: id,
    text: "@$name ",
    style: TextStyle(color: Color(0xFF5BA2FF)),
  ));
}

appendToCursor 方法的作用:

这个方法会把内容插入到当前光标位置,而不是末尾。这样用户体验更好,想在哪@就在哪@。

比如用户输入了"今天天气真好",然后把光标移到"天气"后面,点击@按钮,@内容就会插入到"天气"后面,变成"今天天气@张三 真好"。


关于 text 参数:

text: "@$name "

注意我在昵称后面加了个空格。这个空格很重要,不然@完之后用户继续打字,内容会和@块粘在一起,看起来很奇怪。

比如没有空格的话,用户@完张三继续打字,会变成"@张三你好",@块和后面的文字连在一起了。加了空格之后就是"@张三 你好",看起来舒服多了。


关于 style 参数:

style: TextStyle(color: Color(0xFF5BA2FF))

这里设置了蓝色,这样@的用户名会高亮显示。颜色可以根据你的 UI 设计来调整,我这里用的是一个比较常见的蓝色。

高亮显示的好处是,用户一眼就能看出来哪些是@的人,哪些是普通文本。


插入到末尾

如果你想把@内容插入到末尾而不是光标位置,可以用 appendToEnd 方法:

_textSpanBuilder.appendToEnd(AtTextSpan(
  id: id,
  text: "@$name ",
  style: TextStyle(color: Color(0xFF5BA2FF)),
));

两个方法的区别就是插入位置不同。

什么时候用 appendToEnd

如果你的产品设计是:用户点击@按钮后,@内容总是添加到输入框末尾,那就用这个方法。

比如有些 App 的评论功能,@的人总是在评论内容的最后,这种场景就适合用 appendToEnd

什么时候用 appendToCursor

如果你希望用户可以在任意位置插入@内容,那就用 appendToCursor。这种方式更灵活,用户体验也更好。

大部分社交 App 都是用这种方式,所以我推荐用 appendToCursor

处理用户选择

实际项目中,@用户一般是从一个用户列表里选的。这里我简单模拟一下这个流程。

准备用户数据

首先定义一个用户列表:

final List<Map<String, String>> _userList = [
  {"name": "张三", "id": "10001"},
  {"name": "李四", "id": "10002"},
  {"name": "王五", "id": "10003"},
];

这个列表实际项目中应该是从接口拿的,这里为了演示就写死了。

实际项目中的做法:

一般是用户点击@按钮后,调用接口获取好友列表或者最近联系人列表。如果用户量大,还需要加上搜索功能,让用户可以输入昵称来筛选。


创建用户选择弹窗

然后做一个简单的用户选择弹窗:

_showUserPicker() {
  showModalBottomSheet(
    context: context,
    builder: (context) {
      return ListView.builder(
        itemCount: _userList.length,
        itemBuilder: (context, index) {
          var user = _userList[index];
          return ListTile(
            title: Text(user["name"]!),
            subtitle: Text("ID: ${user["id"]}"),
            onTap: () {
              _atUser(user["name"]!, user["id"]!);
              Navigator.pop(context);
            },
          );
        },
      );
    },
  );
}

这段代码做了什么?

showModalBottomSheet 会从底部弹出一个面板,里面用 ListView.builder 显示用户列表。

每个用户显示昵称和ID(ID在实际项目中可以不显示,这里是为了演示)。

用户点击某一项后,调用 _atUser 方法把@内容插入输入框,然后用 Navigator.pop 关闭弹窗。


触发弹窗

在输入框下面加一个@按钮:

ElevatedButton(
  onPressed: _showUserPicker,
  child: Text("@用户"),
)

用户点击这个按钮,就会弹出用户选择面板。

优化建议:

实际项目中,这个按钮可以做得更好看一些,比如用一个@图标,或者把按钮放在输入框的工具栏里。

有些 App 还支持输入@符号自动触发用户选择,这个需要监听输入内容的变化,判断是否输入了@符号。不过这个功能稍微复杂一点,这里就不展开了。

关于块删除

前面提到过,这个库的一个特点是"块删除"。这个功能虽然是自动的,但理解它的原理对使用很有帮助。

什么是块删除

比如你输入了 今天@张三 天气不错,当你把光标移到"张三"中间,按删除键的时候,会发生什么?

普通 TextField 的行为:

只会删掉"三"这一个字,变成 今天@张 天气不错

FlutterTextSpanField 的行为:

会把整个 @张三 都删掉,变成 今天 天气不错


为什么要这样设计

这个设计是有道理的。

如果@张三被删成了@张,这个@块就失去意义了。因为@块绑定的是用户ID,删掉一半之后,这个ID还在,但显示的昵称不完整了,会造成混乱。

所以最好的做法就是:要么不删,要么全删。

这个行为是库自动处理的,不需要我们写额外的代码。体验上和微信、QQ的@功能是一样的。


光标位置限制

另外还有一点,光标是没办法定位到@块的中间的。

你试着点击"@张三"的"张"和"三"之间,光标会自动跳到整个块的前面或后面。具体跳到哪边,取决于你点击的位置离哪边更近。

为什么要限制光标位置?

如果光标能定位到@块中间,用户输入的内容会插到@块里面,比如变成"@张abc三",这显然不对。

所以库做了限制,光标只能在@块的前面或后面,不能在中间。

这些细节库都帮我们处理好了,省了不少事。我之前自己实现的时候,光这个光标限制就写了好几十行代码,还有各种边界情况要处理。用这个库之后,这些都不用管了。

鸿蒙适配

这个库本身是纯 Dart 实现的,没有用到平台特定的代码,所以在鸿蒙上跑起来基本没什么问题。

Flutter 版本要求

首先确保你的 Flutter SDK 版本支持鸿蒙。

flutter --version

目前鸿蒙适配需要用特定的 Flutter 分支,具体可以参考华为官方文档。我用的是 Flutter 3.x 版本,在鸿蒙上测试没问题。


运行命令

在鸿蒙设备上运行的时候,命令和安卓稍微有点不一样。

先查看设备列表:

flutter devices

会输出类似这样的信息:

HarmonyOS (mobile) • xxx • harmony • HarmonyOS 4.0.0

然后用设备ID运行:

flutter run -d <你的鸿蒙设备ID>

如果只连了一个鸿蒙设备,直接 flutter run 也行,会自动选择。


输入法兼容性

我在测试的时候发现,鸿蒙自带的输入法和这个库配合得还不错。

测试过的场景:

  • 中英文切换 ✓
  • 拼音输入 ✓
  • 表情输入 ✓
  • 语音输入 ✓
  • 手写输入 ✓

但如果你用了第三方输入法(比如搜狗、百度输入法),可能会有一些小问题。主要是候选词的显示位置可能会有点偏移,不过不影响使用。

建议在真机上多测试一下,模拟器上的输入法和真机还是有点区别的。


性能表现

在鸿蒙设备上,这个库的性能表现还不错。

我测试了一下,输入框里有10个@块的情况下,输入、删除、光标移动都很流畅,没有卡顿。

不过如果@块特别多(比如超过50个),可能会有一点点延迟。但这种场景比较少见,一般的社交 App 不会让用户@这么多人。

一些踩过的坑

坑1:不要直接给 controller.text 赋值

如果输入框里已经有@块了,千万不要用 controller.text = "xxx" 这种方式去改内容,会出问题。正确的做法是用 builder 提供的 appendTextdelete 这些方法。

坑2:注意空格

@用户的时候记得在后面加个空格,不然用户继续输入的内容会和@块粘在一起,看起来很奇怪。

坑3:样式继承

AtTextSpan 的样式如果不设置,会继承输入框的默认样式。如果你想让@的内容有不同的颜色或字体,记得在创建 AtTextSpan 的时候传入 style。

写在最后

FlutterTextSpanField 这个库虽然作者已经停止维护了(因为 Flutter 迭代太快,库是基于源码扩展的),但目前已发布的版本还是可以正常使用的。

如果你的项目需要实现@功能,又不想自己从头造轮子,这个库还是值得一试的。

下一篇文章会介绍怎么获取输入框里@的用户ID列表,也就是"隐藏域值获取"功能,敬请期待~


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐