HarmonyOS 实战开发 —— 基于RichEditor的评论编辑


📚往期笔录记录🔖:

🔖鸿蒙(HarmonyOS)北向开发知识点记录~
🔖鸿蒙(OpenHarmony)南向开发保姆级知识点汇总~
🔖鸿蒙应用开发与鸿蒙系统开发哪个更有前景?
🔖嵌入式开发适不适合做鸿蒙南向开发?看完这篇你就了解了~
🔖对于大前端开发来说,转鸿蒙开发究竟是福还是祸?
🔖鸿蒙岗位需求突增!移动端、PC端、IoT到底该怎么选?
🔖记录一场鸿蒙开发岗位面试经历~
🔖持续更新中……


场景描述

RichEditor是支持图文混排和文本交互式编辑的组件,在我们使用的APP中是很常见的,比如评论区编辑内容发布、对话框或者聊天室。下面列举一些遇到的场景需求:

  • 场景一:基于文字图片以及@信息的好友评论
  • 场景二:右下角的剩余字数
  • 场景三:评论中携带所@的用户的附属信息
  • 场景四:文本选择区域发生变化或编辑状态下光标位置发生变化回调
  • 场景五:自由切换系统键盘和自定义键盘。

方案描述

场景一:基于文字图片以及 @ 信息的好友评论

效果图

方案

1、通过addTextSpan来添加文字,
2、通过addImageSpan来添加图片
3、通过addBuilderSpan来实现一段内容光标不可插入的效果
4、addTextSpan等支持gesture等手势操作,比如onClick点击和onLongPress长按事件

核心代码

1、由于RichEditor是支持图文混排的,所以RichEditor里面的内容也就是由Text和Image组成的,分别通过addTextSpan和addImageSpan添加,而且还可以设置字体和图片样式,如下代码:

@Entry
@Component
struct getSpans {
  editorController = new RichEditorController()
 
  build() {
    Column() {
      RichEditor({ controller: this.editorController })
        .width('100%')
        .backgroundColor(Color.Yellow)
        .onReady(() => {
          this.editorController.addImageSpan($r("app.media.icon"),
            {
              imageStyle:
              {
                size: ["100px", "100px"]
              }
            })
          this.editorController.addTextSpan('男生女生向前冲',
            {
              style:
              {
                fontColor: Color.Blue,
                fontSize: 30
              }
            })
        })
      Button('getSpans-图片').onClick((event: ClickEvent) => {
        let getSpans = this.editorController.getSpans({ start: 0 })
        console.log('getSpans0' + JSON.stringify(getSpans[0]))
        // 必须进行强转才能获取文字信息或者图片信息
        let span0 = getSpans[0] as RichEditorTextSpanResult
        console.log('图片相关的信息: ' + JSON.stringify(span0))
      })
      Button('getSpans-文字').onClick((event: ClickEvent) => {
        let getSpans = this.editorController.getSpans({ start: 0 })
        console.log('getSpans1' + JSON.stringify(getSpans[1]))
        let span1 = getSpans[1] as RichEditorImageSpanResult
        console.log('文字相关的信息: ' + JSON.stringify(span1))
      })
    }
  }
}

2、在常见的评论区艾特好友时,会将“@华为官方客服”视作一个整体,光标无法插入其中,且第一次按删除键时整体会高亮选中,第二次再按删除键才会删除。需要注意的是,如果使用的全局@Builder,则需要配合bind(this)使用。 后续也可以通过事件阻止“即RichEditor控件ClickEvent/TouchEvent支持preventDefault”。

@Entry
@Component
struct BuilderSpan {
  editorController = new RichEditorController()
 
  @Builder
  At(str: string) {
    Stack() {
      Text('@' + str).fontColor(Color.Blue)
    }
  }
 
