前往小程序,Get更优阅读体验!
立即前往
发布
社区首页 >专栏 >我写个HarmonyOS Next版本的微信聊天01

我写个HarmonyOS Next版本的微信聊天01

作者头像
万少
修改2025-02-10 17:26:23
修改2025-02-10 17:26:23
7400
代码可运行
举报
运行总次数:0
代码可运行

我写个HarmonyOS Next版本的微信聊天01

前言

代码会统一放在码云上,纯静态的完整代码会放在末尾

案例目标

这个是安卓手机上的真正的微信聊天界面功能效果

image-20240902230122815
image-20240902230122815

实际效果

image-20240902231634937
image-20240902231634937

案例功能

  1. 页面沉浸式
  2. 聊天内容滚动
  3. 输入框状态切换
  4. 聊天信息框宽度自适应
  5. 输入法避让
  6. 语音消息根据时长自动宽度
  7. canvas声纹 按住说话
  8. 手势坐标检测取消发送-语音转文字
  9. 发送文字
  10. 录音-发送语音
  11. 声音播放-语音消息
  12. AI 语音转文字

新建项目

image-20240902223823376
image-20240902223823376

修改项目桌面名称和图标

image-20240902224250634
image-20240902224250634

entry\src\main\resources\zh_CN\element\string.json

代码语言:javascript
代码运行次数:0
复制
{
  "string": [
    {
      "name": "module_desc",
      "value": "模块描述"
    },
    {
      "name": "EntryAbility_desc",
      "value": "description"
    },
    {
      "name": "EntryAbility_label",
      "value": "我的聊天项目" // 😄
    }
  ]
}

\entry\src\main\module.json5

代码语言:javascript
代码运行次数:0
复制
...
"abilities": [
  {
    "name": "EntryAbility",
    "srcEntry": "./ets/entryability/EntryAbility.ets",
    "description": "$string:EntryAbility_desc",
    "icon": "$media:chat",😄 
    ...

$media:chat 来自于 resource下的名为chat的图标

image-20240902224949027
image-20240902224949027

设置沉浸式

image-20240902225153206
image-20240902225153206
  1. 图一为默认情况下的页面布局,可以看到我们的页面是无法触及到顶部状态栏底部菜单栏
  2. 图二为设置了沉浸式效果后,布局按钮可以触及到顶部状态栏了
  3. 图三为动态获取到了顶部状态栏的高度,然后给容器添加了相应的padding,挤压布局元素到顶部状态栏的下方
设置沉浸式和获取顶部状态栏高度

\entry\src\main\ets\entryability\EntryAbility.ets

代码语言:javascript
代码运行次数:0
复制
...
onWindowStageCreate(windowStage: window.WindowStage): void {
  // Main window is created, set main page for this ability
  hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');

  windowStage.loadContent('pages/Index', (err) => {
    if (err.code) {
      hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
      return;
    }
    hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.');
          //   设置应用全屏
    let windowClass: window.Window = windowStage.getMainWindowSync(); // 获取应用主窗口

    windowClass.setWindowLayoutFullScreen(true)

    let type = window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR; // 导航条避让
    let avoidArea = windowClass.getWindowAvoidArea(type);

    let bottomRectHeight = avoidArea.bottomRect.height; // 获取到导航条区域的高度


    const vpHeight = px2vp(bottomRectHeight) //  转换成 vp单位的数值


    //    把导航栏高度数据 存在全局
    AppStorage.setOrCreate("vpHeight", vpHeight)
  });
}
...
页面使用导航栏高度设置padding
代码语言:javascript
代码运行次数:0
复制
@Entry
@Component
struct Index {
  @StorageProp("vpHeight")
  topHeight: number = 0

  build() {
    Column() {
      Button("按钮")
    }
    .width("100%")
    .height("100%")
    .backgroundColor(Color.Yellow)
    .padding({
      top: this.vpHeight,
    })
  }
}

搭建页面基本布局

image-20240902232718492
image-20240902232718492
代码语言:javascript
代码运行次数:0
复制
@Entry
@Component
struct Index {
  // 状态栏高度
  @StorageProp("vpHeight")
  vpHeight: number = 0

  build() {
    Column() {
         // 1 顶部标题栏
      Row() {
        Image($r("app.media.left"))
          .width(25)
        Text("kto卋讓硪玩孫悟空")
        Image($r("app.media.more"))
          .width(25)
      }
      .width("100%")
      .justifyContent(FlexAlign.SpaceBetween)
      .border({
        width: {
          bottom: 1
        },
        color: "#ddd"
      })
      .padding(10)
       // 2 聊天滚动容器
       // 3 输入面板
   
      
    }
    .height('100%')
    .width('100%')
    .backgroundColor("#EDEDED")
    .padding({
      top: this.vpHeight + 20
    })
  }

}

页面滚动和文字信息框

PixPin_2024-09-02_23-34-06
PixPin_2024-09-02_23-34-06
代码语言:javascript
代码运行次数:0
复制
  build() {
    Column() {
      // 1 顶部标题栏 
.....
      // 2 聊天滚动容器
      Scroll() {
        Column({ space: 10 }) {
            this.chatTextBuilder("吃饭", `22:23`)
        }
        .width("100%")
        .padding(10)
        .justifyContent(FlexAlign.Start)

      }
      .layoutWeight(1)
      .align(Alignment.Top)

      .expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.BOTTOM])
    }
    .height('100%')
    .width('100%')
    .backgroundColor("#EDEDED")
    .backgroundImageSize(ImageSize.Cover)
    .padding({
      top: this.vpHeight + 20
    })
  }
  // 文字消息
  @Builder
  chatTextBuilder(text: string, time: string) {
    Column({ space: 5 }) {
      Text(time)
        .width("100%")
        .textAlign(TextAlign.Center)
        .fontColor("#666")
        .fontSize(14)
      Row() {
        Flex({ justifyContent: FlexAlign.End }) {
          Row() {
            Text(text)
              .padding(11);
            Text()
              .width(10)
              .height(10)
              .backgroundColor("#93EC6C")
              .position({
                right: 0,
                top: 15
              })
              .translate({
                x: 5,
              })
              .rotate({
                angle: 45
              });

          }
          .backgroundColor("#93EC6C")
          .margin({ right: 15 })
          .borderRadius(5);

          Image($r("app.media.avatar"))
            .width(40)
            .aspectRatio(1);
        }
        .width("100%");
      }
      .width("100%")
      .padding({
        left: 40
      })
      .justifyContent(FlexAlign.End)
    }
    .width("100%")
  }
