Flutter for OpenHarmony剧本杀组队App实战:聊天对话功能实现
首先导入必要的依赖包。聊天页面需要管理消息列表和输入框的状态,因此使用StatefulWidget。flutter/material.dart提供Material Design组件,get/get.dart提供路由导航和消息提示功能。GetX是一个轻量级的状态管理框架,它的snackbar和bottomSheet方法非常方便。接下来定义ChatPage类。由于聊天页面需要管理消息列表和输入框的状态

引言
聊天功能是组队应用中队友之间沟通的核心渠道,用户通过聊天确认游戏时间、讨论角色分配、分享游戏心得等。本篇文章将详细讲解如何实现一个功能完善的聊天页面,包括消息列表展示、消息发送和更多操作菜单。
聊天页面采用经典的即时通讯布局,消息列表占据主要区域,底部固定输入栏。自己发送的消息靠右显示,他人消息靠左显示,通过颜色和位置区分消息来源。这种设计模式在微信、WhatsApp等主流聊天应用中被广泛采用,用户已经非常熟悉。
功能需求分析
聊天页面的核心功能
- 消息列表展示:展示所有聊天消息,区分自己和他人的消息
- 消息发送:用户可以输入并发送消息
- 消息时间:显示每条消息的发送时间
- 发送者信息:显示他人消息的发送者名称和头像
- 更多操作:提供查看详情、查看成员、退出群聊等操作
- 输入框功能:支持多行输入、发送按钮、附加功能按钮
用户交互需求
- 用户可以查看聊天历史
- 用户可以快速发送消息
- 用户可以区分自己和他人的消息
- 用户可以访问更多操作菜单
- 用户可以查看发送者信息
设计原则
聊天页面的设计需要遵循以下原则:
- 消息区分明确:自己和他人的消息通过位置和颜色区分
- 时间显示合理:消息时间不应过于突兀,但要易于查看
- 输入便捷:输入框始终可见,发送操作简单
- 滚动流畅:消息列表滚动应该流畅,新消息自动滚动到底部
核心代码实现
第一部分:导入依赖与类定义
首先导入必要的依赖包。聊天页面需要管理消息列表和输入框的状态,因此使用StatefulWidget。
import 'package:flutter/material.dart';
import 'package:get/get.dart';
flutter/material.dart提供Material Design组件,get/get.dart提供路由导航和消息提示功能。GetX是一个轻量级的状态管理框架,它的snackbar和bottomSheet方法非常方便。
接下来定义ChatPage类。由于聊天页面需要管理消息列表和输入框的状态,我们使用StatefulWidget。页面接收chatId和chatName两个参数,用于标识当前聊天会话。
class ChatPage extends StatefulWidget {
final String chatId;
final String chatName;
ChatPage({
super.key,
required this.chatId,
required this.chatName,
});
State<ChatPage> createState() => _ChatPageState();
}
ChatPage使用StatefulWidget是因为需要管理消息列表的状态。当用户发送新消息时,需要更新列表并触发UI重建。chatId用于标识会话,在实际项目中会用于从服务器获取消息历史。chatName用于显示在AppBar标题中。
下面是_ChatPageState类的定义,包含了页面需要管理的所有状态和控制器。
class _ChatPageState extends State<ChatPage> {
final TextEditingController _controller = TextEditingController();
final ScrollController _scrollController = ScrollController();
_controller是TextEditingController,用于控制输入框的内容。通过它可以获取用户输入的文字,也可以清空输入框。_scrollController是ScrollController,用于控制消息列表的滚动。发送新消息后,需要自动滚动到底部显示最新消息。
这两个Controller在使用完毕后需要释放资源,否则会造成内存泄漏。我们会在dispose方法中处理这个问题。
下面定义消息数据列表。每条消息包含id、发送者、内容、是否是自己发送的标记、时间和头像等信息。
final List<Map<String, dynamic>> _messages = [
{
'id': '1',
'sender': '小明',
'content': '大家好,今晚7点准时开车!',
'isMe': false,
'time': '10:00',
'avatar': Icons.person,
},
{
'id': '2',
'sender': '我',
'content': '收到,准时到',
'isMe': true,
'time': '10:05',
'avatar': Icons.person,
},
{
'id': '3',
'sender': '玩家A',
'content': '我可能会迟到5分钟',
'isMe': false,
'time': '10:10',
'avatar': Icons.person,
},
{
'id': '4',
'sender': '小明',
'content': '没问题,等你',
'isMe': false,
'time': '10:12',
'avatar': Icons.person,
},
{
'id': '5',
'sender': '玩家B',
'content': '我已经在店里了,先点饮料',
'isMe': false,
'time': '10:15',
'avatar': Icons.person,
},
];
_messages列表存储聊天消息,使用Map<String, dynamic>类型。每条消息的字段说明:
- id:消息的唯一标识,用于数据操作
- sender:发送者名称,显示在消息上方
- content:消息内容
- isMe:是否是自己发送的消息,用于决定消息的显示样式
- time:发送时间
- avatar:发送者头像图标
isMe字段是关键,它决定了消息的对齐方式和颜色。自己的消息靠右显示、使用紫色背景,他人消息靠左显示、使用白色背景。
在实际项目中,应该定义一个Message模型类来替代Map,这样可以获得类型安全和更好的代码提示。消息数据应该从服务器获取,并支持分页加载历史消息。
第二部分:页面主体结构
build方法返回页面的整体结构。页面使用Scaffold作为基础布局,包含AppBar和body两个主要区域。
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.chatName),
centerTitle: true,
elevation: 0,
backgroundColor: const Color(0xFF6B4EFF),
foregroundColor: Colors.white,
actions: [
IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () => _showOptions(),
),
],
),
backgroundColor: const Color(0xFFF5F5F5),
AppBar标题显示聊天名称,使用widget.chatName访问父类的参数。centerTitle: true让标题居中,elevation设为0去除阴影,backgroundColor设为主题紫色。
actions数组放置更多操作按钮,点击时调用_showOptions方法显示底部菜单。这个菜单提供查看组队详情、查看成员、退出群聊等功能。
body: Column(
children: [
Expanded(
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(12),
itemCount: _messages.length,
itemBuilder: (context, index) => _buildMessage(_messages[index]),
),
),
_buildInputBar(),
],
),
);
}
页面主体使用Column垂直排列消息列表和输入栏。Expanded包裹ListView.builder,让消息列表占据除输入栏外的所有空间。
ListView.builder的controller属性绑定_scrollController,这样我们可以在发送新消息时控制列表滚动到底部。padding设置12像素的内边距,让消息不会贴边显示。itemCount指定消息数量,itemBuilder为每条消息构建对应的组件。
_buildInputBar()构建底部输入栏,它固定在页面底部,不会随消息列表滚动。
第三部分:消息项构建
消息项是聊天页面的核心组件,需要根据消息来源(自己或他人)显示不同的样式。自己的消息靠右显示、使用紫色背景,他人消息靠左显示、使用白色背景。
Widget _buildMessage(Map<String, dynamic> msg) {
final isMe = msg['isMe'] as bool;
return Container(
margin: const EdgeInsets.only(bottom: 12),
child: Row(
mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
首先从消息数据中获取isMe字段,判断是否是自己发送的消息。Container设置底部12像素的间距,让消息之间有适当的分隔。
Row的mainAxisAlignment根据isMe决定对齐方式:自己的消息使用end靠右对齐,他人消息使用start靠左对齐。crossAxisAlignment设为start,让头像与消息气泡顶部对齐。
if (!isMe)
CircleAvatar(
radius: 16,
backgroundColor: const Color(0xFF6B4EFF),
child: Icon(
msg['avatar'],
size: 16,
color: Colors.white,
),
),
if (!isMe) const SizedBox(width: 8),
如果不是自己的消息,左侧显示发送者头像。CircleAvatar创建32像素直径的圆形头像(radius为16),紫色背景配合白色图标。头像与消息气泡之间有8像素的间距。
自己的消息不显示头像,因为用户知道这是自己发送的,不需要额外的视觉提示。
Column(
crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
if (!isMe)
Text(
msg['sender'],
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
Column垂直排列发送者名称、消息气泡和时间。crossAxisAlignment根据isMe决定对齐方式,保持与Row的对齐一致。
如果不是自己的消息,显示发送者名称。名称使用12号灰色字体,作为次要信息显示在消息气泡上方。自己的消息不显示名称。
Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: isMe
? const Color(0xFF6B4EFF)
: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
),
],
),
消息气泡使用Container构建。constraints设置最大宽度为屏幕宽度的65%,这样长消息不会占据整行,保持界面美观。padding设置水平12像素、垂直8像素的内边距。
decoration配置气泡样式:自己的消息使用紫色背景,他人消息使用白色背景。borderRadius设为12创建圆角效果。boxShadow添加轻微阴影,让气泡有浮起的感觉。
child: Text(
msg['content'],
style: TextStyle(
color: isMe ? Colors.white : Colors.black87,
fontSize: 14,
),
),
),
const SizedBox(height: 4),
Text(
msg['time'],
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
),
),
],
),
],
),
);
}
消息内容使用Text显示,颜色根据isMe决定:自己的消息使用白色文字(在紫色背景上),他人消息使用黑色文字(在白色背景上)。字体大小为14,是正文的标准大小。
时间显示在消息气泡下方,使用11号灰色字体。时间与气泡之间有4像素的间距。
第四部分:输入栏构建
输入栏是用户发送消息的入口,固定在页面底部。它包含添加按钮、输入框和发送按钮三个部分。
Widget _buildInputBar() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
Container使用白色背景,boxShadow添加向上的阴影效果(offset设为(0, -2)),与消息列表区域形成视觉分隔。padding设置12像素的内边距。
child: SafeArea(
child: Row(
children: [
IconButton(
icon: const Icon(
Icons.add_circle_outline,
color: Color(0xFF6B4EFF),
),
onPressed: () => _showAttachmentOptions(),
),
SafeArea确保在有底部安全区域的设备上(如iPhone X及以后的机型)输入栏不会被遮挡。Row横向排列添加按钮、输入框和发送按钮。
添加按钮使用紫色的add_circle_outline图标,点击时调用_showAttachmentOptions方法。这个按钮可以扩展发送图片、位置、文件等功能。
Expanded(
child: TextField(
controller: _controller,
maxLines: null,
decoration: InputDecoration(
hintText: '输入消息...',
filled: true,
fillColor: Colors.grey[100],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(
Icons.send,
color: Color(0xFF6B4EFF),
),
onPressed: _sendMessage,
),
],
),
),
);
}
void _showAttachmentOptions() {
Get.snackbar('提示', '附加功能开发中');
}
输入框使用Expanded占据中间的所有空间。TextField的controller绑定_controller,maxLines设为null允许多行输入。
decoration配置输入框样式:hintText显示占位符"输入消息…",filled设为true启用填充背景,fillColor设为浅灰色。border使用圆角矩形无边框样式,borderRadius设为20创建胶囊形状。contentPadding设置内边距。
发送按钮使用紫色的send图标,点击时调用_sendMessage方法发送消息。
第五部分:消息发送与操作菜单
消息发送方法负责将用户输入的内容添加到消息列表,并自动滚动到底部显示最新消息。
void _sendMessage() {
if (_controller.text.trim().isEmpty) return;
setState(() {
_messages.add({
'id': DateTime.now().toString(),
'sender': '我',
'content': _controller.text,
'isMe': true,
'time': '刚刚',
'avatar': Icons.person,
});
_controller.clear();
});
首先检查输入内容是否为空,trim()去除首尾空格后判断。如果为空则直接返回,不发送消息。
setState触发UI更新。在回调中,将新消息添加到_messages列表。消息id使用DateTime.now()生成唯一标识,sender设为"我",content使用输入框的内容,isMe设为true,time显示为"刚刚"。添加完成后清空输入框。
在实际项目中,这里应该先调用API发送消息到服务器,成功后再更新本地列表。还应该处理发送失败的情况,显示重试按钮。
// 自动滚动到底部
Future.delayed(const Duration(milliseconds: 100), () {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
});
}
发送消息后自动滚动到底部,让用户能够立即看到自己发送的消息。使用Future.delayed延迟100毫秒执行,确保列表已经更新完成。
animateTo方法实现平滑滚动动画。maxScrollExtent是列表的最大滚动位置,即底部。duration设为300毫秒,curve使用easeOut让动画更自然。
下面是更多操作菜单的实现,使用底部弹出菜单展示各种操作选项。
void _showOptions() {
Get.bottomSheet(
Container(
padding: const EdgeInsets.all(20),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(16),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.info),
title: const Text('查看组队详情'),
onTap: () {
Get.back();
Get.snackbar('提示', '跳转到组队详情页面');
},
),
ListTile(
leading: const Icon(Icons.people),
title: const Text('查看成员'),
onTap: () {
Get.back();
Get.snackbar('提示', '跳转到成员列表页面');
},
),
ListTile(
leading: const Icon(Icons.exit_to_app, color: Colors.red),
title: const Text(
'退出群聊',
style: TextStyle(color: Colors.red),
),
onTap: () {
Get.back();
Get.snackbar('提示', '已退出群聊');
},
),
],
),
),
);
}
Get.bottomSheet显示底部弹出菜单,这是GetX提供的便捷方法。Container使用白色背景,顶部圆角16像素。Column垂直排列菜单项,mainAxisSize.min让菜单只占据必要的高度。
菜单包含三个选项:
- 查看组队详情:跳转到组队详情页面
- 查看成员:跳转到成员列表页面
- 退出群聊:退出当前群聊
退出群聊使用红色图标和文字,强调这是一个危险操作。每个选项点击后先调用Get.back()关闭菜单,然后执行相应的操作。
第六部分:资源释放
dispose方法在页面销毁时调用,用于释放Controller资源。这是使用Controller时必须要做的清理工作,否则会造成内存泄漏。
void dispose() {
_controller.dispose();
_scrollController.dispose();
super.dispose();
}
}
_controller.dispose()释放TextEditingController资源,_scrollController.dispose()释放ScrollController资源。最后调用super.dispose()完成父类的清理工作。
在Flutter中,任何使用了Controller的组件都应该在dispose方法中释放资源。这是一个良好的编程习惯,可以避免内存泄漏和其他潜在问题。
聊天页面的设计要点
1. 消息气泡设计
使用不同的颜色和位置区分自己和他人的消息,让对话双方一目了然。消息气泡的最大宽度限制确保了长消息的可读性,不会占据整行。
气泡的圆角设计让界面更加柔和友好。阴影效果让气泡有浮起的感觉,增加层次感。
2. 自动滚动
发送新消息后自动滚动到底部,让用户能够立即看到自己发送的消息。这是聊天应用的标准行为,用户已经形成了这种预期。
滚动动画使用easeOut曲线,让动画更加自然。延迟执行确保列表已经更新完成。
3. 发送者信息
他人消息显示发送者名称和头像,帮助用户识别消息来源。在群聊中这一点尤其重要,用户需要知道每条消息是谁发送的。
自己的消息不显示头像和名称,因为用户知道这是自己发送的,不需要额外的视觉提示。
4. 输入栏设计
输入栏固定在底部,支持多行输入,提供附加功能按钮和发送按钮。圆角输入框符合现代设计趋势,发送按钮使用图标而非文字节省空间。
SafeArea确保在有底部安全区域的设备上正确显示。
扩展功能建议
1. 消息撤回
允许用户在一定时间内撤回已发送的消息。这需要在消息项上添加长按菜单:
GestureDetector(
onLongPress: () {
if (msg['isMe'] && canRecall(msg['time'])) {
showRecallDialog(msg['id']);
}
},
child: _buildMessageBubble(msg),
)
2. 消息编辑
允许用户编辑已发送的消息。编辑后的消息应该显示"已编辑"标记。
3. 消息搜索
添加搜索功能,用户可以快速查找特定的消息。搜索结果应该高亮显示匹配的关键词。
4. 文件分享
支持发送图片、文件等附件。这需要使用image_picker等插件选择文件,然后上传到服务器。
void _pickImage() async {
final picker = ImagePicker();
final image = await picker.pickImage(source: ImageSource.gallery);
if (image != null) {
// 上传图片并发送消息
}
}
5. 消息已读状态
显示消息的已读/未读状态。这需要服务端支持已读回执功能。
Row(
children: [
Text(msg['time']),
if (msg['isMe'])
Icon(
msg['isRead'] ? Icons.done_all : Icons.done,
size: 14,
color: msg['isRead'] ? Colors.blue : Colors.grey,
),
],
)
性能优化建议
1. 消息分页加载
如果消息数量很多,应该实现分页加载,避免一次性加载所有消息:
ListView.builder(
itemCount: _messages.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return _buildLoadMoreButton();
}
return _buildMessage(_messages[index - 1]);
},
)
2. 图片缓存
如果消息包含图片,应该使用缓存机制避免重复加载:
CachedNetworkImage(
imageUrl: msg['imageUrl'],
placeholder: (context, url) => CircularProgressIndicator(),
errorWidget: (context, url, error) => Icon(Icons.error),
)
3. 消息本地存储
使用SQLite或Hive等本地数据库存储消息,实现离线查看和快速加载:
class MessageDatabase {
Future<void> saveMessage(Message msg) async {
// 保存到本地数据库
}
Future<List<Message>> getMessages(String chatId) async {
// 从本地数据库读取
}
}
总结
通过本篇文章的学习,我们完成了聊天对话功能的实现。这个功能为队友之间的沟通提供了便捷的渠道,是组队应用的重要组成部分。
聊天界面的设计遵循了即时通讯应用的标准模式,用户可以快速上手使用。消息气泡的颜色区分、自动滚动、发送者信息等细节设计都提升了用户体验。
代码结构清晰,每个功能模块都封装为独立的方法,便于维护和扩展。在实际项目中,可以根据需求添加消息撤回、文件分享、已读状态等功能。
下一篇文章我们将实现个人中心页面,敬请期待!
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)