第九讲 分页列表与网格呈现

25 阅读6分钟

前言:

本讲主要聚焦在懒加载和滚动列表上,如何用 Flutter 优雅、高效地展示海量数据,并实现符合用户习惯的滚动交互,比较有用。

一、总览

  1. 懒加载核心:ListView.builder/separated、GridView.builder 基于 Sliver 实现按需渲染,仅构建可视区域组件,解决大量数据渲染性能问题;

  2. 滚动控制:ScrollController 用于监听滚动位置、控制滚动行为,需在 dispose 中销毁避免内存泄漏;

  3. 交互扩展:RefreshIndicator 实现下拉刷新,结合 ScrollController 监听实现上拉加载更多,需加加载锁避免重复请求。

本章节聚焦于 Flutter 中长列表/网格数据的高效展示交互优化,核心解决以下问题:

  • 大量数据渲染时的性能问题(懒加载)
  • 列表/网格的多样化布局(线性/网格)
  • 滚动行为的监听与控制(滚动位置、滚动状态)
  • 移动端常见的下拉刷新、上拉加载更多交互实现

image.png

  1. 懒加载本质:ListView.builder/GridView 基于 Sliver 实现,只构建可视区域内的组件,出界组件会被销毁,大幅降低内存占用;
  2. 滚动体系:Scrollable 处理滚动输入 → Viewport 限定可视区域 → Sliver 负责具体内容渲染;
  3. 交互扩展:基于 ScrollController/ScrollNotification 实现滚动监听,结合 RefreshIndicator 实现下拉刷新,上拉加载更多。

二、核心技术详解

1. ListView:列表渲染

(1)ListView.builder(基础懒加载列表)
核心属性
属性作用必选
itemCount列表总条数否(无限列表可设为null)
itemBuilder列表项构建函数(index → Widget)
scrollDirection滚动方向(Axis.vertical/horizontal)否(默认垂直)
physics滚动物理效果(BouncingScrollPhysics等)
controller滚动控制器
案例代码
import 'package:flutter/material.dart';

class ListViewBuilderDemo extends StatelessWidget {
  // 模拟数据源
  final List<String> dataList = List.generate(1000, (index) => "列表项 ${index + 1}");

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("ListView.builder 懒加载")),
      body: ListView.builder(
        // 列表项数量
        itemCount: dataList.length,
        // 懒加载构建列表项
        itemBuilder: (context, index) {
          // 打印日志验证懒加载(只有可视区域会打印)
          print("构建列表项:$index");
          return ListTile(
            title: Text(dataList[index]),
            leading: Icon(Icons.list),
          );
        },
      ),
    );
  }
}

注意事项
  • itemCount 设为 null 时为无限列表,需配合上拉加载更多使用;
  • itemBuilder 中避免复杂计算,否则会导致滚动卡顿;
  • 列表项高度不固定时,Flutter 会自动计算,若高度固定可通过 itemExtent 属性指定,提升性能。
(2)ListView.separated(带分隔线的懒加载列表)
核心属性

在 ListView.builder 基础上新增:

属性作用必选
separatorBuilder分隔线构建函数
案例代码
class ListViewSeparatedDemo extends StatelessWidget {
  final List<String> dataList = List.generate(50, (index) => "带分隔线列表项 ${index + 1}");

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("ListView.separated 带分隔线")),
      body: ListView.separated(
        itemCount: dataList.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text(dataList[index]),
            leading: Icon(Icons.list_alt),
          );
        },
        // 构建分隔线
        separatorBuilder: (context, index) {
          return Divider(
            color: Colors.grey[300],
            height: 1,
            indent: 16, // 左侧缩进
            endIndent: 16, // 右侧缩进
          );
        },
      ),
    );
  }
}

注意事项
  • separatorBuilder 不会在最后一项后生成分隔线;
  • 分隔线高度通过 Divider 的 height 属性控制,避免设置过大导致空间浪费。

2. GridView:网格列表

