flutter学习第 9 节:常用列表组件

132 阅读3分钟

在移动应用开发中,列表是展示数据的最常用方式之一。无论是联系人列表、商品展示还是新闻资讯,都离不开列表组件。Flutter 提供了丰富的列表相关组件,能够满足各种复杂的布局需求和性能要求。本节课将详细介绍 Flutter 中常用的列表组件及其高级用法,帮助你构建高效、美观的列表界面。

一、基础列表:ListView

ListView 是 Flutter 中最基础的列表组件,它可以沿一个方向线性排列子组件。ListView 有多种构造方法,适用于不同的使用场景。

1. 基本用法:ListView 直接包含子组件

最简单的 ListView 使用方式是直接在 children 属性中传入子组件列表:

ListView(
  // 列表内边距
  padding: const EdgeInsets.all(16),
  // 列表项
  children: const [
    Text('Item 1', style: TextStyle(fontSize: 18)),
    Text('Item 2', style: TextStyle(fontSize: 18)),
    Text('Item 3', style: TextStyle(fontSize: 18)),
    Text('Item 4', style: TextStyle(fontSize: 18)),
    Text('Item 5', style: TextStyle(fontSize: 18)),
  ],
)

这种方式适用于子组件数量较少的情况,因为它会一次性创建所有子组件,即使它们还没有显示在屏幕上。

2. 优化长列表:ListView.builder

当列表项数量较多(超过 10 个)或列表长度不确定时,应该使用 ListView.builder 来构建列表。它会根据滚动位置动态创建和销毁列表项,显著提高性能

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

  @override
  Widget build(BuildContext context) {
    // 模拟 1000 条数据
    return ListView.builder(
      // 列表项数量
      itemCount: 1000,
      // 列表内边距
      padding: const EdgeInsets.all(16),
      // 列表项构建器
      itemBuilder: (context, index) {
        // 只在需要显示时才会创建
        return ListTile(
          title: Text('Item ${index + 1}'),
          subtitle: Text('This is item ${index + 1} in the list'),
          leading: const Icon(Icons.list),
          trailing: const Icon(Icons.arrow_forward_ios),
        );
      },
    );
  }
}

ListView.builder 的核心参数:

  • itemCount:列表项数量,指定后列表会有明确的长度
  • itemBuilder:列表项构建函数,接收索引参数,返回对应位置的列表项
  • physics:控制滚动行为(如 NeverScrollableScrollPhysics 禁用滚动)
  • shrinkWrap:是否根据子组件尺寸确定列表大小,默认为 false

3. 其他 ListView 构造方法

  • ListView.separated:可以在列表项之间添加分隔符,适用于需要分隔线的场景:
ListView.separated(
  itemCount: 50,
  // 列表项构建器
  itemBuilder: (context, index) {
    return ListTile(
      title: Text('Contact ${index + 1}'),
      leading: const CircleAvatar(child: Icon(Icons.person)),
    );
  },
  // 分隔符构建器
  separatorBuilder: (context, index) {
    // 每两个列表项之间的分隔线
    return const Divider(height: 1);
  },
)
  • ListView.custom:完全自定义列表项的构建方式,适用于复杂场景,需要配合 SliverChildBuilderDelegate 或 SliverChildListDelegate 使用。

4. 列表滚动控制

通过 ScrollController 可以控制列表的滚动行为,如滚动到指定位置、监听滚动事件等:

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

  @override
  State<ControlledListExample> createState() => _ControlledListExampleState();
}

class _ControlledListExampleState extends State<ControlledListExample> {
  // 创建滚动控制器
  final ScrollController _scrollController = ScrollController();

  @override
  void dispose() {
    // 释放资源
    _scrollController.dispose();
    super.dispose();
  }

  // 滚动到顶部
  void _scrollToTop() {
    _scrollController.animateTo(
      0,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeInOut,
    );
  }

  // 滚动到底部
  void _scrollToBottom() {
    _scrollController.animateTo(
      _scrollController.position.maxScrollExtent,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeInOut,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Controlled List')),
      body: ListView.builder(
        // 关联滚动控制器
        controller: _scrollController,
        itemCount: 50,
        itemBuilder: (context, index) {
          return ListTile(title: Text('Item ${index + 1}'));
        },
      ),
      floatingActionButton: Row(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            onPressed: _scrollToTop,
            child: const Icon(Icons.arrow_upward),
          ),
          const SizedBox(width: 10),
          FloatingActionButton(
            onPressed: _scrollToBottom,
            child: const Icon(Icons.arrow_downward),
          ),
        ],
      ),
    );
  }
}


二、列表项:ListTile 与自定义列表项

