前言

上一篇讲了下拉刷新的实现,这篇继续聊上拉加载。其实原理差不多,就是换了个回调,但实际开发中上拉加载有一些细节需要注意,比如分页逻辑、加载完成的判断等。

这篇文章会结合实际场景,把这些细节都过一遍。

请添加图片描述

一、上拉加载和下拉刷新的区别

虽然都是刷新操作,但两者的业务逻辑完全不同:

下拉刷新:重新获取第一页数据,替换掉现有列表。用户的预期是"看看有没有新内容"。

上拉加载:获取下一页数据,追加到现有列表末尾。用户的预期是"看更多内容"。

这个区别决定了我们在回调里要做的事情不一样。下拉刷新是"重置",上拉加载是"追加"。


二、库里上拉加载的实现原理

在开始写代码之前,先看看库是怎么处理上拉加载的。打开 lib/src/refresh.dart,找到 _RefreshFooterHandler 这个类:

class _RefreshFooterHandler extends _RefreshHandler {
  _RefreshFooterHandler(
      {required RefreshWidgetController controller,
      required RefresherCallback callback,
      double offset = 50.0})
      : super(controller: controller, callback: callback, offset: offset);

这是上拉加载的处理器,继承自 _RefreshHandleroffset 参数是触发加载的阈值,默认 50 像素。也就是说,用户往上拉超过 50 像素就会触发加载。

再看看它怎么判断滚动位置的:


double getRefreshWidgetMoveValue(ScrollMetrics metrics) {
  return metrics.pixels - metrics.maxScrollExtent;
}

metrics.pixels 是当前滚动位置,metrics.maxScrollExtent 是最大可滚动距离。当用户滚动到底部并继续往上拉时,pixels 会超过 maxScrollExtent,差值就是"过度滚动"的距离。

这个差值超过 offset(默认 50)就会触发加载回调。

还有一个细节,看看加载完成后的处理:

if (refreshHandle == _footerHandler && maxExtent != null) {
  controller?.jumpTo(maxExtent - _footerRefreshOffset * 2);
}

加载完成后,库会把滚动位置往回调一点。为什么?因为加载完新数据后,列表变长了,maxScrollExtent 也变大了。如果不调整位置,用户会感觉列表"跳"了一下。这个处理让体验更平滑。


三、开始写代码

3.1 定义状态变量

上拉加载需要维护更多的状态:

class _RefreshDemoState extends State<RefreshDemo> {
  List<String> _dataList = [];
  int _currentPage = 1;
  bool _hasMore = true;

_dataList 存放列表数据,用 List 而不是简单的 int,因为实际项目中数据肯定是个列表。

_currentPage 记录当前页码,每次加载成功后加 1。

_hasMore 标记是否还有更多数据。当服务端返回的数据少于一页的数量时,说明没有更多了,这时候就不应该再触发加载。

3.2 初始化数据


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

void _loadInitialData() {
  // 模拟初始数据
  _dataList = List.generate(10, (index) => 'Item ${index + 1}');
}

initState 是 State 的生命周期方法,在组件创建时调用一次。这里用来加载初始数据。

List.generate 是 Dart 的便捷方法,生成一个指定长度的列表。第一个参数是长度,第二个参数是生成每个元素的函数。

3.3 构建列表项

Widget _itemBuilder(BuildContext context, int index) {
  return Container(
    padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
    decoration: BoxDecoration(
      border: Border(
        bottom: BorderSide(color: Colors.grey.shade200),
      ),
    ),
    child: Row(
      children: [
        CircleAvatar(
          backgroundColor: Colors.blue.shade100,
          child: Text(
            '${index + 1}',
            style: TextStyle(color: Colors.blue.shade700),
          ),
        ),
        const SizedBox(width: 12),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                _dataList[index],
                style: const TextStyle(
                  fontSize: 16,
                  fontWeight: FontWeight.w500,
                ),
              ),
              const SizedBox(height: 4),
              Text(
                '上拉加载更多数据',
                style: TextStyle(
                  fontSize: 14,
                  color: Colors.grey.shade600,
                ),
              ),
            ],
          ),
        ),
      ],
    ),
  );
}

