一、开场白

今天聊聊 HarmonyOS 里一个贼实用的功能——富文本编辑器。

这玩意儿啥场景用?你随便打开几个 APP 看看:社交评论、笔记应用、内容发布器…到处都是富文本编辑器的身影。
image.png
ArkUI 提供了 RichEditor 组件,支持图文混排和交互式文本编辑。本文旨在探讨如何使用 RichEditor 组件,在内容发布场景中实现自定义表情、@好友、添加话题等功能。

今天就把富文本编辑器的开发门道给你讲明白,帮你解决这些常见问题:

  1. 自定义表情添加
  2. @好友功能实现
  3. 话题标签添加
  4. 光标位置处理
  5. 删除内容逻辑
  6. 获取并序列化内容

二、实现原理

RichEditor 组件内容管理方式选型

RichEditor 组件提供两套内容管理接口:

方式一:使用 RichEditor(options: RichEditorStyledStringOptions) 接口创建基于属性字符串(StyledString/MutableStyledString)进行内容管理的组件。

方式二:使用 RichEditor(value: RichEditorOptions) 接口创建基于 Span 进行内容管理的组件。

在本文内容发布场景中需要频繁进行输入文字、添加表情、@好友、删除等操作,且无需复杂的样式操作,选择使用方式二基于 Span 进行内容管理更为合适

两种方式对比

特性 基于属性字符串管理 基于 Span 管理
样式更新 更加灵活便捷 通过操作 Span
序列化 困难,需手动提取属性存储 支持增量式操作
动态内容维护 成本高,不适合频繁增删 适合频繁交互、动态修改
适用场景 静态富文本展示 内容发布、评论编辑等

添加不同类型内容的方式选型

编辑区域支持输入文字、表情、@好友等内容。

内容类型 实现方式
文字 使用系统键盘输入,通过 RichEditorController.setTypingStyle() 设置默认文本样式
自定义表情 使用 RichEditorController.addImageSpan() 方法实现
@好友 可使用 addTextSpan()addBuilderSpan() 两种方法

addBuilderSpan vs addTextSpan

特性 addBuilderSpan() addTextSpan()
内容长度 默认作为一个整体,长度视为一个文字 以文本形式添加,长度为文本长度
光标与删除 默认作为一个整体,无需额外处理 需手动处理光标变化、删除规则
折行显示 长度超一行:光标高度随 builderSpan 高度自动调整
长度不超一行:无法折行
与普通文本逻辑一致,支持正常折行显示
数据获取 无法获取 builderSpan 中的内容 可获取 textSpan 内容,但额外携带数据需自行维护

本文使用 addTextSpan() 方法实现,提供@好友等自定义内容需要折行显示的开发指导。


三、维护输入内容

由上述选型可知,使用 addTextSpan() 方法实现@好友、添加话题等自定义内容时,需手动处理光标变化、删除内容和携带数据的逻辑。

需要定义一个数据结构能将自定义的 textSpan 维护起来

  • value:用于存储显示的文字内容
  • spanRange:记录内容在编辑区的起始位置和结束位置,以便查找
  • type:用于区分不同类型(例如是@好友还是添加的话题)
  • data:用于携带结构化数据,如好友 id、头像等
interface TextSpan {
  value: string;
  spanRange: [number, number];
  type: string;
  data: ESObject;
}

通过维护一个 textSpans 数组记录所有自定义的 textSpan,涉及编辑区域添加或删除内容的操作都需要去更新当前光标后所有 textSpan 的位置信息(spanRange 字段)。

private textSpans: TextSpan[] = [];

// 更新当前偏移量之后的 textSpans 范围
updateTextSpans(insertOffset: number, insertLength: number) {
  this.textSpans.forEach(textSpan => {
    if (textSpan.spanRange[0] >= insertOffset) {
      textSpan.spanRange[0] += insertLength;
      textSpan.spanRange[1] += insertLength;
    }
  })
}