列表项是列表的基本组成单元,Flutter 提供了预设样式的 ListTile,也支持完全自定义列表项。

1. ListTile 组件

ListTile 是 Material Design 风格的列表项组件,包含了常见的列表项元素,如图标、标题、副标题等:

ListTile(
  // 左侧图标
  leading: const Icon(Icons.email),
  // 主标题
  title: const Text('Contact Us'),
  // 副标题
  subtitle: const Text('support@example.com'),
  // 右侧图标
  trailing: const Icon(Icons.arrow_forward_ios),
  // 是否可点击
  enabled: true,
  // 是否选中
  selected: false,
  // 点击事件
  onTap: () {
    print('ListTile tapped');
  },
  // 长按事件
  onLongPress: () {
    print('ListTile long pressed');
  },
  // 左侧图标与文字之间的间距
  contentPadding: const EdgeInsets.symmetric(horizontal: 16),
)

ListTile 变体:

  • CheckboxListTile:包含复选框的列表项
  • RadioListTile:包含单选按钮的列表项
  • SwitchListTile:包含开关的列表项

示例:

// 带复选框的列表项
CheckboxListTile(
  title: const Text('Remember me'),
  value: true,
  onChanged: (value) {},
  controlAffinity: ListTileControlAffinity.leading, // 复选框位置
)

// 带开关的列表项
SwitchListTile(
  title: const Text('Dark mode'),
  value: false,
  onChanged: (value) {},
  secondary: const Icon(Icons.dark_mode),
)

2. 自定义列表项

当 ListTile 不能满足需求时,可以通过 ContainerRow 等基础组件构建自定义列表项:

final products = [
  PItem(
    'a',
    11.2,
    'https://picsum.photos/300/400?random=$3',
    23.1,
  ),
  PItem(
    'b',
    12.2,
    'https://picsum.photos/300/400?random=$5',
    26.1,
  ),
];

class PItem {
  final String name;
  final double price;
  final String imageUrl;
  final double rating;

  PItem(this.name, this.price, this.imageUrl, this.rating);
}

// 使用自定义列表项
ListView.builder(
  itemCount: products.length,
  itemBuilder: (context, index) {
    final product = products[index];
    return ProductItem(
      name: product.name,
      price: product.price,
      imageUrl: product.imageUrl,
      rating: product.rating,
    );
  },
)

// 自定义商品列表项
class ProductItem extends StatelessWidget {
  final String name;
  final double price;
  final String imageUrl;
  final double rating;

  const ProductItem({
    super.key,
    required this.name,
    required this.price,
    required this.imageUrl,
    required this.rating,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(12),
      margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(8),
        boxShadow: [
          BoxShadow(
            color: Colors.grey.withOpacity(0.2),
            blurRadius: 4,
            offset: const Offset(0, 2),
          ),
        ],
      ),
      child: Row(
        children: [
          // 商品图片
          ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: Image.network(
              imageUrl,
              width: 80,
              height: 80,
              fit: BoxFit.cover,
            ),
          ),
          const SizedBox(width: 12),
          // 商品信息
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  name,
                  style: const TextStyle(
                    fontSize: 16,
                    fontWeight: FontWeight.bold,
                  ),
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                ),
                const SizedBox(height: 4),
                // 评分
                Row(
                  children: [
                    const Icon(Icons.star, color: Colors.yellow, size: 16),
                    const SizedBox(width: 4),
                    Text(
                      rating.toString(),
                      style: const TextStyle(fontSize: 14, color: Colors.grey),
                    ),
                  ],
                ),
              ],
            ),
          ),
          // 价格
          Text(
            '$${price.toStringAsFixed(2)}',
            style: const TextStyle(
              fontSize: 16,
              fontWeight: FontWeight.bold,
              color: Colors.red,
            ),
          ),
        ],
      ),
    );
  }
}


三、网格布局:GridView

GridView 用于构建网格布局,适用于展示图片画廊、商品网格等场景。它同样提供了多种构造方法,以适应不同的使用需求。

1. GridView.count

GridView.count 允许指定每行的列数,是最常用的网格布局构造方法:

GridView.count(
  // 每行的列数
  crossAxisCount: 2,
  // 列之间的间距
  crossAxisSpacing: 10,
  // 行之间的间距
  mainAxisSpacing: 10,
  // 网格内边距
  padding: const EdgeInsets.all(10),
  // 子组件宽高比
  childAspectRatio: 1.0, // 宽高相等
  // 子组件列表
  children: List.generate(20, (index) {
    return Container(
      color: Colors.blue[100 * ((index % 9) + 1)],
      child: Center(
        child: Text('Item $index', style: const TextStyle(fontSize: 18)),
      ),
    );
  }),
)

