Flutter for OpenHarmony 美食烹饪助手 App 实战:难度筛选功能实现
本文介绍了Flutter中实现菜谱难度筛选功能的设计思路和代码实现。通过创建有状态组件,使用ChoiceChip构建难度筛选标签,并将筛选结果动态应用到菜谱列表展示。页面分为顶部筛选区和下方列表区,列表项包含图片、名称、难度和时间等信息。文章详细讲解了界面布局、标签交互逻辑和样式处理,为开发者提供了一个完整的难度筛选组件实现方案,可帮助用户根据烹饪水平快速找到合适的菜谱。

烹饪是一门技艺,不同的菜谱难度差异很大。新手可能只想做些简单的家常菜,而高手则想挑战复杂的大菜。今天我们要实现难度筛选功能,让用户能够根据自己的水平来选择合适的菜谱。
难度筛选的设计思路
难度筛选要解决的核心问题是:如何让用户快速找到适合自己水平的菜谱?我选择了标签筛选的方式,因为难度等级不多,用标签展示最直观。
我把难度分为三个等级:简单、中等、困难。这个分类粗细适中,既能满足筛选需求,又不会让用户难以选择。如果分得太细,比如分成五个等级,用户反而不知道该选哪个。
筛选标签放在页面顶部,固定显示。这样用户滚动列表时,随时可以切换筛选条件,不需要滚回顶部。这种设计在电商应用中很常见,用户体验很好。
创建有状态组件
难度筛选需要记住用户的选择,所以要使用 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
更多推荐

所有评论(0)