四、添加自定义表情

image.png

场景描述

点击下方表情按钮,系统键盘切换为表情面板。点击表情图标,会在编辑区域光标后方添加对应的表情内容。

开发步骤

第一步:使用 customKeyboard 属性给编辑区域绑定自定义键盘

通过状态变量 isEmojiKeyboard 控制系统键盘和自定义键盘(表情面板)的切换。并设置 KeyboardOptions 参数中的 supportAvoidance 属性值为 true,使自定义键盘也支持避让功能。

@Link isEmojiKeyboard: boolean;

build() {
  RichEditor({
    controller: this.richEditorController
  })
  .customKeyboard(this.isEmojiKeyboard ? this.EmojiKeyboard() : undefined, {
    supportAvoidance: true
  })
  // ...
}

第二步:在表情面板中点击表情,通过 addImageSpan() 方法添加表情图片

默认在内容最后方插入。通过设置 offset 属性为当前光标位置,使表情在正确位置插入。当前光标位置可通过 getCaretOffset() 方法获取。

addImageSpan(value: ResourceStr) {
  const controller = this.richEditorController;
  const curOffset = controller.getCaretOffset();
  controller.addImageSpan(value, {
    offset: curOffset,
    imageStyle: {
      size: [16, 16]
    }
  });
  // 更新当前偏移量之后的 textSpans 范围
  this.updateTextSpans(curOffset, 1);
}

五、@好友功能实现

场景描述

点击下方工具栏@图标或使用系统键盘输入@字符,会跳转到好友列表页面,选择对应好友将自动跳转回编辑页面并在编辑区域添加对应的@好友内容。

添加话题、标题与@好友逻辑一致。
zhcn_image_0000002229452041.gif

开发步骤

方式 1:点击工具栏@图标跳转好友列表

第一步:点击工具栏@图标,跳转到好友列表页面

选择好友后,使用 addTextSpan() 方法将@好友作为文本内容添加到编辑区域。通过 offset 属性和 style 属性分别控制插入的位置和样式。可通过添加一个空字符和其他内容做视觉上的分割。

addTextSpan(value: string, type: string, data: ESObject, style: RichEditorTextStyle) {
  const controller = this.richEditorController;
  const curOffset = controller.getCaretOffset();
  controller.addTextSpan(value, {
    offset: curOffset,
    style
  });
  const splitChar = ' ';
  controller.addTextSpan(splitChar, {
    offset: controller.getCaretOffset()
  });
  // ...
}

第二步:维护自定义 textSpan 数据

更新在当前光标后面所有的自定义 textSpan 的位置信息,起始位置往后移动的距离为新添加内容的长度,并在 textSpans 数组中添加该自定义内容 textSpan 的信息。

addTextSpan(value: string, type: string, data: ESObject, style: RichEditorTextStyle) {
  // ...
  this.updateTextSpans(curOffset, value.length + splitChar.length);
  this.textSpans.push({
    value,
    type,
    data,
    spanRange: [curOffset, curOffset + value.length],
  })
}

第三步:在 RichEditor 组件 onReady() 事件回调函数中,为键盘输入内容设置默认样式

这样就能与@好友等自定义 textSpan 内容隔离开。

RichEditor({
  controller: this.richEditorController
})
// ...
.onReady(() => {
  this.richEditorController.setTypingStyle({
    fontColor: Color.Black,
  })
})
方式 2:键盘输入@符号跳转好友列表

通过键盘输入@符号的方式跳转好友列表,键盘默认行为会先在编辑区添加@符号,在选择好友后需要先删除默认行为添加@符号,再将@好友作为整体通过 addTextSpan() 方法添加。

第一步:通过状态变量 isKeyboardTriggered 标志是否为键盘输入触发

@State isKeyboardTriggered: boolean = false;

第二步:在 aboutToIMEInput() 键盘输入事件的回调函数中,判断插入字符是@