核心参数:

  • crossAxisCount:交叉轴方向的组件数量(即每行的列数)
  • childAspectRatio:子组件的宽高比,影响网格项的形状
  • crossAxisSpacing 和 mainAxisSpacing:控制网格项之间的间距

2. GridView.builder

当网格项数量较多时,应使用 GridView.builder 来优化性能,它会动态创建和销毁网格项:

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

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      // 网格布局委托
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3, // 3列
        crossAxisSpacing: 4,
        mainAxisSpacing: 4,
        childAspectRatio: 0.75, // 宽高比 4:3
      ),
      // 网格项数量
      itemCount: 50,
      // 网格项构建器
      itemBuilder: (context, index) {
        // 构建图片网格项
        return ClipRRect(
          borderRadius: BorderRadius.circular(4),
          child: Image.network(
            // 使用网络图片
            'https://picsum.photos/300/400?random=$index',
            fit: BoxFit.cover,
          ),
        );
      },
    );
  }
}

GridView.builder 需要通过 gridDelegate 参数指定网格布局规则,常用的有:

  • SliverGridDelegateWithFixedCrossAxisCount:固定列数
  • SliverGridDelegateWithMaxCrossAxisExtent:根据最大宽度自动计算列数

3. GridView.extent

GridView.extent 允许指定网格项的最大宽度,自动计算每行可以容纳的列数

GridView.extent(
  // 网格项的最大宽度
  maxCrossAxisExtent: 150,
  // 列间距
  crossAxisSpacing: 10,
  // 行间距
  mainAxisSpacing: 10,
  // 内边距
  padding: const EdgeInsets.all(10),
  // 子组件
  children: List.generate(12, (index) {
    return Container(
      color: Colors.green[100 * ((index % 9) + 1)],
      child: Center(child: Text('Item $index')),
    );
  }),
)

这种方式适用于需要在不同屏幕尺寸上自动调整列数的场景。



四、列表滑动监听与下拉刷新

在实际应用中,我们经常需要监听列表的滚动状态(如实现无限滚动加载)或提供下拉刷新功能。

1. 下拉刷新:RefreshIndicator

RefreshIndicator 是实现下拉刷新功能的官方组件,使用简单且符合 Material Design 规范:

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

  @override
  State<RefreshableList> createState() => _RefreshableListState();
}

class _RefreshableListState extends State<RefreshableList> {
  List<int> _items = List.generate(20, (index) => index);

  // 模拟刷新数据
  Future<void> _refreshData() async {
    // 模拟网络请求延迟
    await Future.delayed(const Duration(seconds: 2));

    // 更新数据
    setState(() {
      _items = List.generate(20, (index) => index);
    });
  }

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      // 刷新指示器颜色
      color: Colors.blue,
      // 刷新背景颜色
      backgroundColor: Colors.white,
      // 刷新回调函数(必须返回Future)
      onRefresh: _refreshData,
      // 子列表
      child: ListView.builder(
        itemCount: _items.length,
        itemBuilder: (context, index) {
          return ListTile(title: Text('Item ${_items[index] + 1}'));
        },
      ),
    );
  }
}

RefreshIndicator 的核心是 onRefresh 回调函数,它必须返回一个 Future,当 Future 完成时,刷新指示器会停止动画。

2. 无限滚动与滚动监听

通过监听列表的滚动事件,可以实现无限滚动加载功能(当用户滚动到列表底部时,自动加载更多数据):

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

  @override
  State<InfiniteScrollList> createState() => _InfiniteScrollListState();
}

class _InfiniteScrollListState extends State<InfiniteScrollList> {
  final List<int> _items = List.generate(20, (index) => index);
  final ScrollController _scrollController = ScrollController();
  bool _isLoading = false;
  int _page = 1;

  @override
  void initState() {
    super.initState();
    // 监听滚动事件
    _scrollController.addListener(_onScroll);
  }

  @override
  void dispose() {
    // 移除监听器并释放资源
    _scrollController.removeListener(_onScroll);
    _scrollController.dispose();
    super.dispose();
  }

  // 滚动事件处理
  void _onScroll() {
    // 检查是否滚动到了底部
    if (_scrollController.position.pixels >=
            _scrollController.position.maxScrollExtent - 200 &&
        !_isLoading) {
      // 加载更多数据
      _loadMoreData();
    }
  }

