Flutter for OpenHarmony 美食烹饪助手 App 实战:推荐菜谱功能实现
推荐菜谱列表页面设计与实现 本文介绍了推荐菜谱列表页面的设计思路与实现过程。采用横向卡片布局,左侧图片+右侧文字的结构提升浏览效率。使用ListView.builder实现懒加载优化性能,卡片高度设为100保持视觉平衡。页面包含20个示例卡片,点击跳转详情页。每个卡片包含标题、简介、烹饪时间和难度信息,通过阴影、圆角等视觉效果增强用户体验。文字采用多行省略处理,图标辅助展示关键信息,整体设计兼顾美

在首页我们展示了一些推荐菜谱的预览,但用户往往想看到更多内容。今天我们要实现推荐菜谱列表页面,让用户能够浏览完整的推荐内容。这个页面虽然看起来简单,但其中包含了很多实用的设计技巧。
列表页面的设计思考
推荐菜谱页面的核心是一个列表,但不是简单地把数据堆砌上去就行了。我们要考虑几个问题:如何让用户快速浏览?如何展示关键信息?如何引导用户点击查看详情?
经过思考,我决定采用横向卡片布局。每个卡片左侧是图片,右侧是文字信息。这种布局在很多应用中都能看到,因为它确实有效。图片能快速吸引注意力,文字信息一目了然,整体看起来也不会太拥挤。
卡片的高度要适中,太高会让屏幕显示的内容太少,太低又放不下足够的信息。经过多次调整,我选择了 100 的高度,这个尺寸在大多数设备上都能有不错的显示效果。
搭建页面框架
推荐菜谱页面是一个独立的页面,需要自己的 AppBar 和内容区域。
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../library/recipe_detail_page.dart';
class RecommendedRecipesPage extends StatelessWidget {
const RecommendedRecipesPage({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('推荐菜谱'),
),
这个页面使用 StatelessWidget 就够了,因为推荐列表是静态的,不需要维护复杂的状态。如果以后要添加筛选、排序等功能,再改成 StatefulWidget 也不迟。
AppBar 只显示标题,左侧会自动显示返回按钮。这是 Flutter 的默认行为,当页面是通过 Navigator.push 或 Get.to 打开时,AppBar 会自动添加返回按钮。
导入语句中包含了 RecipeDetailPage,这是用户点击菜谱后要跳转的详情页面。虽然详情页面在 library 模块,但我们可以跨模块引用,这就是模块化设计的灵活性。
使用 ListView.builder 构建列表
列表的实现有多种方式,但对于数据量较大的列表,ListView.builder 是最佳选择。
body: ListView.builder(
padding: EdgeInsets.all(16.w),
itemCount: 20,
itemBuilder: (context, index) {
return _buildRecipeItem(index);
},
),
);
}
ListView.builder 的优势在于懒加载。它不会一次性创建所有列表项,而是只创建可见区域的项。当用户滚动时,才会创建新的项,同时回收不可见的项。这种机制大大减少了内存占用和渲染开销。
padding 设置为 16.w,让列表内容和屏幕边缘保持一定距离。这个间距不大不小,既不会让内容贴边显得局促,也不会浪费太多屏幕空间。
itemCount 设置为 20,表示列表有 20 个项。在实际应用中,这个数字应该来自数据源,比如从数据库查询或者从网络请求获取。现在我们先用固定值,后面再接入真实数据。
itemBuilder 是一个回调函数,它接收 context 和 index 两个参数,返回一个 Widget。Flutter 会根据需要调用这个函数来创建列表项。我们把具体的构建逻辑提取到 _buildRecipeItem 方法中,保持代码清晰。
设计菜谱卡片
每个菜谱卡片是一个独立的组件,包含图片、标题、描述和一些辅助信息。
Widget _buildRecipeItem(int index) {
return GestureDetector(
onTap: () => Get.to(() => RecipeDetailPage(recipeId: 'recipe_$index')),
child: Container(
margin: EdgeInsets.only(bottom: 16.h),
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),
),
],
),
GestureDetector 让整个卡片可以响应点击。点击时使用 Get.to 跳转到详情页面,并传递菜谱 ID。这里我们用 ‘recipe_$index’ 作为临时 ID,实际应用中应该使用真实的菜谱 ID。
Container 的 margin 只设置底部间距,这样卡片之间有适当的分隔,但不会在顶部和左右浪费空间。decoration 设置了白色背景、圆角和阴影。
阴影的设置很讲究。颜色使用半透明的灰色,opacity 设置为 0.1,这样阴影很淡,不会太突兀。blurRadius 是模糊半径,5 是一个比较柔和的值。offset 设置为 (0, 2),表示阴影向下偏移 2 个单位,这样能产生轻微的悬浮效果。
布局图片和文字
卡片内部使用 Row 布局,左侧是图片,右侧是文字信息。
child: Row(
children: [
Container(
width: 100.w,
height: 100.h,
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.horizontal(left: Radius.circular(12.r)),
),
child: Icon(Icons.restaurant, size: 40.sp, color: Colors.orange),
),
图片区域是一个正方形,宽高都是 100。背景色使用浅橙色,这样即使没有图片,也不会显得空白。borderRadius 只设置左侧的圆角,和外层容器的圆角配合,形成统一的视觉效果。
现在我们用图标代替真实图片。在实际应用中,这里应该使用 Image.network 或 CachedNetworkImage 来加载图片。使用图标的好处是不需要准备图片资源,开发阶段可以快速看到效果。
Expanded(
child: Padding(
padding: EdgeInsets.all(12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'推荐菜谱 ${index + 1}',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
),
),
右侧使用 Expanded 包裹,这样它会占据剩余的所有空间。Padding 设置为 12.w,让文字和边缘保持一定距离。
Column 的 crossAxisAlignment 设置为 start,让所有子组件左对齐。标题使用粗体,字号 16.sp,这样能突出显示。在实际应用中,标题应该来自菜谱数据,而不是硬编码的字符串。
添加描述和辅助信息
标题下方是菜谱的简短描述和一些辅助信息。
SizedBox(height: 8.h),
Text(
'这是一道美味的家常菜,简单易做',
style: TextStyle(fontSize: 12.sp, color: Colors.grey),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
描述文字使用灰色,字号较小,作为辅助信息。maxLines 设置为 2,表示最多显示两行。overflow 设置为 ellipsis,如果文字超过两行,会显示省略号。
这种处理方式很常见,既能展示足够的信息,又不会让卡片变得太高。用户如果想看完整描述,可以点击进入详情页面。
SizedBox(height: 8.h),
Row(
children: [
Icon(Icons.timer, size: 14.sp, color: Colors.grey),
SizedBox(width: 4.w),
Text('30分钟', style: TextStyle(fontSize: 11.sp)),
SizedBox(width: 16.w),
Icon(Icons.local_fire_department, size: 14.sp, color: Colors.grey),
SizedBox(width: 4.w),
Text('简单', style: TextStyle(fontSize: 11.sp)),
],
),
],
),
),
),
],
),
),
);
}
}
最下面一行显示烹饪时间和难度。使用小图标配合文字,这种设计很直观。timer 图标表示时间,fire 图标表示难度。图标和文字都使用灰色,字号很小,作为次要信息。
两组信息之间用 SizedBox 分隔,宽度 16.w。这个间距让两组信息既有区分,又不会显得太分散。
优化点击体验
虽然我们已经用 GestureDetector 实现了点击功能,但还可以做一些优化。
比如添加点击反馈效果。当用户按下卡片时,可以改变卡片的背景色或者透明度,让用户知道点击被识别了:
GestureDetector(
onTap: () => Get.to(() => RecipeDetailPage(recipeId: 'recipe_$index')),
onTapDown: (_) {
// 改变状态,显示按下效果
},
onTapUp: (_) {
// 恢复状态
},
onTapCancel: () {
// 取消时恢复状态
},
child: Container(
// ...
),
)
不过这需要把 Widget 改成 StatefulWidget,增加了复杂度。对于这种简单的场景,使用 InkWell 可能更合适:
InkWell(
onTap: () => Get.to(() => RecipeDetailPage(recipeId: 'recipe_$index')),
borderRadius: BorderRadius.circular(12.r),
child: Container(
// ...
),
)
InkWell 会自动显示水波纹效果,不需要额外的状态管理。borderRadius 参数让水波纹遵循容器的圆角,看起来更自然。
处理空状态和加载状态
虽然现在列表总是有数据,但实际应用中可能会遇到空列表或者加载中的情况。
可以在 ListView.builder 外面包一层判断:
Widget build(BuildContext context) {
if (isLoading) {
return Scaffold(
appBar: AppBar(title: const Text('推荐菜谱')),
body: Center(child: CircularProgressIndicator()),
);
}
if (recipes.isEmpty) {
return Scaffold(
appBar: AppBar(title: const Text('推荐菜谱')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('暂无推荐菜谱', style: TextStyle(color: Colors.grey)),
],
),
),
);
}
return Scaffold(
// 正常的列表
);
}
这种处理方式让应用更健壮。即使数据还没加载完成,或者服务器返回了空列表,用户也能看到友好的提示,而不是一个空白页面。
实现下拉刷新
用户可能想刷新推荐列表,看看有没有新的推荐。可以添加下拉刷新功能:
RefreshIndicator(
onRefresh: () async {
// 重新获取数据
await Future.delayed(Duration(seconds: 1));
// 更新列表
},
child: ListView.builder(
// ...
),
)
RefreshIndicator 包裹 ListView,当用户下拉时会触发 onRefresh 回调。在回调中,我们可以重新请求数据,然后更新界面。
注意 onRefresh 必须返回一个 Future,这样 RefreshIndicator 才知道什么时候停止显示加载指示器。如果数据请求是异步的,直接返回那个 Future 就行。如果是同步的,可以用 Future.delayed 模拟一个异步操作。
实现上拉加载更多
如果推荐菜谱很多,一次性加载所有数据会很慢,也会占用大量内存。更好的做法是分页加载,用户滚动到底部时自动加载下一页。
可以给 ListView 添加滚动监听:
final ScrollController _scrollController = ScrollController();
void initState() {
super.initState();
_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
});
}
void _loadMore() {
if (isLoading || !hasMore) return;
// 加载下一页数据
}
这里我们在距离底部还有 200 像素时就开始加载,而不是等到完全滚动到底部。这样可以让加载过程更流畅,用户不需要等待。
isLoading 标志防止重复加载,hasMore 标志表示是否还有更多数据。这两个标志都很重要,否则可能会出现无限加载的问题。
优化列表性能
虽然 ListView.builder 已经做了很多优化,但我们还可以做一些额外的工作。
首先是使用 const 构造函数。对于不变的组件,尽量使用 const,这样 Flutter 可以复用它们:
const Text('推荐菜谱')
const Icon(Icons.timer)
其次是避免在 build 方法中创建新对象。比如 TextStyle,可以定义为常量:
static const titleStyle = TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
);
然后在使用时引用这个常量。不过使用 flutter_screenutil 时,字号需要动态计算,就不能用 const 了。这时候可以考虑缓存 TextStyle 对象。
最后是图片优化。如果使用网络图片,一定要使用缓存机制。CachedNetworkImage 是一个很好的选择:
CachedNetworkImage(
imageUrl: recipe.imageUrl,
width: 100.w,
height: 100.h,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.orange.shade100,
child: Icon(Icons.restaurant),
),
errorWidget: (context, url, error) => Icon(Icons.error),
)
placeholder 在图片加载时显示,errorWidget 在加载失败时显示。这样可以避免出现空白或者错误的情况。
添加搜索和筛选
推荐列表可能很长,用户想快速找到特定的菜谱。可以在 AppBar 添加搜索按钮:
AppBar(
title: const Text('推荐菜谱'),
actions: [
IconButton(
icon: Icon(Icons.search),
onPressed: () {
// 打开搜索页面
},
),
IconButton(
icon: Icon(Icons.filter_list),
onPressed: () {
// 显示筛选选项
},
),
],
)
搜索功能可以跳转到专门的搜索页面,或者在当前页面显示搜索框。筛选功能可以弹出一个对话框,让用户选择筛选条件。
这些功能虽然不是必需的,但能大大提升用户体验,特别是当数据量很大时。
测试列表功能
完成代码后,要进行充分的测试。
首先测试基本功能,滚动列表,点击卡片,看是否能正常跳转。然后测试边界情况,比如列表为空、只有一项、有很多项等。
还要测试性能,快速滚动列表,看是否流畅,内存占用是否正常。可以使用 Flutter DevTools 来监控性能指标。
如果发现卡顿,可以使用 Performance Overlay 来定位问题:
MaterialApp(
showPerformanceOverlay: true,
// ...
)
这会在屏幕上显示性能图表,红色表示掉帧,绿色表示流畅。根据图表可以找出性能瓶颈。
总结
推荐菜谱列表页面看似简单,但其中包含了很多细节。从布局设计到性能优化,从交互体验到异常处理,每个环节都需要仔细考虑。
我们使用 ListView.builder 实现了高效的列表渲染,用横向卡片布局展示菜谱信息,添加了点击跳转功能。整个页面简洁明了,操作流畅,能给用户良好的浏览体验。
下一篇文章我们将实现热门菜谱展示功能,探讨网格布局的设计和实现。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)