flutter_for_openharmony家庭相册app实战+分类浏览实现
本文介绍了如何实现一个相册分类浏览页面。该页面采用可展开卡片列表设计,使用ExpansionTile构建分类卡片,点击展开显示对应分类下的相册。通过Consumer获取相册数据,为不同分类分配不同颜色和图标以增强视觉区分。页面支持点击相册跳转至详情页,并保持了良好的视觉层次感。实现简洁高效,既方便全局浏览又能快速定位具体分类内容。

相册多了之后,按分类查看会方便很多。
旅行的放一起,生日的放一起,找起来快多了。
今天来实现分类浏览页面。
页面设计
分类浏览页面用可展开的卡片列表。
每个分类是一张卡片,点击展开显示该分类下的所有相册。
这种设计既能看到全局,又能深入某个分类。
基础结构
页面比较简单,用StatelessWidget:
class CategoryScreen extends StatelessWidget {
const CategoryScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('分类浏览'),
),
标题"分类浏览",清晰表达页面功能。
没有复杂的本地状态,
StatelessWidget足够用。
数据获取
用Consumer监听分类和相册数据:
body: Consumer<AlbumProvider>(
builder: (context, provider, _) {
final categories = provider.categories.where((c) => c != '全部').toList();
从
provider.categories获取所有分类。过滤掉"全部"这个选项,它不是真正的分类。
转成列表方便后续遍历。
分类列表
用ListView.builder构建分类卡片列表:
return ListView.builder(
padding: EdgeInsets.all(16.w),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
final albumsInCategory = provider.albums.where((a) => a.category == category).toList();
return _buildCategoryCard(context, category, albumsInCategory);
},
);
},
),
);
}
遍历每个分类,筛选出该分类下的相册。
把分类名和相册列表传给卡片构建方法。
四周加16的内边距,不贴着屏幕边缘。
分类卡片
用ExpansionTile实现可展开的卡片:
Widget _buildCategoryCard(BuildContext context, String category, List<AlbumModel> albums) {
return Card(
margin: EdgeInsets.only(bottom: 16.h),
child: ExpansionTile(
leading: Container(
width: 48.w,
height: 48.w,
decoration: BoxDecoration(
color: _getColorForCategory(category).withOpacity(0.1),
borderRadius: BorderRadius.circular(8.r),
),
child: Icon(
_getIconForCategory(category),
color: _getColorForCategory(category),
),
),
Card包裹让卡片有阴影效果。
ExpansionTile自带展开收起的动画和箭头图标。
leading放分类图标,背景色是分类颜色的10%透明度。
title: Text(
category,
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w500),
),
subtitle: Text('${albums.length}个相册'),
标题是分类名,字体稍微加粗。
副标题显示该分类下有多少个相册。
用户不用展开就能知道每个分类的规模。
展开内容
展开后显示该分类下的相册列表:
children: albums.map((album) => ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 24.w),
title: Text(album.name),
subtitle: Text('${album.photoCount}张照片'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => AlbumDetailScreen(album: album)),
),
)).toList(),
),
);
}
children是展开后显示的内容列表。每个相册用
ListTile展示,包含名称和照片数。右侧箭头提示可以点击进入。
contentPadding加大左边距,和父级形成层次感。
分类颜色映射
不同分类用不同颜色区分:
Color _getColorForCategory(String category) {
switch (category) {
case '旅行':
return Colors.blue;
case '生日':
return Colors.orange;
case '节日':
return Colors.red;
case '日常':
return Colors.green;
case '纪念日':
return Colors.pink;
default:
return Colors.grey;
}
}
旅行用蓝色,让人联想到天空和大海。
生日用橙色,温暖喜庆的感觉。
节日用红色,中国传统节日的主色调。
日常用绿色,平和自然。
纪念日用粉色,浪漫温馨。
分类图标映射
每个分类配一个直观的图标:
IconData _getIconForCategory(String category) {
switch (category) {
case '旅行':
return Icons.flight;
case '生日':
return Icons.cake;
case '节日':
return Icons.celebration;
case '日常':
return Icons.home;
case '纪念日':
return Icons.favorite;
default:
return Icons.folder;
}
}
}
旅行用飞机图标,一目了然。
生日用蛋糕,过生日必备元素。
节日用庆祝图标,喜庆的感觉。
日常用房子,代表家庭日常生活。
纪念日用爱心,表达珍贵的回忆。
ExpansionTile的优势
为什么选择ExpansionTile:
ExpansionTile(
title: Text(category),
children: [...],
)
自带展开收起动画,不用自己写。
自带箭头图标,会随展开状态旋转。
点击标题区域就能触发,交互区域大。
同时只能展开一个的话,可以用
ExpansionPanelList。
数据流转
从Provider到页面的数据流:
final albumsInCategory = provider.albums.where((a) => a.category == category).toList();
provider.albums是所有相册的列表。
where过滤出指定分类的相册。每次构建都会重新计算,保证数据最新。
页面跳转
点击相册跳转到详情页:
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => AlbumDetailScreen(album: album)),
),
把相册对象传给详情页。
用户可以继续查看相册里的照片。
返回时回到分类浏览页,展开状态会保持。
视觉层次
通过缩进和颜色建立层次感:
contentPadding: EdgeInsets.symmetric(horizontal: 24.w),
子项比父项多8的左边距。
视觉上形成父子关系。
用户能清楚看出哪些相册属于哪个分类。
空分类处理
如果某个分类下没有相册:
final albumsInCategory = provider.albums.where((a) => a.category == category).toList();
过滤结果可能是空列表。
ExpansionTile的children为空时,展开后什么都不显示。可以加个判断,显示"暂无相册"的提示。
小结
分类浏览页面用ExpansionTile实现可展开的分类卡片。
颜色和图标让每个分类都有辨识度。
层次分明的布局让用户快速找到想要的相册。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
空分类优化处理
当某个分类下没有相册时,应该给用户友好的提示:
Widget _buildCategoryCard(BuildContext context, String category, List<AlbumModel> albums) {
return Card(
margin: EdgeInsets.only(bottom: 16.h),
child: ExpansionTile(
leading: Container(
width: 48.w,
height: 48.w,
decoration: BoxDecoration(
color: _getColorForCategory(category).withOpacity(0.1),
borderRadius: BorderRadius.circular(8.r),
),
child: Icon(
_getIconForCategory(category),
color: _getColorForCategory(category),
),
),
title: Text(
category,
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w500),
),
subtitle: Text(
albums.isEmpty ? '暂无相册' : '${albums.length}个相册',
style: TextStyle(
color: albums.isEmpty ? Colors.grey : null,
),
),
children: albums.isEmpty
? [
Padding(
padding: EdgeInsets.all(16.w),
child: Column(
children: [
Icon(Icons.photo_album_outlined,
size: 48.sp, color: Colors.grey.shade300),
SizedBox(height: 8.h),
Text('该分类下还没有相册',
style: TextStyle(color: Colors.grey.shade600)),
SizedBox(height: 8.h),
TextButton(
onPressed: () {
// 跳转到创建相册页面,并预选该分类
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CreateAlbumScreen(
preselectedCategory: category,
),
),
);
},
child: const Text('创建相册'),
),
],
),
),
]
: albums.map((album) => ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 24.w),
title: Text(album.name),
subtitle: Text('${album.photoCount}张照片'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => AlbumDetailScreen(album: album)),
),
)).toList(),
),
);
}
空分类显示灰色图标和提示文字,并提供创建相册的快捷入口。这种设计既告知用户当前状态,又引导用户采取行动。副标题文字也会变成灰色,与有内容的分类形成对比。点击"创建相册"按钮会跳转到创建页面,并自动选中当前分类,减少用户操作步骤。
分类统计信息
在分类卡片上显示更丰富的统计信息:
Widget _buildCategoryCard(BuildContext context, String category, List<AlbumModel> albums) {
final totalPhotos = albums.fold<int>(0, (sum, album) => sum + album.photoCount);
final latestAlbum = albums.isNotEmpty
? albums.reduce((a, b) => a.createTime.isAfter(b.createTime) ? a : b)
: null;
return Card(
margin: EdgeInsets.only(bottom: 16.h),
child: ExpansionTile(
leading: Container(
width: 48.w,
height: 48.w,
decoration: BoxDecoration(
color: _getColorForCategory(category).withOpacity(0.1),
borderRadius: BorderRadius.circular(8.r),
),
child: Icon(
_getIconForCategory(category),
color: _getColorForCategory(category),
),
),
title: Text(
category,
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w500),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${albums.length}个相册 · $totalPhotos张照片'),
if (latestAlbum != null)
Text(
'最新: ${latestAlbum.name}',
style: TextStyle(fontSize: 12.sp, color: Colors.grey.shade600),
),
],
),
// ... children
),
);
}
统计信息包括相册数量、总照片数和最新相册名称。totalPhotos通过fold方法累加所有相册的照片数。latestAlbum使用reduce找出创建时间最晚的相册。这些信息让用户对每个分类有更全面的了解,不用展开就能看到概况。
分类排序功能
支持按不同方式排序分类:
enum CategorySortType {
name, // 按名称排序
albumCount, // 按相册数量排序
photoCount, // 按照片数量排序
latest, // 按最新更新排序
}
class CategoryScreen extends StatefulWidget {
const CategoryScreen({super.key});
State<CategoryScreen> createState() => _CategoryScreenState();
}
class _CategoryScreenState extends State<CategoryScreen> {
CategorySortType _sortType = CategorySortType.name;
List<String> _sortCategories(List<String> categories, AlbumProvider provider) {
final categoriesWithData = categories.map((category) {
final albums = provider.albums.where((a) => a.category == category).toList();
final totalPhotos = albums.fold<int>(0, (sum, album) => sum + album.photoCount);
final latestTime = albums.isEmpty
? DateTime(2000)
: albums.map((a) => a.createTime).reduce((a, b) => a.isAfter(b) ? a : b);
return {
'name': category,
'albumCount': albums.length,
'photoCount': totalPhotos,
'latestTime': latestTime,
};
}).toList();
switch (_sortType) {
case CategorySortType.name:
categoriesWithData.sort((a, b) => (a['name'] as String).compareTo(b['name'] as String));
break;
case CategorySortType.albumCount:
categoriesWithData.sort((a, b) => (b['albumCount'] as int).compareTo(a['albumCount'] as int));
break;
case CategorySortType.photoCount:
categoriesWithData.sort((a, b) => (b['photoCount'] as int).compareTo(a['photoCount'] as int));
break;
case CategorySortType.latest:
categoriesWithData.sort((a, b) => (b['latestTime'] as DateTime).compareTo(a['latestTime'] as DateTime));
break;
}
return categoriesWithData.map((data) => data['name'] as String).toList();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('分类浏览'),
actions: [
PopupMenuButton<CategorySortType>(
icon: const Icon(Icons.sort),
onSelected: (type) => setState(() => _sortType = type),
itemBuilder: (context) => [
const PopupMenuItem(
value: CategorySortType.name,
child: Text('按名称排序'),
),
const PopupMenuItem(
value: CategorySortType.albumCount,
child: Text('按相册数量'),
),
const PopupMenuItem(
value: CategorySortType.photoCount,
child: Text('按照片数量'),
),
const PopupMenuItem(
value: CategorySortType.latest,
child: Text('按最新更新'),
),
],
),
],
),
body: Consumer<AlbumProvider>(
builder: (context, provider, _) {
final categories = provider.categories.where((c) => c != '全部').toList();
final sortedCategories = _sortCategories(categories, provider);
return ListView.builder(
padding: EdgeInsets.all(16.w),
itemCount: sortedCategories.length,
itemBuilder: (context, index) {
final category = sortedCategories[index];
final albumsInCategory = provider.albums
.where((a) => a.category == category)
.toList();
return _buildCategoryCard(context, category, albumsInCategory);
},
);
},
),
);
}
}
排序功能让用户可以按不同维度查看分类。按名称排序适合查找特定分类,按数量排序可以看出哪些分类内容最多,按最新更新排序能快速找到最近活跃的分类。AppBar右上角的排序按钮使用PopupMenuButton实现下拉菜单。
分类搜索功能
添加搜索框快速定位分类:
class _CategoryScreenState extends State<CategoryScreen> {
CategorySortType _sortType = CategorySortType.name;
String _searchQuery = '';
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('分类浏览'),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () => _showSearchDialog(),
),
PopupMenuButton<CategorySortType>(
icon: const Icon(Icons.sort),
onSelected: (type) => setState(() => _sortType = type),
itemBuilder: (context) => [
// ... 排序选项
],
),
],
),
body: Consumer<AlbumProvider>(
builder: (context, provider, _) {
var categories = provider.categories.where((c) => c != '全部').toList();
// 应用搜索过滤
if (_searchQuery.isNotEmpty) {
categories = categories.where((c) =>
c.toLowerCase().contains(_searchQuery.toLowerCase())
).toList();
}
final sortedCategories = _sortCategories(categories, provider);
if (sortedCategories.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64.sp, color: Colors.grey.shade300),
SizedBox(height: 16.h),
Text('未找到匹配的分类', style: TextStyle(color: Colors.grey.shade600)),
],
),
);
}
return ListView.builder(
padding: EdgeInsets.all(16.w),
itemCount: sortedCategories.length,
itemBuilder: (context, index) {
final category = sortedCategories[index];
final albumsInCategory = provider.albums
.where((a) => a.category == category)
.toList();
return _buildCategoryCard(context, category, albumsInCategory);
},
);
},
),
);
}
void _showSearchDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('搜索分类'),
content: TextField(
autofocus: true,
decoration: const InputDecoration(
hintText: '输入分类名称',
prefixIcon: Icon(Icons.search),
),
onChanged: (value) {
setState(() => _searchQuery = value);
Navigator.pop(context);
},
),
actions: [
TextButton(
onPressed: () {
setState(() => _searchQuery = '');
Navigator.pop(context);
},
child: const Text('清除'),
),
],
),
);
}
}
搜索功能支持模糊匹配分类名称。点击搜索图标弹出对话框,输入关键词实时过滤分类列表。如果没有匹配结果,显示友好的空状态提示。清除按钮可以快速重置搜索条件。
分类编辑功能
长按分类卡片可以编辑分类信息:
Widget _buildCategoryCard(BuildContext context, String category, List<AlbumModel> albums) {
return GestureDetector(
onLongPress: () => _showCategoryOptions(context, category, albums),
child: Card(
margin: EdgeInsets.only(bottom: 16.h),
child: ExpansionTile(
// ... 卡片内容
),
),
);
}
void _showCategoryOptions(BuildContext context, String category, List<AlbumModel> albums) {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text('重命名分类'),
onTap: () {
Navigator.pop(context);
_showRenameCategoryDialog(context, category);
},
),
ListTile(
leading: const Icon(Icons.palette),
title: const Text('更改颜色'),
onTap: () {
Navigator.pop(context);
_showColorPicker(context, category);
},
),
if (albums.isEmpty)
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('删除分类', style: TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(context);
_confirmDeleteCategory(context, category);
},
),
],
),
),
);
}
void _showRenameCategoryDialog(BuildContext context, String oldName) {
final controller = TextEditingController(text: oldName);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('重命名分类'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: '分类名称',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () {
final newName = controller.text.trim();
if (newName.isNotEmpty && newName != oldName) {
context.read<AlbumProvider>().renameCategory(oldName, newName);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已将"$oldName"重命名为"$newName"')),
);
}
},
child: const Text('确定'),
),
],
),
);
}
长按分类卡片弹出操作菜单,提供重命名、更改颜色、删除等选项。只有空分类才能删除,避免误操作导致数据丢失。重命名功能会更新所有相册的分类字段,保持数据一致性。
分类统计图表
添加可视化图表展示分类分布:
Widget _buildCategoryStats(AlbumProvider provider) {
final categories = provider.categories.where((c) => c != '全部').toList();
final categoryData = categories.map((category) {
final albums = provider.albums.where((a) => a.category == category).toList();
final photoCount = albums.fold<int>(0, (sum, album) => sum + album.photoCount);
return {
'category': category,
'count': photoCount,
'color': _getColorForCategory(category),
};
}).toList();
final total = categoryData.fold<int>(0, (sum, data) => sum + (data['count'] as int));
return Card(
margin: EdgeInsets.all(16.w),
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('分类统计', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold)),
SizedBox(height: 16.h),
Row(
children: categoryData.map((data) {
final percentage = total > 0 ? (data['count'] as int) / total : 0;
return Expanded(
flex: (percentage * 100).toInt(),
child: Container(
height: 8.h,
color: data['color'] as Color,
),
);
}).toList(),
),
SizedBox(height: 16.h),
...categoryData.map((data) => Padding(
padding: EdgeInsets.symmetric(vertical: 4.h),
child: Row(
children: [
Container(
width: 12.w,
height: 12.w,
decoration: BoxDecoration(
color: data['color'] as Color,
shape: BoxShape.circle,
),
),
SizedBox(width: 8.w),
Text(data['category'] as String),
const Spacer(),
Text('${data['count']}张 (${((data['count'] as int) / total * 100).toStringAsFixed(1)}%)'),
],
),
)),
],
),
),
);
}
统计图表使用条形图展示各分类的照片数量占比。每个分类用对应的颜色表示,视觉上一目了然。下方列出详细数据,包括照片数量和百分比。这个组件可以放在列表顶部,让用户对整体分布有直观认识。
性能优化
对于大量分类和相册的情况,需要优化性能:
class _CategoryScreenState extends State<CategoryScreen> {
final Map<String, List<AlbumModel>> _categoryCache = {};
List<AlbumModel> _getAlbumsForCategory(String category, AlbumProvider provider) {
if (_categoryCache.containsKey(category)) {
return _categoryCache[category]!;
}
final albums = provider.albums.where((a) => a.category == category).toList();
_categoryCache[category] = albums;
return albums;
}
void didChangeDependencies() {
super.didChangeDependencies();
// 清除缓存,确保数据最新
_categoryCache.clear();
}
}
使用缓存避免重复过滤相册列表。每次构建时先检查缓存,如果存在直接返回。当数据变化时清除缓存,确保显示最新数据。这种优化对于有几十个分类和上百个相册的场景效果明显。
总结与技术要点
分类浏览页面通过ExpansionTile实现可展开的分类卡片,配合颜色和图标让每个分类都有辨识度。排序、搜索、统计等功能让用户可以从不同角度查看和管理分类。空状态处理和编辑功能提升了用户体验。性能优化确保在大量数据时依然流畅。
这个页面是家庭相册应用的重要导航入口,帮助用户快速找到想要的相册。通过合理的信息架构和交互设计,我们创建了一个既美观又实用的分类浏览系统。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)