这次没用 ListTile,而是自己写了布局。实际项目中列表项的样式往往比较复杂,ListTile 不一定能满足需求。

Containerdecoration 实现底部分割线。用 Border 而不是 Divider 组件,是因为 Divider 会占用额外的空间。

Row 横向排列头像和文字。Expanded 让文字部分占满剩余空间,避免文字太长时溢出。

Column 纵向排列标题和副标题。crossAxisAlignment: CrossAxisAlignment.start 让文字左对齐。

3.4 实现上拉加载回调

这是核心逻辑:

Future<void> onFooterRefresh() async {
  if (!_hasMore) {
    return;
  }
  
  await Future.delayed(const Duration(seconds: 1));
  
  final newPage = _currentPage + 1;
  final newData = List.generate(
    10,
    (index) => 'Item ${(_currentPage * 10) + index + 1}',
  );
  
  setState(() {
    _dataList.addAll(newData);
    _currentPage = newPage;
    
    // 模拟加载到第5页后没有更多数据
    if (_currentPage >= 5) {
      _hasMore = false;
    }
  });
}

先看第一个判断:

if (!_hasMore) {
  return;
}

如果已经没有更多数据了,直接返回,不执行加载逻辑。这个判断很重要,不然用户会一直看到加载动画,体验很差。

Future.delayed 模拟网络请求的延迟。实际项目中这里是调接口:

Future<void> onFooterRefresh() async {
  if (!_hasMore) return;
  
  try {
    final response = await api.fetchList(page: _currentPage + 1);
    
    setState(() {
      _dataList.addAll(response.data);
      _currentPage++;
      _hasMore = response.data.length >= pageSize;
    });
  } catch (e) {
    // 加载失败的处理
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('加载失败: $e')),
    );
  }
}

_hasMore 的判断逻辑是:如果返回的数据量小于一页的数量(pageSize),说明服务端已经没有更多数据了。

addAll 是 List 的方法,把新数据追加到列表末尾。注意是"追加"不是"替换",这是上拉加载和下拉刷新的关键区别。

3.5 实现下拉刷新回调

上拉加载通常要配合下拉刷新一起用。下拉刷新的逻辑是重置所有状态:

Future<void> onHeaderRefresh() async {
  await Future.delayed(const Duration(seconds: 1));
  
  setState(() {
    _dataList = List.generate(10, (index) => 'Item ${index + 1}');
    _currentPage = 1;
    _hasMore = true;
  });
}

三个状态都要重置:

  • _dataList 重新生成,相当于获取第一页数据
  • _currentPage 重置为 1
  • _hasMore 重置为 true,允许继续加载

如果只重置数据不重置 _hasMore,用户刷新后就没法继续上拉加载了,这是个常见的 bug。

3.6 组装 Refresh 组件


Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('上拉加载示例'),
      backgroundColor: Theme.of(context).colorScheme.inversePrimary,
    ),
    body: SafeArea(
      child: Refresh(
        onHeaderRefresh: onHeaderRefresh,
        onFooterRefresh: onFooterRefresh,
        childBuilder: (BuildContext context,
            {ScrollController? controller, ScrollPhysics? physics}) {
          return ListView.builder(
            controller: controller,
            physics: physics,
            itemBuilder: _itemBuilder,
            itemCount: _dataList.length,
          );
        },
      ),
    ),
  );
}

和下拉刷新的区别就是多了 onFooterRefresh 参数。两个回调可以同时配置,也可以只配置其中一个。

itemCount 用的是 _dataList.length 而不是固定值,因为上拉加载会动态增加数据。


四、一些进阶处理

