干掉 Loading 转圈:在 Flutter 中实现真正的“无感”分页

0 阅读4分钟

想象一下,你的用户正在沉浸式地刷着信息流,内容精彩纷呈,多巴胺正在分泌,突然——咣当——他们触底了。一个灰色的圈在转动。魔法中断了,体验“出戏”了。

在高质量的 App 中,“无限滚动(Infinite Scroll)”不应该仅仅在逻辑上是无限的,它在体验上也应该是无限的。我们不应该等用户撞了南墙才回头,我们应该预判他们的行为。

今天,我们要干掉那个加载圈。我们将探讨 Flutter 中列表预加载的演进之路:从手动实现的“苦力法”,到生产环境可用的 “低侵入” 方案。

(🐶 我承认有些圈的确能够让人赏心悦目) 15ee8c1cc19a457289632f485ecc6d73.webp

1. 经典方案:ScrollController

检测用户是否接近底部的最直观方法,就是去问 ScrollController

1.1 原始实现(样板代码)

核心逻辑就是简单的数学计算。监听滚动偏移量,如果 当前位置 + 阈值 >= 总长度,就触发加载。

标准代码如下所示。它能跑,但它让你的 Widget 变得乱糟糟:

class MyFeedPage extends StatefulWidget {
  @override
  _MyFeedPageState createState() => _MyFeedPageState();
}

class _MyFeedPageState extends State<MyFeedPage> {
  final ScrollController _controller = ScrollController();

  @override
  void initState() {
    super.initState();
    _controller.addListener(() {
      // 1. 检查是否触底(预留 600px 的缓冲空间)
      if (_controller.position.pixels >= 
          _controller.position.maxScrollExtent - 600) {
        
        // 2. 调用加载数据的逻辑
        _loadMoreData(); 
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose(); // 别忘了销毁!
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _controller, // 你必须手动绑定它
      itemBuilder: (ctx, i) => ListTile(title: Text("Item $i")),
    );
  }
}

1.2 重构:使用 Mixin 解耦

上面的代码有什么问题?重复。 如果你有 10 个列表页面,你就得把这套逻辑写 10 遍。

我们可以将其提取到一个 Mixin 中。这样不仅保持了 UI 代码的整洁,还能让任何 StatefulWidget 瞬间获得“分页能力”。

mixin PaginationMixin<T extends StatefulWidget> on State<T> {
  final ScrollController scrollController = ScrollController();
  
  void _onScroll() {
    // 在这里统一管理阈值逻辑(例如:600px)
    if (scrollController.position.extentAfter < 600) {
      onLoadMore();
    }
  }

  // 强制使用该 Mixin 的类实现具体的加载逻辑
  void onLoadMore(); 
  
  @override
  void initState() {
    super.initState();
    scrollController.addListener(_onScroll);
  }
  
  @override
  void dispose() {
    scrollController.dispose();
    super.dispose();
  }
}

现在好多了。但这仍然强迫你的 UI 去持有并绑定一个 ScrollController。如果你是在一个嵌套视图中,很难直接控制 Controller 怎么办?

2. 纯粹方案:ScrollNotification

Flutter 提供了一个冒泡事件系统,叫做 ScrollNotification。这允许我们在不管理 Controller 实例的情况下监听滚动事件。

2.1 原始实现

我们可以用 NotificationListener 包裹列表:

NotificationListener<ScrollNotification>(
  onNotification: (notification) {
    // 筛选滚动更新事件
    if (notification is ScrollUpdateNotification) {
      // 检查剩余距离(保持一致的 600px 阈值)
      if (notification.metrics.extentAfter < 600) {
        _preloadNextPage();
      }
    }
    return false; // 让通知继续向上冒泡
  },
  child: ListView.builder(...),
)

2.2 重构:组件封装

虽然这种方式很灵活,但把逻辑写在 build 方法里违反了整洁架构原则。它把**布局(Layout)业务逻辑(Business Logic)**混在一起了。

让我们把它封装成一个专用的 PreloadDetector 组件:

class PreloadDetector extends StatelessWidget {
  final Widget child;
  final VoidCallback onTrigger;
  final double threshold;

  const PreloadDetector({
    required this.child, 
    required this.onTrigger,
    this.threshold = 600, // 默认为 600px
  });

  @override
  Widget build(BuildContext context) {
    return NotificationListener<ScrollNotification>(
      onNotification: (notification) {
        if (notification.metrics.extentAfter < threshold) {
          onTrigger();
        }
        return false;
      },
      child: child,
    );
  }
}

很干净。但是,你依然需要手动处理 状态防卫(State Guarding) (例如:“如果正在加载中则不触发”或者“如果没数据了则不触发”)。

3. “低侵入”方案:scroll_preload_detector

不像上述两种方法都需要你手动维护状态逻辑。

我最近使用了一个库 scroll_preload_detector,之所以用它,是因为一个关键特性:低侵入

什么是“低侵入”?

这意味着你现有的代码可以保持原样:

  • 不需要把 StatelessWidget 改为 StatefulWidget
  • 不需要注入 ScrollController
  • 不需要修改你的 ListViewitemBuilder

3.1 清爽的实现

这个库将计算、防抖处理和状态防卫封装成了一个声明式的 API。

import 'package:scroll_preload_detector/scroll_preload_detector.dart';

ScrollPreloadDetector(
  // 当剩余内容不足 600px 时开始加载
  preloadDistance: 600.0,
  
  // 如果数据已耗尽,不要触发
  // 这自动防止了不必要的 API 调用
  hasMore: () => _viewModel.hasMoreData,
  
  // 自动处理异步逻辑
  preload: () async {
    await _viewModel.fetchMoreData();
  },
  
  child: ListView.builder(
    itemCount: items.length,
    itemBuilder: (context, index) => MyCard(items[index]),
  ),
)

为什么这是“更合理”的方案:

  1. 安全性: 它强制你定义 hasMore,解决了 App 在列表底部不断尝试请求无效数据的常见 Bug。
  2. 整洁性: 它分离了布局的职责(你的 List)和分页的职责(Detector)。
  3. 通用性: 因为它是包裹在 Widget 外层的,所以它适用于 GridViewSliverListCustomScrollView 或任何可滚动的组件。

🤝 让我们一起共建

Flutter 生态系统的美妙之处在于,我们不必独自解决这些问题。scroll_preload_detector 是一个小而美的工具,它简化了我们的日常工作。

如果你厌倦了在每个项目里写重复的 addListener 样板代码:

  1. 在你的下一个 App 中试用它
  2. Pub.dev给它点个 Star
  3. 如果你发现了边缘情况,提交一个 PR!

让我们彻底告别 Loading 转圈。

感谢阅读!

如果你觉得这篇技术指南对你有帮助,请点赞鼓励。