亮点
image-20240902234252101
image-20240902234252101

以下代码是实现上面,自适应宽度的关键

  1. 当文字较小时,绿色聊天框宽度自适应
  2. 当文字较多时,绿色聊天框宽度自动变宽,但是不会铺满一行,微信也是这样设计的
image-20240902234447382
image-20240902234447382

底部消息发送框

显示输入框还是 "按住说话"
image-20240903000807542
image-20240903000807542

可以看到,底部消息发送框起码有三种状态

  1. 按住说话
  2. 文本输入框
  3. 文本输入框 - 发送

程序中,通过枚举决定 按住说话-文本输入框两种状态

代码语言:javascript
代码运行次数:0
复制
/**
 * 当前输入状态 语音或者文本
 */
enum WXInputType {
  /**
   * 语音输入
   */
  voice = 0,
  /**
   * 文本输入
   */
  text = 1
}

image-20240903001343178
image-20240903001343178
显示 “发送” 按钮

另外,通过判断文本输入的长度来决定 是否显示 绿色的 发送

image-20240903001128023
image-20240903001128023

image-20240903001214873
image-20240903001214873

image-20240903001146480
image-20240903001146480
显示文本输入框自动获得焦点
PixPin_2024-09-03_00-15-58
PixPin_2024-09-03_00-15-58
image-20240903001515500
image-20240903001515500

设置输入时 键盘避让

不设置避让时,可以看到底部聊天被弹出的键盘顶上去了。

PixPin_2024-09-03_00-19-15
PixPin_2024-09-03_00-19-15
解决方法

设置拓展安全区域为软键盘区域,同时设置扩展安全区域的方向为下方区域

image-20240903002417398
image-20240903002417398

PixPin_2024-09-03_00-26-12
PixPin_2024-09-03_00-26-12

发送文本消息

PixPin_2024-09-03_21-55-36
PixPin_2024-09-03_21-55-36
定义消息类型枚举
代码语言:javascript
代码运行次数:0
复制
enum MessageType {
  /**
   * 声音
   */
  voice = 0,
  /**
   * 文本
   */
  text = 1
}
定义消息类

用来快速生成消息对象,可以表示语音消息和文本消息

代码语言:javascript
代码运行次数:0
复制
// 消息
class ChatMessage {
  /**
   * 消息类型:【录音、文本】
   */
  type: MessageType
  /**
   * 内容 [录音-文件路径,文本-内容]
   */
  content: string
  /**
   * 消息时间
   */
  time: string
  /**
   * 声音的持续时间 单位毫秒
   */
  duration?: number
  /**
   * 录音转的文字
   */
  translateText?: string
  /**
   * 是否显示转好的文字
   */
  isShowTranslateText: boolean = false

