Flutter for OpenHarmony 剧本杀组队App实战10:我的收藏功能实现
本文介绍了实现收藏列表页面的设计方案,主要包括剧本和店铺收藏功能。页面采用Tab切换布局,分为剧本收藏和店铺收藏两个部分,每个收藏项显示名称、评分等关键信息,支持点击跳转详情和取消收藏操作。当无收藏内容时,会展示友好的空状态提示。通过TabController管理切换状态,使用Scaffold构建页面基础结构,确保符合Material Design规范,提供良好的用户体验。
引言
收藏功能是提升用户体验的重要功能,让用户可以快速找到感兴趣的剧本和店铺。本篇将实现带有Tab切换的收藏列表页面,包括剧本收藏、店铺收藏、取消收藏等功能。
功能需求分析
收藏页面的核心功能
- Tab切换:分别展示剧本收藏和店铺收藏
- 收藏列表:展示用户收藏的剧本和店铺
- 取消收藏:用户可以取消已收藏的项目
- 详情跳转:点击收藏项可以查看详情
- 评分展示:显示剧本或店铺的评分
- 空状态处理:当没有收藏时显示友好提示
用户交互需求
- 用户可以快速切换剧本和店铺收藏
- 用户可以查看收藏的详细信息
- 用户可以取消不需要的收藏
- 用户可以看到收藏项的评分和描述
核心代码实现
第一部分:导入依赖与类定义
在开始编写收藏页面之前,我们需要导入必要的依赖包。Flutter的Material库提供了丰富的UI组件,
GetX框架则为我们提供了便捷的路由导航和状态管理功能。同时我们还需要导入剧本详情页和店铺详情页,
以便用户点击收藏项时能够跳转到对应的详情页面查看更多信息。这种模块化的导入方式让代码结构更加清晰,
也方便后续的维护和扩展。合理的依赖管理是构建大型Flutter应用的基础。
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../script/script_detail_page.dart';
import '../store/store_detail_page.dart';
FavoritesPage类继承自StatefulWidget,这是因为我们需要管理TabController的生命周期状态。
在Flutter中,当页面需要维护可变状态时,StatefulWidget是最佳选择。通过createState方法,
我们创建了对应的State类_FavoritesPageState来处理具体的状态逻辑。super.key参数用于
Widget的唯一标识,这在Widget树的diff算法中起着重要作用,能够帮助Flutter高效地更新UI。
这种设计模式是Flutter框架的核心理念之一。
class FavoritesPage extends StatefulWidget {
FavoritesPage({super.key});
State<FavoritesPage> createState() => _FavoritesPageState();
}
_FavoritesPageState类混入了SingleTickerProviderStateMixin,这是使用TabController的必要条件。
这个mixin提供了一个TickerProvider,用于驱动TabController的动画效果。late关键字表示_tabController
将在initState中延迟初始化,这样可以确保在使用前已经正确创建。TabController负责管理Tab的切换状态,
包括当前选中的Tab索引、切换动画等。这种设计让Tab切换具有流畅的动画效果,提升用户体验。
class _FavoritesPageState extends State<FavoritesPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
下面定义了剧本收藏的模拟数据列表_scripts。每个剧本对象包含id、名称、类型、评分、玩家人数和游戏时长等信息。
使用Map<String, dynamic>类型可以灵活存储不同类型的数据。在实际项目中,这些数据通常来自后端API,
这里使用静态数据进行演示。数据结构的设计要考虑到UI展示的需求,确保包含所有需要显示的字段。
合理的数据结构设计能够简化后续的UI开发工作,提高代码的可读性和可维护性。
final List<Map<String, dynamic>> _scripts = [
{
'id': '1',
'name': '年轮',
'type': '情感本',
'rating': 9.2,
'players': 6,
'duration': '120分钟',
},
{
'id': '2',
'name': '古木吟',
'type': '恐怖本',
'rating': 9.5,
'players': 7,
'duration': '150分钟',
},
继续添加更多的剧本数据,包括"你好"和"云使"两个剧本。不同类型的剧本(情感本、恐怖本、机制本)
满足了不同玩家的喜好需求。评分字段使用double类型,可以精确到小数点后一位,更准确地反映剧本质量。
玩家人数和游戏时长是选择剧本时的重要参考因素,用户可以根据自己的时间安排和组队人数来筛选合适的剧本。
这种多维度的数据展示能够帮助用户做出更好的决策。
{
'id': '3',
'name': '你好',
'type': '情感本',
'rating': 8.8,
'players': 5,
'duration': '100分钟',
},
{
'id': '4',
'name': '云使',
'type': '机制本',
'rating': 9.0,
'players': 8,
'duration': '180分钟',
},
];
店铺收藏列表_stores的数据结构与剧本略有不同,包含店铺名称、地址、评分、剧本数量和联系电话等信息。
地址信息对于用户选择店铺非常重要,用户通常会优先选择距离较近的店铺。剧本数量反映了店铺的规模和可选择性,
数量越多意味着用户有更多的剧本可以体验。电话字段方便用户直接联系店铺进行预约或咨询。
这些字段的设计充分考虑了用户在选择店铺时的实际需求。
final List<Map<String, dynamic>> _stores = [
{
'id': '1',
'name': '迷雾剧本杀',
'address': '朝阳区三里屯',
'rating': 4.8,
'scripts': 45,
'phone': '010-1234567',
},
{
'id': '2',
'name': '探案馆',
'address': '海淀区中关村',
'rating': 4.6,
'scripts': 38,
'phone': '010-2345678',
},
最后一个店铺数据"推理社"位于朝阳区建国路,拥有52个剧本,是三家店铺中剧本数量最多的。
店铺评分采用5分制,与剧本的10分制评分区分开来,这是行业内的常见做法。
通过这种差异化的评分体系,用户可以更直观地理解评分的含义。
店铺数据的完整性确保了收藏列表能够展示足够的信息,帮助用户回忆起收藏该店铺的原因。
{
'id': '3',
'name': '推理社',
'address': '朝阳区建国路',
'rating': 4.7,
'scripts': 52,
'phone': '010-3456789',
},
];
initState方法是State生命周期中的重要方法,在State对象被插入到Widget树时调用。
这里我们初始化TabController,设置length为2表示有两个Tab(剧本和店铺),vsync参数
传入this是因为当前类混入了SingleTickerProviderStateMixin。dispose方法在State对象
从Widget树中移除时调用,我们需要在这里释放TabController资源,避免内存泄漏。
这种资源管理模式是Flutter开发中的最佳实践,确保应用的稳定性和性能。
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
void dispose() {
_tabController.dispose();
super.dispose();
}
第二部分:页面主体结构
build方法是构建UI的核心方法,返回一个Scaffold脚手架组件作为页面的基础结构。
Scaffold提供了AppBar、body、bottomNavigationBar等常用布局槽位,是Material Design
应用的标准页面结构。通过Scaffold,我们可以快速搭建出符合设计规范的页面布局,
同时保持代码的简洁性。这种声明式的UI构建方式是Flutter的核心特点之一。
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('我的收藏'),
centerTitle: true,
elevation: 0,
backgroundColor: const Color(0xFF6B4EFF),
foregroundColor: Colors.white,
AppBar的bottom属性用于放置TabBar组件,实现标题栏下方的Tab切换效果。
TabBar的controller绑定到我们之前创建的_tabController,确保Tab状态的同步。
indicatorColor设置为白色,indicatorWeight设置为3使指示器更加醒目。
labelColor和unselectedLabelColor分别设置选中和未选中Tab的文字颜色,
通过颜色对比让用户清楚地知道当前选中的是哪个Tab。
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
indicatorWeight: 3,
labelColor: Colors.white,
unselectedLabelColor: Colors.white70,
tabs: const [
Tab(text: '剧本'),
Tab(text: '店铺'),
],
),
),
页面背景色设置为浅灰色(0xFFF5F5F5),与白色卡片形成层次感。TabBarView是Tab内容的容器,
它的controller同样绑定到_tabController,与TabBar保持同步。children数组包含两个子Widget,
分别对应剧本收藏列表和店铺收藏列表。当用户切换Tab时,TabBarView会自动显示对应的内容,
并带有平滑的滑动动画效果,提供流畅的用户体验。
backgroundColor: const Color(0xFFF5F5F5),
body: TabBarView(
controller: _tabController,
children: [
_buildScriptList(),
_buildStoreList(),
],
),
);
}
第三部分:剧本收藏列表
_buildScriptList方法负责构建剧本收藏列表。首先检查_scripts列表是否为空,
如果为空则显示空状态提示。空状态设计是用户体验的重要组成部分,友好的空状态提示
能够引导用户进行下一步操作,而不是让用户面对一片空白感到困惑。
使用Column组件垂直排列图标和文字,mainAxisAlignment设置为center使内容居中显示。
Widget _buildScriptList() {
if (_scripts.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bookmark_outline,
size: 80,
color: Colors.grey[300],
),
空状态的文字提示分为两行:主提示"还没有收藏剧本"使用较大字号和深灰色,
副提示"去剧本库收藏喜欢的剧本吧"使用较小字号和浅灰色。这种主次分明的设计
让用户能够快速理解当前状态,并知道如何进行下一步操作。SizedBox用于控制元素之间的间距,
保持视觉上的舒适感。空状态图标选用bookmark_outline,与收藏功能的语义相符。
const SizedBox(height: 16),
Text(
'还没有收藏剧本',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'去剧本库收藏喜欢的剧本吧',
style: TextStyle(
fontSize: 13,
color: Colors.grey[500],
),
),
],
),
);
}
当有收藏数据时,使用ListView.builder构建列表。这是Flutter中构建长列表的最佳实践,
它采用懒加载机制,只渲染可见区域的列表项,大大提高了性能。padding设置为12像素的内边距,
itemCount指定列表项数量,itemBuilder回调函数负责构建每个列表项。
这种构建方式即使面对成百上千的收藏项也能保持流畅的滚动体验。
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _scripts.length,
itemBuilder: (context, index) => _buildScriptCard(_scripts[index]),
);
}
_buildScriptCard方法构建单个剧本卡片。GestureDetector包裹整个卡片,使其可以响应点击事件。
点击时使用GetX的Get.to方法导航到剧本详情页,并传递剧本ID作为参数。这种导航方式简洁明了,
GetX框架会自动处理路由栈的管理。Container作为卡片的容器,设置了底部间距、内边距、
圆角和阴影效果,营造出卡片悬浮的视觉效果。
Widget _buildScriptCard(Map<String, dynamic> script) {
return GestureDetector(
onTap: () => Get.to(
() => ScriptDetailPage(scriptId: script['id']),
),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
BoxShadow为卡片添加轻微的阴影效果,使用5%透明度的黑色和10像素的模糊半径,
营造出柔和的立体感。Row组件水平排列卡片内容,包括左侧的图标容器、中间的信息区域和右侧的评分区域。
左侧图标容器使用主题色的10%透明度作为背景,与图标颜色形成呼应,
这种设计让图标区域更加突出,同时保持整体视觉的和谐统一。
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
),
],
),
child: Row(
children: [
Container(
width: 70,
height: 70,
decoration: BoxDecoration(
color: const Color(0xFF6B4EFF).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
图标使用auto_stories(书本图标),颜色为主题紫色,大小为32像素。
SizedBox(width: 12)在图标和信息区域之间添加间距。Expanded组件让中间的信息区域
占据剩余的水平空间,Column组件垂直排列剧本名称、类型和其他信息。
crossAxisAlignment设置为start使文字左对齐,符合阅读习惯。
child: const Icon(
Icons.auto_stories,
color: Color(0xFF6B4EFF),
size: 32,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
script['name'],
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
剧本类型使用灰色小字显示,与名称形成主次对比。下方的Row组件水平排列玩家人数和游戏时长信息,
使用小图标配合文字的方式展示。Icons.people_outline表示人数,Icons.schedule表示时长,
图标大小为12像素,与11像素的文字大小相匹配。这种图文结合的展示方式直观易懂,
用户可以快速获取关键信息,做出是否查看详情的决定。
const SizedBox(height: 4),
Text(
script['type'],
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.people_outline,
size: 12,
color: Colors.grey[500],
),
const SizedBox(width: 4),
Text(
'${script['players']}人',
style: TextStyle(
color: Colors.grey[500],
fontSize: 11,
),
),
时长信息紧跟在人数后面,中间用12像素的间距分隔。schedule图标形象地表示时间概念,
文字直接显示duration字段的值。这两个信息是用户选择剧本时的重要参考因素,
放在一起展示方便用户快速对比。整个信息区域的设计遵循了信息层级原则,
重要信息(名称)突出显示,次要信息(类型、人数、时长)使用较小字号和灰色。
const SizedBox(width: 12),
Icon(
Icons.schedule,
size: 12,
color: Colors.grey[500],
),
const SizedBox(width: 4),
Text(
script['duration'],
style: TextStyle(
color: Colors.grey[500],
fontSize: 11,
),
),
],
),
],
),
),
卡片右侧的Column组件垂直排列评分和收藏按钮。评分使用金色星星图标配合数字显示,
星星图标(Icons.star)是评分的通用视觉符号,用户一眼就能理解其含义。
评分数字使用粗体显示,强调其重要性。mainAxisAlignment设置为spaceBetween
使评分和按钮分布在Column的两端,充分利用垂直空间。
Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(
Icons.star,
size: 16,
color: Colors.amber,
),
const SizedBox(width: 4),
Text(
'${script['rating']}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
收藏按钮使用红色实心爱心图标(Icons.favorite),表示当前项目已被收藏。
IconButton的onPressed回调调用_removeFavorite方法处理取消收藏操作。
padding和constraints设置为零,去除按钮的默认内边距,使布局更加紧凑。
这种设计让用户可以一键取消收藏,操作便捷。红色爱心是收藏功能的经典视觉符号,
用户无需学习就能理解其功能。
IconButton(
icon: const Icon(
Icons.favorite,
color: Colors.red,
size: 20,
),
onPressed: () => _removeFavorite('script', script['id']),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
],
),
),
);
}
第四部分:店铺收藏列表
_buildStoreList方法的结构与_buildScriptList类似,首先处理空状态。
店铺空状态使用store_outlined图标,与店铺的语义相符。提示文字引导用户
去店铺列表收藏喜欢的店铺。这种一致的空状态设计模式让用户在不同Tab下
都能获得相似的体验,降低学习成本。空状态设计是提升用户体验的重要细节。
Widget _buildStoreList() {
if (_stores.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.store_outlined,
size: 80,
color: Colors.grey[300],
),
const SizedBox(height: 16),
Text(
'还没有收藏店铺',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
副提示文字"去店铺列表收藏喜欢的店铺吧"给用户明确的行动指引。
当有店铺数据时,同样使用ListView.builder构建列表,调用_buildStoreCard方法
构建每个店铺卡片。这种代码复用的设计模式让两个列表保持一致的视觉风格和交互体验,
同时减少了代码重复,提高了可维护性。
const SizedBox(height: 8),
Text(
'去店铺列表收藏喜欢的店铺吧',
style: TextStyle(
fontSize: 13,
color: Colors.grey[500],
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _stores.length,
itemBuilder: (context, index) => _buildStoreCard(_stores[index]),
);
}
_buildStoreCard方法构建店铺卡片,整体结构与剧本卡片相似。点击卡片跳转到StoreDetailPage,
传递店铺ID。卡片容器的样式设置与剧本卡片保持一致,包括白色背景、12像素圆角和轻微阴影。
这种视觉一致性让整个收藏页面看起来协调统一,用户在切换Tab时不会感到突兀。
Widget _buildStoreCard(Map<String, dynamic> store) {
return GestureDetector(
onTap: () => Get.to(
() => StoreDetailPage(storeId: store['id']),
),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
),
],
),
店铺卡片左侧使用store图标替代auto_stories图标,更准确地表达店铺的含义。
图标容器的样式与剧本卡片保持一致,使用主题色的10%透明度作为背景。
Row组件水平排列图标、信息和操作区域,Expanded组件让信息区域自适应宽度。
这种布局方式在不同屏幕尺寸下都能保持良好的显示效果。
child: Row(
children: [
Container(
width: 70,
height: 70,
decoration: BoxDecoration(
color: const Color(0xFF6B4EFF).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.store,
color: Color(0xFF6B4EFF),
size: 32,
),
),
const SizedBox(width: 12),
店铺信息区域显示店铺名称、地址和剧本数量。与剧本卡片不同的是,这里显示的是地址而非类型,
剧本数量而非玩家人数和时长。这些信息是用户选择店铺时最关心的因素。
店铺名称使用粗体突出显示,地址使用灰色小字,剧本数量使用图标配合文字的方式展示。
信息的层级设计让用户能够快速扫描并获取关键信息。
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
store['name'],
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
const SizedBox(height: 4),
Text(
store['address'],
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
),
),
剧本数量使用auto_stories图标表示,与剧本收藏中的图标保持一致,形成视觉关联。
文字格式为"XX个剧本",清晰明了。这个信息帮助用户了解店铺的规模和可选择性,
剧本数量多的店铺通常能提供更丰富的游戏体验。右侧的评分和收藏按钮布局与剧本卡片相同,
保持了操作的一致性。
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.auto_stories,
size: 12,
color: Colors.grey[500],
),
const SizedBox(width: 4),
Text(
'${store['scripts']}个剧本',
style: TextStyle(
color: Colors.grey[500],
fontSize: 11,
),
),
],
),
],
),
),
店铺卡片右侧的评分和收藏按钮与剧本卡片完全相同,使用金色星星显示评分,
红色爱心按钮用于取消收藏。点击收藏按钮时调用_removeFavorite方法,
传入’store’类型和店铺ID。这种统一的交互模式让用户无需重新学习,
在两个Tab之间切换时能够无缝操作。
Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(
Icons.star,
size: 16,
color: Colors.amber,
),
const SizedBox(width: 4),
Text(
'${store['rating']}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
收藏按钮的实现与剧本卡片相同,点击后调用_removeFavorite方法处理取消收藏逻辑。
IconButton组件提供了良好的点击反馈效果,用户点击时会有水波纹动画。
padding和constraints的设置确保按钮不会占用过多空间,保持卡片布局的紧凑性。
这种细节处理体现了对用户体验的关注。
IconButton(
icon: const Icon(
Icons.favorite,
color: Colors.red,
size: 20,
),
onPressed: () => _removeFavorite('store', store['id']),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
],
),
),
);
}
_removeFavorite方法处理取消收藏的逻辑。目前使用GetX的snackbar显示操作成功提示,
实际项目中应该调用API删除收藏记录,并更新本地状态。snackbar从底部弹出,
使用绿色背景和白色文字,给用户明确的操作反馈。type参数用于区分是剧本还是店铺,
在提示文字中显示对应的类型名称,让用户清楚地知道取消了什么收藏。
void _removeFavorite(String type, String id) {
Get.snackbar(
'已取消收藏',
'您已取消收藏此${type == 'script' ? '剧本' : '店铺'}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
}
}
收藏页面的设计要点
1. Tab切换设计
使用TabBar实现分类切换,让用户可以快速在剧本和店铺收藏之间切换。TabController负责管理切换状态和动画,
SingleTickerProviderStateMixin提供动画所需的Ticker。这种设计模式是Flutter中实现Tab功能的标准方式,
具有良好的性能和流畅的动画效果。
2. 卡片布局
使用统一的卡片布局展示收藏项,包含图标、信息和操作按钮。卡片采用白色背景配合轻微阴影,
与页面灰色背景形成层次感。Row组件水平排列各个元素,Expanded组件让信息区域自适应宽度,
确保在不同屏幕尺寸下都能正常显示。
3. 空状态处理
当没有收藏时显示友好的空状态提示,引导用户去收藏。空状态包含图标、主提示和副提示三个元素,
图标选择与功能语义相符,文字提示给用户明确的行动指引。良好的空状态设计能够提升用户体验,
避免用户面对空白页面感到困惑。
4. 快速操作
提供一键取消收藏的功能,方便用户管理收藏。红色爱心图标是收藏功能的通用视觉符号,
用户无需学习就能理解其功能。点击后显示snackbar提示,给用户明确的操作反馈。
扩展功能建议
1. 收藏排序
支持按时间、评分等条件排序收藏。可以在AppBar添加排序按钮,点击后显示排序选项菜单。
排序功能让用户能够更方便地找到想要的收藏项,特别是当收藏数量较多时。
2. 收藏分类
支持创建自定义分类来组织收藏。用户可以创建"想玩"、“玩过”、"推荐"等分类,
将收藏项归类管理。这种功能适合重度用户,帮助他们更好地管理大量收藏。
3. 批量操作
支持批量取消收藏或批量导出。长按进入多选模式,用户可以选择多个收藏项进行批量操作。
这种功能在用户需要清理收藏时非常有用,避免逐个取消的繁琐操作。
4. 收藏分享
支持分享收藏给好友。用户可以将自己的收藏列表分享到社交平台或直接发送给好友,
这种社交功能能够增加应用的传播性和用户粘性。
总结
通过本篇文章的学习,我们完成了收藏功能的实现。这个功能让用户可以方便地管理感兴趣的剧本和店铺,提升了应用的易用性。
收藏页面的设计遵循了Material Design的规范,使用Tab切换提供清晰的分类,使用卡片布局展示信息,使用空状态处理提升用户体验。代码结构清晰,各个方法职责明确,便于后续的维护和扩展。
下一篇文章我们将实现游戏记录展示功能,敬请期待!
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)