HarmonyOS 富文本开发完整指南:RichEditor 组件实战
/ 显示的文字内容// 起始和结束位置// 类型:@好友、话题等// 结构化数据(好友 id、头像等)// 维护所有自定义 textSpanvalue?: string;// 文本内容// 表情图片资源type?: string;// 类型:contact、topic 等data?: ESObject;// 结构化数据})supportAvoidance: true // 支持键盘避让})功能关键
富文本编辑器,社交 App、评论区、笔记应用都离不开。文字、表情、@好友、话题标签……这些功能怎么实现?
HarmonyOS 的 RichEditor 组件就是干这个的。今天我把 RichEditor 的用法给大家讲清楚,包括一些容易踩的坑。
二、RichEditor 组件选型
RichEditor 有两种内容管理方式,选对了很重要。
方式一:基于属性字符串(StyledString)
RichEditor({
text: styledString, // MutableStyledString 对象
controller: this.controller
})
优势:
- 样式修改灵活便捷
- 适合复杂样式操作
劣势:
- 序列化困难,需手动提取属性存储
- 动态内容维护成本高,不适合频繁增删
方式二:基于 Span(推荐)
RichEditor({
controller: this.controller
})
优势:
- 使用便捷,直接操作独立的 Span
- 支持增量式操作,适合频繁交互
劣势:
- 复杂样式操作不如方式一灵活
结论:内容发布场景(评论、笔记)推荐方式二。
三、基础用法
3.1 初始化
@BuilderParam richEditorBuilder: () => void = () => {};
RichEditor({
controller: this.richEditorController
})
.onReady(() => {
// 设置默认文本样式
this.richEditorController.setTypingStyle({
fontColor: Color.Black,
fontSize: 14
});
})
3.2 添加文字
// 系统键盘输入的文字自动添加
// 或者手动添加
this.richEditorController.addTextSpan('Hello World', {
offset: this.richEditorController.getCaretOffset(),
style: {
fontColor: Color.Blue,
fontSize: 16
}
});
3.3 添加表情
this.richEditorController.addImageSpan($r('app.media.emoji_1'), {
offset: this.richEditorController.getCaretOffset(), // 当前光标位置
imageStyle: {
size: [16, 16] // 表情大小
}
});
四、@好友功能实现
这是富文本编辑器的核心难点。
4.1 数据结构定义
首先需要定义一个数据结构来维护自定义的 textSpan:
interface TextSpan {
value: string; // 显示的文字内容
spanRange: [number, number]; // 起始和结束位置
type: string; // 类型:@好友、话题等
data: ESObject; // 结构化数据(好友 id、头像等)
}
// 维护所有自定义 textSpan
private textSpans: TextSpan[] = [];
4.2 点击@按钮添加好友
// 点击工具栏@按钮
onAtButtonClick() {
const controller = this.richEditorController;
const curOffset = controller.getCaretOffset();
// 添加@符号
controller.addTextSpan('@', {
offset: curOffset,
style: {
fontColor: '#133667'
}
});
// 添加空格分割
controller.addTextSpan(' ', {
offset: controller.getCaretOffset()
});
// 维护 textSpans
this.updateTextSpans(curOffset, 2); // 更新后续 span 位置
this.textSpans.push({
value: '@好友昵称',
type: 'contact',
data: { id: '123', name: '好友昵称' },
spanRange: [curOffset, curOffset + 5]
});
}
4.3 键盘输入@符号
@State isKeyboardTriggered: boolean = false;
.aboutToIMEInput: (value: RichEditorInsertValue) => boolean = value => {
if (value.insertValue === '@') {
this.isKeyboardTriggered = true; // 标记是键盘输入触发
// 跳转到好友列表页面
this.getUIContext().getRouter().pushUrl({
url: 'pages/ContactListPage'
});
}
return true;
}
// 选择好友后添加
addTextSpan(value: string, type: string, data: ESObject) {
const controller = this.richEditorController;
const curOffset = controller.getCaretOffset();
// 如果是键盘输入触发,先删除默认的@符号
if (this.isKeyboardTriggered) {
this.deletePrevChar();
this.isKeyboardTriggered = false;
}
// 添加@好友内容
controller.addTextSpan(value, {
offset: curOffset,
style: {
fontColor: '#133667'
}
});
// 添加空格分割
controller.addTextSpan(' ', {
offset: controller.getCaretOffset()
});
// 维护 textSpans
this.updateTextSpans(curOffset, value.length + 1);
this.textSpans.push({
value,
type,
data,
spanRange: [curOffset, curOffset + value.length]
});
}
deletePrevChar() {
const controller = this.richEditorController;
const offset = controller.getCaretOffset();
const range: RichEditorRange = { start: offset - 1, end: offset };
controller.deleteSpans(range);
}
4.4 更新 textSpans 位置
// 在插入内容后,更新后续 textSpan 的位置
updateTextSpans(insertOffset: number, insertLength: number) {
this.textSpans.forEach(textSpan => {
if (textSpan.spanRange[0] >= insertOffset) {
textSpan.spanRange[0] += insertLength;
textSpan.spanRange[1] += insertLength;
}
});
}
五、光标位置处理
@好友内容应该作为一个整体,光标不能落入中间。
5.1 点击时调整光标
// 判断光标是否在 textSpan 中间,是则调整到边界
snapCaretToTextSpanBoundary(caretOffset: number, type?: 'start' | 'end'): number {
const textSpan = this.textSpans.find(textSpan => {
return caretOffset > textSpan.spanRange[0] && caretOffset < textSpan.spanRange[1];
});
if (!textSpan) return caretOffset;
if (type === 'start') return textSpan.spanRange[0];
if (type === 'end') return textSpan.spanRange[1];
// 就近原则
const disToStart = caretOffset - textSpan.spanRange[0];
const disToEnd = textSpan.spanRange[1] - caretOffset;
return disToStart <= disToEnd ? textSpan.spanRange[0] : textSpan.spanRange[1];
}
// 监听选区变化
.onSelectionChange: (range: RichEditorRange) => void = range => {
// start === end 表示光标位置变化(非选中)
if (range.start === range.end) {
const targetCaretOffset = this.snapCaretToTextSpanBoundary(range.start!);
this.richEditorController.setCaretOffset(targetCaretOffset);
}
}
5.2 选中时调整
.onSelect: (selection: RichEditorSelection) => void = richEditorSelection => {
const caretStart = richEditorSelection.selection[0];
const caretEnd = richEditorSelection.selection[1];
// 判断选中内容是否在同一个 textSpan 内
const textSpan = this.textSpans.find(textSpan => {
return caretStart >= textSpan.spanRange[0] && caretEnd <= textSpan.spanRange[1];
});
if (textSpan) {
// 在同一个 textSpan 内,选中整个 textSpan
this.richEditorController.setSelection(
textSpan.spanRange[0],
textSpan.spanRange[1]
);
return;
}
// 清除选中
if (caretStart === -1 && caretEnd === -1) {
return;
}
// 否则调整到最近的边界
const selectionStart = this.snapCaretToTextSpanBoundary(caretStart);
const selectionEnd = this.snapCaretToTextSpanBoundary(caretEnd);
this.richEditorController.setSelection(selectionStart, selectionEnd);
}
六、删除逻辑
删除@好友时要整体删除。
.aboutToDelete: (value: RichEditorDeleteValue) => boolean = deleteValue => {
// 有拼音预览时,直接执行默认删除逻辑
const previewText = this.richEditorController.getPreviewText().value;
if (previewText.length !== 0) {
return true;
}
const start = deleteValue.offset;
const end = start + deleteValue.length;
// 调整删除范围到 textSpan 边界
const snapStart = this.snapCaretToTextSpanBoundary(start, 'start');
const snapEnd = this.snapCaretToTextSpanBoundary(end, 'end');
// 删除内容
this.richEditorController.deleteSpans({
start: snapStart,
end: snapEnd
});
// 删除 textSpans 中对应的记录
this.textSpans = this.textSpans.filter(ts => {
const isInRange = ts.spanRange[0] >= snapStart && ts.spanRange[1] <= snapEnd;
return !isInRange;
});
// 更新后续 textSpan 位置
this.updateTextSpans(snapStart, snapStart - snapEnd);
return false; // 阻止默认删除行为
}
七、获取内容
发布内容时,需要获取带结构化信息的富文本数据。
7.1 定义统一数据结构
export interface CustomSpan {
value?: string; // 文本内容
resourceValue?: ResourceStr; // 表情图片资源
type?: string; // 类型:contact、topic 等
data?: ESObject; // 结构化数据
}
7.2 获取并转换数据
getData(): CustomSpan[] {
const customSpans = this.richEditorController.getSpans().map(span => {
const textSpan = span as RichEditorTextSpanResult;
const imageSpan = span as RichEditorImageSpanResult;
// 是图片(表情)
if (!textSpan.value) {
return { resourceValue: imageSpan.valueResourceStr } as CustomSpan;
}
// 是文本,查找是否有对应的自定义 textSpan
const customTextSpan = this.textSpans.find(customTextSpan => {
return this.isTheSameRange(
customTextSpan.spanRange,
textSpan.richEditorTextSpanPosition.spanRange
);
});
if (!customTextSpan) {
// 普通文本
return { value: textSpan.value } as CustomSpan;
}
// 自定义内容(@好友、话题等)
return {
value: customTextSpan.value,
type: customTextSpan.type,
data: customTextSpan.data
} as CustomSpan;
});
return customSpans;
}
isTheSameRange(range1: [number, number], range2: [number, number]): boolean {
return range1[0] === range2[0] && range1[1] === range2[1];
}
7.3 序列化结果
最终生成的数据格式:
[
{
"value": "Hello"
},
{
"resourceValue": "resource:///emoji_3.png"
},
{
"value": "@阿飞",
"type": "contact",
"data": {
"imgName": "app.media.ic_comm_pic1",
"name": "阿飞"
}
},
{
"value": " "
},
{
"value": "#众测主题赛#",
"type": "topic",
"data": {
"topicId": "1",
"title": "众测主题赛"
}
}
]
八、自定义表情面板
8.1 绑定自定义键盘
@State isEmojiKeyboard: boolean = false;
RichEditor({
controller: this.richEditorController
})
.customKeyboard(this.isEmojiKeyboard ? this.EmojiKeyboard() : undefined, {
supportAvoidance: true // 支持键盘避让
})
8.2 表情面板实现
@Builder
EmojiKeyboard() {
Column() {
Grid() {
ForEach(this.emojiIcons, (icon: Resource) => {
GridItem() {
Image(icon)
.width(45)
.height(45)
.onClick(() => {
this.addImageSpan(icon);
})
}
})
}
.width('100%')
.height(this.keyboardHeight) // 高度与软键盘一致
}
.backgroundColor('#f5f5f5')
}
addImageSpan(value: Resource) {
const controller = this.richEditorController;
const curOffset = controller.getCaretOffset();
controller.addImageSpan(value, {
offset: curOffset,
imageStyle: {
size: [16, 16]
}
});
// 更新 textSpans 位置
this.updateTextSpans(curOffset, 1);
}
九、避坑指南
坑 1:@好友前后文字样式混乱
问题:在@好友前后输入的文字,样式和@好友一样了。
解决:在 onReady() 中设置默认文本样式。
.onReady(() => {
this.richEditorController.setTypingStyle({
fontColor: Color.Black,
fontSize: 14
});
})
坑 2:textSpan 位置信息不准确
问题:删除或插入内容后,textSpan 的 spanRange 没更新。
解决:每次增删操作后调用 updateTextSpans() 更新后续所有 textSpan 的位置。
坑 3:光标能落入@好友中间
问题:点击@好友内容,光标在中间。
解决:在 onSelectionChange 中调用 snapCaretToTextSpanBoundary() 调整光标到边界。
坑 4:getSpans() 获取不到结构化数据
问题:getSpans() 只能拿到文本内容,拿不到好友 id 等信息。
解决:自己维护 textSpans 数组,getSpans() 拿到结果后,根据位置信息去 textSpans 里查找对应的结构化数据。
十、总结
RichEditor 富文本开发,记住这几个要点:
| 功能 | 关键方法 | 注意事项 |
|---|---|---|
| 添加文字 | addTextSpan() |
设置 offset 为光标位置 |
| 添加表情 | addImageSpan() |
设置 imageStyle.size |
| @好友 | addTextSpan() + 维护 textSpans |
自己维护结构化数据 |
| 光标处理 | snapCaretToTextSpanBoundary() |
防止落入 textSpan 中间 |
| 删除 | aboutToDelete() |
整体删除 textSpan |
| 获取内容 | getSpans() + textSpans |
合并两者得到完整数据 |
核心原则:
- 自己维护 textSpans:记录所有自定义内容的位置和结构化数据
- 每次增删后更新位置:调用
updateTextSpans() - 光标和删除要特殊处理:保证@好友作为整体
更多推荐

所有评论(0)