则更新标志位为 true,并跳转联系人页面。

aboutToIMEInput: (value: RichEditorInsertValue) => boolean = value => {
  if (value.insertValue === '@') {
    this.isKeyboardTriggered = true;
    this.getUIContext().getRouter().pushUrl({ url: 'pages/ContactListPage' });
    // ...
  }
  return true;
}

第三步:在添加@好友内容时如果是键盘输入触发,则删除光标前一个字符

deletePrevChar() {
  const controller = this.richEditorController;
  const offset = controller.getCaretOffset();
  const range: RichEditorRange = { start: offset - 1, end: offset };
  controller.deleteSpans(range);
}

addTextSpan(value: string, type: string, data: ESObject, style: RichEditorTextStyle) {
  if (this.isKeyboardTriggered) {
    this.deletePrevChar()
    this.isKeyboardTriggered = false;
  }
  // ...
}

后续步骤与点击工具栏@图标跳转好友列表方式的开发步骤中的 1、2、3 步一致。


六、处理光标位置

场景描述

光标不可落入@好友文本的内部。当用户点击或选中@好友这种自定义内容时,光标应自动跳转到内容的开始或结束位置。

开发步骤

第一步:判断当前光标位置是否在自定义内容的 textSpan 中间

是则根据就近原则返回 textSpan 的起始位置或结束位置,否则返回当前光标位置。

snapCaretToTextSpanBoundary(caretOffset: number, type?: 'start' | 'end') {
  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;
  if (disToStart <= disToEnd) return textSpan.spanRange[0];
  return textSpan.spanRange[1];
}

第二步:当用户点击@好友内容时,在 onSelectionChange() 事件的回调函数中重新计算并使用 setCaretOffset() 设置光标位置

onSelectionChange: (range: RichEditorRange) => void = range => {
  // 当 start 和 end 值相同时,表示点击触发光标位置变化,没有选中区域
  if (range.start === range.end) {
    const targetCaretOffset = this.snapCaretToTextSpanBoundary(range.start!);
    this.richEditorController.setCaretOffset(targetCaretOffset);
  }
}

第三步:当选中内容发生变化时,在 onSelect() 事件的回调函数中获取选中内容的起始位置和结束位置

当它们处于同一个自定义内容的 textSpan 中间时,更新两个光标的位置到该 textSpan 的起始位置和结束为止。否则,更新两个光标的位置到各自就近的 textSpan 边缘。最后通过 setSelection() 方法设置计算后的选中区域。

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) {
    this.richEditorController.setSelection(textSpan.spanRange[0], textSpan.spanRange[1]);
    return;
  }
  
  // 两个值都为 -1 表示清除选中操作
  if (caretStart === -1 && caretEnd === -1) {
    return
  }
  const selectionStart = this.snapCaretToTextSpanBoundary(caretStart);
  const selectionEnd = this.snapCaretToTextSpanBoundary(caretEnd);
  this.richEditorController.setSelection(selectionStart, selectionEnd);
}

七、删除内容

场景描述

点击软键盘删除按钮,光标前待删除的是@好友等自定义内容时,则作为整体删除。其余内容删除时无需额外处理。

开发步骤

aboutToDelete() 事件的回调函数中,获取将要删除内容的起始位置和结束位置。

若起始位置在自定义的 textSpan 中间,则更新起始位置为该 textSpan 的起始位置。若结束位置在自定义的 textSpan 中间,则更新结束位置为该 textSpan 的结束位置。

使用 deleteSpans() 方法删除编辑区域中的内容。

更新自行维护的 textSpans 数据,删除起始位置和结束位置中间所有的 textSpan 数据,并更新在删除内容后所有的自定义 textSpan 的位置信息。