核心属性
属性作用必选
gridDelegate网格布局委托(控制列数/宽高比)
itemCount网格项数量
itemBuilder网格项构建函数
physics滚动物理效果
controller滚动控制器
常用网格委托
  • SliverGridDelegateWithFixedCrossAxisCount:固定列数
  • SliverGridDelegateWithMaxCrossAxisExtent:固定子项最大宽度
案例代码(固定列数)
class GridViewDemo extends StatelessWidget {
  final List<String> gridData = List.generate(30, (index) => "网格项 ${index + 1}");

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("GridView 网格列表")),
      body: GridView.builder(
        itemCount: gridData.length,
        // 网格布局配置:2列,间距10
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2, // 列数
          crossAxisSpacing: 10, // 列间距
          mainAxisSpacing: 10, // 行间距
          childAspectRatio: 1.5, // 子项宽高比(宽/高)
        ),
        itemBuilder: (context, index) {
          return Container(
            alignment: Alignment.center,
            color: Colors.blue[100 + index % 9 * 100],
            child: Text(gridData[index]),
          );
        },
      ),
    );
  }
}

注意事项
  • childAspectRatio 是宽/高比值,需根据设计稿调整;
  • 网格项数量过多时,同样支持懒加载,原理与 ListView 一致;
  • 避免网格项内嵌套滚动组件,会导致滚动冲突。

3. ScrollController:滚动监听与控制

核心功能
  • 监听滚动位置、滚动状态;
  • 控制滚动到指定位置(跳转到顶部/底部、指定索引);
  • 监听滚动结束/开始事件。
案例代码
class ScrollControllerDemo extends StatefulWidget {
  @override
  _ScrollControllerDemoState createState() => _ScrollControllerDemoState();
}

class _ScrollControllerDemoState extends State<ScrollControllerDemo> {
  late ScrollController _scrollController;
  // 滚动位置
  double _scrollOffset = 0.0;
  // 是否到达底部
  bool _isAtBottom = false;

  @override
  void initState() {
    super.initState();
    // 初始化控制器
    _scrollController = ScrollController();
    
    // 监听滚动事件
    _scrollController.addListener(() {
      setState(() {
        _scrollOffset = _scrollController.offset;
        // 判断是否到达底部(偏移量 + 可视高度 >= 总高度)
        _isAtBottom = _scrollController.offset + _scrollController.position.viewportDimension 
            >= _scrollController.position.maxScrollExtent - 10;
      });
    });
  }

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

  @override
  void dispose() {
    // 销毁控制器,避免内存泄漏
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("ScrollController 滚动控制"),
        actions: [
          IconButton(
            icon: Icon(Icons.arrow_upward),
            onPressed: _scrollToTop,
          )
        ],
      ),
      body: Column(
        children: [
          // 滚动状态显示
          Padding(
            padding: EdgeInsets.all(8),
            child: Text(
              "滚动偏移:$_scrollOffset\n是否到底部:$_isAtBottom",
              style: TextStyle(fontSize: 16),
            ),
          ),
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              itemCount: 100,
              itemBuilder: (context, index) {
                return ListTile(title: Text("滚动列表项 $index"));
              },
            ),
          ),
        ],
      ),
    );
  }
}

注意事项
  • 必须在 dispose 中销毁 ScrollController,否则会导致内存泄漏;
  • 避免在滚动监听中执行耗时操作,会导致滚动卡顿;
  • 判断是否到底部时,建议预留 10px 误差(避免因精度问题判断不准)。

4. 下拉刷新 & 上拉加载更多

(1)下拉刷新:RefreshIndicator
核心属性
属性作用必选
onRefresh刷新回调函数(返回Future)
child刷新的列表/网格组件
color刷新指示器颜色
displacement刷新指示器下拉的距离
(2)上拉加载更多:基于 ScrollNotification/ScrollController
案例代码(组合实现)

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

  @override
  State<RefreshLoadMoreDemo> createState() => _RefreshLoadMoreDemoState();
}

