带你实现 HarmonyOS Next 微信聊天 01
带你实现 HarmonyOS Next 微信聊天 01
前言
代码会统一放在码云上,纯静态的完整代码会放在末尾
案例目标
这个是安卓手机上的真正的微信聊天界面功能效果
实际效果
案例功能
- 页面沉浸式
- 聊天内容滚动
- 输入框状态切换
- 聊天信息框宽度自适应
- 输入法避让
- 语音消息根据时长自动宽度
- canvas 声纹 按住说话
- 手势坐标检测取消发送-语音转文字
- 发送文字
- 录音-发送语音
- 声音播放-语音消息
- AI 语音转文字
新建项目
修改项目桌面名称和图标
entry\src\main\resources\zh_CN\element\string.json
-
{ "string": [ { "name": "module_desc", "value": "模块描述" }, { "name": "EntryAbility_desc", "value": "description" }, { "name": "EntryAbility_label", "value": "我的聊天项目" // 😄 } ] }
-
\entry\src\main\module.json5
-
... "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "description": "$string:EntryAbility_desc", "icon": "$media:chat",😄 ...
-
$media:chat 来自于 resource 下的名为 chat 的图标
设置沉浸式
- 图一为默认情况下的页面布局,可以看到我们的页面是无法触及到顶部状态栏和底部菜单栏的
- 图二为设置了沉浸式效果后,布局按钮可以触及到顶部状态栏了
- 图三为动态获取到了顶部状态栏的高度,然后给容器添加了相应的 padding,挤压布局元素到顶部状态栏的下方
设置沉浸式和获取顶部状态栏高度
\entry\src\main\ets\entryability\EntryAbility.ets
...
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
@Entry
@Component
struct Index {
@StorageProp("vpHeight")
topHeight: number = 0
build() {
Column() {
Button("按钮")
}
.width("100%")
.height("100%")
.backgroundColor(Color.Yellow)
.padding({
top: this.vpHeight,
})
}
}
搭建页面基本布局
@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
})
}
}
页面滚动和文字信息框
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%")
}
亮点
以下代码是实现上面,自适应宽度的关键
- 当文字较小时,绿色聊天框宽度自适应
- 当文字较多时,绿色聊天框宽度自动变宽,但是不会铺满一行,微信也是这样设计的
底部消息发送框
显示输入框还是 "按住说话"
可以看到,底部消息发送框起码有三种状态
- 按住说话
- 文本输入框
- 文本输入框 - 发送
程序中,通过枚举决定 按住说话-文本输入框两种状态
/**
* 当前输入状态 语音或者文本
*/
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;
}
}
定义消息数组
// 消息
@State
chatList: ChatMessage[] = []
定义发送文本消息的方法
// 发送文本消息
sendTextMessage = () => {
if (!this.textValue.trim()) {
return;
}
const chat = new ChatMessage(MessageType.text, this.textValue.trim());
this.chatList.push(chat);
this.textValue = "";
};
注册发送文本消息事件
Button("发送")
.backgroundColor("#08C060")
.type(ButtonType.Normal)
.fontColor("#fff")
.borderRadius(5)
.onClick(this.sendTextMessage);
遍历消息数组
// 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])
按住说话
定义是否正在说话的变量
// 按住说话 录音模态
@State
showTalkContainer: boolean = false
注册触摸事件 Touch
长按 按住说话时触发, Touch 事件是会持续触发的,通过判断 event.type 来获知 触摸状态
- down 按下
- move 移动
- up 松开
Button("按住说话")
.layoutWeight(1)
.type(ButtonType.Normal)
.borderRadius(5)
.backgroundColor("#fff")
.fontColor("#000")
.onTouch(this.onPressTalk);
定义 this.onPressTalk
// 按住说话 持续触发
onPressTalk = async (event: TouchEvent) => {
if (event.type === TouchType.Down) {
// 按下
this.showTalkContainer = true;
} else if (event.type === TouchType.Up) {
// 松开手
this.showTalkContainer = false;
}
};
实现全屏遮罩效果
该效果利用鸿蒙应用中的全模态实现 bindContentCover
给组件绑定全屏模态页面,点击后显示模态页面。模态页面内容自定义,显示方式可设置无动画过渡,上下切换过渡以及透明渐变过渡方式。
this.talkContainerBuilder 为全模态出现时对应的内容布局,它是一个自定义构建函数
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
// 正在说话 页面布局
@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)")
}
说话声纹
这个绿色容器中的波纹,是通过 canva 来描述的,真正的逻辑应该是监听或者获取当前声音音量的大小,然后根据它转换对应的波纹。但是没有在鸿蒙中直接找到 api,查阅资料发现需要自己分析音频文件数据,自己转化才可以,时间关系就没有继续往下实现。使用随机数简单模拟了下。
配置 CanvasRenderingContext2D 对象的参数
//用来配置 CanvasRenderingContext2D 对象的参数,包括是否开启抗锯齿,true表明开启抗锯齿。
settings: RenderingContextSettings = new RenderingContextSettings(true);
用来创建 CanvasRenderingContext2D 对象
//用来创建CanvasRenderingContext2D对象,通过在canvas中调用CanvasRenderingContext2D对象来绘制。
context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
定义声纹自定义构建函数
这里使用 canvas 画布技术,在onReady
生命周期函数中 通过开启一个定时器,在定时器中不断重复以下过程
- 通过clearRect 清空上一次描绘的波纹
- 通过 fillRect 随机描绘这一次的波纹
最后,如果此组件被销毁了,可以在 onDisAppear 中停止定时器
/**
* 录音中的 动态声纹波浪
*/
@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)
})
}
使用声纹自定义构造函数
发送信息-取消发送
这部分的 UI 交互相对来说比较复杂,当按住 按住说话 时:
- 手指移动到 X, 表示取消发送
- 手指移动到文,表示转换文字
- 手指直接松开时,发送录音
这部分功能的核心思想时,检测手指是否移动到了相应的元素,触发对应的业务逻辑即可。但是现实的问题是,找不到合适的事件,比如元素引入事件,所以后期采取的是检测手指在整个屏幕的坐标是否触及到了 X 和 文 来实现。
定义长按状态的枚举
- 没有长按
- 长按
- 长按-X
- 长按-文
enum PressCancelVoicePostText {
// 没有长按
none = 0,
// 长按 没有选中“取消发送”或者"转语音"
presssing = 1,
// 取消发送
cancelVoice = 2,
// 转文字
postText = 3,
}
定义手指坐标类型
/**
* 长按时,手指的坐标
*/
interface ScreenOffset {
x: number;
y: number;
width: number;
height: number;
}
定义长按状态
// 长按状态
@State
pressCancelVoicePostText: PressCancelVoicePostText = PressCancelVoicePostText.none
定义 X 和 文的坐标状态
// “x ”的坐标
xScreenOffset: ScreenOffset = {
x: 0,
y: 0,
width: 0,
height: 0,
};
TextScreenOffset: ScreenOffset = {
x: 0,
y: 0,
width: 0,
height: 0,
};
实时获取 X 和 文 的坐标
在组件中监听 onAppear 事件,根据组件的唯一标识id来获取坐标数据
X
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);
});
文
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 或者 文 , 然后设置对应的状态
// 按住说话 持续触发
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 和 文字的 样式
当 this.pressCancelVoicePostText
状态发生改变时,需要调整 对应的组件的样式
调整声纹容器的样式
- 如果当前正在录音,显示正常绿色的声纹
- 如果当前取消发送,显示取消红色的声纹
- 如果当前转换文字,显示绿色的空的内容-后期存放实时的语音转换的文字
// 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)
完整代码
/**
* 当前输入状态 语音或者文本
*/
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)
})
}
}
总结
一、清晰的枚举定义
代码中使用枚举类型WXInputType
和MessageType
分别明确了当前输入状态(语音或文本)以及消息类型,使得代码的可读性和可维护性大大增强。这种方式可以避免使用魔法数字,让开发者更容易理解代码的意图。
二、面向对象的消息类设计
定义了ChatMessage
类来表示消息,清晰地封装了消息的各种属性,如消息类型、内容、时间、持续时间、录音转文字结果以及是否显示转好的文字等。这种面向对象的设计方式使得消息的处理更加模块化,方便在不同的地方进行复用和管理。
三、丰富的交互处理
- 通过对触摸事件的处理,实现了按住说话的功能。在手指按下、移动和抬起时分别进行不同的状态判断和操作,包括判断是否碰到 “取消发送” 或 “转文字” 的区域,并根据不同状态进行相应的处理。
- 底部聊天发送框根据输入状态动态切换显示内容,当输入类型为文本时显示文本输入相关的组件,当为语音时显示按住说话的按钮等,为用户提供了灵活的输入方式选择。
四、强大的页面构建和布局
- 使用
build
方法构建页面结构,清晰地划分了顶部标题栏、聊天滚动容器和底部聊天发送框等部分,通过Column
和Row
的组合以及各种属性设置,实现了美观且合理的页面布局。 - 在消息显示部分,通过
chatTextBuilder
方法构建文字消息的布局,包括时间显示、文本内容、背景颜色和图标等,使得消息展示更加清晰美观。 talkContainerBuilder
方法构建了按住说话时的页面布局,包括声纹显示、取消和转文字按钮以及底部白色大球等元素,为用户提供了直观的交互界面。
五、动态声纹效果实现
通过vocalPrint
方法利用Canvas
绘制动态声纹波浪,在录音过程中通过定时器不断更新画布内容,实现了生动的声纹效果,增强了用户体验。