效果图:

使用 Unicode 的私有区做表情的占位符
继承重写 TextEditingController,在 buildTextSpan 内显示时调整为表情图片。
表情键值对:
js
final Map<int, String> emojiMap = {
0xE001: 'assets/images/1.png',
0xE002: 'assets/images/2.png',
0xE003: 'assets/images/3.png',
};
遍历字符串把占位符显示为表情的方法:
js
List<InlineSpan> _parseText(String text, TextStyle? style) {
final spans = <InlineSpan>[];
final buffer = StringBuffer();
for (final codePoint in text.runes) {
// 如果是表情
if (codePoint >= 0xE000 && codePoint <= 0xF8FF) {
// 将 buffer 内的字符加入 spans 并清空 buffer。
if (buffer.isNotEmpty) {
spans.add(TextSpan(text: buffer.toString(), style: style));
buffer.clear();
}
// 处理表情
final asset = emojiMap[codePoint];
if (asset != null) {
spans.add(
WidgetSpan(
child: Image.asset(
asset,
width: style?.fontSize,
height: style?.fontSize,
),
),
);
} else {
buffer.writeCharCode(codePoint);
}
} else {
buffer.writeCharCode(codePoint);
}
}
if (buffer.isNotEmpty) {
spans.add(TextSpan(text: buffer.toString(), style: style));
}
return spans;
}
buildTextSpan:
js
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
assert(
!value.composing.isValid || !withComposing || value.isComposingRangeValid,
);
final bool composingRegionOutOfRange =
!value.isComposingRangeValid || !withComposing;
if (composingRegionOutOfRange) {
// 修改为使用 _parseText()
// 原本的:return TextSpan(style: style, text: text);
return TextSpan(style: style, children: _parseText(text, style));
}
final TextStyle composingStyle =
style?.merge(const TextStyle(decoration: TextDecoration.underline)) ??
const TextStyle(decoration: TextDecoration.underline);
// 这里也是修改为使用 _parseText()
return TextSpan(
style: style,
children: <TextSpan>[
TextSpan(
children: _parseText(value.composing.textBefore(value.text), style),
),
TextSpan(
style: composingStyle,
children: _parseText(value.composing.textInside(value.text), style),
),
TextSpan(
children: _parseText(value.composing.textAfter(value.text), style),
),
],
);
}
添加表情:
js
void addEmoji(int codePoint) {
// 获取当前选区,如果无效(如未聚焦),则默认光标在文本末尾
final TextSelection currentSelection = selection.isValid
? selection
: TextSelection.collapsed(offset: text.length);
// 根据选区情况构造新文本和光标位置
final String newText;
final int newCursorOffset;
if (currentSelection.isCollapsed) {
// 折叠光标:直接插入
final int pos = currentSelection.baseOffset;
newText =
text.substring(0, pos) +
String.fromCharCode(codePoint) +
text.substring(pos);
newCursorOffset = pos + 1;
} else {
// 有选中文本:先删除选中内容,再在开始位置插入
final int start = currentSelection.start;
final int end = currentSelection.end;
newText =
text.substring(0, start) +
String.fromCharCode(codePoint) +
text.substring(end);
newCursorOffset = start + 1;
}
// 更新控制器值,并设置光标折叠在新字符之后
value = value.copyWith(
text: newText,
selection: TextSelection.collapsed(offset: newCursorOffset),
// 清除组合范围,因为插入操作会中断输入法组合
composing: TextRange.empty,
);
}