  constructor(type: MessageType, content: string, duration?: number, translateText?: string) {
    this.type = type
    this.content = content
    const date = new Date()
    this.time = `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
    this.duration = duration
    this.translateText = translateText
  }
}
定义消息数组
代码语言:javascript
代码运行次数:0
复制
  // 消息
  @State
  chatList: ChatMessage[] = []
定义发送文本消息的方法
代码语言:javascript
代码运行次数:0
复制
  // 发送文本消息
  sendTextMessage = () => {
    if (!this.textValue.trim()) {
      return
    }
    const chat = new ChatMessage(MessageType.text, this.textValue.trim())
    this.chatList.push(chat)
    this.textValue = ""
  }
注册发送文本消息事件
代码语言:javascript
代码运行次数:0
复制
  Button("发送")
    .backgroundColor("#08C060")
    .type(ButtonType.Normal)
    .fontColor("#fff")
    .borderRadius(5)
    .onClick(this.sendTextMessage)
遍历消息数组
代码语言:javascript
代码运行次数:0
复制
  //   2 聊天滚动容器
  Scroll() {
    Column({ space: 10 }) {
      ForEach(this.chatList, (item: ChatMessage, index: number) => {
        if (item.type === MessageType.text) {
          this.chatTextBuilder(item.content, item.time)
        }
      })
    }.width("100%")
    .padding(10)
    .justifyContent(FlexAlign.Start)

  }
  .layoutWeight(1)
  .align(Alignment.Top)
  .expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.BOTTOM])

按住说话

PixPin_2024-09-03_22-11-52
PixPin_2024-09-03_22-11-52
定义是否正在说话的变量
代码语言:javascript
代码运行次数:0
复制
  // 按住说话 录音模态
  @State
  showTalkContainer: boolean = false
注册触摸事件 Touch

长按 按住说话时触发, Touch事件是会持续触发的,通过判断 event.type 来获知 触摸状态

  1. down 按下
  2. move 移动
  3. up 松开
代码语言:javascript
代码运行次数:0
复制
Button("按住说话")
    .layoutWeight(1)
    .type(ButtonType.Normal)
    .borderRadius(5)
    .backgroundColor("#fff")
    .fontColor("#000")
    .onTouch(this.onPressTalk)

定义 this.onPressTalk
代码语言:javascript
代码运行次数:0
复制
  // 按住说话 持续触发
  onPressTalk = async (event: TouchEvent) => {
    if (event.type === TouchType.Down) {
      // 按下
      this.showTalkContainer = true
    } else if (event.type === TouchType.Up) {
      // 松开手
      this.showTalkContainer = false
    }
  }
实现全屏遮罩效果

该效果利用鸿蒙应用中的全模态实现 bindContentCover

给组件绑定全屏模态页面,点击后显示模态页面。模态页面内容自定义,显示方式可设置无动画过渡,上下切换过渡以及透明渐变过渡方式。

this.talkContainerBuilder 为全模态出现时对应的内容布局,它是一个自定义构建函数

代码语言:javascript
代码运行次数:0
复制
  Button("按住说话")
    .layoutWeight(1)
    .type(ButtonType.Normal)
    .borderRadius(5)
    .backgroundColor("#fff")
    .fontColor("#000")
    .bindContentCover($$this.showTalkContainer, this.talkContainerBuilder,
      { modalTransition: ModalTransition.NONE })
    .onTouch(this.onPressTalk)

定义this.talkContainerBuilder
代码语言:javascript
代码运行次数:0
复制
  // 正在说话 页面布局
  @Builder
  talkContainerBuilder() {
    Column() {
      //   1 中心的提示   
      Row() {
        Text()
          .width(10)
          .height(10)
          .backgroundColor("#95EC6A")
          .position({
            bottom: -5,
            left: "50%"
          })
          .translate({
            x: "-50%"
          })
          .rotate({
            angle: 45
          })
      }
      .width("50%")
      .height(80)
      .backgroundColor("#95EC6A")
      .position({
        top: "40%",
        left: "50%"
      })
      .translate({
        x: "-50%"
      })
      .borderRadius(10)
      .justifyContent(FlexAlign.Center)
      .alignItems(VerticalAlign.Center)

      //   2 取消和转文字
      Row() {

        Row() {
          Text("X")
            .fontSize(20)
            .width(60)
            .height(60)
            .borderRadius(30)
            .fontColor("#000")
            .backgroundColor("#fff")
            .textAlign(TextAlign.Center)
            .align(Alignment.Center)
            .fontColor("#ccc")
            .id("aabb")
            .rotate({ angle: -20 })

        }

        Row() {
          Text("文")
            .fontSize(20)
            .width(60)
            .height(60)
            .borderRadius(30)
            .fontColor("#ccc")
            .backgroundColor("#333")
            .textAlign(TextAlign.Center)
            .align(Alignment.Center)
            .id("ddee")
            .rotate({ angle: 20 })
        }

        // 3  松开发送
        Text("松开发送")
          .fontColor("#fff")
          .width("100%")
          .position({
            bottom: 0,
            left: 0
          })
          .textAlign(TextAlign.Center)
      }
      .width("100%")
      .position({
        bottom: "23%"
      })
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({
        left: 60, right: 60
      })


      //   4 底部白色大球
      Row() {

      }
      .width(600)
      .height(600)
      .backgroundColor("#fff")
      .position({
        bottom: 0,
        left: "50%"
      })
      .translate({
        x: "-50%",
        y: "70%"
      })
      .borderRadius("50%")

    }
    .width("100%")
    .height("100%")
    .backgroundColor("rgba(0,0,0,0.5)")
  }

说话声纹

image-20240904104914079
image-20240904104914079

这个绿色容器中的波纹,是通过canva来描述的,真正的逻辑应该是监听或者获取当前声音音量的大小,然后根据它转换对应的波纹。但是没有在鸿蒙中直接找到api,查阅资料发现需要自己分析音频文件数据,自己转化才可以,时间关系就没有继续往下实现。使用随机数简单模拟了下。

配置 CanvasRenderingContext2D 对象的参数
代码语言:javascript
代码运行次数:0
复制
  //用来配置 CanvasRenderingContext2D 对象的参数,包括是否开启抗锯齿,true表明开启抗锯齿。
  settings: RenderingContextSettings = new RenderingContextSettings(true)
用来创建CanvasRenderingContext2D对象
代码语言:javascript
代码运行次数:0
复制
  //用来创建CanvasRenderingContext2D对象,通过在canvas中调用CanvasRenderingContext2D对象来绘制。
  context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
定义声纹自定义构建函数

这里使用canvas画布技术,在onReady 生命周期函数中 通过开启一个定时器,在定时器中不断重复以下过程

  1. 通过clearRect 清空上一次描绘的波纹
  2. 通过 fillRect 随机描绘这一次的波纹

最后,如果此组件被销毁了,可以在 onDisAppear 中停止定时器

代码语言:javascript
代码运行次数:0
复制
  /**
   * 录音中的 动态声纹波浪
   */
  @Builder
  vocalPrint() {
    Canvas(this.context)
      .onDisAppear(() => {
        clearInterval(this.voiceTimeId)
      })
      .width('80%')
      .height('80%')
      .onReady(() => {
        //可以在这里绘制内容。
        clearInterval(this.voiceTimeId)
        this.voiceTimeId = setInterval(() => {
          this.context.clearRect(0, 0, 1000, 1000)
          for (let index = 0; index < 35; index++) {
            const random = Math.floor(Math.random() * 10)
            let height = 20 + random
            this.context.fillRect(0 + index * 5, 32 - height / 2, 2, height);
          }
        }, 100)
      })
  }
使用声纹构造函数
image-20240904110251815
image-20240904110251815

发送信息-取消发送

PixPin_2024-09-04_11-12-28
PixPin_2024-09-04_11-12-28

这部分的UI交互相对来说比较复杂,当按住 按住说话 时:

  1. 手指移动到 X, 表示取消发送
  2. 手指移动到,表示转换文字
  3. 手指直接松开时,发送录音

这部分功能的核心思想时,检测手指是否移动到了相应的元素,触发对应的业务逻辑即可。但是现实的问题是,找不到合适的事件,比如元素引入事件,所以后期采取的是检测手指在整个屏幕的坐标是否触及到了 X 来实现。

定义长按状态的枚举
  1. 没有长按
  2. 长按
  3. 长按-X
  4. 长按-
代码语言:javascript
代码运行次数:0
复制
enum PressCancelVoicePostText {
  // 没有长按
  none = 0,
  //   长按 没有选中“取消发送”或者"转语音"
  presssing = 1,
  //   取消发送
  cancelVoice = 2,
  //   转文字
  postText = 3
}
定义手指坐标类型
代码语言:javascript
代码运行次数:0
复制
/**
 * 长按时,手指的坐标
 */
interface ScreenOffset {
  x: number
  y: number
  width: number
  height: number
}
定义长按状态
代码语言:javascript
代码运行次数:0
复制
// 长按状态
@State
pressCancelVoicePostText: PressCancelVoicePostText = PressCancelVoicePostText.none
定义 X 和 文的坐标状态
代码语言:javascript
代码运行次数:0
复制
  // “x ”的坐标
  xScreenOffset: ScreenOffset = {
    x: 0,
    y: 0,
    width: 0,
    height: 0
  }
  TextScreenOffset: ScreenOffset = {
    x: 0,
    y: 0,
    width: 0,
    height: 0
  }
实时获取 X 和 文 的坐标

在组件中监听 onAppear 事件,根据组件的唯一标识id来获取坐标数据

image-20240904115859088
image-20240904115859088
X
代码语言:javascript
代码运行次数:0
复制
          Text("X")
            .fontSize(20)
            .width(60)
            .height(60)
            .borderRadius(30)
            .fontColor(this.pressCancelVoicePostText === PressCancelVoicePostText.cancelVoice ? "#000" : "#ccc")
            .backgroundColor(this.pressCancelVoicePostText === PressCancelVoicePostText.cancelVoice ? "#fff" : "#333")
            .textAlign(TextAlign.Center)
            .align(Alignment.Center)
            .fontColor("#ccc")
            .id("aabb")
            .rotate({ angle: -20 })
            .onAppear(() => {
              let modePosition: componentUtils.ComponentInfo = componentUtils.getRectangleById("aabb");
              this.xScreenOffset.x = px2vp(modePosition.screenOffset.x)
              this.xScreenOffset.y = px2vp(modePosition.screenOffset.y)
              this.xScreenOffset.width = px2vp(modePosition.size.width)
              this.xScreenOffset.height = px2vp(modePosition.size.height)
            })
代码语言:javascript
代码运行次数:0
复制
   Text("文")
            .fontSize(20)
            .width(60)
            .height(60)
            .borderRadius(30)
            .fontColor(this.pressCancelVoicePostText === PressCancelVoicePostText.postText ? "#000" : "#ccc")
            .backgroundColor(this.pressCancelVoicePostText === PressCancelVoicePostText.postText ? "#fff" : "#333")
            .textAlign(TextAlign.Center)
            .align(Alignment.Center)
            .id("ddee")
            .rotate({ angle: 20 })
            .onAppear(() => {
              let modePosition: componentUtils.ComponentInfo = componentUtils.getRectangleById("ddee");
              // px单位
              this.TextScreenOffset.x = px2vp(modePosition.screenOffset.x)
              this.TextScreenOffset.y = px2vp(modePosition.screenOffset.y)
              this.TextScreenOffset.width = px2vp(modePosition.size.width)
              this.TextScreenOffset.height = px2vp(modePosition.size.height)
            })
调整touch事件onPressTalk的逻辑

该函数的调整逻辑是 判断当前手指的坐标是否触碰到了 X 或者 , 然后设置对应的状态

代码语言:javascript
代码运行次数:0
复制
  // 按住说话 持续触发
  onPressTalk = async (event: TouchEvent) => {
    if (event.type === TouchType.Down) {
      // 手指按下时触发
      this.pressCancelVoicePostText = PressCancelVoicePostText.presssing
      // 按下
      this.showTalkContainer = true

    } else if (event.type === TouchType.Move) {
      // 手指移动时持续触发
      this.pressCancelVoicePostText = PressCancelVoicePostText.presssing
      // 获取当前手指的坐标
      const x = event.touches[0].displayX
      const y = event.touches[0].displayY
      // 判断是否碰到了 “X”
      let isTouchX = this.xScreenOffset.x <= x && this.xScreenOffset.x + this.xScreenOffset.width >= x &&
        this.xScreenOffset.y <= y && this.xScreenOffset.y + this.xScreenOffset.width >= y
      // 判断是否碰到了 "文"
      let isTouchText = this.TextScreenOffset.x <= x && this.TextScreenOffset.x + this.TextScreenOffset.width >= x &&
        this.TextScreenOffset.y <= y && this.TextScreenOffset.y + this.TextScreenOffset.width >= y
      if (isTouchX) {
        // 取消发送
        this.pressCancelVoicePostText = PressCancelVoicePostText.cancelVoice
      } else if (isTouchText) {
        // 转换文字
        this.pressCancelVoicePostText = PressCancelVoicePostText.postText
      }
    } else if (event.type === TouchType.Up) {
      // 松开手
      this.showTalkContainer = false
      if (this.pressCancelVoicePostText === PressCancelVoicePostText.postText) {
        // 转换文字
      } else if (this.pressCancelVoicePostText === PressCancelVoicePostText.cancelVoice) {
        // 取消发送
      } else {
        // 发送录音
      }
    }
  }
添加 X 和 文字的 样式
image-20240904120111649
image-20240904120111649

this.pressCancelVoicePostText 状态发生改变时,需要调整 对应的组件的样式

image-20240904120045126
image-20240904120045126

image-20240904120023853
image-20240904120023853
调整声纹容器的样式
PixPin_2024-09-04_12-02-35
PixPin_2024-09-04_12-02-35
  1. 如果当前正在录音,显示正常绿色的声纹
  2. 如果当前取消发送,显示取消红色的声纹
  3. 如果当前转换文字,显示绿色的空的内容-后期存放实时的语音转换的文字
代码语言:javascript
代码运行次数:0
复制
      //   1 中心的提示   显示波浪线
      Row() {
        if (this.pressCancelVoicePostText !== PressCancelVoicePostText.postText) {
          // 声纹
          this.vocalPrint()

        } else {
          Scroll() {
            // 显示录音的文字
            Text("")
              .fontSize(12)
              .fontColor("#666")
          }
          .width("100%")
          .height("100%")
        }

        Text()
          .width(10)
          .height(10)
          .backgroundColor(this.pressCancelVoicePostText === PressCancelVoicePostText.cancelVoice ? Color.Red :
            "#95EC6A")
          .position({
            bottom: -5,
            left: "50%"
          })
          .translate({
            x: "-50%"
          })
          .rotate({
            angle: 45
          })
      }
      .width("50%")
      .height(80)
      .backgroundColor(this.pressCancelVoicePostText === PressCancelVoicePostText.cancelVoice ? Color.Red : "#95EC6A")
      .position({
        top: "40%",
        left: "50%"
      })
      .translate({
        x: "-50%"
      })
      .borderRadius(10)
      .justifyContent(FlexAlign.Center)
      .alignItems(VerticalAlign.Center)

完整代码

代码语言:javascript
代码运行次数:0
复制
/**
 * 当前输入状态 语音或者文本
 */
import { componentUtils } from '@kit.ArkUI'

enum WXInputType {
  /**
   * 语音输入
   */
  voice = 0,
  /**
   * 文本输入
   */
  text = 1
}

enum MessageType {
  /**
   * 声音
   */
  voice = 0,
  /**
   * 文本
   */
  text = 1
}

// 消息
class ChatMessage {
  /**
   * 消息类型:【录音、文本】
   */
  type: MessageType
  /**
   * 内容 [录音-文件路径,文本-内容]
   */
  content: string
  /**
   * 消息时间
   */
  time: string
  /**
   * 声音的持续时间 单位毫秒
   */
  duration?: number
  /**
   * 录音转的文字
   */
  translateText?: string
  /**
   * 是否显示转好的文字
   */
  isShowTranslateText: boolean = false

  constructor(type: MessageType, content: string, duration?: number, translateText?: string) {
    this.type = type
    this.content = content
    const date = new Date()
    this.time = `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
    this.duration = duration
    this.translateText = translateText
  }
}

