Flutter for OpenHarmony 个人理财管理App实战 - 分类列表页面
本文介绍了记账应用中分类管理页面的设计与实现。该页面采用Tab切换展示支出和收入分类,通过网格布局直观呈现分类图标和名称。功能包括分类浏览、添加、编辑和删除操作,支持长按菜单和编辑模式快速删除。系统预置了8种支出和6种收入默认分类,确保基础使用需求。技术实现上使用Flutter框架,采用StatefulWidget管理状态,结合GetX进行依赖注入,通过TabController实现分类切换。页面
分类是记账的基础,合理的分类可以帮助用户更好地分析自己的收支情况。本篇将实现分类管理的列表页面,支持查看和管理收入、支出两种类型的分类。用户可以在这里浏览所有分类,也可以添加、编辑或删除自定义分类。
功能设计
分类列表页面包含以下功能:
- Tab 切换查看支出和收入分类
- 网格布局展示分类图标和名称
- 点击分类进入编辑页面
- 长按分类显示操作菜单
- 添加新分类入口
- 编辑模式支持快速删除
这种设计让用户可以直观地看到所有分类,网格布局比列表更紧凑,一屏能显示更多内容。
默认分类设计
系统预置了常用的收支分类,覆盖日常生活的主要场景:
支出分类: 餐饮美食、交通出行、购物消费、生活缴费、医疗健康、休闲娱乐、教育学习、人情往来
收入分类: 工资薪水、奖金福利、投资理财、兼职副业、报销退款、其他收入
这些默认分类是系统预置的,不能删除,保证用户始终有基础分类可用。用户可以添加自定义分类来满足个性化需求。
页面基础结构
创建 category_list_page.dart,使用 StatefulWidget 管理 Tab 状态:
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../../core/services/category_service.dart';
import '../../data/models/transaction_model.dart';
import '../../data/models/category_model.dart';
import '../../routes/app_pages.dart';
const _primaryColor = Color(0xFF2E7D32);
const _textSecondary = Color(0xFF757575);
class CategoryListPage extends StatefulWidget {
const CategoryListPage({super.key});
State<CategoryListPage> createState() => _CategoryListPageState();
}
导入部分包含了 Flutter 核心库、屏幕适配库、GetX,以及项目内部的服务和模型。CategoryService 提供分类数据的增删改查方法,TransactionModel 中定义了 TransactionType 枚举用于区分收入和支出。
颜色常量定义了主题色和次要文字颜色,和其他页面保持一致。CategoryListPage 使用 StatefulWidget 是因为需要管理 TabController 和编辑模式状态,这些状态只在当前页面使用。
状态类的定义和初始化:
class _CategoryListPageState extends State<CategoryListPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _categoryService = Get.find<CategoryService>();
bool _isEditMode = false;
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
void dispose() {
_tabController.dispose();
super.dispose();
}
}
SingleTickerProviderStateMixin 为 TabController 提供 vsync 信号,这是 Flutter 动画的标准做法。_tabController 控制支出和收入两个 Tab 的切换,length 设为 2。_isEditMode 控制是否处于编辑模式,编辑模式下可以快速删除分类。
initState 中初始化 TabController,dispose 中释放资源。Get.find 获取 CategoryService 的实例,用于获取和操作分类数据。这种依赖注入的方式让代码更易测试和维护。
页面主体结构
build 方法构建页面的整体布局:
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('分类管理'),
centerTitle: true,
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
indicatorWeight: 3,
tabs: const [
Tab(text: '支出分类'),
Tab(text: '收入分类'),
],
),
AppBar 的 title 设为"分类管理",centerTitle 让标题居中。bottom 属性放置 TabBar,实现支出和收入的切换。indicatorColor 设为白色,和 AppBar 背景形成对比。indicatorWeight 设为 3,让指示器稍微粗一些更明显。
两个 Tab 分别是"支出分类"和"收入分类",用户点击可以切换查看不同类型的分类。TabBar 和 TabBarView 通过同一个 controller 关联,保证切换同步。
AppBar 右侧的操作按钮:
actions: [
IconButton(
icon: Icon(_isEditMode ? Icons.done : Icons.edit),
onPressed: () => setState(() => _isEditMode = !_isEditMode),
tooltip: _isEditMode ? '完成' : '编辑',
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () => Get.toNamed(
Routes.categoryEdit,
arguments: {'type': _tabController.index == 0
? TransactionType.expense
: TransactionType.income},
),
tooltip: '添加分类',
),
],
),
编辑按钮根据当前模式显示不同图标:编辑模式下显示完成图标,普通模式下显示编辑图标。点击时切换 _isEditMode 状态,触发界面重建。tooltip 属性提供长按提示,增强可访问性。
添加按钮点击后跳转到分类编辑页面,通过 arguments 传递当前 Tab 对应的分类类型。这样新建的分类会自动归类到当前查看的类型下,用户体验更流畅。
页面主体使用 TabBarView:
body: TabBarView(
controller: _tabController,
children: [
_buildCategoryContent(TransactionType.expense),
_buildCategoryContent(TransactionType.income),
],
),
);
}
Widget _buildCategoryContent(TransactionType type) {
return Column(
children: [
_buildStatisticsBar(type),
Expanded(child: _buildCategoryGrid(type)),
],
);
}
TabBarView 的 children 对应两个 Tab 的内容,分别传入支出和收入类型。_buildCategoryContent 方法构建每个 Tab 的内容,包含统计栏和分类网格两部分。Column 垂直排列,Expanded 让网格占据剩余空间。
分类统计栏
在分类列表上方显示统计信息:
Widget _buildStatisticsBar(TransactionType type) {
final categories = type == TransactionType.expense
? _categoryService.expenseCategories
: _categoryService.incomeCategories;
return Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h),
decoration: BoxDecoration(
color: Colors.grey[50],
border: Border(bottom: BorderSide(color: Colors.grey[200]!)),
),
根据类型从 CategoryService 获取对应的分类列表。Container 设置浅灰色背景和底部边框,和下面的网格形成视觉分隔。padding 设置水平和垂直内边距,让内容有呼吸空间。
统计栏的内容部分:
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
type == TransactionType.expense
? Icons.trending_down
: Icons.trending_up,
color: type == TransactionType.expense
? Colors.red
: _primaryColor,
size: 20.sp,
),
SizedBox(width: 8.w),
Text(
'共 ${categories.length} 个分类',
style: TextStyle(fontSize: 14.sp, color: _textSecondary),
),
],
),
左侧显示分类数量,图标根据类型显示向下或向上的趋势箭头,颜色也相应变化。支出用红色向下箭头,收入用绿色向上箭头,这种视觉暗示和金额显示保持一致。
右侧放置排序按钮:
TextButton.icon(
onPressed: () => _showSortOptions(type),
icon: Icon(Icons.sort, size: 18.sp),
label: const Text('排序'),
style: TextButton.styleFrom(
foregroundColor: _textSecondary,
padding: EdgeInsets.symmetric(horizontal: 8.w),
),
),
],
),
);
}
TextButton.icon 是带图标的文字按钮,点击弹出排序选项。foregroundColor 设为灰色,让按钮不那么突出。padding 减小水平内边距,让按钮更紧凑。排序功能让用户可以按不同方式组织分类。
排序选项底部弹窗
点击排序按钮弹出底部选项:
void _showSortOptions(TransactionType type) {
Get.bottomSheet(
Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'排序方式',
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold),
),
SizedBox(height: 16.h),
Get.bottomSheet 显示底部弹窗,Container 设置白色背景和顶部圆角。mainAxisSize 设为 min 让弹窗高度自适应内容。标题"排序方式"用粗体显示,和选项形成层次。
排序选项列表:
ListTile(
leading: const Icon(Icons.access_time),
title: const Text('按创建时间'),
onTap: () {
_categoryService.sortByCreateTime(type);
Get.back();
setState(() {});
},
),
ListTile(
leading: const Icon(Icons.sort_by_alpha),
title: const Text('按名称排序'),
onTap: () {
_categoryService.sortByName(type);
Get.back();
setState(() {});
},
),
每个排序选项用 ListTile 实现,leading 放图标,title 放文字。点击时调用 CategoryService 的排序方法,然后关闭弹窗并刷新界面。不同的排序方式用不同的图标,让用户更容易识别。
按使用频率排序:
ListTile(
leading: const Icon(Icons.bar_chart),
title: const Text('按使用频率'),
onTap: () {
_categoryService.sortByUsage(type);
Get.back();
setState(() {});
},
),
SizedBox(height: 16.h),
],
),
),
);
}
使用频率排序需要结合交易记录统计,把常用的分类排在前面。底部留出 16.h 的间距,避免内容太贴近屏幕边缘。这种排序方式对经常记账的用户很有用。
分类网格布局
使用 GridView 展示分类,每行 4 个:
Widget _buildCategoryGrid(TransactionType type) {
final categories = type == TransactionType.expense
? _categoryService.expenseCategories
: _categoryService.incomeCategories;
if (categories.isEmpty) {
return _buildEmptyState(type);
}
return Obx(() => GridView.builder(
padding: EdgeInsets.all(16.w),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 16.h,
crossAxisSpacing: 16.w,
childAspectRatio: 0.85,
),
首先检查分类列表是否为空,为空则显示空状态。GridView.builder 按需构建网格项,性能更好。crossAxisCount 设为 4,每行显示 4 个分类。mainAxisSpacing 和 crossAxisSpacing 设置行间距和列间距。
childAspectRatio 设为 0.85,让每个格子稍微高一些,因为要显示图标和文字两行内容。Obx 包裹 GridView,当分类数据变化时自动更新界面。
网格项的构建:
itemCount: categories.length + 1,
itemBuilder: (_, index) {
if (index == categories.length) {
return _buildAddCategoryButton(type);
}
final category = categories[index];
return _buildCategoryItem(category);
},
));
}
itemCount 设为分类数量加 1,最后一个位置放添加按钮。itemBuilder 根据索引判断是显示分类还是添加按钮。这种设计让添加入口始终可见,用户不需要去 AppBar 找添加按钮。
空状态处理
当没有分类时显示引导信息:
Widget _buildEmptyState(TransactionType type) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.category_outlined,
size: 64.sp,
color: Colors.grey[300],
),
SizedBox(height: 16.h),
Text(
'暂无${type == TransactionType.expense ? '支出' : '收入'}分类',
style: TextStyle(fontSize: 16.sp, color: _textSecondary),
),
Center 让内容居中显示,Column 垂直排列图标、提示文字和添加按钮。图标用浅灰色,size 设为 64.sp 足够大能引起注意。提示文字根据类型显示"支出"或"收入"。
添加按钮引导用户操作:
SizedBox(height: 8.h),
TextButton.icon(
onPressed: () => Get.toNamed(
Routes.categoryEdit,
arguments: {'type': type},
),
icon: const Icon(Icons.add),
label: const Text('添加分类'),
),
],
),
);
}
TextButton.icon 提供明确的操作入口,点击跳转到分类编辑页面。这种空状态设计比单纯显示"暂无数据"更友好,引导用户完成下一步操作。
添加分类按钮
网格最后一个位置的添加按钮:
Widget _buildAddCategoryButton(TransactionType type) {
return GestureDetector(
onTap: () => Get.toNamed(
Routes.categoryEdit,
arguments: {'type': type},
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 48.w,
height: 48.w,
decoration: BoxDecoration(
color: Colors.grey[100],
shape: BoxShape.circle,
GestureDetector 处理点击事件,跳转到分类编辑页面。Column 垂直排列图标和文字,和分类项保持一致的布局。Container 创建圆形背景,width 和 height 相等配合 BoxShape.circle 形成正圆。
添加按钮的图标和文字:
border: Border.all(
color: Colors.grey[300]!,
style: BorderStyle.solid,
width: 2,
),
),
child: Icon(
Icons.add,
color: _textSecondary,
size: 24.sp,
),
),
SizedBox(height: 8.h),
Text(
'添加',
style: TextStyle(fontSize: 12.sp, color: _textSecondary),
),
],
),
);
}
边框用虚线效果(这里用实线模拟),颜色用浅灰色,和分类项形成区分。加号图标和"添加"文字都用灰色,视觉上比分类项轻一些,不会抢分类的风头。
分类项组件
每个分类项包含圆形图标和名称:
Widget _buildCategoryItem(CategoryModel category) {
return GestureDetector(
onTap: () => Get.toNamed(Routes.categoryEdit, arguments: category),
onLongPress: () => _showCategoryOptions(category),
child: Stack(
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 24.r,
backgroundColor: category.color.withOpacity(0.2),
child: Icon(
category.icon,
color: category.color,
size: 24.sp,
),
),
GestureDetector 处理点击和长按事件。点击跳转到编辑页面,长按显示操作菜单。Stack 用于在编辑模式下叠加删除按钮。CircleAvatar 显示分类图标,背景色是分类颜色的 20% 透明度。
分类名称和编辑模式删除按钮:
SizedBox(height: 8.h),
Text(
category.name,
style: TextStyle(fontSize: 12.sp),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
if (_isEditMode && !category.isSystem)
Positioned(
top: 0,
right: 0,
child: GestureDetector(
onTap: () => _confirmDelete(category),
child: Container(
padding: EdgeInsets.all(2.w),
分类名称限制一行,超出显示省略号。编辑模式下,非系统分类会显示删除按钮。Positioned 把删除按钮定位在右上角。category.isSystem 判断是否是系统预置分类,系统分类不能删除。
删除按钮的样式:
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: Icon(
Icons.close,
color: Colors.white,
size: 14.sp,
),
),
),
),
],
),
);
}
红色圆形背景加白色叉号图标,是常见的删除按钮样式。size 设为 14.sp 比较小,不会遮挡太多分类图标。点击时调用 _confirmDelete 方法显示确认对话框。
长按操作菜单
长按分类显示更多操作选项:
void _showCategoryOptions(CategoryModel category) {
Get.bottomSheet(
Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40.w,
height: 4.h,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2.r),
),
),
底部弹窗的顶部有一个小横条,这是 iOS 风格的拖动指示器,暗示用户可以下拉关闭。Container 设置白色背景和顶部圆角,mainAxisSize 设为 min 让高度自适应内容。
弹窗头部显示分类信息:
SizedBox(height: 16.h),
Row(
children: [
CircleAvatar(
radius: 24.r,
backgroundColor: category.color.withOpacity(0.2),
child: Icon(category.icon, color: category.color),
),
SizedBox(width: 12.w),
Text(
category.name,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
),
),
],
),
SizedBox(height: 16.h),
const Divider(),
Row 水平排列分类图标和名称,让用户确认操作的是哪个分类。Divider 分隔头部和操作列表。这种设计在操作前给用户一个确认的机会。
操作选项列表:
ListTile(
leading: const Icon(Icons.edit),
title: const Text('编辑分类'),
onTap: () {
Get.back();
Get.toNamed(Routes.categoryEdit, arguments: category);
},
),
ListTile(
leading: const Icon(Icons.bar_chart),
title: const Text('查看统计'),
onTap: () {
Get.back();
Get.toNamed(Routes.categoryAnalysis, arguments: category);
},
),
编辑选项跳转到分类编辑页面,统计选项跳转到该分类的统计分析页面。每个选项点击后先关闭弹窗再跳转,避免页面叠加。leading 放图标让选项更易识别。
删除选项(仅非系统分类显示):
if (!category.isSystem)
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('删除分类',
style: TextStyle(color: Colors.red)),
onTap: () {
Get.back();
_confirmDelete(category);
},
),
SizedBox(height: 16.h),
],
),
),
);
}
删除选项用红色图标和文字,强调危险操作。if 语句判断是否是系统分类,系统分类不显示删除选项。点击后先关闭弹窗再显示确认对话框,避免弹窗叠加。
删除确认对话框
删除是危险操作,需要二次确认:
void _confirmDelete(CategoryModel category) {
Get.dialog(
AlertDialog(
title: const Text('确认删除'),
content: Text(
'确定要删除分类"${category.name}"吗?\n\n'
'删除后,该分类下的交易记录将变为"未分类"。'
),
actions: [
TextButton(
onPressed: () => Get.back(),
child: const Text('取消'),
),
AlertDialog 显示确认对话框,content 说明删除的后果:相关交易记录会变为未分类。这让用户在删除前了解影响,做出知情的决定。
确认删除按钮:
TextButton(
onPressed: () {
_categoryService.deleteCategory(category.id);
Get.back();
setState(() {});
Get.snackbar('已删除', '分类"${category.name}"已删除');
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('删除'),
),
],
),
);
}
确认后调用服务删除分类,关闭对话框,刷新界面,显示成功提示。删除按钮用红色,和其他地方的删除按钮保持一致。snackbar 提示包含分类名称,让用户确认删除的是正确的分类。
设计要点总结
分类列表的设计考虑了以下几点:
- 网格布局直观展示所有分类,一目了然,比列表更紧凑
- Tab 切换区分收入和支出,逻辑清晰,避免混淆
- 点击即可编辑,长按显示更多选项,操作便捷
- 图标和颜色让分类更易识别,形成视觉记忆
- 编辑模式支持快速删除,批量操作更高效
- 系统预置分类不可删除,保证基础功能可用
- 支持多种排序方式,满足不同用户习惯
小结
分类列表页面提供了分类的浏览和管理入口,通过网格布局和 Tab 切换,让用户可以方便地管理自己的收支分类。编辑模式和长按菜单提供了多种操作方式,满足不同场景的需求。
下一篇将实现分类编辑页面,支持添加和修改分类的名称、图标和颜色。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)