在这里插入图片描述

上一篇我们实现了推荐菜谱的列表展示,今天要实现热门菜谱功能。和推荐菜谱不同,热门菜谱采用网格布局,这种布局能在有限的屏幕空间内展示更多内容,也更适合展示以图片为主的内容。

网格布局的设计考量

在设计热门菜谱页面时,我首先考虑的是如何让用户快速浏览大量内容。列表布局虽然信息展示完整,但一屏只能显示几个项目。网格布局则可以在一屏内展示更多内容,让用户能快速扫视。

网格的列数是个关键决策。一列太少,浪费屏幕空间;太多又会让每个卡片太小,看不清内容。经过权衡,我选择了两列布局。这个数字在手机屏幕上刚刚好,既能展示足够的内容,每个卡片又有足够的空间显示图片和文字。

卡片的宽高比也很重要。我设置为 0.75,也就是宽度是高度的 75%。这个比例让卡片略微偏高,能容纳图片和必要的文字信息,同时不会显得太瘦长。

搭建网格页面

热门菜谱页面的基本结构和推荐菜谱类似,但使用 GridView 代替 ListView。

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

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('热门菜谱'),
      ),

页面依然使用 StatelessWidget,因为热门列表是相对静态的。AppBar 只显示标题和自动的返回按钮,保持简洁。

这里没有导入 GetX,因为暂时不需要页面跳转。如果以后要添加点击跳转到详情的功能,再添加也不迟。这种按需导入的方式能让代码更清晰,也能减少不必要的依赖。

配置 GridView

GridView.builder 是构建网格的最佳选择,它和 ListView.builder 一样支持懒加载。

      body: GridView.builder(
        padding: EdgeInsets.all(16.w),
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          crossAxisSpacing: 12.w,
          mainAxisSpacing: 12.h,
          childAspectRatio: 0.75,
        ),
        itemCount: 20,
        itemBuilder: (context, index) {
          return _buildPopularCard(index);
        },
      ),
    );
  }

gridDelegate 是网格布局的核心配置。SliverGridDelegateWithFixedCrossAxisCount 表示使用固定列数的网格。crossAxisCount 设置为 2,表示两列布局。

crossAxisSpacing 是列之间的间距,mainAxisSpacing 是行之间的间距。我把它们都设置为 12,这个间距不大不小,既能区分不同的卡片,又不会浪费太多空间。

childAspectRatio 是宽高比,0.75 表示宽度是高度的 75%。这个比例需要根据实际内容调整,如果卡片内容较多,可以设置得更小一些,让卡片更高。

padding 设置为 16.w,让网格内容和屏幕边缘保持距离。这个间距和列间距、行间距配合,形成统一的视觉节奏。

设计热门卡片

热门菜谱的卡片设计要突出"热门"这个特点,所以我添加了热度标签。

  Widget _buildPopularCard(int index) {
    return Container(
      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),
          ),
        ],
      ),

卡片的基本样式和推荐菜谱类似,白色背景、圆角、轻微的阴影。这种统一的设计语言能让应用看起来更协调。