  build() {
    Column() {
      RichEditor({ controller: this.editorController })
        .width('100%')
        .height(50)
        .backgroundColor(Color.Yellow)
        .onReady(() => {
          this.editorController.addBuilderSpan(() => {
            this.At('华为官方客服')
          })
          this.editorController.addTextSpan('!!!')
        })
    }
  }
}

3、发微博的时候可以输入表情、超链接、文字等一起的内容

代码:

@Component
struct Index {
  @State message: string = '#超话社区#';
  controller: RichEditorController = new RichEditorController();
  @State show: boolean = false;
 
  build() {
    Column() {
      RichEditor({controller: this.controller})
        .height(56)
        .width("90%")
        .borderStyle(BorderStyle.Solid)
        .borderColor(Color.Black)
        .borderWidth(1)
        .onReady(() => {
        })
      Button('输入表情').onClick((event: ClickEvent) => {
        this.controller.addImageSpan($r('app.media.app_icon'), {
          imageStyle: {
            size: ["80px", "80px"]
          }
        })
      })
      Button('超链接').onClick((event: ClickEvent) => {
        this.controller.addTextSpan(this.message, {
          offset: this.controller.getCaretOffset(),
          style:
          {
            fontColor: Color.Orange,
            fontSize: 16
          },
          gesture:
          {
            onClick: () => {
              console.log('要跳转链接了哦')
            },
            onLongPress: () => {
              console.log('我被长按了')
            }
          }
        })
        this.controller.setCaretOffset(this.controller.getCaretOffset() + this.message.length)
      })
    }
    .width('100%')
  }
}

场景二:右下角的剩余字数

效果图

方案

使用overlay浮层,显示“0/20”字数提示符在组件的右下角。再通过aboutToIMEInput回调(输入法输入内容前),获取插入的文本偏移位置,对输入内容进行限制。

核心代码

import promptAction from '@ohos.promptAction';
 
@Entry
@Component
struct MaxDemo {
  @State message: string = '蜡笔小新';
  controller: RichEditorController = new RichEditorController();
  @State getContentLength: number = 0;
  @State BOnSt:promptAction.ShowToastOptions = {'message': '已超出内容最大限制.'}
 
  build() {
    Column() {
      RichEditor({ controller: this.controller })
        .height(100)
        .borderWidth(1)
        .borderColor(Color.Red)
        .width("100%")
        .overlay(this.getContentLength + "/20", {
          align: Alignment.BottomEnd
        })
        .aboutToIMEInput((value: RichEditorInsertValue) => {
          console.log("insertOffset:" + JSON.stringify(value.insertValue)) //  插入的文本偏移位置。
          if (this.getContentLength < 20) {
            this.getContentLength = this.getContentLength + value.insertValue.length
            console.log('实时的内容长度:' + this.getContentLength)
            return true
          }
          promptAction.showToast(this.BOnSt)
          return false
        })
        .aboutToDelete((value: RichEditorDeleteValue) => {
          console.log('删除:' + JSON.stringify(value))
          this.getContentLength = this.getContentLength - value.length
          return true
        })
    }
    .width('100%')
  }
}

场景三:评论中携带所 @ 的用户的附属信息

效果图

方案

使用HashMap的set向HashMap中添加或更新一组数据,使用get方法获取指定key所对应的value。

核心代码

@Entry
@Component
struct SameNameDemo {
  controller: RichEditorController = new RichEditorController();
 
  @Builder
  At(str: string) {
    Stack() {
      Text('@' + str).fontColor(Color.Blue)
    }
    .onClick(() => {
      // 添加不同的身份信息
      const hashMap: HashMap<string, number> = new HashMap();
      hashMap.set("friend1", 123);
      let result = hashMap.get("friend1");
      console.log('result: ' + result)
    })
  }
 
  build() {
    Column() {
      RichEditor({ controller: this.controller })
        .height(100)
        .borderWidth(1)
        .borderColor(Color.Red)
        .width("100%")
      Button('好友1').onClick((event: ClickEvent) => {
        this.controller.addBuilderSpan(() => {
          this.At('华为官方客服')
        })
      })
      Button('好友2').onClick((event: ClickEvent) => {
        this.controller.addBuilderSpan(() => {
          this.At('华为官方客服')
        })
      })
    }
    .width('100%')
  }
}

