在这里插入图片描述

分类详情页面是用户浏览商品的重要入口。当用户点击某个分类后,就会进入分类详情页面,看到该分类下的所有商品。这个页面需要支持排序、筛选、分页等功能,帮助用户快速找到他们想要的商品。这篇文章会详细讲解如何实现一个功能完整的分类详情页面。

分类详情页面的用户流程

理解用户的使用流程对于设计好的分类详情页面很重要。

用户进入分类 - 用户从分类列表页面点击某个分类,进入分类详情页面。

浏览商品列表 - 用户看到该分类下的所有商品,可以向下滚动查看更多商品。

使用排序功能 - 用户可以按综合、最新、价格、评分等方式排序商品。这样可以快速找到他们想要的商品。

使用筛选功能 - 用户可以按价格范围、评分等条件筛选商品。这样可以进一步缩小搜索范围。

查看商品详情 - 用户点击某个商品,进入商品详情页面,查看商品的详细信息。

返回分类列表 - 用户可以返回分类详情页面,继续浏览其他商品。

这个流程看似简单,但每一步都需要精心设计,才能提供良好的用户体验。

分类详情页面的架构设计

一个好的分类详情页面应该包含以下几个部分:

页面标题 - 显示分类的名称。这样用户可以清楚地知道他们在哪个分类中。

操作按钮 - 包括筛选按钮和搜索按钮。这些按钮让用户可以快速访问筛选和搜索功能。

排序选项 - 显示当前的排序方式,并允许用户改变排序方式。

商品网格 - 显示该分类下的所有商品。使用网格布局可以充分利用屏幕空间。

加载状态 - 显示商品正在加载。这给用户反馈,让用户知道应用正在处理他们的请求。

空状态 - 当没有商品时,显示友好的提示。

分页加载 - 当用户滚动到列表底部时,自动加载更多的商品。

分类详情页面的实现

让我们先看一下如何实现分类详情页面。首先定义分类详情页面的 Widget:

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

  
  State<CategoryDetailPage> createState() => _CategoryDetailPageState();
}

这里使用 StatefulWidget 因为分类详情页面需要管理排序、筛选等状态。当用户改变排序方式或筛选条件时,页面需要重新构建。

为什么选择 StatefulWidget? 分类详情页面需要响应用户的交互,管理多个状态变量。这些都需要状态管理。

分类详情页面的状态定义

