如何在Flutter中实现无限滚动分页

1,867 阅读9分钟

你有没有想过,像Instagram和Twitter这样的社交媒体平台是如何在用户滚动浏览应用程序时不断为他们提供帖子的?感觉这些页面永远不会结束,即使你可能也注意到屏幕上周期性出现的加载指示灯。

这是无限滚动分页的视觉实现的一个例子。它有时也被称为无尽滚动分页、自动分页、懒惰加载分页或渐进加载分页。

分页,在软件开发中,是将数据分离成连续的片段,从而允许用户以他们所需的速度消费数据的位数的过程。当你的应用程序提供了一个巨大但有序的数据量时,这可能非常有用,就像用户在探索Instagram或Twitter时经历的那样。这也是有益的,因为一次性加载所有的数据可能会影响你的应用程序的性能,而用户最终可能不会消费你提供的所有数据。

在本教程中,你将学习如何使用ListViewScrollControllerinfinite_scroll_pagination包对你的Flutter小部件进行分页,给你的用户带来无限滚动的感觉。

我们将具体介绍以下几个部分。

开始使用

前提条件

无限滚动分页是什么样子的?

本教程将演示如何通过建立一个基本的博客应用程序来实现无限滚动分页。该应用利用JSONPlaceholder APIPost 资源作为其数据来源。屏幕上显示的每篇文章将提供其标题和正文,如图所示。

An example of infinite scroll pagination

该应用程序从获取前十个帖子开始。当用户向下滚动页面时,它会获取更多的帖子。

注意在帖子加载到屏幕上之前出现的圆形进度指示器的位置。同样地,屏幕底部的第二个指示器也表明正在获取更多的帖子。

无限滚动加载指标的最佳实践

应用程序应该在哪一点上获取下一组帖子取决于你的偏好。你可以选择在用户到达屏幕上最后一个可用的帖子时获取更多的数据,因为到那时,你可以确定用户有兴趣看到更多的帖子。

然而,等待这么久也会迫使用户在每次到达屏幕底部时等待应用程序获取帖子;你应该知道这个指标的位置会影响你的应用程序的用户体验。用户越是等待你的帖子,他们对你的应用程序就越不感兴趣。

通常建议你在用户看到时间轴上60%-80%的数据后,再获取新的或额外的数据。这样,当你推断出用户有兴趣看到更多的数据时,你也在获取和准备这些数据。在用户完成查看当前部分的帖子之前,获取额外的数据需要较短的等待时间。

另一方面,在用户还没有看到一半的时间线时获取更多的数据,可能会导致你的应用程序占用不必要的资源来获取用户可能不感兴趣或不准备看的数据。

无限滚动分页的错误信息传递

错误通信是另一个需要考虑的问题。在获取数据时,有两个主要的点可能发生错误。

  1. 当应用程序在获取第一个分页数据时。这时,屏幕上没有帖子,当错误发生时,错误显示在屏幕的中心,如下图所示。
    The message displayed when there is an error fetching the first set of paginated data
  2. 当应用程序正在获取更多的分页数据时。这里,应用程序已经有帖子呈现在屏幕上了。在这一点上发生的错误应该显示一个信息,表明用户仍然可以访问以前加载的数据,也可以要求重试。
    The message displayed when there is an error fetching more paginated data

实现无限滚动,使用ListView

ListView是一个为您提供可滚动功能的Flutter小部件。这使您可以拥有一个小部件的集合,这些小部件不必完全适合屏幕,但可以在您滚动屏幕时单独查看。让我们来看看如何使用ListView ,从头开始实现上述无限滚动的功能。

在终端运行以下命令,创建项目文件夹,然后用你喜欢的代码编辑器打开它。

flutter create infinite_scroll

创建一个名称为post 的Dart文件,以包含Post 模型的代码,然后在该文件中添加以下代码。

class Post {
  final String title;
  final String body;
  Post(this.title, this.body);

}

接下来,创建一个名为post_item 的widget文件,包含post widget所需的代码。该文件应包含以下内容。

import 'package:flutter/material.dart';

class PostItem extends StatelessWidget {

  final String title;
  final String body;

