在这里插入图片描述

在首页我们展示了一些推荐菜谱的预览,但用户往往想看到更多内容。今天我们要实现推荐菜谱列表页面,让用户能够浏览完整的推荐内容。这个页面虽然看起来简单,但其中包含了很多实用的设计技巧。

列表页面的设计思考

推荐菜谱页面的核心是一个列表,但不是简单地把数据堆砌上去就行了。我们要考虑几个问题:如何让用户快速浏览?如何展示关键信息?如何引导用户点击查看详情?

经过思考,我决定采用横向卡片布局。每个卡片左侧是图片,右侧是文字信息。这种布局在很多应用中都能看到,因为它确实有效。图片能快速吸引注意力,文字信息一目了然,整体看起来也不会太拥挤。

卡片的高度要适中,太高会让屏幕显示的内容太少,太低又放不下足够的信息。经过多次调整,我选择了 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

Logo

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

更多推荐