  // 模拟加载更多数据
  Future<void> _loadMoreData() async {
    setState(() {
      _isLoading = true;
    });

    // 模拟网络请求延迟
    await Future.delayed(const Duration(seconds: 2));

    setState(() {
      _page++;
      // 添加新数据
      final newItems = List.generate(10, (index) => _items.length + index);
      _items.addAll(newItems);
      _isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      // 列表项数量(原有数据 + 加载指示器)
      itemCount: _items.length + (_isLoading ? 1 : 0),
      itemBuilder: (context, index) {
        // 如果是最后一项且正在加载,则显示加载指示器
        if (index == _items.length) {
          return const Padding(
            padding: EdgeInsets.symmetric(vertical: 20),
            child: Center(child: CircularProgressIndicator()),
          );
        }

        // 正常列表项
        return ListTile(
          title: Text('Item ${_items[index] + 1}'),
          subtitle: Text('Page ${(_items[index] ~/ 10) + 1}'),
        );
      },
    );
  }
}

实现无限滚动的关键点:

  1. 使用 ScrollController 监听滚动事件
  2. 判断是否滚动到接近底部的位置(通常是距离底部一定距离)
  3. 触发加载更多数据的逻辑,并显示加载指示器
  4. 加载完成后更新列表数据,并隐藏加载指示器

3. 滚动位置恢复

在某些场景下(如屏幕旋转、页面切换后返回),需要恢复列表的滚动位置。Flutter 提供了 ScrollController 的 keepScrollOffset 属性来实现这一功能:

final ScrollController _scrollController = ScrollController(
  keepScrollOffset: true, // 默认为true,会保存滚动位置
);

对于列表项动态变化的场景,可能需要使用 PageStorageKey 来帮助 Flutter 保存和恢复滚动位置:

ListView.builder(
  key: const PageStorageKey<String>('my_list_key'),
  // ...其他参数
)


五、列表性能优化

当列表包含大量数据或复杂列表项时,性能优化变得尤为重要。以下是一些常用的列表性能优化技巧:

1. 使用合适的列表构造方法

  • 少量固定数据:使用 ListView 或 GridView 的普通构造方法
  • 大量数据或动态数据:使用 ListView.builder 或 GridView.builder
  • 需要分隔符:使用 ListView.separated

2. 减少重建范围

  • 将列表项抽取为独立的 StatelessWidget 或 StatefulWidget
  • 使用 const 构造函数创建不变的列表项
// 优化前
itemBuilder: (context, index) {
  return ListTile(
    title: Text('Item $index'),
    leading: Icon(Icons.item),
  );
}

// 优化后
itemBuilder: (context, index) {
  return MyListItem(
    index: index,
  );
}

// 列表项组件
class MyListItem extends StatelessWidget {
  final int index;

  // 使用const构造函数
  const MyListItem({super.key, required this.index});

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text('Item $index'),
      leading: const Icon(Icons.item), // 不变的部分使用const
    );
  }
}

3. 图片优化

  • 使用合适尺寸的图片,避免大图缩小显示
  • 实现图片缓存(可使用 cached_network_image 库)
  • 列表滑动时暂停图片加载,停止滑动后再加载

4. 避免在构建方法中执行耗时操作

确保 itemBuilder 方法简洁高效,避免在其中执行:

  • 复杂计算
  • 网络请求
  • 大量对象创建

5. 使用 RepaintBoundary 减少重绘

对于频繁重绘的列表项,可以使用 RepaintBoundary 包裹,避免影响其他列表项:

itemBuilder: (context, index) {
  return RepaintBoundary(
    child: AnimatedListItem(index: index),
  );
}


六、实例:商品列表应用

下面实现一个功能完整的商品列表应用,包含网格 / 列表切换、下拉刷新、无限滚动等功能:

// 商品模型
class Product {
  final int id;
  final String name;
  final String description;
  final double price;
  final double rating;
  final String imageUrl;

  Product({
    required this.id,
    required this.name,
    required this.description,
    required this.price,
    required this.rating,
    required this.imageUrl,
  });
}

// 主应用
class ProductListApp extends StatefulWidget {
  const ProductListApp({super.key});

  @override
  State<ProductListApp> createState() => _ProductListAppState();
}

class _ProductListAppState extends State<ProductListApp> {
  // 商品列表数据
  final List<Product> _products = [];
  // 滚动控制器
  final ScrollController _scrollController = ScrollController();
  // 加载状态
  bool _isLoading = false;
  // 当前页码
  int _page = 1;
  // 布局模式(网格/列表)
  bool _isGridMode = true;

  @override
  void initState() {
    super.initState();
    // 初始加载数据
    _loadProducts();
    // 监听滚动事件
    _scrollController.addListener(_onScroll);
  }

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

