收藏功能是教育百科App中很实用的功能,用户可以把感兴趣的图书、国家、大学等内容收藏起来,方便以后查看。做这个功能的时候我考虑了挺久,因为收藏涉及到跨页面的数据共享——在详情页点击收藏,收藏页要能立刻看到新增的内容。

最后我选择了Provider来管理收藏数据,这样任何页面都可以方便地读取和修改收藏状态。下面来看看具体实现。
请添加图片描述

为什么用StatelessWidget

收藏页面本身不需要管理状态,因为数据都来自Provider:

class FavoritesTab extends StatelessWidget {
const FavoritesTab({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(‘我的收藏’),
actions: [
Consumer(
builder: (context, provider, child) {
if (provider.favorites.isEmpty) return const SizedBox.shrink();
return IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () => _showClearDialog(context, provider),
);
},
),
],
),

AppBar右侧有一个清空按钮,但只有在有收藏内容时才显示。Consumer组件监听FavoritesProvider的变化,当收藏列表为空时返回SizedBox.shrink()——这是一个零尺寸的Widget,相当于什么都不显示。

为什么用Consumer而不是Provider.of?

Consumer只会重建它包裹的那部分Widget,而Provider.of会导致整个build方法重新执行。对于这个场景来说差别不大,但养成用Consumer的习惯是好的,性能更优。

空状态处理

当没有收藏内容时,显示友好的空状态提示:

  body: Consumer<FavoritesProvider>(
    builder: (context, provider, child) {
      if (provider.favorites.isEmpty) {
        return const EmptyWidget(
          message: '还没有收藏任何内容',
          icon: Icons.favorite_border,
        );
      }

      final groupedFavorites = _groupByType(provider.favorites);

      return ListView(
        padding: const EdgeInsets.all(16),
        children: groupedFavorites.entries.map((entry) {
          return Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              _buildTypeHeader(context, entry.key),
              ...entry.value.map((item) => _buildFavoriteItem(context, item, provider)),
              const SizedBox(height: 16),
            ],
          );
        }).toList(),
      );
    },
  ),

EmptyWidget是我自己封装的一个空状态组件,显示一个图标和提示文字。用Icons.favorite_border(空心爱心)暗示"还没有收藏"的含义。

有收藏内容时,先按类型分组,然后遍历每个分组生成UI。这样图书归图书,国家归国家,看起来更有条理。

按类型分组

将收藏列表按内容类型分组:

Map<String, List> _groupByType(List favorites) {
final Map<String, List> grouped = {};
for (final item in favorites) {
grouped.putIfAbsent(item.type, () => []).add(item);
}
return grouped;
}

putIfAbsent这个方法挺巧妙的:如果key不存在就创建一个空列表,然后返回这个列表;如果key已存在就直接返回对应的列表。不管哪种情况,都可以直接调用add方法。一行代码搞定分组逻辑。

为什么不用groupBy?

Dart的collection包里有groupBy方法,但需要额外引入依赖。对于这么简单的分组逻辑,自己写几行代码就够了,没必要为了一个方法引入一个包。

分类标题

每个分类都有一个带图标的标题:

Widget _buildTypeHeader(BuildContext context, String type) {
final typeInfo = _getTypeInfo(type);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(typeInfo[‘icon’] as IconData, color: typeInfo[‘color’] as Color, size: 20),
const SizedBox(width: 8),
Text(
typeInfo[‘label’] as String,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
],
),
);
}

图标和颜色根据类型动态获取,让不同类型的收藏在视觉上有明显区分。比如图书用蓝色书本图标,国家用绿色地球图标。

类型信息映射

定义每种类型的图标、颜色和标签:

Map<String, dynamic> _getTypeInfo(String type) {
switch (type) {
case ‘book’:
return {‘icon’: Icons.menu_book, ‘color’: Colors.blue, ‘label’: ‘图书’};
case ‘country’:
return {‘icon’: Icons.public, ‘color’: Colors.green, ‘label’: ‘国家’};
case ‘university’:
return {‘icon’: Icons.school, ‘color’: Colors.orange, ‘label’: ‘大学’};
case ‘article’:
return {‘icon’: Icons.article, ‘color’: Colors.purple, ‘label’: ‘文章’};
default:
return {‘icon’: Icons.star, ‘color’: Colors.grey, ‘label’: ‘其他’};
}
}

用switch语句处理不同类型,default分支处理未知类型。这样即使以后加了新的收藏类型但忘了更新这个方法,页面也不会崩溃,只是显示"其他"而已。

颜色的选择逻辑

和探索页一样,不同类型用不同颜色:蓝色给图书,绿色给国家,橙色给大学,紫色给文章。保持整个App的颜色编码一致,用户更容易形成记忆。

收藏项展示

每个收藏项使用Card和ListTile展示:

Widget _buildFavoriteItem(BuildContext context, FavoriteItem item, FavoritesProvider provider) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: item.imageUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: NetworkImageWidget(
imageUrl: item.imageUrl,
width: 50,
height: 50,
),
)
: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Icon(_getTypeInfo(item.type)[‘icon’] as IconData, color: Colors.grey),
),

leading区域显示图片或占位图标。如果有图片URL就显示网络图片,否则显示一个带图标的灰色方块。ClipRRect给图片加上圆角,和Card的风格保持一致。

关于图片尺寸