  PostItem(this.title, this.body);

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 220,
      width: 200,
      decoration: const BoxDecoration(
          borderRadius: BorderRadius.all(Radius.circular(15)),
          color: Colors.amber
      ),
      child: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: <Widget>[
            Text(title,
              style: const TextStyle(
                  color: Colors.purple,
                  fontSize: 20,
                  fontWeight: FontWeight.bold
              ),),
            const SizedBox(height: 10,),
            Text(body,
              style: const TextStyle(
                  fontSize: 15
              ),)
          ],
        ),
      ),
    );
  }
}

上面的片段渲染了一个Container widget,它将包含一个帖子的标题和正文。A BoxDecoration小部件对容器进行样式设计,使其具有以下输出。

The container with the title and body of our post

下一步是创建一个Dart文件,名称为post-overview_screen ,该文件将使用ListView ,以可滚动的格式呈现帖子。在该文件中添加以下代码。

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart';

import '../model/post.dart';
import '../widgets/post_item.dart';

class PostsOverviewScreen extends StatefulWidget {

  @override
  _PostsOverviewScreenState createState() => _PostsOverviewScreenState();
}
class _PostsOverviewScreenState extends State<PostsOverviewScreen> {
  late bool _isLastPage;
  late int _pageNumber;
  late bool _error;
  late bool _loading;
  final int _numberOfPostsPerRequest = 10;
  late List<Post> _posts;
  final int _nextPageTrigger = 3;

  @override
  void initState() {
    super.initState();
    _pageNumber = 0;
    _posts = [];
    _isLastPage = false;
    _loading = true;
    _error = false;
    fetchData();
  }

}

上面的代码段包含了建立页面所需的初始化属性。这些包括。

  • _isLastPage:一个boolean 变量,表示是否有更多的数据需要获取
  • _pageNumber:一个int 变量,确定要获取的分页数据的段。它的初始值为零,因为JSONPlaceholder API的分页数据是基于零的。
  • _error:一个boolean 变量,表示在获取数据的时候是否发生了错误。
  • _loading:另一个boolean 变量,取决于应用程序当前是否在请求数据。
  • _numberOfPostsPerRequest:决定每次请求要获取的元素的数量
  • _posts:一个持有所有获取的帖子的变量
  • _nextPageTrigger:决定下次请求获取更多数据的时间点。将该值初始化为3 ,意味着当用户在当前页面上还有三个帖子需要查看时,应用程序将请求更多的数据;你可以用不同的值测试应用程序并比较其经验

接下来,在_PostsOverviewScreenState 类中添加以下方法。这个方法执行从API获取数据的逻辑,并使用响应来创建一个存储在_posts 变量中的Post 对象的列表。

Future<void> fetchData() async {
    try {
      final response = await get(Uri.parse(
          "https://jsonplaceholder.typicode.com/posts?_page=$_pageNumber&_limit=$_numberOfPostsPerRequest"));
      List responseList = json.decode(response.body);
      List<Post> postList = responseList.map((data) => Post(data['title'], data['body'])).toList();

      setState(() {
        _isLastPage = postList.length < _numberOfPostsPerRequest;
        _loading = false;
        _pageNumber = _pageNumber + 1;
        _posts.addAll(postList);
      });
    } catch (e) {
      print("error --> $e");
      setState(() {
        _loading = false;
        _error = true;
      });
    }
  }

上面的fetchData 方法使用_pageNumber_numberOfPostsPerRequest 变量作为参数,向API的post资源发送了一个GET请求。收到的数据是一个JSON对象的列表,其中包含不同帖子的值。下面是一个从API收到的数据的例子。

{
    "userId": 1,
    "id": 1,
    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
    "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
  }

在对API的请求成功后,json.decode ,对响应进行解码。每个提取的标题和正文被用来创建Post 对象的列表。使用setState ,变量接收更新。

_isLastPage 的值取决于新收到的数据量是否小于请求的数据量。例如,如果应用程序请求10个帖子,但收到7个,这意味着它已经用尽了帖子的来源。

_pageNumber 的值也会递增,这样应用程序就可以在下一个请求中请求下一个分页的数据。