4.1 显示"没有更多了"提示

_hasMore 为 false 时,可以在列表底部显示一个提示:

Widget _itemBuilder(BuildContext context, int index) {
  // 最后一项显示"没有更多了"
  if (index == _dataList.length) {
    return Container(
      padding: const EdgeInsets.all(16),
      alignment: Alignment.center,
      child: Text(
        '没有更多了',
        style: TextStyle(color: Colors.grey.shade500),
      ),
    );
  }
  
  // 正常的列表项
  return Container(
    // ... 省略
  );
}

同时要修改 itemCount

itemCount: _hasMore ? _dataList.length : _dataList.length + 1,

当还有更多数据时,itemCount 就是数据长度;当没有更多数据时,多加一项用来显示提示。

4.2 加载失败的重试

网络请求可能失败,失败后应该允许用户重试:

bool _loadError = false;

Future<void> onFooterRefresh() async {
  if (!_hasMore) return;
  
  try {
    // ... 加载逻辑
    _loadError = false;
  } catch (e) {
    setState(() {
      _loadError = true;
    });
  }
}

然后在列表底部显示重试按钮:

if (_loadError) {
  return GestureDetector(
    onTap: onFooterRefresh,
    child: Container(
      padding: const EdgeInsets.all(16),
      alignment: Alignment.center,
      child: const Text(
        '加载失败,点击重试',
        style: TextStyle(color: Colors.red),
      ),
    ),
  );
}

GestureDetector 是手势检测组件,onTap 处理点击事件。点击后重新调用加载方法。

4.3 空列表的处理

如果列表为空,应该显示一个空状态页面:


Widget build(BuildContext context) {
  return Scaffold(
    // ... appBar
    body: SafeArea(
      child: _dataList.isEmpty
          ? _buildEmptyView()
          : Refresh(
              // ... 正常的列表
            ),
    ),
  );
}

Widget _buildEmptyView() {
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(
          Icons.inbox_outlined,
          size: 64,
          color: Colors.grey.shade400,
        ),
        const SizedBox(height: 16),
        Text(
          '暂无数据',
          style: TextStyle(
            fontSize: 16,
            color: Colors.grey.shade600,
          ),
        ),
      ],
    ),
  );
}

空状态页面不需要 Refresh 组件包裹,因为没有内容可以滚动。


五、运行到鸿蒙设备

5.1 检查设备

flutter devices

确认鸿蒙设备已连接并授权。

5.2 运行

flutter run -d <设备ID>

第一次编译会比较慢,后续热重载就很快了。

5.3 测试上拉加载

应用启动后,滑动到列表底部,继续往上拉,应该能看到加载动画。松手后会加载新数据,列表会变长。

多拉几次,加载到第 5 页后就不会再触发加载了(因为我们设置了 _hasMore = false)。

下拉刷新后,状态会重置,又可以继续上拉加载了。


六、常见问题

6.1 上拉加载不触发

检查几个地方:

  • controllerphysics 有没有传给子列表
  • _hasMore 是不是 false 了
  • 列表内容是不是不够一屏(不够一屏的话滚不到底部)

6.2 数据重复

检查 addAll 的逻辑,确保不是每次都从第一页开始加载。_currentPage 要正确递增。

6.3 加载动画不消失

检查回调是不是返回了 Future。如果回调里有 async,确保所有异步操作都 await 了。


七、小结

上拉加载的核心就是 onFooterRefresh 回调,配合分页状态管理。实际项目中还要处理加载失败、空列表、没有更多数据等边界情况。

flutter_refresh 这个库把滚动监听、动画效果都封装好了,我们只需要关注业务逻辑就行。而且因为是纯 Dart 实现,在鸿蒙上跑起来完全没问题。

两篇文章把下拉刷新和上拉加载都讲完了,基本覆盖了列表页面的常见需求。如果有更复杂的场景,可以看看库的源码,自己扩展一下。

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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