在这里插入图片描述

烹饪是一门技艺,不同的菜谱难度差异很大。新手可能只想做些简单的家常菜,而高手则想挑战复杂的大菜。今天我们要实现难度筛选功能,让用户能够根据自己的水平来选择合适的菜谱。

难度筛选的设计思路

难度筛选要解决的核心问题是:如何让用户快速找到适合自己水平的菜谱?我选择了标签筛选的方式,因为难度等级不多,用标签展示最直观。

我把难度分为三个等级:简单、中等、困难。这个分类粗细适中,既能满足筛选需求,又不会让用户难以选择。如果分得太细,比如分成五个等级,用户反而不知道该选哪个。

筛选标签放在页面顶部,固定显示。这样用户滚动列表时,随时可以切换筛选条件,不需要滚回顶部。这种设计在电商应用中很常见,用户体验很好。

创建有状态组件

难度筛选需要记住用户的选择,所以要使用 StatefulWidget。

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';

class DifficultyFilterPage extends StatefulWidget {
  const DifficultyFilterPage({super.key});

  
  State<DifficultyFilterPage> createState() => _DifficultyFilterPageState();
}

class _DifficultyFilterPageState extends State<DifficultyFilterPage> {
  String selectedDifficulty = '全部';

selectedDifficulty 变量存储当前选中的难度。默认值是"全部",表示不筛选,显示所有菜谱。

使用 String 类型而不是枚举,是因为这样更灵活。如果以后要添加新的难度等级,只需要在列表中添加,不需要修改枚举定义。

构建页面结构

页面分为两部分:顶部的筛选标签和下方的菜谱列表。

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('难度筛选'),
      ),
      body: Column(
        children: [
          _buildFilterChips(),
          Expanded(
            child: ListView.builder(
              padding: EdgeInsets.all(16.w),
              itemCount: 15,
              itemBuilder: (context, index) {
                return _buildRecipeItem(index);
              },
            ),
          ),
        ],
      ),
    );
  }

使用 Column 来垂直排列两部分。筛选标签不需要滚动,所以直接放在 Column 中。菜谱列表需要滚动,所以用 Expanded 包裹,让它占据剩余空间。

ListView.builder 的 itemCount 现在是固定的 15,实际开发中应该根据筛选条件动态计算。比如如果选择"简单",就只显示简单的菜谱。

实现筛选标签

筛选标签使用 ChoiceChip 组件,这是 Flutter 提供的单选标签组件。

  Widget _buildFilterChips() {
    final difficulties = ['全部', '简单', '中等', '困难'];
    
    return Container(
      padding: EdgeInsets.all(16.w),
      color: Colors.white,
      child: Wrap(
        spacing: 8.w,
        children: difficulties.map((difficulty) {
          final isSelected = selectedDifficulty == difficulty;
          return ChoiceChip(
            label: Text(difficulty),
            selected: isSelected,
            onSelected: (selected) {
              setState(() {
                selectedDifficulty = difficulty;
              });
            },
            selectedColor: Colors.orange,
            labelStyle: TextStyle(
              color: isSelected ? Colors.white : Colors.black,
            ),
          );
        }).toList(),
      ),
    );
  }

difficulties 列表定义了所有的难度选项。使用 Wrap 组件来排列标签,它会自动换行,适应不同的屏幕宽度。

spacing 设置为 8.w,让标签之间有适当的间距。如果标签很多,一行放不下,Wrap 会自动换到下一行。

ChoiceChip 的 selected 属性控制是否选中。我们通过比较 selectedDifficulty 和当前标签的值来判断。选中的标签会显示不同的样式。

onSelected 回调在用户点击标签时触发。我们调用 setState 更新 selectedDifficulty,这会触发页面重建,标签的选中状态和菜谱列表都会更新。

selectedColor 设置为橙色,和应用的主题色保持一致。labelStyle 根据是否选中来设置文字颜色,选中时是白色,未选中时是黑色。

构建菜谱列表项

每个菜谱列表项显示菜谱的基本信息,包括图片、名称、难度和时间。

  Widget _buildRecipeItem(int index) {
    return Container(
      margin: EdgeInsets.only(bottom: 12.h),
      padding: EdgeInsets.all(12.w),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12.r),
        boxShadow: [
          BoxShadow(
            color: Colors.grey.withOpacity(0.1),
            blurRadius: 5,
            offset: const Offset(0, 2),
          ),
        ],
      ),