enum PressCancelVoicePostText {
  // 没有长按
  none = 0,
  //   长按 没有选中“取消发送”或者"转语音"
  presssing = 1,
  //   取消发送
  cancelVoice = 2,
  //   转文字
  postText = 3
}


/**
 * 长按时,手指的坐标
 */
interface ScreenOffset {
  x: number
  y: number
  width: number
  height: number
}

@Entry
@Component
struct Index {
  // 状态栏高度
  @StorageProp("vpHeight")
  vpHeight: number = 0
  // 输入框内容
  @State
  textValue: string = ""
  // 输入状态 语音或者文字
  @State
  inputType: WXInputType = WXInputType.voice
  // 消息
  @State
  chatList: ChatMessage[] = []
  // 按住说话 录音模态
  @State
  showTalkContainer: boolean = false
  // 长按状态
  @State
  pressCancelVoicePostText: PressCancelVoicePostText = PressCancelVoicePostText.none
  //用来配置 CanvasRenderingContext2D 对象的参数,包括是否开启抗锯齿,true表明开启抗锯齿。
  settings: RenderingContextSettings = new RenderingContextSettings(true)
  //用来创建CanvasRenderingContext2D对象,通过在canvas中调用CanvasRenderingContext2D对象来绘制。
  context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
  // 声明波纹定时器id
  voiceTimeId: number = -1
  // “x ”的坐标
  xScreenOffset: ScreenOffset = {
    x: 0,
    y: 0,
    width: 0,
    height: 0
  }
  TextScreenOffset: ScreenOffset = {
    x: 0,
    y: 0,
    width: 0,
    height: 0
  }
  // 发送文本消息
  sendTextMessage = () => {
    if (!this.textValue.trim()) {
      return
    }
    const chat = new ChatMessage(MessageType.text, this.textValue.trim())
    this.chatList.push(chat)
    this.textValue = ""
  }
  // 按住说话 持续触发
  onPressTalk = async (event: TouchEvent) => {
    if (event.type === TouchType.Down) {
      // 手指按下时触发
      this.pressCancelVoicePostText = PressCancelVoicePostText.presssing
      // 按下
      this.showTalkContainer = true

    } else if (event.type === TouchType.Move) {
      // 手指移动时持续触发
      this.pressCancelVoicePostText = PressCancelVoicePostText.presssing
      // 获取当前手指的坐标
      const x = event.touches[0].displayX
      const y = event.touches[0].displayY
      // 判断是否碰到了 “X”
      let isTouchX = this.xScreenOffset.x <= x && this.xScreenOffset.x + this.xScreenOffset.width >= x &&
        this.xScreenOffset.y <= y && this.xScreenOffset.y + this.xScreenOffset.width >= y
      // 判断是否碰到了 "文"
      let isTouchText = this.TextScreenOffset.x <= x && this.TextScreenOffset.x + this.TextScreenOffset.width >= x &&
        this.TextScreenOffset.y <= y && this.TextScreenOffset.y + this.TextScreenOffset.width >= y
      if (isTouchX) {
        // 取消发送
        this.pressCancelVoicePostText = PressCancelVoicePostText.cancelVoice
      } else if (isTouchText) {
        // 转换文字
        this.pressCancelVoicePostText = PressCancelVoicePostText.postText
      }
    } else if (event.type === TouchType.Up) {
      // 松开手
      this.showTalkContainer = false
      if (this.pressCancelVoicePostText === PressCancelVoicePostText.postText) {
        // 转换文字
      } else if (this.pressCancelVoicePostText === PressCancelVoicePostText.cancelVoice) {
        // 取消发送
      } else {
        // 发送录音
      }
    }
  }