class _CategoryDetailPageState extends State<CategoryDetailPage> {
  String _sortBy = 'popular';
  RangeValues _priceRange = const RangeValues(0, 500);

_sortBy 存储当前的排序方式。默认值是 ‘popular’,表示按综合排序。

_priceRange 存储当前的价格范围。默认范围是 0 到 500。这个范围应该根据实际的商品价格来调整。

获取分类名称

  
  Widget build(BuildContext context) {
    final categoryName = ModalRoute.of(context)?.settings.arguments as String? ?? '分类';

    return SimpleScaffoldPage(
      title: categoryName,

ModalRoute.of(context)?.settings.arguments 用来获取从分类列表页面传递过来的分类名称。

?? ‘分类’ 是一个默认值。如果没有传递分类名称,就使用 ‘分类’ 作为默认值。这样可以避免应用崩溃。

为什么需要获取分类名称? 分类名称应该显示在页面的标题中,这样用户可以清楚地知道他们在哪个分类中。

页面的操作按钮

      actions: [
        IconButton(icon: const Icon(Icons.filter_list), onPressed: () => _showFilterSheet(context)),
        IconButton(icon: const Icon(Icons.search), onPressed: () => Navigator.of(context).pushNamed(AppRoutes.search)),
      ],

actions 是 AppBar 中的操作按钮。这些按钮通常用来执行一些快速操作。

Icons.filter_list 是筛选按钮。点击时会显示筛选面板,让用户可以根据价格、评分等条件来筛选商品。

Icons.search 是搜索按钮。点击时会导航到搜索页面,让用户可以搜索特定的商品。

为什么需要这两个按钮? 筛选和搜索是用户快速找到商品的两种重要方式。提供这两个按钮可以让用户快速访问这些功能。

排序功能的实现

排序功能让用户可以按不同的方式来排列商品。这对于帮助用户找到他们想要的商品很重要。

排序选项的显示

      child: Column(
        children: [
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            child: Row(
              children: [
                const Text('排序:'),
                DropdownButton<String>(
                  value: _sortBy,
                  underline: const SizedBox(),

Container 用来包装排序选项。添加了内边距,让内容不会紧贴屏幕边缘。

Row 用来水平排列排序标签和下拉菜单。

DropdownButton 是一个下拉菜单组件。用户可以从中选择排序方式。

underline: const SizedBox() 移除了下拉菜单下面的下划线。这样看起来更简洁。

排序选项的定义

                  items: const [
                    DropdownMenuItem(value: 'popular', child: Text('综合')),
                    DropdownMenuItem(value: 'newest', child: Text('最新')),
                    DropdownMenuItem(value: 'price_low', child: Text('价格从低到高')),
                    DropdownMenuItem(value: 'price_high', child: Text('价格从高到低')),
                    DropdownMenuItem(value: 'rating', child: Text('评分')),
                  ],
                  onChanged: (v) => setState(() => _sortBy = v!),

items 定义了所有可用的排序方式。每个排序方式都有一个值和一个显示的文本。

综合 - 按综合排序,通常是按热度或销量排序。这是最常见的排序方式。

最新 - 按上架时间排序,最新的商品排在前面。这对于想要了解最新商品的用户很有用。

价格从低到高 - 按价格升序排序。这对于想要找到便宜商品的用户很有用。

价格从高到低 - 按价格降序排序。这对于想要找到高端商品的用户很有用。

评分 - 按用户评分排序。这对于想要找到高质量商品的用户很有用。

onChanged 回调在用户选择排序方式时触发。这里调用 setState() 来更新排序方式,然后页面会重新构建,显示按新的排序方式排列的商品。

排序的实际应用

在实际项目中,排序应该在服务器端进行,而不是在客户端。这样可以提升性能:

Future<List<Product>> _fetchProducts({
  required String categoryId,
  required String sortBy,
  required RangeValues priceRange,
}) async {
  try {
    return await _api.getProducts(
      categoryId: categoryId,
      sortBy: sortBy,
      minPrice: priceRange.start.toInt(),
      maxPrice: priceRange.end.toInt(),
    );
  } catch (e) {
    print('Failed to fetch products: $e');
    return [];
  }
}

_fetchProducts() 方法从服务器获取商品。

sortBy 参数告诉服务器按哪种方式排序。

priceRange 参数告诉服务器只返回价格在指定范围内的商品。

为什么要在服务器端排序? 在服务器端排序可以减少网络流量,提升性能。如果在客户端排序,需要先下载所有商品,然后再排序,这会消耗更多的流量和时间。

排序方式的性能对比

不同的排序方式对性能的影响是不同的。让我们看一下实际的性能数据:

综合排序 - 通常基于热度、销量、评分等多个因素的综合计算。这种排序方式需要在服务器端进行复杂的计算,但结果对用户最有价值。

最新排序 - 只需要按上架时间排序。这是最快的排序方式,因为只需要按单一字段排序。

价格排序 - 按价格升序或降序排序。这种排序方式很快,因为只需要按单一字段排序。

评分排序 - 按用户评分排序。这种排序方式需要计算平均评分,但通常会被缓存。

在实际项目中,应该根据用户的需求和服务器的性能来选择合适的排序方式。

排序状态的保持

用户改变排序方式后,应该保持这个选择,直到用户再次改变:

void _onSortChanged(String newSort) {
  setState(() {
    _sortBy = newSort;
  });
  
  // 重新加载商品
  _loadProducts();
  
  // 可选:保存排序方式到本地存储
  _savePreference('sortBy', newSort);
}

Future<void> _savePreference(String key, String value) async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setString(key, value);
}

Future<void> _loadPreference() async {
  final prefs = await SharedPreferences.getInstance();
  final savedSort = prefs.getString('sortBy') ?? 'popular';
  
  setState(() {
    _sortBy = savedSort;
  });
}

_onSortChanged() 方法在用户改变排序方式时调用。

setState() 更新排序方式。

_loadProducts() 重新加载商品。

_savePreference() 将排序方式保存到本地存储。这样用户下次打开应用时,会看到之前选择的排序方式。

_loadPreference() 从本地存储加载之前保存的排序方式。

为什么需要保持排序状态? 用户可能有自己偏好的排序方式。保持这个选择可以提升用户体验。

商品网格的实现

商品网格是分类详情页面的核心部分。它显示了该分类下的所有商品。

使用 GridView.builder 显示商品

          Expanded(
            child: GridView.builder(
              padding: const EdgeInsets.all(16),
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2, 
                mainAxisSpacing: 12, 
                crossAxisSpacing: 12, 
                childAspectRatio: 0.7
              ),
              itemCount: 12,

Expanded 用来让商品网格占据剩余的屏幕空间。这样排序选项会固定在顶部,商品网格会占据下面的所有空间。

GridView.builder 用来显示商品列表。这是一个高效的网格组件,只会构建可见的 Widget。

padding: const EdgeInsets.all(16) 添加了内边距,让内容不会紧贴屏幕边缘。

SliverGridDelegateWithFixedCrossAxisCount 定义了网格的布局。

crossAxisCount: 2 表示每行显示 2 个商品。这样可以充分利用屏幕空间。

mainAxisSpacing: 12 是行之间的间距。12 像素的间距看起来比较舒适。

crossAxisSpacing: 12 是列之间的间距。

childAspectRatio: 0.7 是每个商品卡片的宽高比。0.7 表示高度比宽度更大。这样可以为商品信息留出更多的空间。

商品卡片的结构

              itemBuilder: (context, index) {
                return ShopCard(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Expanded(
                        child: Container(
                          decoration: BoxDecoration(
                            color: Colors.grey.shade200, 
                            borderRadius: BorderRadius.circular(8)
                          ),
                          child: const Center(
                            child: Icon(Icons.image, size: 40, color: Colors.grey)
                          ),
                        ),
                      ),

ShopCard 是一个自定义的卡片组件。

Column 用来垂直排列商品的图片和信息。

crossAxisAlignment: CrossAxisAlignment.start 让内容从左边开始对齐。

Expanded 用来让商品图片占据卡片的大部分空间。

Container 用来显示商品图片的占位符。在实际项目中,这里应该显示真实的商品图片。

BoxDecoration 定义了容器的样式,包括背景颜色和圆角。圆角可以让图片看起来更美观。

商品信息的显示

                      const SizedBox(height: 8),
                      Text('商品 ${index + 1}', maxLines: 2, overflow: TextOverflow.ellipsis),
                      const SizedBox(height: 4),
                      Text(${(19.99 + index * 10).toStringAsFixed(2)}', 
                        style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.red)
                      ),
                      Row(
                        children: [
                          const Icon(Icons.star, size: 14, color: Colors.amber), 
                          Text(' ${(4.0 + index * 0.1).toStringAsFixed(1)}', 
                            style: Theme.of(context).textTheme.bodySmall
                          )
                        ]
                      ),

Text(‘商品 ${index + 1}’) 显示商品的名称。

maxLines: 2 限制商品名称最多显示 2 行。如果名称太长,就会被截断。

overflow: TextOverflow.ellipsis 如果商品名称超过 2 行,就用省略号表示。这样可以避免文字溢出。

Text(‘¥…’) 显示商品的价格。价格用红色显示,这是电商应用的常见做法。红色可以吸引用户的注意力。

fontWeight: FontWeight.bold 让价格看起来更突出。

Row 用来水平排列评分图标和评分数字。

Icon(Icons.star) 显示一个星形图标,表示评分。

bodySmall 样式让评分看起来比较小,不会抢占太多的视觉空间。

商品卡片的点击事件

在实际项目中,用户应该能够点击商品卡片来查看商品详情:

                return GestureDetector(
                  onTap: () {
                    Navigator.of(context).pushNamed(
                      AppRoutes.productDetail,
                      arguments: {'productId': index, 'productName': '商品 ${index + 1}'},
                    );
                  },
                  child: ShopCard(
                    child: Column(

GestureDetector 是一个手势检测组件。用户可以点击来执行操作。

onTap 回调在用户点击时触发。这里导航到商品详情页面,并传递商品 ID 和名称。

Navigator.of(context).pushNamed() 导航到指定的路由。

arguments 传递参数到目标页面。

为什么需要点击事件? 用户需要能够点击商品来查看详情。这是一个基本的交互模式。

商品卡片的长按事件

除了点击事件,还可以添加长按事件来实现快速操作,比如添加到购物车或收藏:

                return GestureDetector(
                  onTap: () {
                    Navigator.of(context).pushNamed(
                      AppRoutes.productDetail,
                      arguments: {'productId': index},
                    );
                  },
                  onLongPress: () {
                    _showProductMenu(context, index);
                  },
                  child: ShopCard(
                    child: Column(

onLongPress 回调在用户长按时触发。这里显示一个菜单,让用户可以快速执行操作。

_showProductMenu() 方法显示一个菜单,包含添加到购物车、收藏等选项。

商品菜单的实现

void _showProductMenu(BuildContext context, int productId) {
  showModalBottomSheet(
    context: context,
    builder: (context) => Container(
      padding: const EdgeInsets.all(16),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          ListTile(
            leading: const Icon(Icons.shopping_cart),
            title: const Text('添加到购物车'),
            onTap: () {
              Navigator.of(context).pop();
              _addToCart(productId);
            },
          ),
          ListTile(
            leading: const Icon(Icons.favorite),
            title: const Text('收藏'),
            onTap: () {
              Navigator.of(context).pop();
              _addToFavorites(productId);
            },
          ),
          ListTile(
            leading: const Icon(Icons.share),
            title: const Text('分享'),
            onTap: () {
              Navigator.of(context).pop();
              _shareProduct(productId);
            },
          ),
        ],
      ),
    ),
  );
}

showModalBottomSheet 显示一个从底部弹出的菜单。

ListTile 是菜单项。每个菜单项都有一个图标、标题和点击事件。

onTap 回调在用户点击菜单项时触发。

为什么需要长按菜单? 长按菜单可以让用户快速执行常见的操作,而不需要进入商品详情页面。这可以提升用户体验。

商品卡片的动画效果

为了提升用户体验,可以在商品卡片上添加一些动画效果:

                return GestureDetector(
                  onTap: () {
                    Navigator.of(context).pushNamed(
                      AppRoutes.productDetail,
                      arguments: {'productId': index},
                    );
                  },
                  child: ScaleTransition(
                    scale: Tween<double>(begin: 1.0, end: 1.05).animate(
                      CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
                    ),
                    child: ShopCard(
                      child: Column(

ScaleTransition 是一个缩放动画组件。

Tween(begin: 1.0, end: 1.05) 定义了动画的范围。从 1.0(原始大小)到 1.05(放大 5%)。

CurvedAnimation 定义了动画的曲线。这里使用 easeInOut 曲线,让动画看起来更自然。

为什么需要动画效果? 动画效果可以让应用看起来更生动,提升用户体验。

筛选功能的实现

筛选功能让用户可以根据价格、评分等条件来筛选商品。这对于帮助用户快速找到他们想要的商品很重要。

显示筛选面板

  void _showFilterSheet(BuildContext context) {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      builder: (context) => StatefulBuilder(
        builder: (context, setSheetState) => DraggableScrollableSheet(
          initialChildSize: 0.6,
          minChildSize: 0.4,
          maxChildSize: 0.9,
          expand: false,

showModalBottomSheet 显示一个从底部弹出的面板。这是一个很常见的 UI 模式,用来显示额外的选项或信息。

isScrollControlled: true 让面板可以占据整个屏幕(除了 AppBar)。这样可以显示更多的筛选选项。

StatefulBuilder 用来在面板中管理本地状态。这样面板中的状态改变不会影响页面的状态。

DraggableScrollableSheet 让面板可以拖动调整大小。用户可以向上拖动面板来扩大它,或向下拖动来缩小它。

initialChildSize: 0.6 表示面板初始占据屏幕的 60%。这是一个合理的默认值。

minChildSize: 0.4 表示面板最小占据屏幕的 40%。用户不能把面板拖得太小。

maxChildSize: 0.9 表示面板最大占据屏幕的 90%。用户不能把面板拖得太大,这样可以保留一些空间来关闭面板。

筛选面板的标题和重置按钮

            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Text('筛选', style: Theme.of(context).textTheme.titleLarge),
                  TextButton(
                    onPressed: () => setSheetState(() => _priceRange = const RangeValues(0, 500)), 
                    child: const Text('重置')
                  ),
                ],
              ),

Row 用来水平排列标题和重置按钮。

mainAxisAlignment: MainAxisAlignment.spaceBetween 让标题和重置按钮分别显示在两端。这样可以充分利用屏幕空间。

Text(‘筛选’) 是面板的标题。使用 titleLarge 样式让标题看起来比较突出。

TextButton 是一个文本按钮。点击时会重置筛选条件。这对于用户想要清除所有筛选条件很有用。

价格范围筛选

              const SizedBox(height: 24),
              Text('价格区间', style: Theme.of(context).textTheme.titleMedium),
              RangeSlider(
                values: _priceRange,
                min: 0,
                max: 500,
                divisions: 50,
                labels: RangeLabels(${_priceRange.start.round()}', ${_priceRange.end.round()}'),

RangeSlider 是一个范围滑块组件。用户可以通过拖动滑块来选择价格范围。

values: _priceRange 是当前的价格范围。

min: 0, max: 500 是价格范围的最小值和最大值。这些值应该根据实际的商品价格来调整。

divisions: 50 表示滑块被分成 50 个部分。这样用户可以更精确地选择价格。如果 divisions 太小,用户可能无法选择他们想要的价格。

labels 显示当前选择的价格范围。这样用户可以看到他们选择的具体价格。

价格范围的更新

                onChanged: (v) { 
                  setSheetState(() => _priceRange = v); 
                  setState(() => _priceRange = v); 
                },

onChanged 回调在用户改变价格范围时触发。这里需要同时调用 setSheetState()setState() 来更新面板和页面的状态。

setSheetState() 更新面板中的状态,这样滑块会立即响应用户的操作。

setState() 更新页面的状态,这样当用户关闭面板时,页面会显示按新的价格范围筛选的商品。

评分筛选

              const SizedBox(height: 24),
              Text('评分', style: Theme.of(context).textTheme.titleMedium),
              const SizedBox(height: 8),
              Wrap(
                spacing: 8,
                children: [4, 3, 2, 1].map((rating) => FilterChip(
                      label: Row(
                        mainAxisSize: MainAxisSize.min, 
                        children: [
                          Text('$rating'), 
                          const Icon(Icons.star, size: 14, color: Colors.amber), 
                          const Text('分以上')
                        ]
                      ),
                      selected: false,
                      onSelected: (v) {},
                    )).toList(),
              ),

Wrap 用来水平排列评分筛选选项。如果空间不足,会自动换行。这样可以在小屏幕上也能显示所有的选项。

FilterChip 是一个筛选芯片组件。用户可以点击来选择评分。

spacing: 8 是芯片之间的间距。

[4, 3, 2, 1] 定义了所有可用的评分选项。这些选项表示 “4 分以上”、“3 分以上” 等。

Row 用来显示评分选项的内容。包括评分数字、星形图标和 “分以上” 的文字。

品牌筛选的实现

除了价格和评分,还可以添加品牌筛选。这对于用户想要选择特定品牌的商品很有用:

              const SizedBox(height: 24),
              Text('品牌', style: Theme.of(context).textTheme.titleMedium),
              const SizedBox(height: 8),
              Wrap(
                spacing: 8,
                children: ['苹果', '三星', '华为', '小米'].map((brand) => FilterChip(
                      label: Text(brand),
                      selected: _selectedBrands.contains(brand),
                      onSelected: (selected) {
                        setState(() {
                          if (selected) {
                            _selectedBrands.add(brand);
                          } else {
                            _selectedBrands.remove(brand);
                          }
                        });
                      },
                    )).toList(),
              ),

_selectedBrands 是一个列表,存储用户选择的品牌。

selected: _selectedBrands.contains(brand) 检查品牌是否被选择。如果被选择,芯片会显示为选中状态。

onSelected 回调在用户点击芯片时触发。这里添加或移除品牌。

为什么需要品牌筛选? 用户可能只想看特定品牌的商品。提供品牌筛选可以让用户更快地找到他们想要的商品。

库存状态筛选

              const SizedBox(height: 24),
              Text('库存状态', style: Theme.of(context).textTheme.titleMedium),
              const SizedBox(height: 8),
              CheckboxListTile(
                title: const Text('仅显示有货商品'),
                value: _showOnlyInStock,
                onChanged: (value) {
                  setState(() {
                    _showOnlyInStock = value ?? false;
                  });
                },
              ),

CheckboxListTile 是一个复选框列表项。用户可以点击来选择或取消选择。

_showOnlyInStock 是一个布尔值,表示是否只显示有货商品。

onChanged 回调在用户改变复选框状态时触发。

为什么需要库存状态筛选? 用户可能不想看没有货的商品。提供库存状态筛选可以避免用户浪费时间查看没有货的商品。

确定按钮

              const SizedBox(height: 24),
              ShopButton(label: '确定', icon: Icons.check, onPressed: () => Navigator.of(context).pop()),

ShopButton 是一个自定义的按钮组件。点击时会关闭筛选面板。

label: ‘确定’ 是按钮的文字。

icon: Icons.check 是按钮的图标。

onPressed 回调在用户点击按钮时触发。这里调用 Navigator.of(context).pop() 来关闭筛选面板。

分类详情页面的高级功能

动态加载商品

在实际项目中,商品应该从服务器动态加载,而不是硬编码:

class _CategoryDetailPageState extends State<CategoryDetailPage> {
  String _sortBy = 'popular';
  RangeValues _priceRange = const RangeValues(0, 500);
  List<Product> _products = [];
  bool _isLoading = false;
  String? _error;

  
  void initState() {
    super.initState();
    _loadProducts();
  }

  Future<void> _loadProducts() async {
    setState(() => _isLoading = true);
    
    try {
      final categoryName = ModalRoute.of(context)?.settings.arguments as String? ?? '分类';
      final products = await _api.getProducts(
        categoryId: categoryName,
        sortBy: _sortBy,
        minPrice: _priceRange.start.toInt(),
        maxPrice: _priceRange.end.toInt(),
      );
      
      setState(() {
        _products = products;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _error = e.toString();
        _isLoading = false;
      });
    }
  }

initState() 是 StatefulWidget 的生命周期方法,在 Widget 创建时调用一次。这是加载初始数据的最佳时机。

_loadProducts() 方法从服务器加载商品。

setState() 更新页面的状态,显示加载的商品。

try-catch 块用来处理可能的错误。

排序和筛选的联动

当用户改变排序方式或筛选条件时,应该重新加载商品:

void _onSortChanged(String newSort) {
  setState(() => _sortBy = newSort);
  _loadProducts();
}

void _onFilterChanged(RangeValues newRange) {
  setState(() => _priceRange = newRange);
  _loadProducts();
}

_onSortChanged() 方法在用户改变排序方式时调用。

_onFilterChanged() 方法在用户改变筛选条件时调用。

_loadProducts() 重新加载商品。

为什么需要联动? 当用户改变排序方式或筛选条件时,显示的商品应该立即改变。这样可以提升用户体验。

分页加载

当用户滚动到列表底部时,应该自动加载更多的商品:

class _CategoryDetailPageState extends State<CategoryDetailPage> {
  int _currentPage = 1;
  final int _pageSize = 20;
  List<Product> _products = [];
  bool _isLoading = false;
  bool _hasMore = true;

  Future<void> _loadMore() async {
    if (_isLoading || !_hasMore) return;

    setState(() => _isLoading = true);

    try {
      final categoryName = ModalRoute.of(context)?.settings.arguments as String? ?? '分类';
      final newProducts = await _api.getProducts(
        categoryId: categoryName,
        sortBy: _sortBy,
        minPrice: _priceRange.start.toInt(),
        maxPrice: _priceRange.end.toInt(),
        page: _currentPage,
        pageSize: _pageSize,
      );

      setState(() {
        _products.addAll(newProducts);
        _currentPage++;
        _hasMore = newProducts.length == _pageSize;
        _isLoading = false;
      });
    } catch (e) {
      setState(() => _isLoading = false);
      print('Failed to load more products: $e');
    }
  }

_currentPage 记录当前的页码。

_pageSize 定义每页加载的商品数量。

_hasMore 表示是否还有更多的商品。

_loadMore() 方法加载下一页的商品。

_products.addAll() 将新加载的商品添加到列表中。

_currentPage++ 增加页码。

_hasMore = newProducts.length == _pageSize 判断是否还有更多的商品。如果返回的商品数量少于 pageSize,说明已经到了最后一页。

空状态和错误状态的处理

if (_isLoading && _products.isEmpty) {
  return const Center(child: CircularProgressIndicator());
}

if (_error != null) {
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(Icons.error_outline, size: 80, color: Colors.red.shade400),
        const SizedBox(height: 16),
        Text('加载失败: $_error'),
        const SizedBox(height: 24),
        ElevatedButton(
          onPressed: _loadProducts,
          child: const Text('重试'),
        ),
      ],
    ),
  );
}

if (_products.isEmpty) {
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(Icons.shopping_bag, size: 80, color: Colors.grey.shade400),
        const SizedBox(height: 16),
        const Text('暂无商品'),
        const SizedBox(height: 8),
        const Text('试试其他筛选条件'),
      ],
    ),
  );
}

if (_isLoading && _products.isEmpty) 检查是否正在加载且没有商品。显示加载指示器。

if (_error != null) 检查是否有错误。显示错误提示和重试按钮。

if (_products.isEmpty) 检查是否没有商品。显示空状态提示。

为什么需要这些状态处理? 这些状态处理可以改善用户体验,让用户知道应用正在做什么。

加载更多的实现

当用户滚动到列表底部时,应该自动加载更多的商品。这可以通过监听滚动事件来实现:

ScrollController _scrollController = ScrollController();


void initState() {
  super.initState();
  _scrollController.addListener(_onScroll);
  _loadProducts();
}

void _onScroll() {
  if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
    // 用户滚动到了列表底部
    _loadMore();
  }
}


void dispose() {
  _scrollController.dispose();
  super.dispose();
}

ScrollController 用来监听滚动事件。

addListener() 添加一个监听器。当滚动位置改变时,会调用 _onScroll() 方法。

position.pixels 是当前的滚动位置。

position.maxScrollExtent 是最大的滚动位置。

if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) 检查用户是否滚动到了列表底部。

_loadMore() 加载更多的商品。

dispose() 是 StatefulWidget 的生命周期方法,在 Widget 被销毁时调用。这里释放 ScrollController 占用的资源。

为什么需要监听滚动事件? 这样可以实现无限滚动,让用户可以不断地加载更多的商品。

加载更多的优化

为了避免重复加载,应该添加一些检查:

Future<void> _loadMore() async {
  // 检查是否已经在加载或没有更多商品
  if (_isLoading || !_hasMore) {
    return;
  }

  setState(() => _isLoading = true);

  try {
    final categoryName = ModalRoute.of(context)?.settings.arguments as String? ?? '分类';
    final newProducts = await _api.getProducts(
      categoryId: categoryName,
      sortBy: _sortBy,
      minPrice: _priceRange.start.toInt(),
      maxPrice: _priceRange.end.toInt(),
      page: _currentPage,
      pageSize: _pageSize,
    );

    if (newProducts.isEmpty) {
      setState(() => _hasMore = false);
    } else {
      setState(() {
        _products.addAll(newProducts);
        _currentPage++;
      });
    }
  } catch (e) {
    print('Failed to load more products: $e');
  } finally {
    setState(() => _isLoading = false);
  }
}

if (_isLoading || !_hasMore) return 检查是否已经在加载或没有更多商品。如果是,就直接返回,避免重复加载。

if (newProducts.isEmpty) 检查是否返回了空列表。如果是,说明已经到了最后一页。

finally 块确保无论是否成功,都会设置 _isLoading 为 false。

为什么需要这些检查? 这些检查可以避免重复加载和不必要的网络请求。

分类详情页面的最佳实践

1. 合理的网格布局

网格布局应该根据屏幕大小来调整。在大屏幕上可以显示更多的列:

int _getCrossAxisCount(BuildContext context) {
  final width = MediaQuery.of(context).size.width;
  
  if (width > 900) {
    return 4; // 大屏幕显示 4 列
  } else if (width > 600) {
    return 3; // 中等屏幕显示 3 列
  } else {
    return 2; // 小屏幕显示 2 列
  }
}

MediaQuery.of(context).size.width 获取屏幕的宽度。

根据屏幕宽度返回不同的列数。这样可以在不同的设备上提供最佳的用户体验。

响应式网格的应用

GridView.builder(
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: _getCrossAxisCount(context),
    mainAxisSpacing: 12,
    crossAxisSpacing: 12,
    childAspectRatio: 0.7,
  ),
  itemCount: _products.length,
  itemBuilder: (context, index) {
    return _buildProductCard(context, index);
  },
)

_getCrossAxisCount(context) 动态获取列数。

这样可以在不同的设备上自动调整网格布局

2. 性能优化

使用 ListView.builder 而不是 ListView 可以提升性能。ListView.builder 只会构建可见的 Widget。

使用缓存来避免重复的网络请求。

使用分页加载来减少初始加载时间。

缓存的实现

class ProductCache {
  static final Map<String, List<Product>> _cache = {};
  static final Map<String, DateTime> _cacheTime = {};
  static const Duration _cacheDuration = Duration(minutes: 30);

  static List<Product>? get(String key) {
    if (!_cache.containsKey(key)) {
      return null;
    }

    final cacheTime = _cacheTime[key];
    if (cacheTime != null && 
        DateTime.now().difference(cacheTime) > _cacheDuration) {
      _cache.remove(key);
      _cacheTime.remove(key);
      return null;
    }

    return _cache[key];
  }

  static void set(String key, List<Product> products) {
    _cache[key] = products;
    _cacheTime[key] = DateTime.now();
  }

  static void clear() {
    _cache.clear();
    _cacheTime.clear();
  }
}

_cache 存储缓存的商品列表。

_cacheTime 存储每个缓存的时间。

_cacheDuration 定义缓存的有效期。30 分钟后缓存会过期。

get() 方法获取缓存。如果缓存已过期,返回 null。

set() 方法保存缓存。

clear() 方法清除所有缓存。

3. 用户体验

提供清晰的排序和筛选选项。

显示加载状态和错误状态。

支持分页加载,让用户可以无限滚动。

用户反馈的实现

void _showSuccessMessage(String message) {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text(message),
      backgroundColor: Colors.green,
      duration: const Duration(seconds: 2),
    ),
  );
}

void _showErrorMessage(String message) {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text(message),
      backgroundColor: Colors.red,
      duration: const Duration(seconds: 2),
    ),
  );
}

ScaffoldMessenger.of(context).showSnackBar() 显示一个 SnackBar。

SnackBar 是一个临时的消息提示。

backgroundColor 定义 SnackBar 的背景颜色。

duration 定义 SnackBar 显示的时间。

为什么需要用户反馈? 用户需要知道他们的操作是否成功。提供清晰的反馈可以改善用户体验。

4. 数据一致性

当用户改变排序方式或筛选条件时,应该重新加载商品。

当用户返回分类详情页面时,应该保持之前的排序方式和筛选条件。

状态保持的实现


void initState() {
  super.initState();
  _loadSavedState();
  _loadProducts();
}

Future<void> _loadSavedState() async {
  final prefs = await SharedPreferences.getInstance();
  
  final savedSort = prefs.getString('sortBy') ?? 'popular';
  final savedMinPrice = prefs.getDouble('minPrice') ?? 0;
  final savedMaxPrice = prefs.getDouble('maxPrice') ?? 500;
  
  setState(() {
    _sortBy = savedSort;
    _priceRange = RangeValues(savedMinPrice, savedMaxPrice);
  });
}

Future<void> _saveState() async {
  final prefs = await SharedPreferences.getInstance();
  
  await prefs.setString('sortBy', _sortBy);
  await prefs.setDouble('minPrice', _priceRange.start);
  await prefs.setDouble('maxPrice', _priceRange.end);
}

_loadSavedState() 从本地存储加载之前保存的状态。

_saveState() 将当前的状态保存到本地存储。

这样用户下次打开分类详情页面时,会看到之前的排序方式和筛选条件

为什么需要保持状态? 用户可能有自己偏好的排序方式和筛选条件。保持这些选择可以提升用户体验。

总结

这篇文章实现了一个功能完整的分类详情页面,包括排序、筛选、分页等功能。

分类详情页面是用户浏览商品的重要入口。一个好的分类详情页面可以帮助用户快速找到他们想要的商品,提升用户体验。

关键要点:

  • 使用 StatefulWidget 来管理排序、筛选等状态
  • 使用 GridView.builder 来高效地显示商品
  • 提供排序和筛选功能 来帮助用户快速找到商品
  • 支持分页加载 来提升性能
  • 处理空状态和错误状态 来改善用户体验
  • 根据屏幕大小调整网格布局 来提供最佳的用户体验

代码都来自实际项目,可以直接运行。下一篇我们会实现商品详情页面,讲解如何展示商品的详细信息。


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

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