接下来,还是在同一个类中,添加以下代码来处理错误。

Widget errorDialog({required double size}){
    return SizedBox(
      height: 180,
      width: 200,
      child:  Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text('An error occurred when fetching the posts.',
            style: TextStyle(
                fontSize: size,
                fontWeight: FontWeight.w500,
                color: Colors.black
            ),
          ),
          const SizedBox(height: 10,),
          FlatButton(
              onPressed:  ()  {
                setState(() {
                  _loading = true;
                  _error = false;
                  fetchData();
                });
              },
              child: const Text("Retry", style: TextStyle(fontSize: 20, color: Colors.purpleAccent),)),
        ],
      ),
    );
  }

上面的小组件包含一列文本,用来传达错误,还有一个按钮,允许用户重新尝试加载帖子。

在文件中添加下面的build 方法。

@override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Blog App"), centerTitle: true,),
      body: buildPostsView(),
    );
  }

  Widget buildPostsView() {
    if (_posts.isEmpty) {
      if (_loading) {
        return const Center(
            child: Padding(
              padding: EdgeInsets.all(8),
              child: CircularProgressIndicator(),
            ));
      } else if (_error) {
        return Center(
            child: errorDialog(size: 20)
        );
      }
    }
      return ListView.builder(
          itemCount: _posts.length + (_isLastPage ? 0 : 1),
          itemBuilder: (context, index) {

            if (index == _posts.length - _nextPageTrigger) {
              fetchData();
            }
            if (index == _posts.length) {
              if (_error) {
                return Center(
                    child: errorDialog(size: 15)
                );
              } else {
                return const Center(
                    child: Padding(
                      padding: EdgeInsets.all(8),
                      child: CircularProgressIndicator(),
                    ));
              }
            }
            final Post post = _posts[index];
            return Padding(
              padding: const EdgeInsets.all(15.0),
              child: PostItem(post.title, post.body)
            );
          });
    }

上面的build 方法检查帖子列表是否为空,然后进一步检查应用程序是否正在加载或发生错误。如果是前者,它在屏幕中央渲染进度指示器;否则,它显示errorDialog widget。

Our progress indicator is rendered in the center of the screen

如果应用程序当前没有加载,并且在向API发出第一次请求时没有发生错误,那么应用程序就会使用PostItem widget渲染Post 对象列表中的数据。

在这一点上,它对当前是否正在请求更多的数据进行了进一步的检查,为此它在屏幕底部渲染了加载指标。如果在加载更多的数据时发生错误,它将在屏幕底部显示errorDialog

An error dialog appears at the bottom of the screen

如果这两种情况都没有发生,那么成功获取的数据就会在屏幕上渲染出来。

最后,这里是main.dart 文件。

import 'package:flutter/material.dart';
import 'package:infinte_scroll/base_infinite_scroll/post_overview_screen.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(

        primarySwatch: Colors.purple,
      ),
      home:  MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: PostsOverviewScreen()
    );
  }
}

实现无限滚动,使用ScrollController

Flutter ScrollControllerListenable abstract 类的后代,它允许你在它所监听的部件有更新时通知客户端。你可以使用ScrollController 来监听可滚动的小部件,如 ListView, GridViewCustomScrollView.

在我们的教程应用中,ScrollController 将负责监测用户在页面上向下滚动了多远。根据这些信息和你为nextPageTrigger 值设置的阈值,它将触发从API获取数据的方法。

如下图所示,在post_overview_screen 文件中声明并初始化一个ScrollController 对象。

class ScrollControllerDemo extends StatefulWidget {

  @override
  _ScrollControllerDemoState createState() => _ScrollControllerDemoState();
}
class _ScrollControllerDemoState extends State<ScrollControllerDemo> {
  late bool _isLastPage;
  late int _pageNumber;
  late bool _error;
  late bool _loading;
  late int _numberOfPostsPerRequest;
  late List<Post> _posts;
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _pageNumber = 0;
    _posts = [];
    _isLastPage = false;
    _loading = true;
    _error = false;
    _numberOfPostsPerRequest = 10;
    _scrollController = ScrollController();
    fetchData();
  }

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

}

