【高心星出品】

仿头条评论功能

概述

评论回复模块在图文和视频应用中被广泛使用,包含编辑区域、好友列表、常用表情列表和表情面板(见下图),它允许用户进行输入文字、表情、@好友、选择图片等操作。该模块一般以弹窗的形式展现给用户,通常在图文、视频界面中直接弹出,或者在评论列表上层弹出,本文将从评论列表上层弹出这种相对复杂的场景出发,重点对以下几个方面进行介绍,为开发者提供评论回复弹窗模块开发的最佳实践。

  • 弹窗组件的选型以及最终方案的实现
  • 软键盘和表情面板切换的适配
  • 编辑区域主要细节功能的实现

图1 效果图

在这里插入图片描述

为方便阅读,下面表格对本文常出现的模块名称进行:

模块名称 对应图1中序号
评论列表 1
好友列表 2
编辑区域 3
常用表情列表 4
软键盘 5
表情面板 6

实现原理

弹窗组件选型

通过对CustomDialog自定义弹窗、bindSheet半模态弹窗、Navigation Dialog三种弹窗方案进行尝试,发现自定义弹窗和半模态弹窗有一定规格限制,会产生一些无法避免的问题,最终选用Navigation Dialog方案实现评论模块弹窗。以下对三种方案优劣势进行一个详细的。

  • CustomDialog自定义弹窗

    方案优势:

    1. 现成封装好的组件,无需开发者实现弹窗相关的交互逻辑。
    2. 自定义弹窗默认避让软键盘,评论模块无需计算高度,调用时直接被软键盘抬起即可。

    方案劣势:

    1. 由于自定义弹窗完全避让软键盘,且该行为无法配置。在点击表情按钮时,展示表情面板,此过程评论模块高度发生变化,到软键盘完全收起的过程中,表情面板仍然处于软键盘上方,评论模块会被短暂顶起。
    2. 软键盘动画不能自定义配置或获取,无法配合动画能力抵消顶起操作。

    PromptAction.openCustomDialog与自定义弹窗呈现效果相同,不再赘述。

    在这里插入图片描述

  • bindSheet半模态弹窗

    方案优势:

    1. 现成封装好的组件,无需开发者实现弹窗相关的交互逻辑。
    2. 可解决CustomDialog中评论模块被顶起的问题。

    方案劣势:

    1. 设置高度自适应后,bindSheet内部的Scroll依然生效,在bindSheet内部可滚动。
    2. 设置dragBar为false时,bindSheet依然可以上下拖动,松手后回到原位,但此过程会暴露软键盘下方的表情面板区域。

    请添加图片描述

  • Navigation Dialog

    方案优势:

    1. 可解决上述两种方案中存在的问题。
    2. 基于Navigation路由形式,以进出路由栈的方式打开或关闭弹窗,可以实现弹窗与UI界面解耦。

    方案劣势:

    1. 弹窗遮罩层以及点击遮罩关闭弹窗的逻辑需要手动实现。

注意

Navigation Dialog在z轴的层级较低,评论模块如果基于该方案实现,那么在其他使用了CustomDialog、bindSheet的弹窗模块(例如评论列表)中调用评论模块,评论模块会在其他弹窗模块下层,所以其他弹窗模块也需要一同改为Navigation Dialog实现,通过路由栈进行弹窗层级的控制。

编辑区域组件及方法选型

编辑区域支持输入文字、表情、@好友等内容。目前支持图文混排和文本交互式编辑的组件,RichEditor是不二的选择。针对添加表情,可使用RichEditorController.addImageSpan方法实现。@好友可使用RichEditorController.addTextSpan和RichEditorController.addBuilderSpan两种方法实现,通过以下对两种方法的分析对比,最终选择addBuilderSpan实现@好友功能。