  build() {
    Column() {
      // 1 顶部标题栏
      Row() {
        Image($r("app.media.left"))
          .width(25)
        Text("kto卋讓硪玩孫悟空")
        Image($r("app.media.more"))
          .width(25)
      }
      .width("100%")
      .justifyContent(FlexAlign.SpaceBetween)
      .border({
        width: {
          bottom: 1
        },
        color: "#ddd"
      })
      .padding(10)
      .expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.BOTTOM])

      //   2 聊天滚动容器
      Scroll() {
        Column({ space: 10 }) {
          ForEach(this.chatList, (item: ChatMessage, index: number) => {
            if (item.type === MessageType.text) {
              this.chatTextBuilder(item.content, item.time)
            }
          })
        }.width("100%")
        .padding(10)
        .justifyContent(FlexAlign.Start)

      }
      .layoutWeight(1)
      .align(Alignment.Top)
      .expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.BOTTOM])

      //   3 底部聊天发送框
      Row({ space: 5 }) {
        if (WXInputType.text === this.inputType) {
          Image($r("app.media.voice"))
            .width(40)
            .fillColor("#333")
            .borderRadius(20)
            .border({ width: 2 })
            .onClick(() => {
              this.inputType = WXInputType.voice
            })
          TextInput({ text: $$this.textValue })
            .onAppear(() => {
              // 自动显示焦点
              this.getUIContext().getFocusController().requestFocus("textinput1")
            })
            .layoutWeight(1)
            .backgroundColor("#fff")
            .borderRadius(3)
            .defaultFocus(true)
            .id("textinput1")
        } else if (WXInputType.voice === this.inputType) {
          Image($r("app.media.keyboard"))
            .width(40)
            .fillColor("#333")
            .borderRadius(20)
            .border({ width: 2 })
            .onClick(() => {
              this.inputType = WXInputType.text
            })
          Button("按住说话")
            .layoutWeight(1)
            .type(ButtonType.Normal)
            .borderRadius(5)
            .backgroundColor("#fff")
            .fontColor("#000")
            .bindContentCover($$this.showTalkContainer, this.talkContainerBuilder,
              { modalTransition: ModalTransition.NONE })
            .onTouch(this.onPressTalk)

        }

        Image($r("app.media.smile"))
          .width(40)
          .fillColor("#333")

        if (this.textValue.length) {
          Button("发送")
            .backgroundColor("#08C060")
            .type(ButtonType.Normal)
            .fontColor("#fff")
            .borderRadius(5)
            .onClick(this.sendTextMessage)
        } else {
          Image($r("app.media.plus"))
            .width(48)
            .fillColor("#333")
        }
      }
      .width("100%")
      .padding(10)
      .backgroundColor("#F7F7F7")
    }
    .height('100%')
    .width('100%')
    .backgroundColor("#EDEDED")
    .backgroundImageSize(ImageSize.Cover)
    .padding({
      top: this.vpHeight + 20
    })
  }

  // 文字消息
  @Builder
  chatTextBuilder(text: string, time: string) {
    Column({ space: 5 }) {
      Text(time)
        .width("100%")
        .textAlign(TextAlign.Center)
        .fontColor("#666")
        .fontSize(14)
      Row() {
        Flex({ justifyContent: FlexAlign.End }) {
          Row() {
            Text(text)
              .padding(11);
            Text()
              .width(10)
              .height(10)
              .backgroundColor("#93EC6C")
              .position({
                right: 0,
                top: 15
              })
              .translate({
                x: 5,
              })
              .rotate({
                angle: 45
              });
          }
          .backgroundColor("#93EC6C")
          .margin({ right: 15 })
          .borderRadius(5);

          Image($r("app.media.avatar"))
            .width(40)
            .aspectRatio(1);
        }
        .width("100%");
      }
      .width("100%")
      .padding({
        left: 40
      })
      .justifyContent(FlexAlign.End)
    }
    .width("100%")
  }

  // 正在说话 页面布局
  @Builder
  talkContainerBuilder() {
    Column() {
      //   1 中心的提示   显示波浪线
      Row() {
        if (this.pressCancelVoicePostText !== PressCancelVoicePostText.postText) {
          // 声纹
          this.vocalPrint()

        } else {
          Scroll() {
            // 显示录音的文字
            Text("")
              .fontSize(12)
              .fontColor("#666")
          }
          .width("100%")
          .height("100%")
        }

        Text()
          .width(10)
          .height(10)
          .backgroundColor(this.pressCancelVoicePostText === PressCancelVoicePostText.cancelVoice ? Color.Red :
            "#95EC6A")
          .position({
            bottom: -5,
            left: "50%"
          })
          .translate({
            x: "-50%"
          })
          .rotate({
            angle: 45
          })
      }
      .width("50%")
      .height(80)
      .backgroundColor(this.pressCancelVoicePostText === PressCancelVoicePostText.cancelVoice ? Color.Red : "#95EC6A")
      .position({
        top: "40%",
        left: "50%"
      })
      .translate({
        x: "-50%"
      })
      .borderRadius(10)
      .justifyContent(FlexAlign.Center)
      .alignItems(VerticalAlign.Center)

      //   2 取消和转文字
      Row() {
        Row() {
          Text("X")
            .fontSize(20)
            .width(60)
            .height(60)
            .borderRadius(30)
            .fontColor(this.pressCancelVoicePostText === PressCancelVoicePostText.cancelVoice ? "#000" : "#ccc")
            .backgroundColor(this.pressCancelVoicePostText === PressCancelVoicePostText.cancelVoice ? "#fff" : "#333")
            .textAlign(TextAlign.Center)
            .align(Alignment.Center)
            .fontColor("#ccc")
            .id("aabb")
            .rotate({ angle: -20 })
            .onAppear(() => {
              let modePosition: componentUtils.ComponentInfo = componentUtils.getRectangleById("aabb");
              this.xScreenOffset.x = px2vp(modePosition.screenOffset.x)
              this.xScreenOffset.y = px2vp(modePosition.screenOffset.y)
              this.xScreenOffset.width = px2vp(modePosition.size.width)
              this.xScreenOffset.height = px2vp(modePosition.size.height)
            })
        }

        Row() {
          Text("文")
            .fontSize(20)
            .width(60)
            .height(60)
            .borderRadius(30)
            .fontColor(this.pressCancelVoicePostText === PressCancelVoicePostText.postText ? "#000" : "#ccc")
            .backgroundColor(this.pressCancelVoicePostText === PressCancelVoicePostText.postText ? "#fff" : "#333")
            .textAlign(TextAlign.Center)
            .align(Alignment.Center)
            .id("ddee")
            .rotate({ angle: 20 })
            .onAppear(() => {
              let modePosition: componentUtils.ComponentInfo = componentUtils.getRectangleById("ddee");
              // px单位
              this.TextScreenOffset.x = px2vp(modePosition.screenOffset.x)
              this.TextScreenOffset.y = px2vp(modePosition.screenOffset.y)
              this.TextScreenOffset.width = px2vp(modePosition.size.width)
              this.TextScreenOffset.height = px2vp(modePosition.size.height)
            })

        }

        // 3  松开发送
        Text(this.pressCancelVoicePostText === PressCancelVoicePostText.cancelVoice ? '取消发送' :
          (this.pressCancelVoicePostText === PressCancelVoicePostText.postText ? '转换文字' : "松开发送"))
          .fontColor("#fff")
          .width("100%")
          .position({
            bottom: 0,
            left: 0
          })
          .textAlign(TextAlign.Center)
      }
      .width("100%")
      .position({
        bottom: "23%"
      })
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({
        left: 60, right: 60
      })

      //   4 底部白色大球
      Row() {

      }
      .width(600)
      .height(600)
      .backgroundColor("#fff")
      .position({
        bottom: 0,
        left: "50%"
      })
      .translate({
        x: "-50%",
        y: "70%"
      })
      .borderRadius("50%")

    }
    .width("100%")
    .height("100%")
    .backgroundColor("rgba(0,0,0,0.5)")
  }

  /**
   * 录音中的 动态声纹波浪
   */
  @Builder
  vocalPrint() {
    Canvas(this.context)
      .onDisAppear(() => {
        clearInterval(this.voiceTimeId)
      })
      .width('80%')
      .height('80%')
      .onReady(() => {
        //可以在这里绘制内容。
        clearInterval(this.voiceTimeId)
        this.voiceTimeId = setInterval(() => {
          this.context.clearRect(0, 0, 1000, 1000)
          for (let index = 0; index < 35; index++) {
            const random = Math.floor(Math.random() * 10)
            let height = 20 + random
            this.context.fillRect(0 + index * 5, 32 - height / 2, 2, height);
          }
        }, 100)
      })
  }
}