图片固定为50x50,这个尺寸在ListTile里刚好合适。太大会显得拥挤,太小又看不清楚。

  title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis),
  subtitle: item.subtitle != null
      ? Text(item.subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis)
      : null,
  trailing: IconButton(
    icon: const Icon(Icons.favorite, color: Colors.red),
    onPressed: () => provider.removeFavorite(item.id),
  ),
  onTap: () => _navigateToDetail(context, item),
),

);
}

标题和副标题都限制为单行,超出部分显示省略号。右侧的红心按钮点击后取消收藏,整个ListTile点击后跳转到详情页。

为什么取消收藏用红心而不是空心?

因为这是收藏页面,所有显示的内容都是已收藏的。用实心红心表示"已收藏"状态,点击后取消。如果用空心,用户可能会误以为还没收藏。

跳转到详情页

根据收藏类型跳转到对应的详情页:

void navigateToDetail(BuildContext context, FavoriteItem item) {
switch (item.type) {
case ‘book’:
Navigator.push(context, MaterialPageRoute(
builder: (
) => BookDetailScreen(bookKey: item.id),
));
break;
case ‘country’:
// 国家详情需要完整的country对象,这里暂时不处理
break;
case ‘university’:
// 大学详情同理
break;
default:
break;
}
}

目前只实现了图书类型的跳转,其他类型暂时留空。这是因为国家和大学的详情页需要完整的数据对象,而收藏只保存了id,要跳转的话还需要先请求详情数据。后续可以优化这部分逻辑。

清空确认弹窗

清空收藏前需要用户确认,避免误操作:

void _showClearDialog(BuildContext context, FavoritesProvider provider) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text(‘清空收藏’),
content: const Text(‘确定要清空所有收藏吗?此操作不可撤销。’),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(‘取消’),
),
TextButton(
onPressed: () {
for (final item in List.from(provider.favorites)) {
provider.removeFavorite(item.id);
}
Navigator.pop(context);
},
child: const Text(‘确定’, style: TextStyle(color: Colors.red)),
),
],
),
);
}

确定按钮用红色文字,提醒用户这是危险操作。

为什么用List.from创建副本?

因为在遍历过程中修改原列表会导致ConcurrentModificationErrorList.from创建一个新的列表,遍历副本、删除原列表,就不会有问题了。

FavoriteItem数据模型

收藏项的数据结构定义在Provider中:

class FavoriteItem {
final String id;
final String title;
final String type;
final String? imageUrl;
final String? subtitle;

FavoriteItem({
required this.id,
required this.title,
required this.type,
this.imageUrl,
this.subtitle,
});
}

id用于唯一标识收藏项,type区分不同类型的内容。imageUrlsubtitle是可选的,有些内容可能没有图片或副标题。

为什么不存储完整的数据对象?

存储完整对象会占用更多内存,而且序列化/反序列化也更复杂。只存储必要的字段,需要完整数据时再请求API,是更合理的做法。

FavoritesProvider的实现

Provider负责管理收藏数据:

class FavoritesProvider extends ChangeNotifier {
final List _favorites = [];

List get favorites => List.unmodifiable(_favorites);
int get count => _favorites.length;

bool isFavorite(String id) {
return _favorites.any((item) => item.id == id);
}

Future toggleFavorite(FavoriteItem item) async {
if (isFavorite(item.id)) {
_favorites.removeWhere((i) => i.id == item.id);
} else {
_favorites.add(item);
}
notifyListeners();
}

void removeFavorite(String id) {
_favorites.removeWhere((item) => item.id == id);
notifyListeners();
}
}

List.unmodifiable返回一个不可修改的列表视图,防止外部直接修改_favorites。所有修改都必须通过Provider的方法,这样才能正确触发notifyListeners

toggleFavorite vs addFavorite/removeFavorite

我提供了toggleFavorite方法,调用时不需要关心当前是否已收藏,方法内部会自动判断。这样在详情页使用时更方便,一个方法搞定添加和取消。

在详情页添加收藏

在图书详情页中添加收藏的代码:

IconButton(
icon: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Colors.red : Colors.white,
),
onPressed: () async {
await favProvider.toggleFavorite(FavoriteItem(
id: widget.bookKey,
title: _book![‘title’] ?? ‘未知标题’,
type: ‘book’,
imageUrl: coverUrl,
));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isFav ? ‘已取消收藏’ : ‘已添加收藏’),
duration: const Duration(seconds: 1),
),
);
}
},
),

图标根据收藏状态显示实心或空心爱心。点击后调用toggleFavorite,然后显示一个简短的SnackBar提示用户操作结果。SnackBar的duration设为1秒,不会太打扰用户。

为什么要检查mounted?

因为toggleFavorite是异步的,等它完成时用户可能已经离开了这个页面。如果页面已经销毁还调用ScaffoldMessenger,会报错。

数据持久化的考虑

目前的实现有一个问题:收藏数据只保存在内存里,App重启后就没了。要解决这个问题,可以用SharedPreferences或本地数据库来持久化存储。

大概的思路是:

  1. 在Provider初始化时从本地存储读取数据
  2. 每次修改收藏后同步保存到本地存储
  3. FavoriteItem需要支持序列化(toJson/fromJson)

这部分代码我就不展开了,有兴趣的可以自己实现。

小结

收藏功能的实现关键在于数据的组织和展示。通过按类型分组,用户可以快速找到想要的内容。Provider模式让数据在不同页面间共享变得简单,详情页添加收藏后,收藏页会自动更新。空状态和确认弹窗的处理让用户体验更加完善。

下一篇我们来看"我的"页面的实现,了解如何展示用户的学习统计和个人设置入口。


本文是Flutter for OpenHarmony教育百科实战系列的第四篇。

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