需求
在App开发中,我们常常会遇到发布文章、评论的时候需要添加话题或者@用户的需求,就像微博那样。这在Android、iOS或者其他平台上都有现成的组件可供使用,但是HarmonyOS NEXT作为一个新兴平台,三方库实在匮乏,连微博鸿蒙版App本身都没完全实现这个功能。要想实现这个功能,那就必须手搓轮子了。撸起袖子,一把梭,干就完了。
效果图
实现原理
ArkUI组件中,文本输入组件主要有:TextInput、TextArea和RichEditor。TextInput是单行文本输入框,TextArea是多行文本输入框,两者都不支持多Span、也不支持多种样式的文本。故而我们只能选用RichEditor富文本编辑组件来实现需求。
RichEditor支持TextSpan、ImageSpan、SymbolSpan和BuilderSpan,查看相关API就知道BuilderSpan最适合实现我们的添加话题和@用户的需求。但是坑爹的是文档提到:
不支持通过getSpans,getSelection,onSelect,aboutToDelete获取builderSpan信息。
我们知道,富文本编辑器的操作其实十分复杂,完全由我们手动记录和维护BuilderSpan和关联的实体对象的关系会非常复杂。我们需要知道哪个BuilderSpan对应哪个用户实体或者话题实体、BiulderSpan什么时候添加了、删除等等,需要监听RichEditor的一系列回调事件,极容易出错。
对于调用者来说,最简单的无疑是通过一个getSpans接口获取到所有Span的信息,无奈官方声明不支持。实际测试中发现,RichEditorController的getSpans方法返回的数组中包含了代表BuilderSpan的RichEditorImageSpanResult且其valueResourceStr属性为空串,只是无法获取BuilderSpan的详细信息。那么如果我们能在添加BuilderSpan的时候记录下它的信息,然后在getSpans方法中替换原来的RichEditorImageSpanResult,问题就解决了。
那么问题就变成了如何把getSpans返回的RichEditorImageSpanResult替换成对应的其他对象呢。虽说代表BuilderSpan的RichEditorImageSpanResult实例没有包含BuilderSpan的具体信息,但是至关重要的是它的顺序(数组下标)是按序排列的。所以我们只需要按序获取到BuilderSpan的信息,然后按序替换即可。
由于RichEditorController的addBuilderSpan方法中,如何构建BuilderSpan是由开发者自行控制的,那么我们可以给每个BuilderSpan的组件都添加一个唯一ID,在getSpans的时候再通过 componentUtils.getRectangleById(id) 获取到各个BuilderSpan的坐标和宽高,通过组件位置判断出它们的顺序就大功告成了!
实现如下:
import { componentUtils } from '@kit.ArkUI'
@Component
export struct TopicEditor {
controller: TopicEditorController = new TopicEditorController()
/** 是否点两次删除才把TopicSpan删除 */
@Prop doubleDelete: boolean = true
/** 为了方便灵活地控制各种样式、设置选项,RichEditor通过BuilderParam传入,由调用者自行创建 */
@BuilderParam richEditorBuilder: (controller: RichEditorController, aboutToDeleteCallback: Callback<RichEditorDeleteValue, boolean>) => void
/** 删除监听 */
private aboutToDeleteCallback = (val: RichEditorDeleteValue) => {
// 处理双击删除,只有一个Span的时候才可能是删除TopicSpan
if(this.doubleDelete && val.richEditorDeleteSpans.length == 1) {
let span = val.richEditorDeleteSpans[0]
let editorController = this.controller.getRichEditorController()
let selection = editorController.getSelection()
// 判断是否TopicSpan以及当前是否被选中,如果选中了直接删除,未选中则选中但不删除
if(isTopicSpan(span) && !(selection.selection.length == 2 && selection.selection[0] == span.spanPosition.spanRange[0] && selection.selection[1] == span.spanPosition.spanRange[1])) {
let spanRange: number[] = span.spanPosition.spanRange
editorController.setSelection(spanRange[0], spanRange[1])
return false
}
}
return true
}
build() {
this.richEditorBuilder(this.controller.getRichEditorController(), this.aboutToDeleteCallback)
}
}
/** 判断是否TopicSpan */
function isTopicSpan(span: RichEditorTextSpanResult | RichEditorImageSpanResult) : boolean {
return span['imageStyle'] != undefined && (span['valueResourceStr'] == '' || span['valueResourceStr'] == ' ')
}
export interface TopicSpan {
id: string
builder: CustomBuilder
}
/**
* TopicEditor控制器,通过它添加TopicSpan和TextSpan,以及获取最终结果
*/
export class TopicEditorController {
private internal = new RichEditorController()
private topicIds : string[] = []
/** 清空 */
clear() {
this.topicIds = []
this.internal.deleteSpans()
}
/** 暴露RichEditorController以便灵活控制RichEditor */
getRichEditorController(): RichEditorController {
return this.internal
}
/**
* 添加TopicSpan
* @param span 话题、用户信息,ID必须唯一
* @param offset 添加位置
*/
addTopicSpan(span: TopicSpan, offset?: number) {
this.topicIds.push(span.id)
this.internal.addBuilderSpan(span.builder, { offset })
}
/**
* 同RichEditorController
* @param value
* @param options
* @returns
*/
addTextSpan(value: string, options?: RichEditorTextSpanOptions | undefined): number {
return this.internal.addTextSpan(value, options)
}
/**
* 获取当前内容,数组每个元素代表一个Span,调用者可以根据返回结果和业务逻辑组合成最终内容
*/
getSpans(): TopicSpanResult[] {
let topicSpanInfos: TopicSpanInfo[] = []
for (let id of this.topicIds) {
let componentInfo = componentUtils.getRectangleById(id)
// 宽高为0代表已经被删除了
if(componentInfo.size.width == 0 && componentInfo.size.height == 0) {
continue
}
topicSpanInfos.push({ id, componentInfo })
}
// 根据组件位置排序
topicSpanInfos = topicSpanInfos.sort((a, b) => {
if(a.componentInfo.windowOffset.y >= b.componentInfo.windowOffset.y + b.componentInfo.size.height) {
return 1
}
if(a.componentInfo.windowOffset.y + a.componentInfo.size.height <= b.componentInfo.windowOffset.y) {
return -1
}
return a.componentInfo.windowOffset.x - b.componentInfo.windowOffset.x
})
// 按序替换得到最终结果
let results : TopicSpanResult[] = []
let spans = this.internal.getSpans()
let index = 0
for (let span of spans) {
if(isTopicSpan(span)) {
results.push({
value: topicSpanInfos[index++].id,
isTopicSpan: true
})
} else {
let textSpan = span as RichEditorTextSpanResult
results.push({
value: textSpan.value,
isTopicSpan: false
})
}
}
return results
}
}
interface TopicSpanInfo {
id: string
componentInfo : componentUtils.ComponentInfo
}
export interface TopicSpanResult {
/** isTopicSpan为true时,这是TopicSpan的id,为false时,是TextSpan的文本 */
value: string
/** 此Span是否是TopicSpan */
isTopicSpan: boolean
}
局限性及注意事项
只支持TextSpan和TopicSpan,加入其他Span可能会导致未知异常。
另外,该实现思路未得到生产环境验证。
成品
GITHUB: https://github.com/sahooz/oh-topic-editor
OpenHarmony仓库审核中,后续补上
更多
我开发的其他鸿蒙库:
- oh-crop: OpenHarmony/HarmonyOS上的简单的图片剪裁库,可用于头像剪裁等常见场景。
- oh-date-picker: OpenHarmony/HarmonyOS平台日期选择器增强版。
我的博客:https://blog.xinyanruanjian.com/
我的公众号:程序员吹白
鸿蒙开发交流QQ群:546723002
开源协议
MIT