class _RefreshLoadMoreDemoState extends State<RefreshLoadMoreDemo> {
  late ScrollController _scrollController;
  List<String> _dataList = [];
  int _page = 1; // 当前页码
  bool _isLoading = false; // 是否正在加载

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
    // 初始化加载数据
    _loadData();
    // 监听滚动(上拉加载)
    _scrollController.addListener(() {
      // 到达底部且不在加载中
      if (_scrollController.offset + _scrollController.position.viewportDimension 
          >= _scrollController.position.maxScrollExtent - 10 && !_isLoading) {
        _loadMore();
      }
    });
  }

  // 加载数据(模拟网络请求)
  Future<void> _loadData({bool isRefresh = false}) async {
    if (isRefresh) {
      _page = 1;
      _dataList.clear();
    }
    setState(() {
      _isLoading = true;
    });
    // 模拟网络延迟
    await Future.delayed(Duration(seconds: 1));
    // 模拟数据
    List<String> newData = List.generate(25, (index) => "数据 ${(_page - 1) * 10 + index + 1}");
    setState(() {
      _dataList.addAll(newData);
      _isLoading = false;
    });
  }

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

  // 上拉加载更多
  Future<void> _loadMore() async {
    if (_isLoading) return;
    setState(() {
      _page++;
      _isLoading = true;
    });
    await _loadData();
  }

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

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      onRefresh: _onRefresh,
      child: ListView.builder(
        controller: _scrollController,
        itemCount: _dataList.length + 1,
        itemBuilder: (context, index) {
          if (index == _dataList.length) {
            return _isLoading
                ? const Padding(
                    padding: EdgeInsets.symmetric(vertical: 16),
                    child: Center(child: CircularProgressIndicator()),
                  )
                : const SizedBox.shrink();
          }
          return ListTile(title: Text(_dataList[index]));
        },
      ),
    );
  }
}

注意事项
  • 上拉加载时需加 _isLoading 锁,避免重复请求;
  • 模拟网络请求时,需处理异常(如请求失败后重置加载状态);
  • RefreshIndicator 仅支持垂直滚动组件,水平滚动需自定义刷新组件。

三、综合应用案例

需求说明

实现一个“商品列表页”,包含以下功能:

  1. 支持网格/列表布局切换;
  2. 下拉刷新获取最新商品;
  3. 上拉加载更多商品;
  4. 滚动监听,显示滚动位置,支持一键返回顶部;
  5. 列表项带分隔线,网格项有间距。

完整代码

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "分页列表与网格综合案例",
      theme: ThemeData(primarySwatch: Colors.blue),
      home: ProductListPage(),
    );
  }
}

class ProductListPage extends StatefulWidget {
  @override
  _ProductListPageState createState() => _ProductListPageState();
}

class _ProductListPageState extends State<ProductListPage> {
  late ScrollController _scrollController;
  List<Map<String, dynamic>> _productList = [];
  int _page = 1;
  bool _isLoading = false;
  bool _isGridView = false; // 是否网格布局
  double _scrollOffset = 0.0;