阴影的设置保持一致,使用半透明灰色,模糊半径 5,向下偏移 2。这个阴影很微妙,但能让卡片有轻微的悬浮感,增加层次。

      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Stack(
            children: [
              Container(
                height: 140.h,
                decoration: BoxDecoration(
                  color: Colors.orange.shade100,
                  borderRadius: BorderRadius.vertical(top: Radius.circular(12.r)),
                ),
                child: Center(
                  child: Icon(
                    Icons.restaurant,
                    size: 50.sp,
                    color: Colors.orange,
                  ),
                ),
              ),

卡片顶部是图片区域,高度设置为 140.h。这个高度比推荐菜谱的图片稍高一些,因为网格卡片的宽度较小,需要更高的图片来保持视觉平衡。

使用 Stack 布局是为了在图片上叠加热度标签。Stack 的第一个子组件是图片容器,它会作为背景层。

添加热度标签

热度标签是热门菜谱的特色,它能直观地显示菜谱的受欢迎程度。

              Positioned(
                top: 8.h,
                right: 8.w,
                child: Container(
                  padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
                  decoration: BoxDecoration(
                    color: Colors.red,
                    borderRadius: BorderRadius.circular(12.r),
                  ),
                  child: Row(
                    children: [
                      Icon(Icons.local_fire_department, size: 12.sp, color: Colors.white),
                      SizedBox(width: 2.w),
                      Text(
                        '${1000 + index * 100}',
                        style: TextStyle(color: Colors.white, fontSize: 10.sp),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),

Positioned 让标签定位在图片的右上角。top 和 right 都设置为 8,让标签和边缘保持一定距离,不会紧贴边缘。

标签使用红色背景,这个颜色能吸引注意力,也符合"热门"的概念。圆角设置为 12.r,和整体的圆角风格保持一致。

标签内容是一个火焰图标配合数字。火焰图标很形象地表达了"热度"的概念。数字使用白色,在红色背景上很醒目。这里我用 1000 + index * 100 生成模拟数据,实际应用中应该使用真实的热度值。

图标大小 12.sp,文字大小 10.sp,都比较小,因为标签只是辅助信息,不应该太突出。Row 中的 SizedBox 宽度只有 2.w,让图标和文字紧密排列。

显示菜谱信息

图片下方是菜谱的基本信息,包括名称和评分。

          Padding(
            padding: EdgeInsets.all(8.w),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  '热门菜谱 ${index + 1}',
                  style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.bold),
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                ),

Padding 设置为 8.w,比推荐菜谱的 12.w 小一些。这是因为网格卡片的空间有限,需要节省每一点空间。

标题使用粗体,字号 14.sp。maxLines 设置为 1,overflow 设置为 ellipsis,确保标题不会换行,太长的标题会显示省略号。这种处理方式在网格布局中很常见,因为空间有限,必须做出取舍。

                SizedBox(height: 4.h),
                Row(
                  children: [
                    Icon(Icons.star, size: 12.sp, color: Colors.amber),
                    SizedBox(width: 4.w),
                    Text('4.${8 - (index % 3)}', style: TextStyle(fontSize: 11.sp)),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

评分信息使用星星图标配合数字。星星图标使用琥珀色,这是评分的标准颜色。数字使用 4.8、4.7、4.6 这样的模拟数据,通过 index % 3 来产生变化。

图标大小 12.sp,文字大小 11.sp,都比较小。在网格布局中,信息要精简,只保留最重要的内容。评分是用户很关心的信息,所以保留了,但烹饪时间、难度等信息就省略了。

优化网格性能

网格布局比列表布局更消耗性能,因为同时显示的项目更多。我们需要特别注意性能优化。

首先是使用 const 构造函数。对于不变的组件,尽量使用 const:

const Icon(Icons.star)
const Text('热门菜谱')

其次是避免在 build 方法中创建复杂对象。比如 BoxDecoration,如果多个卡片使用相同的装饰,可以定义为常量:

static final cardDecoration = BoxDecoration(
  color: Colors.white,
  borderRadius: BorderRadius.circular(12),
  boxShadow: [
    BoxShadow(
      color: Colors.grey.withOpacity(0.1),
      blurRadius: 5,
      offset: Offset(0, 2),
    ),
  ],
);

不过使用 flutter_screenutil 时,圆角半径需要动态计算,就不能完全用 const 了。这时候可以考虑缓存计算结果。

实现瀑布流布局

如果菜谱卡片的高度不固定,可以考虑使用瀑布流布局。瀑布流能让卡片高度自适应,看起来更自然。

可以使用 flutter_staggered_grid_view 包:

StaggeredGridView.countBuilder(
  crossAxisCount: 2,
  itemCount: 20,
  itemBuilder: (context, index) => _buildPopularCard(index),
  staggeredTileBuilder: (index) => StaggeredTile.fit(1),
  mainAxisSpacing: 12,
  crossAxisSpacing: 12,
)

StaggeredTile.fit(1) 表示卡片占一列,高度自适应内容。这样不同高度的卡片可以紧密排列,不会留下空白。

不过瀑布流的实现比较复杂,而且性能开销更大。对于我们这个应用,固定高度的网格已经足够了。

添加筛选和排序

热门菜谱可能有不同的维度,比如按浏览量、收藏量、评分等排序。可以在 AppBar 添加筛选按钮:

AppBar(
  title: const Text('热门菜谱'),
  actions: [
    PopupMenuButton<String>(
      onSelected: (value) {
        // 根据选择的排序方式重新排序
      },
      itemBuilder: (context) => [
        PopupMenuItem(value: 'views', child: Text('按浏览量')),
        PopupMenuItem(value: 'favorites', child: Text('按收藏量')),
        PopupMenuItem(value: 'rating', child: Text('按评分')),
      ],
    ),
  ],
)

PopupMenuButton 会显示一个下拉菜单,用户可以选择排序方式。选择后,在 onSelected 回调中更新数据,重新渲染列表。

这种交互方式很常见,用户也容易理解。不过要注意,如果添加了排序功能,页面就需要改成 StatefulWidget,以便维护当前的排序状态。

处理不同屏幕尺寸

虽然我们使用了 flutter_screenutil 进行适配,但在特别大或特别小的屏幕上,两列布局可能不是最优选择。

可以根据屏幕宽度动态调整列数:

LayoutBuilder(
  builder: (context, constraints) {
    int crossAxisCount = 2;
    if (constraints.maxWidth > 600) {
      crossAxisCount = 3;
    } else if (constraints.maxWidth > 900) {
      crossAxisCount = 4;
    }
    
    return GridView.builder(
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: crossAxisCount,
        // ...
      ),
      // ...
    );
  },
)

LayoutBuilder 可以获取父容器的约束,根据宽度判断应该使用几列。这样在平板等大屏设备上,可以显示更多列,充分利用屏幕空间。

实现骨架屏

在数据加载时,可以显示骨架屏,让用户知道内容正在加载,而不是看到空白页面。

骨架屏就是用灰色的占位块模拟真实内容的布局:

Widget _buildSkeletonCard() {
  return Container(
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(12.r),
    ),
    child: Column(
      children: [
        Container(
          height: 140.h,
          color: Colors.grey.shade200,
        ),
        Padding(
          padding: EdgeInsets.all(8.w),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Container(
                height: 14.h,
                width: double.infinity,
                color: Colors.grey.shade200,
              ),
              SizedBox(height: 4.h),
              Container(
                height: 12.h,
                width: 60.w,
                color: Colors.grey.shade200,
              ),
            ],
          ),
        ),
      ],
    ),
  );
}

可以添加一个闪烁动画,让骨架屏看起来更生动:

AnimatedContainer(
  duration: Duration(milliseconds: 1000),
  color: _shimmer ? Colors.grey.shade200 : Colors.grey.shade300,
)

配合定时器不断切换 _shimmer 的值,就能产生闪烁效果。不过这需要把 Widget 改成 StatefulWidget。

添加空状态提示

如果没有热门菜谱,应该显示友好的提示,而不是空白页面。

Widget build(BuildContext context) {
  if (recipes.isEmpty) {
    return Scaffold(
      appBar: AppBar(title: const Text('热门菜谱')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.trending_up, size: 64, color: Colors.grey),
            SizedBox(height: 16),
            Text('暂无热门菜谱', style: TextStyle(color: Colors.grey)),
            SizedBox(height: 8),
            TextButton(
              onPressed: () {
                // 刷新数据
              },
              child: Text('刷新'),
            ),
          ],
        ),
      ),
    );
  }
  
  // 正常的网格
}

空状态页面使用图标、文字和按钮的组合。图标使用 trending_up,表示热门趋势。文字说明当前状态,按钮让用户可以主动刷新。

这种设计让应用更友好,即使出现异常情况,用户也知道发生了什么,以及可以做什么。

测试网格布局

网格布局的测试要特别注意不同屏幕尺寸的表现。

在模拟器中测试不同的设备,看网格是否正常显示。特别要注意小屏幕设备,卡片会不会太小,文字会不会看不清。

还要测试滚动性能,快速滚动网格,看是否流畅。如果出现卡顿,可以使用 Performance Overlay 来定位问题。

可以使用 Flutter Inspector 来检查布局,看是否有溢出或者布局错误。Inspector 可以显示每个组件的边界,帮助我们理解布局结构。

总结

热门菜谱页面使用网格布局,能在有限的空间内展示更多内容。我们添加了热度标签来突出"热门"的特点,使用评分来展示菜谱质量。

网格布局的实现比列表布局稍微复杂一些,需要仔细调整列数、间距、宽高比等参数。但一旦调整好,它能提供很好的浏览体验,特别适合展示以图片为主的内容。

下一篇文章我们将实现今日推荐功能,探讨如何设计一个有吸引力的推荐页面。


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

Logo

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

更多推荐