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

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


请添加图片描述

为什么用StatelessWidget

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

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('我的收藏'),
        actions: [
          Consumer<FavoritesProvider>(
            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<FavoriteItem>> _groupByType(List<FavoriteItem> favorites) {
  final Map<String, List<FavoriteItem>> 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<FavoriteItem> _favorites = [];
  
  List<FavoriteItem> get favorites => List.unmodifiable(_favorites);
  int get count => _favorites.length;
  
  bool isFavorite(String id) {
    return _favorites.any((item) => item.id == id);
  }
  
  Future<void> 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

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

更多推荐