aboutToDelete: (value: RichEditorDeleteValue) => boolean = deleteValue => {
  // 当启用屏幕拼音预览时,每次输入都会触发 aboutToDelete 事件
  // 在这种情况下,直接应用默认删除逻辑
  const previewText = this.richEditorController.getPreviewText().value;
  if (previewText.length !== 0) {
    return true;
  }

  const start = deleteValue.offset;
  const end = start + deleteValue.length;
  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;
  });
  
  this.updateTextSpans(snapStart, snapStart - snapEnd);
  return false;
}

八、获取内容

场景描述

发布内容时,需要获取编辑区域的文本内容,同时还需要获取带有结构化信息(如好友的 id)的数据,用于提交到服务器或持久化存储。
image.png

开发步骤

第一步:定义统一的数据结构 CustomSpan

实际开发中编辑区域不同类型的内容往往需要一种统一的数据结构来表达,方便传输和存储。这里定义为 CustomSpan,其中:

  • value:表示文本内容
  • resourceValue:表示表情图片资源
  • type:用于区分不同类型的 textSpan,例如是@好友还是添加的话题
  • data:用于存储携带的信息,例如好友 id 等
export interface CustomSpan {
  value?: string;
  resourceValue?: ResourceStr;
  type?: string;
  data?: ESObject;
}

第二步:RichEditor 组件提供 getSpans() 方法来获取内容

但是例如@好友中一些结构化信息仍需要根据位置信息去手动维护的 textSpans 数组中去查找。最后将 getSpans() 方法获取的 RichEditorTextSpanResultRichEditorImageSpanResult 转换成 CustomSpan

getData(): CustomSpan[] {
  const customSpans = this.richEditorController.getSpans().map(span => {
    const textSpan = span as RichEditorTextSpanResult;
    const imageSpan = span as RichEditorImageSpanResult;
    
    // 是 imageSpan
    if (!textSpan.value) {
      return { resourceValue: imageSpan.valueResourceStr } as CustomSpan;
    }
    
    // 是 textSpan
    const customTextSpan = this.textSpans.find(customTextSpan => {
      return this.isTheSameRange(customTextSpan.spanRange, textSpan.spanPosition.spanRange);
    })
    if (!customTextSpan) {
      return { value: textSpan.value } as CustomSpan;
    }
    return {
      value: customTextSpan.value,
      type: customTextSpan.type,
      data: customTextSpan.data
    } as CustomSpan;
  });
  return customSpans;
}

图中内容通过 getData() 方法生成的数据序列化后的数据如下

[
  {
    "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": "众测主题赛"
    }
  },
  {
    "value": " "
  }
]

如何与服务端交互或使用这些数据,根据业务需求调整即可。


九、常见问题

问题 1:使用 addBuilderSpan 自定义内容时,折行显示与光标异常

解决方案:对折行显示有硬性需求可使用 addTextSpan 方式实现。参考添加不同类型内容的方式选型。

问题 2:发布内容时如何处理富文本数据

解决方案:通过维护自定义 textSpan 数据的方式实现。参考获取内容。

问题 3:如何自定义选择菜单

解决方案:通过 bindSelectionMenu 属性对不同类型的 Span 绑定不同的菜单。


十、总结一下

富文本编辑器就这几个核心:

  1. 内容管理选型:基于 Span 管理适合频繁交互、动态修改的场景
  2. 自定义表情:使用 addImageSpan() 方法,设置 offset 为当前光标位置
  3. @好友实现:使用 addTextSpan() 方法,需手动维护 textSpans 数组
  4. 光标处理:使用 snapCaretToTextSpanBoundary() 确保光标不落入自定义内容内部
  5. 删除逻辑:在 aboutToDelete() 中处理,确保自定义内容作为整体删除
  6. 内容获取:通过 getSpans() 获取,结合维护的 textSpans 数组转换为 CustomSpan
  7. 数据序列化:定义为统一的 CustomSpan 数据结构,方便传输和存储

记住啊,使用 addTextSpan() 需要手动维护 textSpans 数组,每次添加或删除都要更新位置信息!

Logo

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

更多推荐