为了方便表述,下文对通过addTextSpan、addImageSpan、addBuilderSpan方法添加到编辑区域的内容分别命名为textSpan、imageSpan、builderSpan。

  • 使用addTextSpan实现@好友
    1. 在textSpan前后通过软键盘输入的文字会自动与之合并成一个textSpan,而@好友具有特殊样式和逻辑,需要对前后文字进行分割,这些细节需要手动处理。
    2. @好友作为一个整体,光标不能在中间点击,删除需要整体删除,使用textSpan需要手动处理这些细节。
    3. 获取编辑区域内容时,可以获取到textSpan中的文字(好友昵称),但是如果想获取到该好友的其他信息,仅用好友昵称去关联显然不是一种可靠的方案。
  • 使用addBuilderSpan实现@好友
    1. builderSpan不会和前后文字合并。
    2. builderSpan默认作为一个整体,对光标点击以及删除逻辑不需要另外处理。
    3. 获取编辑区域内容时,无法获取到builderSpan中的内容,需要手动对添加或删除的builderSpan信息进行维护,自己维护的同时自然也能与好友的其他信息进行关联。

关键场景实现

弹窗显示

在视频页面点击消息按钮,弹出评论列表页面弹窗。在评论列表页点击写评论按钮,弹出评论模块弹窗。

在这里插入图片描述

基于Navigation的弹窗方案,Navigation的mode属性需要设置为NavigationMode.Stack。弹窗需要全屏显示,Navigation则需要添加在最外层组件上。

// features/home/src/main/ets/view/Home.etsbuild() {  Navigation(this.navDialogPageInfos) {    // ...  }  .hideTitleBar(true)  .mode(NavigationMode.Stack)  // features/home/src/main/ets/view/Home.ets
build() {
  Navigation(this.navDialogPageInfos) {
    // ...
  }
  .hideTitleBar(true)
  .mode(NavigationMode.Stack)
  .navDestination(this.NavDialogPageMap)
}.navDestination(this.NavDialogPageMap)}

代码逻辑走读:

  1. 函数定义:定义了一个名为 build的函数,该函数没有参数。

  2. 导航组件构建:在 build函数内部,使用 Navigation组件构建导航结构。Navigation组件的参数为 this.navDialogPageInfos,这表明导航依赖于一个页面信息集合。

  3. 属性设置

    • .hideTitleBar(true):设置导航组件隐藏标题栏,以提供更简洁的用户界面。
    • .mode(NavigationMode.Stack):将导航模式设置为堆栈模式,这意味着页面导航将按照堆栈的方式进行管理,即后进先出。
    • .navDestination(this.NavDialogPageMap):指定导航的目标页面,this.NavDialogPageMap可能是一个包含页面映射信息的对象或数据结构。

弹窗模块需要用NavDestination包裹,设置NavDestination的mode属性为NavDestinationMode.DIALOG弹窗类型,设置expandSafeArea属性为[SafeAreaType.KEYBOARD]扩展安全区域,不避让软键盘,以解决CustomDialog自定义弹窗的问题。并在弹窗内容下层通过Stack添加遮罩,点击可关闭弹窗,考虑到多个弹窗模块要进行相同的处理,将弹窗模块封装为组件,评论列表和评论模块调用该组件则无需关注弹窗相关的交互,只需关注弹窗中需要展示的内容即可。

// features/home/src/main/ets/view/NavigationDialog.ets
@Component
export struct NavigationDialog {
  @Consume navDialogPageInfos: NavPathStack;
  @Prop alignContent: Alignment = Alignment.Bottom;
  @Prop maskBackgroundColor: ResourceColor

  @Builder
  DefaultContentBuilder() {}

  @BuilderParam
  contentBuilderParam: () => void = this.DefaultContentBuilder

  onClose = () => {
    this.navDialogPageInfos.pop();
  }

  build() {
    NavDestination() {
      Stack() {
        Column()
          .height('100%')
          .width('100%')
          .backgroundColor(this.maskBackgroundColor)
          .onClick(this.onClose)
        this.contentBuilderParam()
      }
      .height('100%')
      .alignContent(this.alignContent)
    }
    .mode(NavDestinationMode.DIALOG)
    .hideTitleBar(true)
    .expandSafeArea([SafeAreaType.KEYBOARD])
  }
}