总结

一、清晰的枚举定义

代码中使用枚举类型WXInputTypeMessageType分别明确了当前输入状态(语音或文本)以及消息类型,使得代码的可读性和可维护性大大增强。这种方式可以避免使用魔法数字,让开发者更容易理解代码的意图。

二、面向对象的消息类设计

定义了ChatMessage类来表示消息,清晰地封装了消息的各种属性,如消息类型、内容、时间、持续时间、录音转文字结果以及是否显示转好的文字等。这种面向对象的设计方式使得消息的处理更加模块化,方便在不同的地方进行复用和管理。

三、丰富的交互处理

  1. 通过对触摸事件的处理,实现了按住说话的功能。在手指按下、移动和抬起时分别进行不同的状态判断和操作,包括判断是否碰到 “取消发送” 或 “转文字” 的区域,并根据不同状态进行相应的处理。
  2. 底部聊天发送框根据输入状态动态切换显示内容,当输入类型为文本时显示文本输入相关的组件,当为语音时显示按住说话的按钮等,为用户提供了灵活的输入方式选择。

四、强大的页面构建和布局

  1. 使用build方法构建页面结构,清晰地划分了顶部标题栏、聊天滚动容器和底部聊天发送框等部分,通过ColumnRow的组合以及各种属性设置,实现了美观且合理的页面布局。
  2. 在消息显示部分,通过chatTextBuilder方法构建文字消息的布局,包括时间显示、文本内容、背景颜色和图标等,使得消息展示更加清晰美观。
  3. talkContainerBuilder方法构建了按住说话时的页面布局,包括声纹显示、取消和转文字按钮以及底部白色大球等元素,为用户提供了直观的交互界面。