  // 加载商品数据
  Future<void> _loadProducts({bool isRefresh = false}) async {
    if (isRefresh) {
      // 刷新时重置页码
      _page = 1;
    }

    setState(() {
      _isLoading = true;
    });

    // 模拟网络请求
    await Future.delayed(const Duration(seconds: 1));

    // 生成模拟数据
    final newProducts = List.generate(10, (index) {
      final id = ((_page - 1) * 10) + index + 1;
      return Product(
        id: id,
        name: 'Product $id',
        description: 'This is a description for product $id',
        price: 10.0 + (id * 0.5),
        rating: 3.0 + (id % 2) + (id % 10) * 0.1,
        imageUrl: 'https://picsum.photos/300/300?random=$id',
      );
    });

    setState(() {
      if (isRefresh) {
        _products.clear();
      }
      _products.addAll(newProducts);
      _page++;
      _isLoading = false;
    });
  }

  // 下拉刷新
  Future<void> _refreshProducts() async {
    await _loadProducts(isRefresh: true);
  }

  // 滚动到底部加载更多
  void _onScroll() {
    if (_scrollController.position.pixels >=
            _scrollController.position.maxScrollExtent - 300 &&
        !_isLoading) {
      _loadProducts();
    }
  }

  // 切换布局模式
  void _toggleLayoutMode() {
    setState(() {
      _isGridMode = !_isGridMode;
    });
  }

  // 构建列表项
  Widget _buildProductItem(Product product) {
    if (_isGridMode) {
      // 网格模式列表项
      return Card(
        elevation: 2,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Expanded(
              child: ClipRRect(
                borderRadius: const BorderRadius.vertical(
                  top: Radius.circular(4),
                ),
                child: Image.network(
                  product.imageUrl,
                  width: double.infinity,
                  fit: BoxFit.cover,
                ),
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(8),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    product.name,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 4),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Text(
                        '$${product.price.toStringAsFixed(2)}',
                        style: const TextStyle(color: Colors.red),
                      ),
                      Row(
                        children: [
                          const Icon(
                            Icons.star,
                            color: Colors.yellow,
                            size: 16,
                          ),
                          const SizedBox(width: 2),
                          Text(
                            product.rating.toStringAsFixed(1),
                            style: const TextStyle(fontSize: 14),
                          ),
                        ],
                      ),
                    ],
                  ),
                ],
              ),
            ),
          ],
        ),
      );
    } else {
      // 列表模式列表项
      return ListTile(
        leading: ClipRRect(
          borderRadius: BorderRadius.circular(4),
          child: Image.network(
            product.imageUrl,
            width: 50,
            height: 50,
            fit: BoxFit.cover,
          ),
        ),
        title: Text(product.name),
        subtitle: Text(
          product.description,
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
        ),
        trailing: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              '$${product.price.toStringAsFixed(2)}',
              style: const TextStyle(
                color: Colors.red,
                fontWeight: FontWeight.bold,
              ),
            ),
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                const Icon(Icons.star, color: Colors.yellow, size: 14),
                const SizedBox(width: 2),
                Text(
                  product.rating.toStringAsFixed(1),
                  style: const TextStyle(fontSize: 12),
                ),
              ],
            ),
          ],
        ),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Product List'),
        actions: [
          // 切换布局按钮
          IconButton(
            icon: Icon(_isGridMode ? Icons.list : Icons.grid_view),
            onPressed: _toggleLayoutMode,
          ),
        ],
      ),
      body: RefreshIndicator(
        onRefresh: _refreshProducts,
        child: _products.isEmpty && _isLoading
            ? const Center(child: CircularProgressIndicator())
            : _products.isEmpty
            ? const Center(child: Text('No products found'))
            : _isGridMode
            ? GridView.builder(
                controller: _scrollController,
                padding: const EdgeInsets.all(8),
                gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                  crossAxisCount: 2,
                  crossAxisSpacing: 8,
                  mainAxisSpacing: 8,
                  childAspectRatio: 0.8,
                ),
                itemCount: _products.length + (_isLoading ? 1 : 0),
                itemBuilder: (context, index) {
                  if (index == _products.length) {
                    return const Center(child: CircularProgressIndicator());
                  }
                  return _buildProductItem(_products[index]);
                },
              )
            : ListView.builder(
                controller: _scrollController,
                itemCount: _products.length + (_isLoading ? 1 : 0),
                itemBuilder: (context, index) {
                  if (index == _products.length) {
                    return const Padding(
                      padding: EdgeInsets.symmetric(vertical: 20),
                      child: Center(child: CircularProgressIndicator()),
                    );
                  }
                  return _buildProductItem(_products[index]);
                },
              ),
      ),
    );
  }
}