代码逻辑走读:

  1. 组件定义与属性声明
    • 使用@Component装饰器定义了一个名为NavigationDialog的组件。
    • 声明了三个属性:navDialogPageInfos(类型为NavPathStack,用于管理导航路径)、alignContent(默认值为Alignment.Bottom)和maskBackgroundColor(遮罩背景颜色)。
  2. 默认内容构建器
    • 定义了一个名为DefaultContentBuilder的方法,但没有实现任何内容。
  3. 内容构建器参数
    • 使用@BuilderParam装饰器定义了一个名为contentBuilderParam的属性,类型为函数,默认值为DefaultContentBuilder
  4. 关闭对话框的方法
    • 定义了一个名为onClose的方法,当点击遮罩背景时调用,会从navDialogPageInfos中弹出当前页面。
  5. 构建对话框
    • build方法中,使用NavDestination组件创建对话框。
    • 使用Stack组件创建一个堆叠布局,其中包含一个Column组件(作为遮罩背景)和一个调用contentBuilderParam的占位符(用于插入自定义内容)。
    • 设置Column组件的背景颜色为maskBackgroundColor,并绑定点击事件为onClose方法。
    • 设置Stack组件的高度和对齐方式。
    • 设置NavDestination组件的模式为对话框,隐藏标题栏,并扩展安全区域。

各个弹窗模块的弹出和关闭通过路由的进栈出栈控制,弹窗的层级关系通过路由进栈的顺序来控制。

软键盘和表情面板切换适配

点击编辑区域表情按钮,软键盘切换为表情面板,表情按钮图标变成键盘图标。再次点击,表情面板切换回软键盘,按钮图标由键盘变回表情。

在这里插入图片描述

本文选择自定义键盘来控制软键盘和表情面板的切换。通过设置RichEditor.customKeyboard为表情面板组件的构建函数EmojiKeyboard,来展示表情面板,设置该属性为undefined,则展示默认软键盘。通过这种方式在软键盘与表情面板切换时也无需手动进行richEditor焦点的处理。

// features/home/src/main/ets/view/CommentKeyboard.ets
RichEditor({ controller: this.richEditorController })
  .customKeyboard(this.isEmojiKeyboardVisible ? this.EmojiKeyboard() : undefined)
  // ...

代码逻辑走读:

  1. 创建富文本编辑器
    • 使用RichEditor组件,并传入一个控制器this.richEditorController
    • 该组件用于实现富文本编辑功能,允许用户输入和编辑复杂的文本内容。
  2. 条件显示自定义键盘
    • 通过this.isEmojiKeyboardVisible判断是否显示自定义键盘。
    • 如果this.isEmojiKeyboardVisibletrue,则调用this.EmojiKeyboard()方法显示表情键盘;否则不显示任何自定义键盘。

为保证切换软键盘和表情面板时,评论模块整体高度不发生改变,则需要获取软键盘高度对表情面板高度进行计算和手动设置。有可能软键盘高度被手动更改,所以需要通过keyboardHeightChange事件对软键盘高度进行监听,当高度大于0时,更新记录软键盘高度的状态变量。注意在组件销毁前取消对应的监听事件。

aboutToAppear(): void {
  window.getLastWindow(this.getUIContext().getHostContext()).then(win => {
    this.addKeyboardHeightListener(win);
  });
}

aboutToDisappear(): void {
  window.getLastWindow(this.getUIContext().getHostContext()).then(win => {
    this.removeKeyboardHeightListener(win);
  });
}

getResourceString(resource: Resource): string {
  return this.getUIContext().getHostContext()!.resourceManager.getStringSync(resource.id);
}

addKeyboardHeightListener(win: window.Window) {
  win.on('keyboardHeightChange', height => {
    console.info('keyboard height has changed', this.getUIContext().px2vp(height));
    if (height !== 0) {
      this.keyboardHeight = this.getUIContext().px2vp(height);
      return;
    }
    // ...
  });
}

removeKeyboardHeightListener(win: window.Window) {
  win.off('keyboardHeightChange');
}

