用户在浏览游戏、宝可梦等内容时,经常会想收藏一些喜欢的项目,方便以后快速找到。这篇文章我们来实现一个完整的收藏功能,包括收藏数据的持久化、多分类展示、滑动删除等交互。这个功能涉及到状态管理、本地存储、UI交互等多个方面,是一个很好的综合练习。
请添加图片描述

收藏功能的核心需求

首先明确一下收藏功能需要做什么:

  1. 数据持久化 - 用户收藏的内容要保存到本地,App重启后还能看到
  2. 多类型支持 - 可以收藏宝可梦、游戏、角色等不同类型的内容
  3. 快速查看 - 用户能快速查看所有收藏或按类型筛选
  4. 便捷删除 - 用户可以轻松移除不想要的收藏
  5. 撤销操作 - 误删时能快速恢复

定义收藏数据模型

首先定义一个通用的收藏项目模型,能够容纳不同类型的数据:

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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