富文本编辑器,社交 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 合并两者得到完整数据

核心原则:

  1. 自己维护 textSpans:记录所有自定义内容的位置和结构化数据
  2. 每次增删后更新位置:调用 updateTextSpans()
  3. 光标和删除要特殊处理:保证@好友作为整体
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