代码逻辑走读:

  1. aboutToAppear() 方法
    • 当组件即将出现在界面上时,调用window.getLastWindow(this.getUIContext().getHostContext())获取当前窗口对象。
    • 使用then方法处理异步操作,获取到窗口对象后,调用addKeyboardHeightListener(win)方法添加键盘高度变化的监听器。
  2. aboutToDisappear() 方法
    • 当组件即将从界面上消失时,调用window.getLastWindow(this.getUIContext().getHostContext())获取当前窗口对象。
    • 使用then方法处理异步操作,获取到窗口对象后,调用removeKeyboardHeightListener(win)方法移除键盘高度变化的监听器。
  3. getResourceString(resource: Resource) 方法
    • 该方法用于获取指定资源的字符串值,通过调用this.getUIContext().getHostContext()!.resourceManager.getStringSync(resource.id)实现。
  4. addKeyboardHeightListener(win: window.Window) 方法
    • 该方法用于在指定的窗口上添加键盘高度变化的监听器。
    • 使用win.on('keyboardHeightChange', height => {...})方法监听键盘高度变化事件。
    • 当键盘高度变化时,更新UI上下文中的keyboardHeight属性,并通过console.info输出当前键盘高度。
  5. removeKeyboardHeightListener(win: window.Window) 方法
    • 该方法用于在指定的窗口上移除键盘高度变化的监听器。
    • 使用win.off('keyboardHeightChange')方法移除监听器。

如图1效果图所示,对比软键盘显示界面和表情面板显示界面可知,表情面板的高度 = 常用表情列表的高度 + 软键盘的高度。由于弹窗不避让软键盘和自定义键盘,在切换到软键盘时,需要一个占位元素来将软键盘上方区域顶起,且高度为软键盘的高度。同理,切换到表情面板时,需要将占位元素的高度设置为表情面板的高度(即常用表情列表的高度 + 软键盘的高度)。

评论弹窗模块高度适配代码:

// features/home/src/main/ets/view/CommentKeyboard.ets
build() {
  NavigationDialog({ maskBackgroundColor: 'rgba(0, 0, 0, 0.1)' }) {
    Column() {
      this.AtFriendList()
      this.ToolBar()
      Divider()
      if (!this.isEmojiKeyboardVisible) {
        this.FrequentEmojiList()
      }
      Column()
        .height(
          this.isEmojiKeyboardVisible ?
            this.keyboardHeight + this.frequentEmojiListHeight :
            this.keyboardHeight
        )
    }
    .backgroundColor(Color.White)
  }
}

代码逻辑走读:

  1. 构建方法定义:定义了一个名为build的方法,用于构建CommentKeyboard组件。

  2. 导航对话框创建:使用NavigationDialog组件创建一个对话框,并设置其背景色为半透明黑色。

  3. 列布局创建:在对话框内部,使用Column组件创建一个垂直布局。

  4. 调用方法

    • 调用this.AtFriendList()方法,可能用于显示或处理@好友的功能。
    • 调用this.ToolBar()方法,可能用于显示或处理工具栏的功能。
  5. 分隔线:使用Divider组件在表情符号列表和常用表情列表之间添加分隔线。

  6. 条件渲染表情符号列表:使用if语句检查this.isEmojiKeyboardVisible的值,如果为false,则调用this.FrequentEmojiList()方法显示常用表情列表。

  7. 列布局高度设置:使用Column组件的height方法根据this.isEmojiKeyboardVisible的值设置列布局的高度,以决定是否显示常用表情列表。

  8. 背景色设置:为整个列布局设置白色背景色。

表情面板高度适配代码:

// features/home/src/main/ets/view/CommentKeyboard.ets
@Builder
EmojiKeyboard() {
  Grid() {
    ForEach(this.getEmojiIcons(), (icon: Resource) => {
      GridItem() {
        Image(icon)
          .width(45)
          .onClick(() => { this.onEmojiClick(icon) })
      }
    })
  }
  .width('100%')
  .height(this.keyboardHeight + this.frequentEmojiListHeight)
  // ...
}

代码逻辑走读:

  1. 定义构建器函数:使用@Builder装饰器定义了一个名为EmojiKeyboard的构建器函数。
  2. 创建网格布局:在EmojiKeyboard函数内部,使用Grid()组件创建一个网格布局。
  3. 遍历表情符号:通过ForEach循环遍历this.getEmojiIcons()返回的表情符号资源数组。
  4. 生成网格项:对于每个表情符号,使用GridItem()组件创建一个网格项。
  5. 设置图像组件:在每个网格项中,使用Image(icon)组件显示表情符号图像。
  6. 设置图像宽度:通过.width(45)设置图像宽度为45像素。
  7. 绑定点击事件:通过.onClick(() => { this.onEmojiClick(icon) })为图像绑定点击事件,点击时调用onEmojiClick函数并传入当前表情符号。
  8. 设置网格布局尺寸:通过.width('100%')设置网格布局宽度为100%,通过.height(this.keyboardHeight + this.frequentEmojiListHeight)设置高度为键盘高度加上常用表情符号列表高度。

