155.[HarmonyOS NEXT 实战案例十二 :List系列] 聊天消息列表 - 基础篇
[HarmonyOS NEXT 实战案例十二 :List系列] 聊天消息列表 - 基础篇
项目已开源,开源地址: https://gitcode.com/nutpi/HarmonyosNextCaseStudyTutorial , 欢迎fork & star
效果演示
1. 概述
聊天应用是移动设备上最常用的应用类型之一,而聊天消息列表是这类应用的核心组件。一个优秀的聊天消息列表需要支持多种消息类型(文本、图片、语音、文件等),并且能够清晰地区分自己和对方的消息。本教程将介绍如何使用HarmonyOS NEXT的ArkUI框架实现一个功能完善的聊天消息列表。
1.1 应用场景
- 即时通讯应用
- 社交媒体的私信功能
- 客服聊天系统
- 团队协作工具的聊天功能
2. 核心组件介绍
在实现聊天消息列表时,我们将使用以下HarmonyOS NEXT的核心组件:
- List & ListItem:用于创建垂直滚动的消息列表和列表项
- Row & Column:用于布局排列
- Image:用于显示头像和图片消息
- Text:用于显示文本消息和时间
- TextInput:用于输入消息
- Scroller:用于控制列表滚动
- LoadingProgress:用于显示消息发送状态
3. 数据模型设计
在开始实现UI之前,我们需要设计合适的数据模型来表示不同类型的消息和聊天对象。
3.1 聊天对象信息模型
interface ChatMessage {
name: string, // 聊天对象名称
avatar: Resource, // 聊天对象头像
isOnline: boolean, // 是否在线
lastSeen?: string // 最后在线时间
}
3.2 文件信息模型
interface FileInfo {
name: string, // 文件名
size: string, // 文件大小
type: string // 文件类型
}
3.3 位置信息模型
interface Location {
name: string, // 位置名称
address: string // 位置地址
}
3.4 消息模型
interface Message {
id: number; // 消息ID
sender: 'me' | 'other'; // 发送者(自己或对方)
type: 'text' | 'image' | 'voice' | 'file' | 'location'; // 消息类型
content: string; // 文本内容
media?: Resource; // 媒体资源(图片等)
duration?: number; // 语音消息时长(秒)
fileInfo?: FileInfo; // 文件信息
location?: Location; // 位置信息
time: string; // 发送时间
status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed'; // 消息状态
}
4. 实现步骤
4.1 创建组件结构
首先,我们创建聊天消息列表组件的基本结构:
@Component
export struct ChatMessageList {
// 聊天对象信息
private chatInfo: ChatMessage = {
name: '张三',
avatar: $r('app.media.big22'),
isOnline: true
}
// 消息数据
private messages: Message[] = [...] // 初始化数据
// 输入框内容
@State inputMessage: string = ''
// 是否显示更多输入选项
@State showMoreOptions: boolean = false
// 滚动控制器
private scroller: Scroller = new Scroller()
// 发送消息
sendMessage() {
// 实现发送消息逻辑
}
// 获取当前时间
getCurrentTime(): string {
// 实现获取当前时间逻辑
}
// 滚动到底部
scrollToBottom() {
// 实现滚动到底部逻辑
}
build() {
Column() {
// 聊天头部
// 消息列表
// 输入区域
}
.width('100%')
.height('100%')
}
}
4.2 实现不同类型消息的构建器
为了处理不同类型的消息,我们使用@Builder
装饰器创建专用的构建函数:
4.2.1 文本消息构建器
@Builder
TextMessage(message: Message) {
Text(message.content)
.fontSize(16)
.fontColor(message.sender === 'me' ? '#FFFFFF' : '#333333')
.padding(12)
.backgroundColor(message.sender === 'me' ? '#007AFF' : '#F0F0F0')
.borderRadius(message.sender === 'me' ? 16 : 16)
.borderRadius({
topLeft: message.sender === 'me' ? 16 : 4,
topRight: message.sender === 'me' ? 4 : 16,
bottomLeft: 16,
bottomRight: 16
})
}
4.2.2 图片消息构建器
@Builder
ImageMessage(message: Message) {
Image(message.media)
.width(200)
.height(150)
.objectFit(ImageFit.Cover)
.borderRadius(8)
}
4.2.3 语音消息构建器
@Builder
VoiceMessage(message: Message) {
Row() {
if (message.sender === 'other') {
Image($r('app.media.note_icon'))
.width(24)
.height(24)
.margin({ right: 8 })
}
Text(`${message.duration}''`)
.fontSize(16)
.fontColor(message.sender === 'me' ? '#FFFFFF' : '#333333')
if (message.sender === 'me') {
Image($r('app.media.01'))
.width(24)
.height(24)
.margin({ left: 8 })
}
}
.width(message?.duration??1 * 8 + 60) // 根据语音长度动态调整宽度
.height(40)
.padding({ left: 12, right: 12 })
.backgroundColor(message.sender === 'me' ? '#007AFF' : '#F0F0F0')
.borderRadius(20)
}
4.2.4 文件消息构建器
@Builder
FileMessage(message: Message) {
Row() {
Image($r('app.media.big20'))
.width(40)
.height(40)
Column() {
Text(message?.fileInfo?.name||'')
.fontSize(16)
.fontColor(message.sender === 'me' ? '#FFFFFF' : '#333333')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text(message?.fileInfo?.type||'')
.fontSize(14)
.fontColor(message.sender === 'me' ? 'rgba(255, 255, 255, 0.7)' : '#666666')
Text(message?.fileInfo?.size||'')
.fontSize(14)
.fontColor(message.sender === 'me' ? 'rgba(255, 255, 255, 0.7)' : '#666666')
.margin({ left: 8 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width(240)
.padding(12)
.backgroundColor(message.sender === 'me' ? '#007AFF' : '#F0F0F0')
.borderRadius(8)
}
4.2.5 位置消息构建器
@Builder
LocationMessage(message: Message) {
Column() {
// 位置图片(实际应用中应该是地图)
Image($r('app.media.map_icon2'))
.width(240)
.height(120)
.objectFit(ImageFit.Cover)
.borderRadius({ topLeft: 8, topRight: 8 })
// 位置信息
Column() {
Text(message?.location?.name || '')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(message.sender === 'me' ? '#FFFFFF' : '#333333')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(message?.location?.address ||'')
.fontSize(14)
.fontColor(message.sender === 'me' ? 'rgba(255, 255, 255, 0.7)' : '#666666')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(12)
}
.width(240)
.backgroundColor(message.sender === 'me' ? '#007AFF' : '#F0F0F0')
.borderRadius(8)
}
4.2.6 消息状态构建器
@Builder
MessageStatus(status: string) {
if (status === 'sending') {
LoadingProgress()
.width(16)
.height(16)
.color('#999999')
} else if (status === 'sent') {
Image($r('app.media.big18'))
.width(16)
.height(16)
.fillColor('#999999')
} else if (status === 'delivered') {
Image($r('app.media.big17'))
.width(16)
.height(16)
.fillColor('#999999')
} else if (status === 'read') {
Image($r('app.media.big16'))
.width(16)
.height(16)
.fillColor('#4FC3F7')
} else if (status === 'failed') {
Image($r('app.media.big13'))
.width(16)
.height(16)
.fillColor('#F44336')
}
}
4.3 实现聊天头部
// 聊天头部
Row() {
Image($r('app.media.big11'))
.width(24)
.height(24)
Image(this.chatInfo.avatar)
.width(40)
.height(40)
.borderRadius(20)
.margin({ left: 16 })
Column() {
Text(this.chatInfo.name)
.fontSize(18)
.fontWeight(FontWeight.Medium)
Row() {
if (this.chatInfo.isOnline) {
Text('在线')
.fontSize(14)
.fontColor('#4CAF50')
} else if (this.chatInfo.lastSeen) {
Text(`最后在线:${this.chatInfo.lastSeen}`)
.fontSize(14)
.fontColor('#999999')
}
}
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
Blank()
Image($r('app.media.big12'))
.width(24)
.height(24)
.margin({ right: 16 })
Image($r('app.media.big13'))
.width(24)
.height(24)
.margin({ right: 16 })
Image($r('app.media.big14'))
.width(24)
.height(24)
}
.width('100%')
.height(60)
.padding({ left: 16, right: 16 })
.backgroundColor('#FFFFFF')
.borderColor('#E5E5E5')
.borderWidth({ bottom: 1 })
4.4 实现消息列表
// 消息列表
List({ scroller: this.scroller }) {
ForEach(this.messages, (message:Message) => {
ListItem() {
Row() {
// 对方头像(仅在对方消息时显示)
if (message.sender === 'other') {
Image(this.chatInfo.avatar)
.width(40)
.height(40)
.borderRadius(20)
.margin({ right: 8 })
} else {
// 占位,保持对齐
Blank()
.layoutWeight(1)
}
// 消息内容
Column() {
// 根据消息类型显示不同内容
if (message.type === 'text') {
this.TextMessage(message)
} else if (message.type === 'image') {
this.ImageMessage(message)
} else if (message.type === 'voice') {
this.VoiceMessage(message)
} else if (message.type === 'file') {
this.FileMessage(message)
} else if (message.type === 'location') {
this.LocationMessage(message)
}
// 消息时间和状态
Row() {
Text(message.time)
.fontSize(12)
.fontColor('#999999')
if (message.sender === 'me') {
this.MessageStatus(message.status)
}
}
.width('100%')
.margin({ top: 4 })
.justifyContent(message.sender === 'me' ? FlexAlign.End : FlexAlign.Start)
}
.alignItems(message.sender === 'me' ? HorizontalAlign.End : HorizontalAlign.Start)
.layoutWeight(message.sender === 'me' ? 5 : 5)
// 自己头像(仅在自己消息时显示)
if (message.sender === 'me') {
Image($r('app.media.comment'))
.width(40)
.height(40)
.borderRadius(20)
.margin({ left: 8 })
} else {
// 占位,保持对齐
Blank()
.layoutWeight(1)
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.alignItems(VerticalAlign.Top)
.justifyContent(message.sender === 'me' ? FlexAlign.End : FlexAlign.Start)
}
})
}
.width('100%')
.layoutWeight(1)
.backgroundColor('#F5F5F5')
.onScrollIndex((start: number, end: number) => {
// 可以在这里处理滚动事件
})
4.5 实现输入区域
// 输入区域
Column() {
// 更多选项区域(可折叠)
if (this.showMoreOptions) {
Grid() {
// 相册
GridItem() {
Column() {
Image($r('app.media.big17'))
.width(48)
.height(48)
.borderRadius(24)
.backgroundColor('#EEEEEE')
.padding(12)
Text('相册')
.fontSize(12)
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Center)
}
// 拍照
GridItem() {
Column() {
Image($r('app.media.big12'))
.width(48)
.height(48)
.borderRadius(24)
.backgroundColor('#EEEEEE')
.padding(12)
Text('拍照')
.fontSize(12)
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Center)
}
// 文件
GridItem() {
Column() {
Image($r('app.media.Facebook_icon_03'))
.width(48)
.height(48)
.borderRadius(24)
.backgroundColor('#EEEEEE')
.padding(12)
Text('文件')
.fontSize(12)
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Center)
}
// 位置
GridItem() {
Column() {
Image($r('app.media.dcc_health_icon'))
.width(48)
.height(48)
.borderRadius(24)
.backgroundColor('#EEEEEE')
.padding(12)
Text('位置')
.fontSize(12)
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Center)
}
// 联系人
GridItem() {
Column() {
Image($r('app.media.note_icon'))
.width(48)
.height(48)
.borderRadius(24)
.backgroundColor('#EEEEEE')
.padding(12)
Text('联系人')
.fontSize(12)
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Center)
}
// 收藏
GridItem() {
Column() {
Image($r('app.media.music_icon'))
.width(48)
.height(48)
.borderRadius(24)
.backgroundColor('#EEEEEE')
.padding(12)
Text('收藏')
.fontSize(12)
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Center)
}
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsTemplate('1fr')
.columnsGap(16)
.rowsGap(16)
.padding(16)
.height(100)
.backgroundColor('#FFFFFF')
}
// 输入框和按钮
Row() {
// 更多选项按钮
Image($r('app.media.mobile_calculator_ap'))
.width(32)
.height(32)
.onClick(() => {
this.showMoreOptions = !this.showMoreOptions
})
// 输入框
TextInput({ text: this.inputMessage })
.width('100')
.height(40)
.backgroundColor('#F5F5F5')
.borderRadius(20)
.padding({ left: 16, right: 16 })
.margin({ left: 8, right: 8 })
.onChange((value: string) => {
this.inputMessage = value
})
// 语音按钮
Image($r('app.media.active_weather_icon'))
.width(32)
.height(32)
.margin({ right: 8 })
// 发送按钮(仅在有输入内容时显示)
if (this.inputMessage.trim() !== '') {
Button() {
Image($r('app.media.02'))
.width(24)
.height(24)
.fillColor('#FFFFFF')
}
.width(32)
.height(32)
.borderRadius(16)
.backgroundColor('#007AFF')
.onClick(() => {
this.sendMessage()
})
} else {
// 表情按钮
Image($r('app.media.02'))
.width(32)
.height(32)
}
}
.width('80%')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor('#FFFFFF')
.borderColor('#E5E5E5')
.borderWidth({ top: 1 })
}
4.6 实现发送消息功能
// 发送消息
sendMessage() {
if (this.inputMessage.trim() === '') {
return
}
// 添加新消息
this.messages.push({
id: this.messages.length + 1,
sender: 'me',
type: 'text',
content: this.inputMessage,
time: this.getCurrentTime(),
status: 'sending'
})
// 清空输入框
this.inputMessage = ''
// 模拟发送过程
setTimeout(() => {
// 更新最后一条消息状态为已发送
this.messages[this.messages.length - 1].status = 'sent'
// 滚动到底部
this.scrollToBottom()
}, 500)
// 模拟对方回复
setTimeout(() => {
this.messages.push({
id: this.messages.length + 1,
sender: 'other',
type: 'text',
content: '好的,我知道了!',
time: this.getCurrentTime(),
status: 'read'
})
// 滚动到底部
this.scrollToBottom()
}, 2000)
}
// 获取当前时间
getCurrentTime(): string {
const now = new Date()
const hours = now.getHours().toString().padStart(2, '0')
const minutes = now.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
// 滚动到底部
scrollToBottom() {
this.scroller.scrollToIndex(this.messages.length - 1)
}
aboutToAppear() {
// 组件出现时滚动到底部
setTimeout(() => {
this.scrollToBottom()
}, 100)
}
5. 技术要点分析
5.1 消息气泡的样式处理
在聊天应用中,区分自己和对方的消息是非常重要的。我们通过不同的颜色和气泡形状来实现这一点:
.backgroundColor(message.sender === 'me' ? '#007AFF' : '#F0F0F0')
.fontColor(message.sender === 'me' ? '#FFFFFF' : '#333333')
.borderRadius({
topLeft: message.sender === 'me' ? 16 : 4,
topRight: message.sender === 'me' ? 4 : 16,
bottomLeft: 16,
bottomRight: 16
})
这种设计使得自己的消息显示为蓝色背景、白色文字,右侧有一个尖角;而对方的消息显示为灰色背景、黑色文字,左侧有一个尖角。
5.2 消息列表的布局处理
聊天消息列表的布局需要考虑自己和对方消息的对齐方式:
.alignItems(message.sender === 'me' ? HorizontalAlign.End : HorizontalAlign.Start)
.justifyContent(message.sender === 'me' ? FlexAlign.End : FlexAlign.Start)
自己的消息靠右对齐,对方的消息靠左对齐,这样可以清晰地区分消息的发送者。
5.3 消息状态的显示
在聊天应用中,显示消息的发送状态是很重要的功能。我们通过不同的图标来表示不同的状态:
- 发送中:显示加载动画
- 已发送:显示单勾图标
- 已送达:显示双勾图标
- 已读:显示蓝色双勾图标
- 发送失败:显示红色感叹号图标
这样用户可以清楚地知道自己的消息是否成功发送和对方是否已读。
5.4 滚动控制
在聊天应用中,新消息发送后需要自动滚动到底部,这可以通过Scroller
控制器实现:
private scroller: Scroller = new Scroller()
scrollToBottom() {
this.scroller.scrollToIndex(this.messages.length - 1)
}
在发送新消息和接收新消息后,调用scrollToBottom()
方法,确保用户总是能看到最新的消息。
6. 常见问题与解决方案
6.1 消息列表性能问题
问题:当消息数量很多时,可能导致列表加载缓慢和滚动卡顿。
解决方案:
- 使用
LazyForEach
替代ForEach
,实现虚拟列表 - 设置合理的
cachedCount
值,控制缓存的列表项数量 - 考虑分页加载历史消息,而不是一次性加载所有消息
6.2 输入框高度自适应
问题:当输入内容较多时,单行输入框可能不够用。
解决方案:
- 使用
TextArea
替代TextInput
,支持多行输入 - 实现输入框高度自适应,根据内容自动调整高度
- 设置最大高度限制,避免输入框占据过多空间
6.3 消息发送失败处理
问题:网络不稳定时,消息可能发送失败。
解决方案:
- 实现消息发送失败的状态显示
- 提供重新发送的功能
- 实现本地消息缓存,确保用户数据不会丢失
7. 总结与扩展
在本教程中,我们学习了如何使用HarmonyOS NEXT的ArkUI框架实现一个功能完善的聊天消息列表,包括多种消息类型的展示、消息状态的显示、输入区域的实现等。
- 0回答
- 4粉丝
- 0关注
- 156.[HarmonyOS NEXT 实战案例十二 :List系列] 聊天消息列表 - 进阶篇
- 151.[HarmonyOS NEXT 实战案例十二:List系列] 卡片样式列表组件实战:打造精美电商应用 基础篇
- 147.[HarmonyOS NEXT 实战案例八 :List系列] 粘性头部列表基础篇
- 145.[HarmonyOS NEXT 实战案例七 :List系列] 可选择列表基础篇
- [HarmonyOS NEXT 实战案例六:List系列] 垂直列表组件实战:打造高效联系人列表 基础篇
- 153.[HarmonyOS NEXT 实战案例十一 :List系列] 自定义内容列表 - 基础篇
- 152.[HarmonyOS NEXT 实战案例十二:List系列] 卡片样式列表组件实战:打造精美电商应用 进阶篇
- 141.[HarmonyOS NEXT 实战案例九:List系列] 分组列表组件实战:打造分类设置菜单 基础篇
- 137.[HarmonyOS NEXT 实战案例七:List系列] 多列列表组件实战:打造精美应用推荐页 基础篇
- [HarmonyOS NEXT 实战案例:聊天应用] 基础篇 - 垂直分割布局构建聊天界面
- 148.[HarmonyOS NEXT 实战案例八 :List系列] 粘性头部列表进阶篇
- 139.[HarmonyOS NEXT 实战案例八:List系列] 滑动操作列表组件实战:打造高效待办事项应用 基础篇
- 143.[HarmonyOS NEXT 实战案例十:List系列] 字母索引列表组件实战:打造高效联系人应用 基础篇
- 135.[HarmonyOS NEXT 实战案例七:List系列] 水平列表组件实战:打造精美图片库 基础篇
- 146.[HarmonyOS NEXT 实战案例七 :List系列] 可选择列表进阶篇