Flutter QQ聊天项目(2):消息与联系人界面实现
这里在上一篇博客:Flutter QQ聊天项目(1):登录界面实现 的基础上,进一步扩展实现了包含消息列表界面和联系人界面的主界面,在登录界面成功登录即可进入。先看下效果图:
一、初步实现主界面
1.1 主界面(MainWidget.dart)
这里就初步实现了一个主界面框架,左侧是菜单按钮列表,中间是堆叠选择界面,右侧是还未实现的聊天界面。效果图如下所示:
MainWidget.dart
代码如下:
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../Util/Controller.dart';
import '../Widget/ContactWidget.dart';
import '../Widget/messageWidget.dart';
// ...(状态与控制器类重复,故省略)
// 主界面
class MainWidget extends StatelessWidget {
MainWidget({super.key});
final MainController ctrl = Get.find<MainController>(); // 主界面控制器
@override
Widget build(BuildContext context) {
return Positioned(
left: (globalCtrl.screenSize.width - ctrl.width) / 2,
top: (globalCtrl.screenSize.height - ctrl.height) / 2,
child: Obx(() => Transform.translate(
// 让部件在 x、y 轴上平移指定的距离
offset: ctrl.offset, // 平移距离
child: GestureDetector(
// 手势识别组件,以让鼠标移动到该组件上时光标为"选中样式"
behavior: HitTestBehavior.opaque,
child: Visibility(
// 是一个用于根据布尔值条件显示或隐藏小部件的控件
visible: !ctrl.isHidden(), // 控制是否显示
maintainState: true,
child: Container(
width: ctrl.width,
height: ctrl.height,
padding: const EdgeInsets.symmetric(horizontal: 0.0),
decoration: BoxDecoration(
color: Color.fromARGB(255, 245, 245, 245),
border: Border.all(width: 2.0, color: Color.fromARGB(255, 242, 242, 242)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.start, // 确保子部件从顶部开始排列
crossAxisAlignment: CrossAxisAlignment.start, // 确保子部件从左侧开始排列
children: [
// 菜单按钮列表界面
MenuBtnList(),
// 堆叠选择界面
StackSelWidget(),
],
),
)),
// 按压拖动回调,以支持鼠标移动界面
onPanUpdate: (details) {
ctrl.offset += details.delta;
}
)
))
);
}
}
// 菜单按钮列表界面
class MenuBtnList extends StatefulWidget {
const MenuBtnList({super.key});
@override
// ignore: library_private_types_in_public_api
_MenuBtnListState createState() => _MenuBtnListState();
}
// 菜单按钮列表界面实现
class _MenuBtnListState extends State<MenuBtnList> {
// ...(详细代码在下面,这里省略)
}
// 堆叠选择界面
class StackSelWidget extends StatelessWidget {
// ...(详细代码在下面,这里省略)
}
// 提示图标按钮实现
class CustomTipBtn extends StatelessWidget {
// ...(详细代码在下面,这里省略)
}
1.2 菜单按钮列表界面(左侧)
代码如下:
// 菜单按钮列表界面
class MenuBtnList extends StatefulWidget {
const MenuBtnList({super.key});
@override
// ignore: library_private_types_in_public_api
_MenuBtnListState createState() => _MenuBtnListState();
}
// 菜单按钮列表界面实现
class _MenuBtnListState extends State<MenuBtnList> {
// 控制器定义
final MainController loginCtrl = Get.find<MainController>();
final ContactController contactCtrl = Get.find<ContactController>();
final MessageController messageCtrl = Get.find<MessageController>();
int selectedIndex = 0; // 当前选中的按钮索引,以实现互斥按钮组
@override
void initState() {
super.initState();
// 默认选中第一个选项并执行其点击函数
selectedIndex = 0;
messageCtrl.show();
contactCtrl.hide();
}
@override
Widget build(BuildContext context) {
return Container(
height: loginCtrl.height,
color: Colors.transparent,
padding: EdgeInsets.all(8.0), // 容器内部的间距为0(如果有的话)
child: Column(
mainAxisAlignment: MainAxisAlignment.start, // 确保子部件从顶部开始排列
crossAxisAlignment: CrossAxisAlignment.end, // 确保子部件从左侧开始排列
children: <Widget>[
// 距离上一个View距离
const SizedBox(height: 6),
// QQ名称
Text(
'QQ ',
style: TextStyle(
color: Colors.black,
fontSize: 16,
),
),
// 距离上一个View距离
const SizedBox(height: 12),
// 登录用户头像
ClipOval(
child: Image.asset(
"assets/Contact/head.png",
width: 40,
height: 40,
),
),
// 距离上一个View距离
const SizedBox(height: 12),
/*** 自定义逻辑来实现互斥按钮组 ***/
// 消息按钮
CustomTipBtn(
text: '消息',
icon: Icon(
Icons.messenger,
color: selectedIndex == 0 ? Colors.blue : Colors.grey,
),
onPressed: () {
setState(() {
selectedIndex = 0;
// 控制界面显示
messageCtrl.show();
contactCtrl.hide();
});
},
),
// 距离上一个View距离
const SizedBox(height: 12),
// 联系人按钮
CustomTipBtn(
text: '联系人',
icon: Icon(
Icons.person_3,
color: selectedIndex == 1 ? Colors.blue : Colors.grey,
),
onPressed: () {
setState(() {
selectedIndex = 1;
// 控制界面显示
messageCtrl.hide();
contactCtrl.show();
});
},
),
// 距离上一个View距离
const SizedBox(height: 12),
// 收藏按钮
CustomTipBtn(
text: '收藏',
icon: Icon(
Icons.favorite,
color: selectedIndex == 2 ? Colors.blue : Colors.grey,
),
onPressed: () {
setState(() {
selectedIndex = 2;
// 控制界面显示
messageCtrl.hide();
contactCtrl.hide();
});
},
),
// 距离上一个View距离
const SizedBox(height: 12),
// 设置按钮
CustomTipBtn(
text: '设置',
icon: Icon(
Icons.settings_applications,
color: selectedIndex == 3 ? Colors.blue : Colors.grey,
),
onPressed: () {
setState(() {
selectedIndex = 3;
// 控制界面显示
messageCtrl.hide();
contactCtrl.hide();
});
},
),
// 距离上一个View距离
const SizedBox(height: 12),
],
),
);
}
}
- 在鼠标悬浮到左侧的四个
IconButton
上时会显示提示文本,是使用Tooltip
组件包裹IconButton
实现的,为了代码的简洁性,专门实现了个提示图标按钮类CustomTipBtn
。 - 使用
selectedIndex
这个当前选中的按钮索引,以实现互斥按钮组。
1.3 堆叠选择界面(中间)
代码如下:
// 堆叠选择界面
class StackSelWidget extends StatelessWidget {
StackSelWidget({super.key});
// 控制器定义
final MainController ctrl = Get.find<MainController>();
// 各界面对象创建
final MessageWidget messageWidget = MessageWidget();
final ContactWidget contactWidget = ContactWidget();
@override
Widget build(BuildContext context) {
return Container(
width: 250,
height: ctrl.height,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(0),
),
child: Stack(
// 重叠存放多个子界面在同一个Stack界面中,根据左边按钮来选择哪个界面显示出来,其它隐藏
alignment: Alignment.topLeft,
children: <Widget>[
messageWidget, // 聊天消息界面
contactWidget, // 联系人界面
],
),
);
}
}
重叠存放多个子界面在同一个 Stack
界面中,根据左边按钮来选择哪个界面显示出来,其它隐藏。
二、实现最近聊天消息列表界面
最近聊天消息列表 MessageWidget.dart
,消息项包括头像、名字、最后一条消息和时间。后续实现点击某个聊天项会跳转到聊天详情页面,显示更多信息。效果图如下所示:
具体实现有以下几个步骤:
-
设计数据结构:定义聊天消息的数据模型。
-
模拟数据:使用模拟数据或从后端 API 获取数据,这里使用的模拟数据。
-
构建 UI:使用
ListView
或ListView.builder
显示聊天列表。 -
实现交互:列表项支持悬浮高亮和右键菜单。
2.1 定义数据模型
首先,定义一个 Message
类来表示每条聊天消息:
// 聊天消息类定义
class Message {
final String id; // 索引id
final String name; // 联系人名称
final String lastMessage; // 最近消息
final String image; // 头像
final DateTime timestamp; // 时间
Message({
required this.id,
required this.name,
required this.lastMessage,
required this.image,
required this.timestamp,
});
}
2.2 模拟数据
创建一个模拟数据列表,用于显示聊天消息:
// 聊天消息数据源
final List<Message> messageList = [
Message(
id: '1',
name: '李达',
lastMessage: '好的,有空再聚',
image: 'assets/Contact/8.png',
timestamp: DateTime.now().subtract(Duration(minutes: 10)),
),
Message(
id: '2',
name: '王虎',
lastMessage: '拜拜,下次一起玩',
image: 'assets/Contact/6.png',
timestamp: DateTime.now().subtract(Duration(hours: 1)),
),
Message(
id: '3',
name: '明兰',
lastMessage: '有人在玩吗',
image: 'assets/Contact/1.png',
timestamp: DateTime.now().subtract(Duration(hours: 3))),
Message(
id: '4',
name: '李思思',
lastMessage: '没什么想玩的',
image: 'assets/Contact/3.png',
timestamp: DateTime.now().subtract(Duration(hours: 5))),
Message(
id: '5',
name: '武无敌',
lastMessage: '拜拜,晚安',
image: 'assets/Contact/9.png',
timestamp: DateTime.now().subtract(Duration(hours: 14))),
Message(
id: '6',
name: '郑航',
lastMessage: '芜湖,起飞',
image: 'assets/Contact/10.png',
timestamp: DateTime.now().subtract(Duration(days: 1)),
),
Message(
id: '7',
name: '贺强',
lastMessage: '有空一起钓鱼啊',
image: 'assets/Contact/7.png',
timestamp: DateTime.now().subtract(Duration(days: 2)),
),
Message(
id: '8',
name: '美琴',
lastMessage: '嗯嗯,你也晚安',
image: 'assets/Contact/11.png',
timestamp: DateTime.now().subtract(Duration(days: 4)),
),
Message(
id: '9',
name: '静静',
lastMessage: '六六六',
image: 'assets/Contact/2.png',
timestamp: DateTime.now().subtract(Duration(days: 4)),
),
];
2.3 构建聊天列表 UI
使用 ListView.builder
构建聊天列表:
// 聊天消息界面
class MessageWidget extends StatefulWidget {
const MessageWidget({super.key});
@override
// ignore: library_private_types_in_public_api
_MessageWidgetState createState() => _MessageWidgetState();
}
// 聊天消息界面
class _MessageWidgetState extends State<MessageWidget> {
final MessageController ctrl = Get.find<MessageController>(); // 聊天消息界面控制器
// 删除项目的方法
void _deleteItem(int index) {
// 下方弹出提示
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已删除"${messageList[index].name}"的聊天消息')),
);
setState(() {
messageList.removeAt(index); // 从数据源中删除项目
});
}
@override
Widget build(BuildContext context) {
return Obx(() => GestureDetector(
// 必须使用Obx才能正常显示下面界面
// 手势识别组件,以让鼠标移动到该组件上时光标为"选中样式"
behavior: HitTestBehavior.opaque,
child: Visibility(
// 是一个用于根据布尔值条件显示或隐藏小部件的控件
visible: !ctrl.isHidden(), // 控制是否显示
maintainState: true,
child: Container(
width: ctrl.width,
height: ctrl.height,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(0),
),
child: ListView.builder(
itemCount: messageList.length,
itemBuilder: (context, index) {
final message = messageList[index];
return HoverListTile(
image: message.image, // 头像
title: message.name, // 名称
lastMessage: message.lastMessage, // 最近聊天消息
timestamp: message.timestamp, // 时间
onDelete: () {
// 在这里处理删除逻辑
debugPrint('删除项目 $index');
_deleteItem(index);
},
);
},
),
))));
}
}
2.4 列表项支持悬浮高亮和右键菜单
代码如下:
// 自定义悬浮高亮的ListTile
class HoverListTile extends StatefulWidget {
final String image;
final String title;
final String lastMessage;
final DateTime timestamp;
final VoidCallback onDelete;
// ignore: use_key_in_widget_constructors
const HoverListTile(
{required this.image,
required this.title,
required this.lastMessage,
required this.timestamp,
required this.onDelete});
@override
// ignore: library_private_types_in_public_api
_HoverListTileState createState() => _HoverListTileState();
}
// 自定义悬浮高亮的ListTile(使用 InkWell 实现)
class _HoverListTileState extends State<HoverListTile> {
bool isHovered = false; // 鼠标是否悬浮的状态变量
@override
Widget build(BuildContext context) {
return MouseRegion(
// 移入事件
onEnter: (_) {
setState(() {
isHovered = true;
});
},
// 移出事件
onExit: (_) {
setState(() {
isHovered = false;
});
},
child: GestureDetector(
onTap: () {
debugPrint('${widget.title} tapped');
},
onSecondaryTapDown: (details) {
// 右键点击事件
_showCustomMenu(context, details.globalPosition);
},
child: Container(
color: isHovered ? Color.fromRGBO(245, 245, 245, 1.0) : Colors.transparent, // 通过 isHovered 状态变量来控制是否显示高亮效果
child: ListTile(
leading: CircleAvatar(backgroundImage: AssetImage(widget.image),), // 左侧头像图标
title: Text(widget.title),
subtitle: Text(widget.lastMessage),
trailing: Text('${widget.timestamp.hour}:${widget.timestamp.minute}', style: TextStyle(color: Colors.grey), // 右侧时间文本
),
),
),
),
);
}
// 显示自定义右键菜单的函数
void _showCustomMenu(BuildContext context, Offset position) {
// 设置右键菜单弹出的位置
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final RelativeRect positionRelativeToOverlay = RelativeRect.fromRect(
Rect.fromPoints(position, position),
Offset.zero & overlay.size,
);
// 右键菜单列表
showMenu(
context: context,
position: positionRelativeToOverlay,
items: [
_buildMenuItem(context, '复制QQ号', Icons.copy, () {
debugPrint('复制QQ号');
}),
_buildMenuItem(context, '从消息列表中移除', Icons.delete, () {
debugPrint('从消息列表中移除');
widget.onDelete();
}),
_buildMenuItem(context, '屏蔽此人消息', Icons.disabled_visible, () {
debugPrint('屏蔽此人消息');
}),
],
elevation: 8, // 菜单阴影
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
) // 菜单圆角
);
}
// 自定义菜单列表项
PopupMenuItem _buildMenuItem(
BuildContext context, String text, IconData icon, VoidCallback onTap) {
return PopupMenuItem(
onTap: onTap,
child: Row(
children: [
Icon(icon, color: Colors.grey), // 图标
SizedBox(width: 10),
Text(text, style: TextStyle(fontSize: 14)), // 文字
],
),
);
}
}
-
悬浮高亮:使用
MouseRegion
监听鼠标的进入 (onEnter
) 和离开 (onExit
) 事件,通过isHovered
状态变量来控制是否显示高亮效果。 -
右键菜单:
GestureDetector
监听右键点击事件 (onSecondaryTapDown
),获取点击的位置 (details.globalPosition
);使用showMenu
方法显示自定义菜单。
三、实现联系人界面
效果图如下所示(ContactWidget.dart
):
3.1 定义数据模型
为了更清晰地管理数据,需要自定义数据类。代码如下:
// 在线状态
enum OnlineState { Online, Leave, Busy, DoNot }
// 联系人项
class ContactItem {
final String title;
bool isExpand;
final List<ContactChildItem> children;
ContactItem({required this.title, required this.isExpand, required this.children});
}
// 联系人子项
class ContactChildItem {
final String image;
final String text;
final OnlineState state;
ContactChildItem({required this.image, required this.text, required this.state});
}
3.2 模拟数据
代码如下:
// 联系人数据源
final List<ContactItem> items = [
ContactItem(
title: '我的好友',
isExpand: false,
children: [
ContactChildItem(
image: 'assets/Contact/1.png',
text: '明兰',
state: OnlineState.Online
),
ContactChildItem(
image: 'assets/Contact/2.png',
text: '静静',
state: OnlineState.Leave
),
ContactChildItem(
image: 'assets/Contact/3.png',
text: '李思思',
state: OnlineState.Leave
),
],
),
ContactItem(
title: '家人',
isExpand: false,
children: [
ContactChildItem(
image: 'assets/Contact/4.png',
text: '东东',
state: OnlineState.Online
),
ContactChildItem(
image: 'assets/Contact/5.png',
text: '胖胖',
state: OnlineState.Leave
),
],
),
ContactItem(
title: '同学',
isExpand: false,
children: [
ContactChildItem(
image: 'assets/Contact/6.png',
text: '王虎',
state: OnlineState.Online
),
ContactChildItem(
image: 'assets/Contact/7.png',
text: '贺强',
state: OnlineState.Leave
),
],
),
ContactItem(
title: '同事',
isExpand: false,
children: [
ContactChildItem(
image: 'assets/Contact/8.png',
text: '李达',
state: OnlineState.Online
),
ContactChildItem(
image: 'assets/Contact/9.png',
text: '武无敌',
state: OnlineState.Leave
),
],
),
ContactItem(
title: '陌生人',
isExpand: false,
children: [
ContactChildItem(
image: 'assets/Contact/10.png',
text: '郑航',
state: OnlineState.Online
),
ContactChildItem(
image: 'assets/Contact/11.png',
text: '美琴',
state: OnlineState.Leave
),
],
),
];
3.3 构建联系人列表 UI
代码如下:
// 联系人列表界面
class ContactWidget extends StatefulWidget {
const ContactWidget({super.key});
@override
// ignore: library_private_types_in_public_api
_ContactWidgetState createState() => _ContactWidgetState();
}
// 联系人列表界面
class _ContactWidgetState extends State<ContactWidget> {
final ContactController ctrl = Get.find<ContactController>(); // 登录界面控制器
@override
Widget build(BuildContext context) {
return Obx(() => GestureDetector(
// 必须使用Obx才能正常显示下面界面
// 手势识别组件,以让鼠标移动到该组件上时光标为"选中样式"
behavior: HitTestBehavior.opaque,
child: Visibility(
// 是一个用于根据布尔值条件显示或隐藏小部件的控件
visible: !ctrl.isHidden(), // 控制是否显示
maintainState: true,
child: Container(
width: ctrl.width,
height: ctrl.height,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(0),
),
child: ListView.builder(
// 使用 ListView.builder 可以高效地批量生成 ExpansionTile 列表
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ExpansionTile(
backgroundColor: Colors.white,
tilePadding: EdgeInsets.only(left: 10, right: 20),
shape: Border(
top: BorderSide(color: Colors.transparent),
bottom: BorderSide(color: Colors.transparent)),
dense: true,
minTileHeight: 40,
leading: Icon(
item.isExpand
? Icons.arrow_drop_down
: Icons.arrow_right, // 左侧动态图标
),
title: Text(
// 联系人组名称
item.title,
style: TextStyle(
color: Colors.black,
fontSize: 14,
),
),
trailing: SizedBox.shrink(), // 隐藏右侧的默认图标
onExpansionChanged: (bool expanded) {
setState(() {
item.isExpand = expanded;
});
},
children: item.children
.map<Widget>((child) => ListTile(
contentPadding: EdgeInsets.only(left: 10),
hoverColor: Color.fromRGBO(245, 245, 245, 1.0),
dense: true,
minTileHeight: 40,
leading: CircleAvatar(
backgroundImage: AssetImage(
child.image)), // 联系人头像(左侧圆形图片)
title: Text(
// 联系人名称
child.text,
style: TextStyle(
color: Colors.black,
fontSize: 14,
),
),
subtitle: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Image(
// 状态图标
image: AssetImage(
child.state == OnlineState.Online
? 'assets/Contact/icon_state_online.png'
: 'assets/Contact/icon_state_leave.png',
),
width: 12,
height: 12,
fit: BoxFit.fill,
),
const SizedBox(width: 4),
const Text(
// 状态文本
"在线",
style: TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
]),
onTap: () {},
))
.toList(),
);
},
)
)
)
)
);
}
}
- 使用
ListView.builder
可以高效地批量生成ExpansionTile
列表。 - 通过自定义数据类可以更好地管理复杂的数据结构。
- 每个
ExpansionTile
可以显示图像和文本,子项也可以自定义布局。
四、代码下载
程序下载:Flutter_Demo/qq_chat-2 at main · confidentFeng/Flutter_Demo · GitHub
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
2021-03-04 OBJ格式模型详细介绍
2021-03-04 计算机图形学底层知识