这里在上一篇博客: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/ChatWidget.dart';// ...(状态与控制器类重复,故省略)// 主界面
class MainWidget extends StatelessWidget {MainWidget({super.key});final MainController ctrl = Get.find<MainController>(); // 主界面控制器// 各界面对象创建final ContactWidget contactWidget = ContactWidget();final ChatWidget chatWidget = ChatWidget();@overrideWidget 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 ChatController chatCtrl = Get.find<ChatController>(); // 联系人列表界面控制器int selectedIndex = 0; // 当前选中的按钮索引,以实现互斥按钮组@overridevoid initState() {super.initState();// 默认选中第一个选项并执行其点击函数selectedIndex = 0;chatCtrl.show();contactCtrl.hide();}@overrideWidget 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;// 控制界面显示chatCtrl.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;// 控制界面显示chatCtrl.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;// 控制界面显示chatCtrl.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;// 控制界面显示chatCtrl.hide();contactCtrl.hide();});},),// 距离上一个View距离const SizedBox(height: 12),],),);}
}// 提示图标按钮实现
class CustomTipBtn extends StatelessWidget {final String text;final Widget icon;final VoidCallback? onPressed;const CustomTipBtn({super.key, required this.text, required this.icon, required this.onPressed});@overrideWidget build(BuildContext context) {return Tooltip(message: text,decoration: BoxDecoration(color: Colors.white, // 背景色borderRadius: BorderRadius.circular(5), // 圆角),textStyle: TextStyle(color: Colors.grey, // 文字颜色fontSize: 14, // 文字大小),child: IconButton(icon: icon,onPressed: onPressed,),);}
}
- 在鼠标悬浮到左侧的四个
IconButton
上时会显示提示文本,是使用Tooltip
组件包裹IconButton
实现的,为了代码的简洁性,专门实现了个提示图标按钮类CustomTipBtn
。 - 使用
selectedIndex
这个当前选中的按钮索引,以实现互斥按钮组。
1.3 堆叠选择界面(中间)
代码如下:
// 堆叠选择界面
class StackSelWidget extends StatelessWidget {StackSelWidget({super.key});// 控制器定义final MainController ctrl = Get.find<MainController>();// 各界面对象创建final ChatWidget chatWidget = ChatWidget(); final ContactWidget contactWidget = ContactWidget();@overrideWidget 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>[chatWidget, // 聊天消息界面 contactWidget, // 联系人界面],),);}
}
重叠存放多个子界面在同一个 Stack
界面中,根据左边按钮来选择哪个界面显示出来,其它隐藏。
二、实现最近聊天消息列表界面
最近聊天消息列表 ChatWidget.dart
,消息项包括头像、名字、最后一条消息和时间。后续实现点击某个聊天项会跳转到聊天详情页面,显示更多信息。效果图如下所示:
具体实现有以下几个步骤:
-
设计数据结构:定义聊天消息的数据模型。
-
模拟数据:使用模拟数据或从后端 API 获取数据,这里使用的模拟数据。
-
构建 UI:使用
ListView
或ListView.builder
显示聊天列表。 -
实现交互:列表项支持悬浮高亮和右键菜单。
2.1 定义数据模型
首先,定义一个 Chat
类来表示每条聊天消息:
// 聊天消息类定义
class Chat {final String id; // 索引idfinal String name; // 联系人名称final String lastMessage; // 最近消息final String image; // 头像final DateTime timestamp; // 时间Chat({required this.id,required this.name,required this.lastMessage,required this.image,required this.timestamp,});
}
2.2 模拟数据
创建一个模拟数据列表,用于显示聊天消息:
// 聊天消息数据源
final List<Chat> chatList = [Chat(id: '1',name: '李达',lastMessage: '好的,有空再聚',image: 'assets/Contact/8.png',timestamp: DateTime.now().subtract(Duration(minutes: 10)),),Chat(id: '2',name: '王虎',lastMessage: '拜拜,下次一起玩',image: 'assets/Contact/6.png',timestamp: DateTime.now().subtract(Duration(hours: 1)),),Chat(id: '3',name: '明兰',lastMessage: '有人在玩吗',image: 'assets/Contact/1.png',timestamp: DateTime.now().subtract(Duration(hours: 3))),Chat(id: '4',name: '李思思',lastMessage: '没什么想玩的',image: 'assets/Contact/3.png',timestamp: DateTime.now().subtract(Duration(hours: 5))),Chat(id: '5',name: '武无敌',lastMessage: '拜拜,晚安',image: 'assets/Contact/9.png',timestamp: DateTime.now().subtract(Duration(hours: 14))),Chat(id: '6',name: '郑航',lastMessage: '芜湖,起飞',image: 'assets/Contact/10.png',timestamp: DateTime.now().subtract(Duration(days: 1)),),Chat(id: '7',name: '贺强',lastMessage: '有空一起钓鱼啊',image: 'assets/Contact/7.png',timestamp: DateTime.now().subtract(Duration(days: 2)),),Chat(id: '8',name: '美琴',lastMessage: '嗯嗯,你也晚安',image: 'assets/Contact/11.png',timestamp: DateTime.now().subtract(Duration(days: 4)),),Chat(id: '9',name: '静静',lastMessage: '六六六',image: 'assets/Contact/2.png',timestamp: DateTime.now().subtract(Duration(days: 4)),),
];
2.3 构建聊天列表 UI
使用 ListView.builder
构建聊天列表:
// 聊天消息界面
class ChatWidget extends StatefulWidget {const ChatWidget({super.key});@override// ignore: library_private_types_in_public_api_ContactWidgetState createState() => _ContactWidgetState();
}// 聊天消息界面
class _ContactWidgetState extends State<ChatWidget> {final ChatController ctrl = Get.find<ChatController>(); // 聊天消息界面控制器// 删除项目的方法void _deleteItem(int index) {// 下方弹出提示ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已删除"${chatList[index].name}"的聊天消息')),);setState(() {chatList.removeAt(index); // 从数据源中删除项目});}@overrideWidget 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: chatList.length,itemBuilder: (context, index) {final chat = chatList[index];return HoverListTile(image: chat.image, // 头像title: chat.name, // 名称lastMessage: chat.lastMessage, // 最近聊天消息timestamp: chat.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_constructorsconst 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; // 鼠标是否悬浮的状态变量@overrideWidget 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) {// 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>(); // 登录界面控制器@overrideWidget 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