Flutter for OpenHarmony 万能游戏库App实战 - 我的收藏列表实现
本文介绍了一个完整的收藏功能实现方案,涵盖数据持久化、多分类展示和交互操作。通过定义灵活的FavoriteItem数据模型,支持不同类型内容的收藏。使用Provider模式管理状态,结合SharedPreferences实现本地存储。功能包括添加/删除收藏、按类型筛选、滑动删除等交互,并考虑数据加载状态和错误处理。该方案具有良好的扩展性和用户体验,适用于游戏、动漫等内容的收藏场景。
用户在浏览游戏、宝可梦等内容时,经常会想收藏一些喜欢的项目,方便以后快速找到。这篇文章我们来实现一个完整的收藏功能,包括收藏数据的持久化、多分类展示、滑动删除等交互。这个功能涉及到状态管理、本地存储、UI交互等多个方面,是一个很好的综合练习。
收藏功能的核心需求
首先明确一下收藏功能需要做什么:
- 数据持久化 - 用户收藏的内容要保存到本地,App重启后还能看到
- 多类型支持 - 可以收藏宝可梦、游戏、角色等不同类型的内容
- 快速查看 - 用户能快速查看所有收藏或按类型筛选
- 便捷删除 - 用户可以轻松移除不想要的收藏
- 撤销操作 - 误删时能快速恢复
定义收藏数据模型
首先定义一个通用的收藏项目模型,能够容纳不同类型的数据:
class FavoriteItem {
final String id;
final String type;
final String name;
final String? imageUrl;
final Map<String, dynamic> data;
FavoriteItem({
required this.id,
required this.type,
required this.name,
this.imageUrl,
required this.data,
});
这个模型设计得很灵活。id和type组合能唯一标识一个收藏项,name是显示名称,imageUrl是缩略图,data是通用的数据容器,可以存放任何类型的额外信息。
JSON序列化方法:
Map<String, dynamic> toJson() => {
'id': id,
'type': type,
'name': name,
'imageUrl': imageUrl,
'data': data,
};
factory FavoriteItem.fromJson(Map<String, dynamic> json) => FavoriteItem(
id: json['id']?.toString() ?? '',
type: json['type']?.toString() ?? '',
name: json['name']?.toString() ?? '',
imageUrl: json['imageUrl']?.toString(),
data: Map<String, dynamic>.from(json['data'] ?? {}),
);
toJson和fromJson方法让我们能够序列化和反序列化收藏数据。注意这里用了
?.toString() ?? ''这样的防御性编程,确保即使数据格式不对也不会报错。
相等性判断:
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is FavoriteItem && other.id == id && other.type == type;
}
int get hashCode => id.hashCode ^ type.hashCode;
重写==和hashCode方法,这样两个FavoriteItem如果id和type相同就认为是同一个。这对于去重和查找很重要。
收藏状态管理
FavoritesProvider负责管理所有收藏数据,使用Provider模式:
class FavoritesProvider extends ChangeNotifier {
List<FavoriteItem> _favorites = [];
bool _isInitialized = false;
List<FavoriteItem> get favorites => List.unmodifiable(_favorites);
bool get isInitialized => _isInitialized;
FavoritesProvider() {
_loadFavorites();
}
用List.unmodifiable()返回不可修改的列表,这样外部代码不能直接修改_favorites,必须通过Provider提供的方法。_isInitialized标记用来表示数据是否加载完成,这对于避免UI闪烁很重要。
数据加载:
Future<void> _loadFavorites() async {
try {
final prefs = await SharedPreferences.getInstance();
final favoritesJson = prefs.getString('favorites');
if (favoritesJson != null && favoritesJson.isNotEmpty) {
final List<dynamic> decoded = jsonDecode(favoritesJson);
_favorites = decoded
.map((e) => FavoriteItem.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
} catch (e) {
debugPrint('Error loading favorites: $e');
_favorites = [];
}
_isInitialized = true;
notifyListeners();
}
App启动时会调用_loadFavorites()从SharedPreferences读取收藏数据。try-catch确保即使读取失败也不会导致App崩溃。最后调用**notifyListeners()**通知所有监听者数据已加载。
数据保存:
Future<void> _saveFavorites() async {
try {
final prefs = await SharedPreferences.getInstance();
final encoded = jsonEncode(_favorites.map((e) => e.toJson()).toList());
await prefs.setString('favorites', encoded);
} catch (e) {
debugPrint('Error saving favorites: $e');
}
}
每次收藏列表改变时都会调用_saveFavorites()。jsonEncode会把列表转成JSON字符串,然后保存到SharedPreferences。这样即使App被杀死,数据也不会丢失。
收藏操作的实现
检查是否已收藏:
bool isFavorite(String id, String type) {
return _favorites.any((f) => f.id == id && f.type == type);
}
这个方法用来判断某个项目是否已经被收藏,通常用来更新UI中的收藏按钮状态。
添加收藏:
Future<void> addFavorite(FavoriteItem item) async {
if (!isFavorite(item.id, item.type)) {
_favorites.add(item);
notifyListeners();
await _saveFavorites();
}
}
先检查是否已收藏,避免重复。然后添加到列表,通知监听者,最后保存到本地。这个顺序很重要:先更新UI,再保存数据,这样用户能立即看到反馈。
移除收藏:
Future<void> removeFavorite(String id, String type) async {
final index = _favorites.indexWhere((f) => f.id == id && f.type == type);
if (index != -1) {
_favorites.removeAt(index);
notifyListeners();
await _saveFavorites();
}
}
先找到要删除的项目的索引,然后删除。这样做的好处是保留了删除前的数据,方便后续的撤销操作。
切换收藏状态:
Future<void> toggleFavorite(FavoriteItem item) async {
if (isFavorite(item.id, item.type)) {
await removeFavorite(item.id, item.type);
} else {
await addFavorite(item);
}
}
toggleFavorite是一个便捷方法,用来在收藏和取消收藏之间切换。这样在UI中只需要一个按钮就能处理两种情况。
按类型筛选:
List<FavoriteItem> getFavoritesByType(String type) {
return _favorites.where((f) => f.type == type).toList();
}
这个方法用来获取特定类型的收藏,比如只显示宝可梦收藏。
收藏列表页面的实现
首先是页面的整体结构,使用TabBar来分类展示:
class FavoritesScreen extends StatelessWidget {
const FavoritesScreen({super.key});
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: Text(l10n.favorites),
bottom: TabBar(
isScrollable: true,
tabs: [
Tab(text: '全部'),
Tab(text: l10n.pokemon),
Tab(text: l10n.freeGames),
Tab(text: l10n.rickMorty),
],
),
),
用DefaultTabController来管理Tab状态。isScrollable: true让Tab在数量多时能横向滚动。这里定义了4个Tab:全部、宝可梦、免费游戏、瑞克和莫蒂。
TabBarView的内容:
body: Consumer<FavoritesProvider>(
builder: (context, favorites, _) {
return TabBarView(
children: [
_FavoritesList(items: favorites.favorites, l10n: l10n),
_FavoritesList(items: favorites.getFavoritesByType('pokemon'), l10n: l10n),
_FavoritesList(items: favorites.getFavoritesByType('free_game'), l10n: l10n),
_FavoritesList(items: favorites.getFavoritesByType('rick_morty'), l10n: l10n),
],
);
},
),
用Consumer来监听FavoritesProvider的变化。每个Tab对应一个_FavoritesList,传入不同的数据源。这样当收藏列表改变时,所有Tab都会自动更新。
收藏列表项的展示
空状态的处理:
Widget build(BuildContext context) {
if (items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.favorite_border, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text('暂无收藏', style: TextStyle(color: Colors.grey[600], fontSize: 16)),
const SizedBox(height: 8),
Text('点击爱心图标添加收藏', style: TextStyle(color: Colors.grey[400], fontSize: 14)),
],
),
);
}
当没有收藏时显示一个友好的空状态提示,包括图标、主文本和辅助文本。这比直接显示空白列表要好得多。
列表项的构建:
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Dismissible(
用Dismissible包装每个列表项,这样用户可以滑动删除。Dismissible是Flutter提供的一个很好用的组件,能自动处理滑动动画。
滑动删除的背景:
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.delete, color: Colors.white),
),
direction: DismissDirection.endToStart表示从右向左滑动才能删除。background是滑动时显示的背景,红色加删除图标,一眼就能看出是删除操作。
删除确认对话框:
confirmDismiss: (direction) async {
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要移除 "${item.name}" 吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('删除'),
),
],
),
) ?? false;
},
confirmDismiss是一个回调,返回true才会真正删除。这里弹出一个对话框让用户确认,防止误删。对话框有取消和删除两个按钮,删除按钮用红色突出。
删除后的撤销功能:
onDismissed: (_) {
context.read<FavoritesProvider>().removeFavorite(item.id, item.type);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已移除 ${item.name}'),
action: SnackBarAction(
label: '撤销',
onPressed: () {
context.read<FavoritesProvider>().addFavorite(item);
},
),
),
);
},
删除后显示一个SnackBar,包含"撤销"按钮。用户如果误删可以立即恢复。这是一个很好的UX实践。
列表项的UI:
child: Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: item.imageUrl != null && item.imageUrl!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: AppNetworkImage(
imageUrl: item.imageUrl!,
width: 50,
height: 50,
fit: BoxFit.cover,
),
)
: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: _getTypeColor(item.type).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_getTypeIcon(item.type), color: _getTypeColor(item.type)),
),
leading部分显示缩略图或类型图标。如果有imageUrl就显示图片,否则显示一个彩色的类型图标。这样即使没有图片也能看出是什么类型的收藏。
标题和副标题:
title: Text(
item.name,
style: const TextStyle(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Row(
children: [
Icon(_getTypeIcon(item.type), size: 14, color: _getTypeColor(item.type)),
const SizedBox(width: 4),
Text(
_getTypeName(item.type),
style: TextStyle(fontSize: 12, color: _getTypeColor(item.type)),
),
],
),
标题显示收藏项的名称,副标题显示类型。用小图标+文字的组合来表示类型,这样信息更丰富。
点击导航:
trailing: const Icon(Icons.chevron_right),
onTap: () => _navigateToDetail(context, item),
点击列表项会跳转到对应的详情页。_navigateToDetail方法根据类型选择不同的目标页面。
导航逻辑
_navigateToDetail方法根据收藏项的类型跳转到不同的页面:
void _navigateToDetail(BuildContext context, FavoriteItem item) {
switch (item.type) {
case 'pokemon':
final id = item.data['id'];
if (id != null) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => PokemonDetailScreen(pokemonId: id is int ? id : int.tryParse(id.toString()) ?? 1)),
);
}
break;
case 'free_game':
final id = item.data['id'];
if (id != null) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => GameDetailScreen(gameId: id is int ? id : int.tryParse(id.toString()) ?? 1)),
);
}
break;
case 'rick_morty':
Navigator.push(
context,
MaterialPageRoute(builder: (_) => CharacterDetailScreen(character: item.data)),
);
break;
}
}
这里用switch语句处理不同的类型。注意对id的处理:先检查是否为null,然后判断类型,最后用tryParse安全地转换。这样即使data里的数据格式不对也不会报错。
类型相关的辅助方法
获取类型图标:
IconData _getTypeIcon(String type) {
switch (type) {
case 'pokemon':
return Icons.catching_pokemon;
case 'free_game':
return Icons.sports_esports;
case 'rick_morty':
return Icons.movie;
default:
return Icons.star;
}
}
每种类型都有对应的图标,这样用户能快速识别。
获取类型名称和颜色:
String _getTypeName(String type) {
switch (type) {
case 'pokemon':
return l10n.pokemon;
case 'free_game':
return l10n.freeGames;
case 'rick_morty':
return l10n.rickMorty;
default:
return '其他';
}
}
Color _getTypeColor(String type) {
switch (type) {
case 'pokemon':
return Colors.red;
case 'free_game':
return Colors.blue;
case 'rick_morty':
return Colors.teal;
default:
return Colors.grey;
}
}
这些方法集中管理类型相关的配置,如果以后要改某个类型的颜色或名称,只需要改这里就行。
总结
这篇文章我们实现了一个完整的收藏功能,涉及到的知识点包括:
- 数据模型设计 - 如何设计一个通用的数据模型支持多种类型
- 本地存储 - 使用SharedPreferences持久化数据
- 状态管理 - 用Provider管理收藏状态
- UI交互 - Dismissible实现滑动删除,SnackBar实现撤销
- 导航跳转 - 根据类型跳转到不同的详情页
- 国际化 - 使用AppLocalizations支持多语言
这个功能虽然看起来简单,但实际上涉及到了App开发的很多方面。好的收藏功能能让用户更方便地管理自己喜欢的内容,提升App的粘性。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)