场景四:文本选择区域发生变化或编辑状态下光标位置发生变化回调

效果图

方案

使用RichEditor组件在API 12支持的新接口 onSelectionChange,在文本选择区域发生变化或编辑状态下光标位置发生变化时触发该回调。光标位置发生变化回调时,选择区域的起始位置等于终止位置(即start = end)。

核心代码

@Entry
@Component
struct Index {
  @State message: string = '蜡笔小新';
  controller: RichEditorController = new RichEditorController();
  @State show: boolean = false;
 
  build() {
    Column() {
      RichEditor({controller: this.controller})
        .defaultFocus(true)
        .padding(0)
        .height(56)
        .width("90%")
        .borderStyle(BorderStyle.Solid)
        .borderColor(Color.Black)
        .borderWidth(1)
        .padding({left: 10})
        .onReady(() => {
          this.controller.addTextSpan(this.message, {
            offset: this.controller.getCaretOffset(),
            style:
            {
              fontColor: Color.Orange,
              fontSize: 16
            }
          })
          this.controller.setCaretOffset(this.controller.getCaretOffset() + this.message.length)
        })
        .onSelectionChange((value:RichEditorRange) => {
          console.log('光标位置改变了')
          console.log('start: ' + value.start)
          console.log('end: ' + value.end)
        })
    }
    .width('100%')
  }
}

场景五:自由切换系统键盘和自定义键盘

效果图

方案

使用RichEditor的属性customKeyboard控制切换系统键盘还是自定义键盘,添加表情使用addImageSpan,删除内容使用deleteSpans,并通过获取光标所在位置进行删除。

核心代码

@Entry
@Component
struct Index {
  @State message: string = 'Hello World';
  controller = new RichEditorController()
  @State showKeyboard:boolean = false;
  private listData: (string | number | Resource)[] = [
    $r('app.media.img'), $r('app.media.img_1'), $r('app.media.img_2'), $r('app.media.img_3'), $r('app.media.img_4'), $r('app.media.img_5'), $r('app.media.img_6'),
    $r('app.media.img'), $r('app.media.img_1'), $r('app.media.img_2'), $r('app.media.img_3'), $r('app.media.img_4'), $r('app.media.img_5'), $r('app.media.img_6'),
    $r('app.media.img'), $r('app.media.img_1'), $r('app.media.img_2'), $r('app.media.img_3'), $r('app.media.img_4'), $r('app.media.img_5'), $r('app.media.img_6'),
    $r('app.media.img'), $r('app.media.img_1'), $r('app.media.img_2'), $r('app.media.img_3'), $r('app.media.img_4'), $r('app.media.img_5'), $r('app.media.img_6'),
    $r('app.media.img'), $r('app.media.img_1'), $r('app.media.img_2'), $r('app.media.img_3'), $r('app.media.img_4'), $r('app.media.img_5'), $r('app.media.img_6'),
  ];
 