然后将build 方法替换为以下内容。

@override
  Widget build(BuildContext context) {
    _scrollController.addListener(() {
// nextPageTrigger will have a value equivalent to 80% of the list size.
      var nextPageTrigger = 0.8 * _scrollController.position.maxScrollExtent;

// _scrollController fetches the next paginated data when the current postion of the user on the screen has surpassed 
      if (_scrollController.position.pixels > nextPageTrigger) {
        _loading = true;
        fetchData();
      }
    });

    return Scaffold(
      appBar: AppBar(title: const Text("Blog App"), centerTitle: true,),
      body: buildPostsView(),
    );
  }

  Widget buildPostsView() {
    if (_posts.isEmpty) {
      if (_loading) {
        return const Center(
            child: Padding(
              padding: EdgeInsets.all(8),
              child: CircularProgressIndicator(),
            ));
      } else if (_error) {
        return Center(
            child: errorDialog(size: 20)
        );
      }
    }
    return ListView.builder(
        controller: _scrollController,
        itemCount: _posts.length + (_isLastPage ? 0 : 1),
        itemBuilder: (context, index) {

          if (index == _posts.length) {
            if (_error) {
              return Center(
                  child: errorDialog(size: 15)
              );
            }
            else {
              return const Center(
                  child: Padding(
                    padding: EdgeInsets.all(8),
                    child: CircularProgressIndicator(),
                  ));
            }
          }

            final Post post = _posts[index];
            return Padding(
                padding: const EdgeInsets.all(15.0),
                child: PostItem(post.title, post.body)
            );
        }
        );
  }

在构建方法中,scrollController 对象添加了一个监听器,用于监控ListView 的滚动。然后,当用户消耗了当前帖子列表中80%以上的数据时,它就会调用fetchData 方法。

使用infinite_scroll_pagination 包实现无限滚动