列表项使用卡片样式,白色背景、圆角和轻微阴影。margin 设置为 bottom: 12.h,让卡片之间有间距。padding 设置为 12.w,让内容不要紧贴边缘。

      child: Row(
        children: [
          Container(
            width: 80.w,
            height: 80.h,
            decoration: BoxDecoration(
              color: Colors.orange.shade100,
              borderRadius: BorderRadius.circular(8.r),
            ),
            child: Icon(Icons.restaurant, size: 35.sp, color: Colors.orange),
          ),

左侧是菜谱图片的占位符。实际开发中应该显示真实的图片,这里用图标代替。容器大小 80x80,背景色是橙色的浅色版本。

圆角设置为 8.r,比卡片的圆角小一些,形成层次感。图标大小 35.sp,颜色是橙色,和背景色搭配。

          SizedBox(width: 12.w),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  '菜谱 ${index + 1}',
                  style: TextStyle(fontSize: 15.sp, fontWeight: FontWeight.bold),
                ),
                SizedBox(height: 6.h),
                Row(
                  children: [
                    _buildDifficultyBadge(index % 3),
                    SizedBox(width: 8.w),
                    Icon(Icons.timer, size: 12.sp, color: Colors.grey),
                    SizedBox(width: 4.w),
                    Text('${20 + index * 5}分钟', style: TextStyle(fontSize: 11.sp)),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

右侧是菜谱信息。使用 Expanded 让这部分占据剩余空间。菜谱名称使用粗体,字号 15.sp。

下方是难度标签和时间信息。它们水平排列,难度标签在左,时间在右。时间图标使用灰色,大小 12.sp,表示这是次要信息。

时间使用公式 20 + index * 5 来计算,让不同的菜谱显示不同的时间。这是模拟数据,实际开发中应该从数据库读取。

实现难度标签

难度标签使用不同的颜色来区分不同的难度等级。

  Widget _buildDifficultyBadge(int level) {
    final difficulties = ['简单', '中等', '困难'];
    final colors = [Colors.green, Colors.orange, Colors.red];
    
    return Container(
      padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
      decoration: BoxDecoration(
        color: colors[level].withOpacity(0.1),
        borderRadius: BorderRadius.circular(4.r),
      ),
      child: Text(
        difficulties[level],
        style: TextStyle(fontSize: 10.sp, color: colors[level]),
      ),
    );
  }
}

difficulties 和 colors 两个列表定义了难度名称和对应的颜色。简单用绿色,中等用橙色,困难用红色。这些颜色符合人们的直觉认知。

标签使用半透明的背景色,这样既能展示颜色,又不会太突兀。padding 设置为水平 8.w、垂直 4.h,让文字周围有适当的空间。

圆角设置为 4.r,比较小,让标签看起来紧凑。文字大小 10.sp,颜色使用对应的主色,和背景色搭配。

实现筛选逻辑

现在筛选标签只是改变了状态,但列表内容还没有根据筛选条件变化。我们需要添加筛选逻辑。

List<Recipe> _getFilteredRecipes() {
  if (selectedDifficulty == '全部') {
    return allRecipes;
  }
  return allRecipes.where((recipe) {
    return recipe.difficulty == selectedDifficulty;
  }).toList();
}

这个方法根据 selectedDifficulty 来过滤菜谱列表。如果选择"全部",就返回所有菜谱。否则只返回难度匹配的菜谱。

然后在 ListView.builder 中使用过滤后的列表:

final filteredRecipes = _getFilteredRecipes();

ListView.builder(
  itemCount: filteredRecipes.length,
  itemBuilder: (context, index) {
    return _buildRecipeItem(filteredRecipes[index]);
  },
)

这样当用户切换筛选条件时,列表会自动更新,只显示符合条件的菜谱。

添加动画效果

为了让筛选过程更流畅,可以给列表添加动画效果。当筛选条件改变时,列表项淡入淡出:

AnimatedSwitcher(
  duration: Duration(milliseconds: 300),
  child: ListView.builder(
    key: ValueKey(selectedDifficulty),
    itemCount: filteredRecipes.length,
    itemBuilder: (context, index) {
      return _buildRecipeItem(filteredRecipes[index]);
    },
  ),
)

AnimatedSwitcher 会在 child 改变时播放动画。我们给 ListView 设置一个 key,当 selectedDifficulty 改变时,key 也会改变,触发动画。

duration 设置为 300 毫秒,这是一个比较舒适的动画时长。太快会让人感觉突兀,太慢又会让人觉得卡顿。

添加空状态

如果某个难度等级下没有菜谱,需要显示空状态:

if (filteredRecipes.isEmpty) {
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(Icons.search_off, size: 64.sp, color: Colors.grey),
        SizedBox(height: 16.h),
        Text(
          '暂无${selectedDifficulty}难度的菜谱',
          style: TextStyle(color: Colors.grey),
        ),
      ],
    ),
  );
}

空状态要友好,让用户知道这是正常的情况,不是出错了。使用图标和文字说明,并且文字中包含当前的筛选条件,让用户明白为什么没有结果。

优化性能

如果菜谱数量很多,每次筛选都重新过滤列表可能会影响性能。可以考虑缓存过滤结果:

Map<String, List<Recipe>> _cachedResults = {};

List<Recipe> _getFilteredRecipes() {
  if (_cachedResults.containsKey(selectedDifficulty)) {
    return _cachedResults[selectedDifficulty]!;
  }
  
  final result = selectedDifficulty == '全部'
      ? allRecipes
      : allRecipes.where((r) => r.difficulty == selectedDifficulty).toList();
  
  _cachedResults[selectedDifficulty] = result;
  return result;
}

使用 Map 来缓存每个难度等级的过滤结果。第一次筛选时计算并缓存,后续直接从缓存读取。这样可以避免重复计算,提升性能。

不过要注意,如果菜谱列表会动态变化,需要在变化时清空缓存,否则会显示过期的数据。

总结

难度筛选功能使用标签选择的方式,让用户能够快速找到适合自己水平的菜谱。通过合理的颜色设计,不同难度等级一目了然。

筛选标签固定在顶部,用户可以随时切换条件。列表根据筛选条件动态更新,配合动画效果,整个交互流畅自然。

下一篇文章我们将实现菜谱库主界面,这是应用的核心模块之一。


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

Logo

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

更多推荐