编辑区功能

编辑区通常包括输入文字、表情、@好友、选择图片功能,本节通过效果图展示结合代码讲解的方式对上述功能开发做相应介绍。

  • 添加表情

    在软键盘上方常用表情列表点击表情图片,或者切换到表情面板点击表情图片,会在编辑区域光标后方添加对应的表情内容。

    在这里插入图片描述

    在表情面板或常用表情列表中点击表情时可通过RichEditorController.addImageSpan在编辑区域进行添加图片表情,注意需要设置offset属性为当前光标位置,当前光标的位置可通过RichEditorController.getCaretOffset获取。这样使得表情在当前光标后添加,否则默认在内容的最后方添加,后文类似的添加操作都遵循此规则。

    // features/home/src/main/ets/view/CommentKeyboard.ets
    onEmojiClick: (icon: Resource) => void = icon => {
      this.richEditorController.addImageSpan(icon, {
        offset: this.richEditorController.getCaretOffset(),
        imageStyle: { size: [20, 20] }
      });
      // ...
    }
    

    代码逻辑走读:

    1. 函数定义:定义了一个名为 onEmojiClick的函数,该函数接受一个参数 icon,类型为 Resource,表示用户点击的表情图标。
    2. 获取光标位置:通过调用 this.richEditorController.getCaretOffset()获取富文本编辑器控制器的当前光标位置,用于确定图像跨度的插入位置。
    3. 添加图像跨度:调用 this.richEditorController.addImageSpan(icon, {...})方法,在当前光标位置添加一个图像跨度。图像跨度的样式属性包括 offset(图像插入位置)和 imageStyle(图像尺寸)。
    4. 注释说明:代码中包含注释,说明了图像跨度的插入位置和图像尺寸的设置方式。
  • @好友

    点击编辑区域@按钮,或在软键盘输入@符号,会展示好友列表。点击好友列表中好友头像,会在编辑区域添加@好友内容。

    在这里插入图片描述

    点击@按钮时,通过RichEditorController.addTextSpan添加@符号,并显示好友列表。同时需要监听RichEditor.aboutToIMEInput事件 ,该事件在输入内容前触发回调,在回调中获取要输入的内容,如果输入的内容为@,则相当于点击了@按钮的效果,这样统一了点击@按钮和键盘输入@的逻辑,方便后续一些细节的处理。

    // features/home/src/main/ets/view/CommentKeyboard.ets
    onAtButtonClick: (event?: ClickEvent) => void = event => {
      const controller = this.richEditorController;
      this.isAtFriendListVisible = true;
      controller.addTextSpan('@', { offset: controller.getCaretOffset() });
    }
    

    代码逻辑走读:

    1. 函数定义:定义了一个名为 onAtButtonClick的函数,该函数接受一个可选的 event参数,类型为 ClickEvent
    2. 变量获取:从当前对象中获取 richEditorController,并将其赋值给 controller变量。
    3. 状态更新:将对象的 isAtFriendListVisible属性设置为 true,表示好友列表可见。
    4. 文本插入:调用 controlleraddTextSpan方法,在当前光标位置插入一个“@”符号。光标位置通过调用 controller.getCaretOffset()获取。

    在好友列表中点击好友头像时,通过RichEditorController.getSpans可以获取光标前一个span的内容,若光标前一个span是内容为@的textSpan,则先删除,然后通过RichEditorController.addBuilderSpan将“@[好友昵称]”以指定的样式作为一个整体添加到编辑区域中。

    // features/home/src/main/ets/view/CommentKeyboard.ets
    onAtFriendClick: (friend: User) => void = friend => {
      const controller = this.richEditorController;
      const offset = controller.getCaretOffset();
      const range: RichEditorRange = { start: offset - 1, end: offset };
      const span = controller.getSpans(range);
      if (offset !== 0 && (span[0] as RichEditorTextSpanResult).value === '@') {
        controller.deleteSpans(range);
      }
      controller.addBuilderSpan(() => this.AtSpan(friend.nickname), {
        offset: controller.getCaretOffset()
      });
      this.setBuilderSpans(controller, friend);
    }
    
    @Builder
    AtSpan(nickname: string) {
      Text(`@${nickname}`)
        .fontColor(0xFF133667)
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    

    代码逻辑走读:

    1. 定义onAtFriendClick方法,接收一个friend参数,类型为User
    2. 获取richEditorController对象,用于操作富文本编辑器。
    3. 获取当前光标的位置offset
    4. 定义一个range对象,表示从光标前一个字符到当前光标的位置。
    5. 调用getSpans方法获取range范围内的标签。
    6. 检查光标位置是否为起始位置,以及光标前的字符是否为@
    7. 如果条件满足,调用deleteSpans方法删除光标前的@标签。
    8. 调用addBuilderSpan方法在当前光标位置添加一个新的@标签,标签内容由AtSpan构建。
    9. 调用setBuilderSpans方法更新评论键盘中的好友信息。
    10. 定义AtSpan构建器,接收一个nickname参数,类型为string
    11. 使用Text组件创建文本标签,内容为@加上nickname
    12. 设置文本标签的字体颜色为0xFF133667
    13. 设置文本标签的最大行数为1。
    14. 设置文本标签的文本溢出处理方式为省略号。
  • 删除内容

    点击软键盘删除按钮,如果要编辑区域光标前删除的内容是builderSpan(@好友)且没有被选中,则进行选中,否则直接删除光标前的内容。选中内容会作为整体删除。

    点击放大

    监听RichEditor.aboutToDelete事件,可通过回调中返回false阻止编辑区域默认的删除行为。在第一次删除builderSpan(@好友)的时候,先使用RichEditorController.setSelection对整体进行选中,再次点击删除键时选中内容在RichEditor中会默认被整体删除。

    // features/home/src/main/ets/view/CommentKeyboard.ets
    aboutToDelete: (value: RichEditorDeleteValue) => boolean = value => {
      const controller = this.richEditorController;
      const span = value.richEditorDeleteSpans[0];
      if (span && this.isBuilderSpan(span)) {
        if (this.hasSelection(controller)) {
          this.deleteBuilderSpan();
          return true;
        }
        controller.setSelection(value.offset, value.offset + 1);
        return false;
      }
      return true;
    }
    

    代码逻辑走读:

    1. 方法定义:定义了一个名为aboutToDelete的方法,接受一个参数value,该参数是一个RichEditorDeleteValue类型的对象。

    2. 变量初始化:从this对象中获取richEditorController,并将其赋值给controller变量。

    3. 获取删除对象:从value.richEditorDeleteSpans数组中获取第一个元素,并将其赋值给span变量。

    4. 检查对象类型:使用this.isBuilderSpan(span)检查span是否是构建器范围(builder span)。

    5. 处理选择状态:

      • 如果span是构建器范围,并且当前有选择(通过this.hasSelection(controller)检查),则调用this.deleteBuilderSpan()删除该范围,并返回true
      • 如果没有选择,则使用controller.setSelection(value.offset, value.offset + 1)调整编辑器的选择位置,并返回false
    6. 默认返回:如果span不是构建器范围,则直接返回true

  • 内容获取与展示

    在编辑区域输入文字、表情、@好友内容,点击发送按钮,获取编辑区域内容,并弹窗展示内容以及@好友中好友的相关信息。

    在这里插入图片描述

    可以通过RichEditorController.getSpans来获取编辑区域所有的内容,获取到的内容在getSpans方法的返回值中表现为RichEditorTextSpanResult和RichEditorImageSpanResult两种类型。上文中提到过文字、图片表情、@好友三种内容与这两种类型的对应关系如下表:

    本文中的定义 对应编辑区域内容 添加方式 getSpans方法返回值中对应的类型
    textSpan 连续的文字 键盘输入或RichEditorController.addTextSpan RichEditorTextSpanResult
    imageSpan 图片表情 RichEditorController.addImageSpan RichEditorImageSpanResult
    builderSpan @[好友昵称] RichEditorController.addBuilderSpan RichEditorImageSpanResult

    textSpan可通过RichEditorTextSpanResult.value获取文字内容。imageSpan可通过RichEditorImageSpanResult.valueResourceStr获取图片资源。但是builderSpan在RichEditorImageSpanResult中获取不到任何相关的内容信息,所以在点击好友头像添加@好友内容时需要手动将这些builderSpan进行维护。

    // features/home/src/main/ets/view/CommentKeyboard.ets
    onAtFriendClick: (friend: User) => void = friend => {
      // ...
      this.setBuilderSpans(controller, friend);
    }
    

    代码逻辑走读:

    1. 定义了一个方法onAtFriendClick,该方法接受一个User类型的参数friend
    2. 在方法体内,调用了setBuilderSpans函数,并将controllerfriend作为参数传递,以设置构建器中的文本样式。
    3. 该方法没有返回值,即返回类型为void

    实际开发中编辑区域不同类型的内容往往需要一种统一的数据结构来表达,方便传输和存储。该数据结构需要不仅能对编辑区域内容进行记录,也需要有携带一些额外信息的能力,比如携带@好友相关的用户信息。本文定义为RichEditorSpan。(实际开发中需要的属性字段根据需求灵活调整)。

    // features/home/src/main/ets/view/CommentKeyboard.ets
    export interface RichEditorSpan {
      value?: string
      resourceValue?: ResourceStr
      type: 'text' | 'image' | 'builder'
      data?: User | ImageInfo
    }
    

    代码逻辑走读:

    1. 定义了一个名为RichEditorSpan的接口,该接口用于描述富文本编辑器中的一个文本片段或元素。
    2. 接口包含四个属性:
      • valueresourceValue:这两个属性是可选的,类型分别为stringResourceStr,可能用于存储文本内容或资源标识符。
      • type:这是一个必需的属性,类型为字符串,可以是'text''image''builder',用于标识该片段的类型。
      • data:这也是一个必需的属性,类型为UserImageInfo,用于存储与该片段相关的数据,具体类型取决于type属性。

    使用RichEditorSpan[]类型的数组builderSpans来维护@好友时的builderSpan,需要注意的是要保证每个builderSpan在数组中的顺序要与实际内容中出现的顺序一致。在添加builderSpan时,通过计算当前光标位置前面builderSpan的个数,来确定添加到builderSpans数组中的位置,并把需要携带的好友信息放入data属性中。

    setBuilderSpans(controller: RichEditorController, friend: User) {
      const builderSpan: RichEditorSpan = {
        value: `@${friend.nickname}`,
        data: friend,
        type: 'builder'
      };
      const range: RichEditorRange = { end: controller.getCaretOffset() };
      const index = this.getBuilderSpanCount(controller, range) - 1;
      this.builderSpans.splice(index, 0, builderSpan);
    }
    
    getBuilderSpanCount(controller: RichEditorController, range: RichEditorRange) {
      return controller.getSpans(range).reduce((count: number, span) => {
        return this.isBuilderSpan(span) ? count + 1 : count;
      }, 0);
    }
    

    代码逻辑走读:

    1. setBuilderSpans函数首先创建一个新的 RichEditorSpan对象,其中 value属性包含一个格式化的字符串,由 @friend.nickname组成,data属性存储了 friend对象,type属性被设置为 'builder'
    2. 接着,函数创建了一个 RichEditorRange对象,其 end属性设置为当前光标的位置。
    3. 使用 getBuilderSpanCount函数计算在当前光标位置之前的相同类型的Span对象数量,并将新创建的 builderSpan插入到编辑器中。
    4. getBuilderSpanCount函数通过调用 controller.getSpans方法获取指定范围内的所有Span对象,然后使用 reduce方法遍历这些对象。对于每个Span对象,通过调用 isBuilderSpan函数检查其类型是否为 'builder',如果是,则计数加一。
    5. 最终返回计数结果,表示在指定范围内 builder类型的Span对象的数量。

    发送评论时,将获取到的内容用RichEditorSpan[]类型的数组richEditorSpans进行统一地表达。通过getSpans获取所有内容,如果是textSpan,通过value属性取出文字内容,设置RichEditorSpan.type为text,如果是imageSpan,通过valueResourceStr属性获取图片资源,设置RichEditorSpan.type为image。如果是builderSpan,按顺序从数组builderSpans中获取,并将他们按顺序添加到richEditorSpans中。

    // features/home/src/main/ets/view/CommentKeyboard.ets
    onSendComment: () => void = () => {
      let builderSpanIndex = 0;
      let richEditorSpan: RichEditorSpan;
      const richEditorSpans: RichEditorSpan[] = [];
      this.richEditorController.getSpans().forEach((span, index) => {
        const textSpan = span as RichEditorTextSpanResult;
        const imageSpan = span as RichEditorImageSpanResult;
        if (textSpan.value) {
          richEditorSpan = { value: textSpan.value, type: 'text' };
        } else if (this.isBuilderSpan(span)) {
          richEditorSpan = this.builderSpans[builderSpanIndex];
          builderSpanIndex += 1;
        } else {
          richEditorSpan = { resourceValue: imageSpan.valueResourceStr, type: 'image' };
        }
        richEditorSpans.push(richEditorSpan);
      });
      // ...
    }
    

    代码逻辑走读:

    1. 初始化变量
      • builderSpanIndex初始化为0,用于遍历builderSpans数组。
      • richEditorSpan用于存储当前处理的文本或图像片段。
      • richEditorSpans是一个空数组,用于存储处理后的文本或图像片段。
    2. 遍历富文本编辑器的片段
      • 使用forEach方法遍历richEditorController.getSpans()返回的片段集合。
      • 对于每个片段,首先尝试将其转换为RichEditorTextSpanResult类型,检查其value属性是否存在。
      • 如果value存在,则创建一个richEditorSpan对象,类型为'text',并将其添加到richEditorSpans数组中。
      • 如果value不存在,则检查当前片段是否为builderSpan。如果是,则从builderSpans数组中获取对应的片段,并将其添加到richEditorSpans数组中。
      • 如果当前片段既不是文本片段也不是builderSpan,则将其视为图像片段,创建一个richEditorSpan对象,类型为'image',并将其添加到richEditorSpans数组中。
    3. 处理完成
      • 最终,richEditorSpans数组中包含了所有处理后的文本和图像片段,可以用于后续的操作或发送。

    最终生成的richEditorSpans数据格式如下:

    [
      {
        "value": "@朋友1",
        "data": {
          "id": "0",
          "avatar": {
            "id": 16777268,
            "type": 20000,
            "params": [],
            "bundleName": "com.example.commentreply",
            "moduleName": "default"
          },
          "nickname": "朋友1"
        },
        "type": "builder"
      },
      {
        "value": "Hello",
        "type": "text"
      },
      {
        "resourceValue": "resource:///emoji_3.png",
        "type": "image"
      }
    ]
    

    当需要展示评论内容时,只需要对richEditorSpans进行遍历,根据type属性,分别对文字、表情、@好友进行展示逻辑的处理。具体展示形式开发者根据实际需求确定。

    // features/home/src/main/ets/view/CommentSendDialog.ets
    Column() {
      Text($r('app.string.send_title'))
        .margin({ bottom: 20 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(this.getComment(), (richEditorSpan: RichEditorSpan) => {
          if (richEditorSpan.type === 'text') {
            Text(richEditorSpan.value)
          } else if (richEditorSpan.type === 'image') {
            Image(richEditorSpan.resourceValue)
              .width(20)
          } else {
            Text(`${richEditorSpan.value}(`)
              .fontColor(0xFF133667)
            Text(`id:${(richEditorSpan.data as User).id}; avatar:`)
            Image((richEditorSpan.data as User).avatar)
              .width(20)
            Text(')')
          }
        }, (richEditorSpan: RichEditorSpan) => JSON.stringify(richEditorSpan))
      }
    }
    

    代码逻辑走读:

    1. 初始化布局:使用Column()组件作为根布局,确保所有子组件垂直排列。

    2. 标题文本:在Column()中首先添加一个Text组件,显示评论发送的标题,并设置底部外边距为20。

    3. Flex布局:使用Flex()组件来包裹评论内容,设置wrap属性为FlexWrap.Wrap,使得子组件在超出宽度时自动换行。

    4. 遍历评论内容:使用ForEach循环遍历this.getComment()返回的评论数据,每个评论项是一个RichEditorSpan对象。

    5. 处理不同类型的评论:

      • 如果评论类型为text,则显示文本内容。
      • 如果评论类型为image,则显示图片,并设置宽度为20。
      • 如果评论类型为其他,则显示用户信息,包括用户ID和头像。
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