有时,你可能不想通过从头开始建立和配置一个分页滚动的麻烦。你可以安装 [infinite scroll pagination package](https://pub.dev/packages/infinite_scroll_pagination)是一个外部包,你可以安装它来处理无限滚动操作中的数据分页。这个包抽象了请求第一个或额外的分页数据时的错误和进度处理过程。

负责构建无限滚动的包有三个基本组件,都是通用类。

  1. PagingController:监视分页数据的状态,并在其监听器通知时请求额外的数据
  2. PagedListView:负责接收所需控制器的页面上呈现的数据视图;在这种情况下,负责分页的pagingController
  3. PagedChildBuilderDelegate:负责在视图中建立每个项目,并为错误和进度处理建立默认或自定义小工具

下面是使用这个包来构建前面几节演示的基本博客应用的实现。

在你的终端运行以下命令,将该包添加到你的pubspec.yaml 文件中。

flutter pub add infinite_scroll_pagination

下载该依赖项。

flutter pub get

创建一个新的Dart文件,并在其中添加以下代码。

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';

import '../model/post.dart';
import '../widgets/post_item.dart';

class InfiniteScrollPaginatorDemo extends StatefulWidget {
  @override
  _InfiniteScrollPaginatorDemoState createState() => _InfiniteScrollPaginatorDemoState();
}

class _InfiniteScrollPaginatorDemoState extends State<InfiniteScrollPaginatorDemo> {
  final _numberOfPostsPerRequest = 10;

  final PagingController<int, Post> _pagingController =
  PagingController(firstPageKey: 0);

  @override
  void initState() {
    _pagingController.addPageRequestListener((pageKey) {
      _fetchPage(pageKey);
    });
    super.initState();
  }

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

}

回顾一下,PagingController 是一个通用类,它接收两个通用类型的参数,如上所示。第一个参数,int ,代表你要消费的API的页码的数据类型。JSONPlaceholder使用int 值来代表每个页面。这个值在不同的API之间可能有所不同,所以从你想消费的API中找出这个值是很重要的。

firstPageKey 参数代表你要请求的第一个页面的索引。这个参数的初始值是0,因为JSONPlacholder中的页面有一个基于0的索引,即第一个页码是0 ,而不是1

initState_pagingController 设置了一个监听器,根据pageKey 的当前值来获取一个页面。

下面是获取一个页面的实现。在类中添加这个方法。

Future<void> _fetchPage(int pageKey) async {
    try {
      final response = await get(Uri.parse(
          "https://jsonplaceholder.typicode.com/posts?_page=$pageKey&_limit=$_numberOfPostsPerRequest"));
      List responseList = json.decode(response.body);
      List<Post> postList = responseList.map((data) =>
          Post(data['title'], data['body'])).toList();
      final isLastPage = postList.length < _numberOfPostsPerRequest;
      if (isLastPage) {
        _pagingController.appendLastPage(postList);
      } else {
        final nextPageKey = pageKey + 1;
        _pagingController.appendPage(postList, nextPageKey);
      }
    } catch (e) {
      print("error --> $e");
      _pagingController.error = e;
    }
  }

fetchPage 方法接收pageKey 作为其参数,并使用其值和数据的首选大小从API中获取页面。它使用API响应中的数据创建一个Post 对象的列表。然后控制器使用appendLastPageappendPage 方法保存创建的列表,这取决于新获取的数据是否在最后一页。如果在获取数据时发生错误,控制器会使用它的error 属性来处理它。

下面是屏幕的build 方法。

 @override
  Widget build(BuildContext context) {
      return Scaffold(
        appBar:
          AppBar(title: const Text("Blog App"), centerTitle: true,),
        body: RefreshIndicator(
          onRefresh: () => Future.sync(() => _pagingController.refresh()),
          child: PagedListView<int, Post>(
            pagingController: _pagingController,
            builderDelegate: PagedChildBuilderDelegate<Post>(
              itemBuilder: (context, item, index) =>
                  Padding(
                    padding: const EdgeInsets.all(15.0),
                    child: PostItem(
                        item.title, item.body
                    ),
                  ),

            ),

          ),
        ),
      );
}

infinite scroll pagination包让你可以灵活地将你的小部件包裹在一个刷新指示器周围。这允许你向下拖动屏幕来触发刷新。刷新实现调用控制器的refresh 方法来清除其数据。

PageListView 也接收你在创建控制器时分配的相同类型的通用类。通过分配给它的builderDelegate 参数的PageChildBuilderDelegate 实例,它建立了每个PostItem 小部件。

定制你的进度指示器和错误处理

请注意,你不需要像前面几节那样配置进度指示器或错误处理操作。这是因为软件包使用其默认值为你处理所有这些。
The center-screen error message rendered by the infinite_scroll_pagination package
The bottom-screen error message rendered by the infinite_scroll_pagination package

PageChildBuilderDelegate 对象也给你提供了灵活性,通过以下可选参数来定制你的错误处理和进度操作。

  • newPageErrorIndicatorBuilder:这个处理在对数据进行更多请求时发生的错误。它接收一个小部件,当错误发生时,它将在已经加载的数据下面呈现出来
  • firstPageErrorIndicatorBuilder:处理第一次请求数据时发生的错误。分配给这个操作的部件会在屏幕中心渲染,因为此时屏幕是空的。
  • firstPageProgressIndicatorBuilder:当应用程序请求其第一个分页数据时,这将接收一个出现在屏幕中心的小部件
  • newPageProgressIndicatorBuilder:当应用程序请求更多的数据时,这将接收一个出现在预先存在的数据下面的小部件
  • noItemsFoundIndicatorBuilder:当API返回一个空的数据集合时,这将接收一个显示的小部件。这不被认为是一个错误,因为从技术上讲,API调用是成功的,但没有发现任何数据。
  • noMoreItemsIndicatorBuilder:当用户用尽API返回的所有数据时,这个接收小部件将被渲染。

结论

在本教程中,你了解到需要对你提供给用户的数据进行分页,以及在构建分页无限滚动时需要考虑的问题。你还建立了一个基本的博客应用,使用一个 ListView从头开始。 ScrollController来跟踪分页和 >infinite_scroll_pagination外部包。

该应用程序可在GitHub上找到,以便进一步深入了解。干杯!

The postHow to implement infinite scroll pagination in Flutterappeared first onLogRocket Blog.