Flutter QQ聊天项目(2):消息与联系人界面实现

 


这里在上一篇博客:Flutter QQ聊天项目(1):登录界面实现 的基础上,进一步扩展实现了包含消息列表界面联系人界面的主界面,在登录界面成功登录即可进入。先看下效果图:

Flutter_QQ_LoginW_B.gif


一、初步实现主界面

1.1 主界面(MainWidget.dart)

这里就初步实现了一个主界面框架,左侧是菜单按钮列表,中间是堆叠选择界面,右侧是还未实现的聊天界面效果图如下所示:

Flutter_QQ_Chat_B.png


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,消息项包括头像、名字、最后一条消息和时间。后续实现点击某个聊天项会跳转到聊天详情页面,显示更多信息。效果图如下所示:

Flutter_QQ_Chat_C.png


具体实现有以下几个步骤:

  • 设计数据结构:定义聊天消息的数据模型。

  • 模拟数据:使用模拟数据或从后端 API 获取数据,这里使用的模拟数据。

  • 构建 UI:使用 ListViewListView.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):

Flutter_QQ_Chat_D.png


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


posted @   fengMisaka  阅读(20)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
历史上的今天:
2021-03-04 OBJ格式模型详细介绍
2021-03-04 计算机图形学底层知识
点击右上角即可分享
微信分享提示