  // 模拟商品数据
  Future<void> _loadProducts({bool isRefresh = false}) async {
    if (_isLoading) return;
    setState(() => _isLoading = true);

    try {
      // 模拟网络请求
      await Future.delayed(Duration(seconds: 1));
      List<Map<String, dynamic>> newProducts = List.generate(
        10,
        (index) => {
          "id": (_page - 1) * 10 + index + 1,
          "name": "商品 ${(_page - 1) * 10 + index + 1}",
          "price": "${(99 + index) * _page}",
        },
      );

      setState(() {
        if (isRefresh) {
          _productList = newProducts;
          _page = 1;
        } else {
          _productList.addAll(newProducts);
        }
        _isLoading = false;
      });
    } catch (e) {
      setState(() => _isLoading = false);
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text("加载失败:$e")),
      );
    }
  }

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
    _loadProducts();

    // 滚动监听
    _scrollController.addListener(() {
      setState(() {
        _scrollOffset = _scrollController.offset;
        // 上拉加载更多
        if (_scrollController.offset + _scrollController.position.viewportDimension 
            >= _scrollController.position.maxScrollExtent - 10 && !_isLoading) {
          _loadMore();
        }
      });
    });
  }

  // 下拉刷新
  Future<void> _onRefresh() async => await _loadProducts(isRefresh: true);

  // 上拉加载更多
  Future<void> _loadMore() async {
    setState(() => _page++);
    await _loadProducts();
  }

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

  // 切换布局
  void _toggleLayout() => setState(() => _isGridView = !_isGridView);

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

  // 构建列表项
  Widget _buildListItem(Map<String, dynamic> product) {
    return ListTile(
      leading: Icon(Icons.shopping_bag, color: Colors.blue),
      title: Text(product["name"]),
      subtitle: Text("价格:¥${product["price"]}"),
      trailing: Text("ID: ${product["id"]}"),
    );
  }

  // 构建网格项
  Widget _buildGridItem(Map<String, dynamic> product) {
    return Container(
      margin: EdgeInsets.all(8),
      padding: EdgeInsets.all(12),
      decoration: BoxDecoration(
        border: Border.all(color: Colors.grey[200]!),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Icon(Icons.shopping_bag, color: Colors.blue, size: 32),
          SizedBox(height: 8),
          Text(product["name"], style: TextStyle(fontSize: 16)),
          SizedBox(height: 4),
          Text("¥${product["price"]}", style: TextStyle(color: Colors.red)),
          Align(
            alignment: Alignment.bottomRight,
            child: Text("ID: ${product["id"]}", style: TextStyle(fontSize: 12, color: Colors.grey)),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("商品列表(${_isGridView ? "网格" : "列表"})"),
        actions: [
          IconButton(
            icon: Icon(_isGridView ? Icons.list : Icons.grid_view),
            onPressed: _toggleLayout,
          ),
          IconButton(
            icon: Icon(Icons.arrow_upward),
            onPressed: _scrollOffset > 100 ? _scrollToTop : null,
          ),
        ],
      ),
      body: Column(
        children: [
          // 滚动状态提示
          Container(
            padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            color: Colors.grey[100],
            child: Text(
              "滚动位置:${_scrollOffset.toStringAsFixed(0)}px | 当前页码:$_page",
              style: TextStyle(fontSize: 14),
            ),
          ),
          Expanded(
            child: RefreshIndicator(
              onRefresh: _onRefresh,
              child: _isGridView
                  ? // 网格布局
                  GridView.builder(
                      controller: _scrollController,
                      itemCount: _productList.length + (_isLoading ? 1 : 0),
                      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                        crossAxisCount: 2,
                        crossAxisSpacing: 8,
                        mainAxisSpacing: 8,
                        childAspectRatio: 0.9,
                      ),
                      itemBuilder: (context, index) {
                        if (index == _productList.length) {
                          return Center(child: CircularProgressIndicator());
                        }
                        return _buildGridItem(_productList[index]);
                      },
                    )
                  : // 列表布局(带分隔线)
                  ListView.separated(
                      controller: _scrollController,
                      itemCount: _productList.length + (_isLoading ? 1 : 0),
                      itemBuilder: (context, index) {
                        if (index == _productList.length) {
                          return Padding(
                            padding: EdgeInsets.symmetric(vertical: 16),
                            child: Center(child: CircularProgressIndicator()),
                          );
                        }
                        return _buildListItem(_productList[index]);
                      },
                      separatorBuilder: (context, index) => Divider(
                        color: Colors.grey[200],
                        height: 1,
                      ),
                    ),
            ),
          ),
        ],
      ),
    );
  }
}

功能说明

  1. 布局切换:点击右上角图标可切换列表/网格布局;
  2. 下拉刷新:顶部下拉触发刷新,重置页码并加载第一页数据;
  3. 上拉加载:滚动到底部自动加载下一页数据;
  4. 滚动控制:显示滚动位置,点击向上箭头可平滑返回顶部;
  5. 异常处理:模拟请求失败时显示 SnackBar 提示;
  6. 性能优化:所有列表/网格均使用懒加载,避免性能问题。

开发注意事项

  • 列表/网格项中避免复杂计算和嵌套滚动,防止卡顿;
  • 网络请求需处理异常,加载状态需合理展示;
  • ScrollController 必须销毁,懒加载组件需指定 itemCount(无限列表除外)。