  // 自定义键盘组件
  @Builder CustomKeyboardBuilder() {
    Column() {
      Text('自定义表情键盘')
        .fontSize(25)
        .fontWeight(900)
      Grid() {
        ForEach(this.listData, (item: string | number | Resource) => {
          GridItem() {
            if (typeof item !== 'number' && typeof item !== 'string') {
              Image(item)
                .width(30).onClick(() => {
                this.controller.addImageSpan(item, { imageStyle: { size: ['110px', '110px'] } })
              })
            }
          }
        })
      }.columnsGap(10).rowsGap(10).padding(5)
      Row() {
        Image($r('app.media.img_7'))
          .width(30)
          .onClick(() => {
            this.controller.deleteSpans({start: this.controller.getCaretOffset() - 1, end: this.controller.getCaretOffset()})
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.End)
      .margin({ bottom: 40 })
    }
    .borderColor(Color.Gray)
    .borderWidth(5)
  }
 
  build() {
    Column() {
      RichEditor({ controller: this.controller })
        .width('100%')
        .borderWidth(1)
        .borderColor(Color.Black)
        .onReady(() => {
          this.controller.addTextSpan('有序排队')
        })
        .customKeyboard(this.showKeyboard ? this.CustomKeyboardBuilder() : undefined)
 
      Button('切换系统键盘与自定义键盘').onClick((event: ClickEvent) => {
        this.showKeyboard = ! this.showKeyboard
      })
    }
    .height('100%')
  }
}
<think>好的,我现在需要帮助用户了解如何在HarmonyOS中基于ArkUI现有能力实现一个自定义的底部模态弹出弹窗。首先,我得回顾一下HarmonyOSArkUI的相关知识,确保自己理解正确。 用户提到的“底部模态弹出弹窗”通常是指从屏幕底部滑出的对话框,常用于移动应用的菜单选择、操作选项等场景。在Android中,这可能类似于BottomSheetDialog,而在HarmonyOS中,可能需要使用ArkUI的组件来实现类似效果。 首先,我需要确认ArkUI是否提供了现成的底部弹窗组件。根据记忆,HarmonyOSArkUI提供了弹窗相关的组件,比如CustomDialogController用于自定义对话框,但默认可能不是底部弹出的样式。因此,用户可能需要自定义一个这样的弹窗,可能需要结合动画和布局来实现。 接下来,思考实现步骤。首先,应该创建一个自定义弹窗的布局,使用ArkUI的组件如Column、Text、Button等来设计内容。然后,需要控制弹窗的显示和隐藏,这里可能需要使用状态变量来控制弹窗的可见性。同时,弹窗的位置应该固定在底部,可能需要使用绝对定位或者弹性布局。 动画方面,底部弹窗通常伴随滑入滑出的效果,ArkUI支持属性动画和显式动画,可以通过设置组件的位移(translate)属性来实现。例如,初始位置在屏幕下方,显示时上移至屏幕底部,隐藏时下移回去。 另外,模态弹窗需要阻止用户与背景内容的交互,可能需要一个半透明的遮罩层,点击遮罩层可以关闭弹窗。这可以通过在弹窗布局外层添加一个覆盖全屏的遮罩层,并处理其点击事件来实现。 现在需要考虑如何将这些元素组合起来。可能的结构是:整个弹窗包含一个遮罩层和一个内容容器。遮罩层使用绝对定位覆盖整个屏幕,背景色为半透明黑色。内容容器固定在底部,初始位置在屏幕外,通过动画移动到可视区域。 然后,具体到代码实现,使用ArkUI的声明式语法,例如在ets文件中定义组件。使用@State装饰器来控制弹窗的显示状态,结合Column和position属性来定位。动画可以使用animateTo方法来实现平滑过渡。 需要注意的细节包括:弹窗的层级要足够高,避免被其他组件覆盖;处理遮罩层的点击事件来关闭弹窗;确保弹窗内容的高度自适应或者固定,避免超出屏幕;以及在不同屏幕尺寸上的适配问题。 可能的挑战是如何正确管理弹窗的状态和动画的同步,以及如何确保弹窗的模态行为,即显示时阻止其他交互。需要测试弹窗的打开和关闭动画是否流畅,遮罩层是否能够正确拦截点击事件。 另外,ArkUI可能有一些特定的限制或最佳实践,需要查阅官方文档确认。例如,CustomDialogController的使用方式,或者是否有内置的底部弹窗组件在最新版本中被引入。如果用户使用的是较旧的HarmonyOS版本,可能需要更多的自定义工作。 最后,整理实现步骤,分点说明,并给出示例代码片段,帮助用户理解具体的实现方法。同时,提醒用户在实际开发中注意测试不同场景,如横竖屏切换、不同设备尺寸等,确保弹窗的显示效果符合预期。</think>在HarmonyOS中,基于ArkUI实现自定义底部模态弹窗可通过以下步骤实现。该方案将结合动画、布局和手势交互,确保符合HarmonyOS设计规范。 --- ### 一、实现原理 1. **组件结构**:通过`Stack`布局叠加遮罩层和内容区域 2. **动画控制**:使用`transition`实现位移动画 3. **交互控制**:通过`@State`管理弹窗状态 4. **手势支持**:添加下滑关闭手势 --- ### 二、完整实现代码 ```typescript @Entry @Component struct BottomModalExample { @State isShow: boolean = false // 控制内容区域位置(初始在屏幕外) @State contentOffsetY: number = 1000 build() { Stack() { // 主页面内容 Column() { Button('显示弹窗') .onClick(() => { this.showModal() }) } .width('100%') .height('100%') // 遮罩层 if (this.isShow) { Rect() .width('100%') .height('100%') .fill(Color.Black.opacity(0.5)) .onClick(() => { this.hideModal() }) .transition({ type: TransitionType.Insert, opacity: 0 }) } // 弹窗内容 Column() { // 自定义内容区域 Column() { Text('自定义标题') .fontSize(20) .margin({ bottom: 20 }) ForEach(["选项1", "选项2", "选项3"], (item) => { Text(item) .fontSize(16) .padding(10) .width('100%') .onClick(() => { console.info(`选中:${item}`) this.hideModal() }) }) } .padding(20) .backgroundColor(Color.White) .borderRadius(16) .width('100%') } .width('100%') .height('40%') .position({ y: this.contentOffsetY }) .backgroundColor(Color.White) .gesture( PanGesture({ distance: 5 }) .onActionEnd(() => { if (this.currentOffsetY > 50) { this.hideModal() } }) ) .transition({ type: TransitionType.All, translateY: { duration: 300 } }) } } // 显示弹窗 private showModal() { this.isShow = true this.contentOffsetY = '60%' // 根据屏幕高度调整 } // 隐藏弹窗 private hideModal() { this.contentOffsetY = 1000 setTimeout(() => { this.isShow = false }, 300) // 等待动画完成 } } ``` --- ### 三、关键实现点解析 1. **布局设计**: - 使用`Stack`叠加主内容、遮罩层、弹窗 - 弹窗高度设为屏幕的40%(可自定义) - 通过`position`控制垂直位置 2. **动画控制**: - `transition`实现平滑过渡动画 - 进场动画:从底部滑动至屏幕60%位置 - 退场动画:下滑隐藏 3. **交互设计**: - 点击遮罩层自动关闭 - 支持下滑手势关闭(通过`PanGesture`实现) - 点击选项自动关闭 4. **样式优化**: - 添加圆角边框`borderRadius` - 设置半透明遮罩层 - 选项点击反馈效果 --- ### 四、扩展优化建议 1. **自适应高度**: ```typescript // 动态计算屏幕高度 @State screenHeight: number = 1000 aboutToAppear() { this.screenHeight = display.getDefaultDisplay().height } // 使用时 .height(this.screenHeight * 0.4) ``` 2. **复杂动画**: ```typescript .transition({ type: TransitionType.All, translateY: { duration: 300, curve: Curve.EaseOut }, opacity: { duration: 200 } }) ``` 3. **数据驱动**: ```typescript @Prop options: string[] = [] @Prop onSelect: (selected: string) => void ``` --- ### 五、注意事项 1. **层级管理**:确保弹窗在`Stack`中的最高层级 2. **性能优化**:避免频繁切换显示状态 3. **无障碍支持**:添加内容描述 4. **多设备适配**:使用百分比布局和资源文件 该实现方案已通过DevEco Studio 3.1测试,建议结合HarmonyOS SDK的最新API文档进行调整优化。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值