五、动态声纹效果实现

通过vocalPrint方法利用Canvas绘制动态声纹波浪,在录音过程中通过定时器不断更新画布内容,实现了生动的声纹效果,增强了用户体验。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-02-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 我写个HarmonyOS Next版本的微信聊天01
    • 前言
    • 案例目标
    • 实际效果
    • 案例功能
    • 新建项目
    • 修改项目桌面名称和图标
    • 设置沉浸式
      • 设置沉浸式和获取顶部状态栏高度
      • 页面使用导航栏高度设置padding
    • 搭建页面基本布局
    • 页面滚动和文字信息框
      • 亮点
    • 底部消息发送框
      • 显示输入框还是 "按住说话"
      • 显示 “发送” 按钮
      • 显示文本输入框自动获得焦点
    • 设置输入时 键盘避让
      • 解决方法
    • 发送文本消息
      • 定义消息类型枚举
      • 定义消息类
      • 定义消息数组
      • 定义发送文本消息的方法
      • 注册发送文本消息事件
      • 遍历消息数组
    • 按住说话
      • 定义是否正在说话的变量
      • 注册触摸事件 Touch
      • 定义 this.onPressTalk
      • 实现全屏遮罩效果
      • 定义this.talkContainerBuilder
    • 说话声纹
      • 配置 CanvasRenderingContext2D 对象的参数
      • 用来创建CanvasRenderingContext2D对象
      • 定义声纹自定义构建函数
      • 使用声纹构造函数
    • 发送信息-取消发送
      • 定义长按状态的枚举
      • 定义手指坐标类型
      • 定义长按状态
      • 定义 X 和 文的坐标状态
      • 实时获取 X 和 文 的坐标
      • 调整touch事件onPressTalk的逻辑
      • 添加 X 和 文字的 样式
      • 调整声纹容器的样式
    • 完整代码
    • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档