Flutter: 创建一次加载一页的ListView

Update: The method followed in this article is very simple, but might not be the best or most efficient. There are other methods that you can find online that do not require you to put a ListView inside another ListView. Feel free to keep reading, however, and see for yourself whether you prefer this method or something else.
更新:本文采用的方法非常简单,但可能不是最好或最有效的。 你可以在网上找到其他方法,这些方法不需要你将 ListView 放在另一个 ListView 中。 但是,请随意继续阅读,并亲自查看你是否更喜欢这种方法或其他方法。

Flutter provides the awesome ListView.builder; a ListView constructor that allows us to create a lazy-loaded list, where entries are created only when we scroll down to them. This constructor accepts as an input a callback named itemBuilder, and calls this callback whenever it wants to create a new item as a result of scrolling:
Flutter 提供了很棒的 ListView.builder; 一个 ListView 构造函数,它允许我们创建一个懒加载的列表,只有当我们向下滚动到它们时才会创建条目。 这个构造函数接受一个名为 itemBuilder 的callback作为输入,并在它想要创建一个作为滚动结果的新项目时调用这个callback:

ListView.builder(
  itemBuilder: (context, index) {
    return ListTile(
      leading: Icon(Icons.shopping_cart),
      title: Text('product $index'),
      subtitle: Text('price: ${Random().nextInt(100)} USD'),    
    );
  }
)

This will create an infinite scrolling list of items, which are only loaded when we scroll down to them.
这将创建一个无限滚动的 item list,仅当我们向下滚动到它们时才会加载。
However, when we want to use this in a real-life scenario, things get more complicated. We would probably want:
但是,当我们想在现实生活中使用它时,事情会变得更加复杂。 我们可能想要:

  1. to fetch entries asynchronously, from some remote server
    从某个远程服务器异步获取条目

  2. to fetch entries by batch (also known as page). That is, to fetch each, say 20, entries together, not one by one.
    按批次(也称为页面)获取条目。 也就是说,要一起获取每个(例如 20 个)条目,而不是一个一个地获取。

In this tutorial, we are going to discuss how to do exactly that! We will start by learning how to fetch ListView entries asynchronously using FutureBuilder. Then, we will see how we can fetch these entries one page at a time.
在本教程中,我们将讨论如何做到这一点! 我们将从学习如何使用 FutureBuilder 异步获取 ListView 条目开始。 然后,我们将看到如何一次一页地获取这些条目。
Flutter: 创建一次加载一页的ListView_第1张图片

tl;dr

If you’re in a hurry, and don’t have the time to go through the tutorial, you can check out my package, flutter_pagewise, which achieves exactly what I will explain in this tutorial. I do recommend, though, to go through the article, as it discusses several flutter concepts that you might find useful.
如果你赶时间,并且没有时间阅读本教程,你可以查看我的package,flutter_pagewise,它完全实现了我将在本教程中解释的内容。 不过,我十分建议通读这篇文章,因为它讨论了几个你可能会觉得有用的flutter概念。

Fetching entries asynchronously-异步获取条目

We want to fetch entries asynchronously from a remote server, but only when we scroll down to them. To do that, we will use the aforementionedListView.builder constructor, along with FutureBuilder.
我们希望从远程服务器异步获取条目,但只发生在我们向下滚动到它们时。 为此,我们将使用上述ListView.builder 构造函数和FutureBuilder。

Let’s assume that we have a function _fetchEntry that looks like:
假设我们有一个函数 _fetchEntry,如下所示:

_fetchEntry(int index) async {
  await Future.delayed(Duration(milliseconds: 500));

  return {
    'name': 'product $index',
    'price': Random().nextInt(100)
  };
}
  1. This function emulates a server that gives you the name and price of the product at the given index, and takes half a second to do that
    这个函数模拟一个服务器,在给定索引处为你提供产品的名称和价格,并花费半秒时间完成该操作

Then we can call this function in our ListView.builder:
然后我们可以在 ListView.builder 中调用这个函数:

ListView.builder(
  itemBuilder: (context, index) {
    return FutureBuilder(
      future: this._fetchEntry(index),
      builder: (context, snapshot) {
        switch (snapshot.connectionState) {
          case ConnectionState.none:          
          case ConnectionState.waiting:
            return CircularProgressIndicator();
          case ConnectionState.done:
            if (snapshot.hasError) {
              return Text('Error: ${snapshot.error}');
            } else {

              var productInfo = snapshot.data;

              return ListTile(
                leading: Icon(Icons.shopping_cart),
                title: Text(productInfo['name']),
                subtitle: 
                  Text('price: ${productInfo['price']}USD'),
              );
            }
        }
      },
    );
   }
)
  1. The FutureBuilder is a widget that awaits a given future, and uses its builder function to build different widgets depending on the status of the future
    FutureBuilder 是一个await future的widget,并使用其构建器功能根据future的状态构建不同的widget

  2. When the future has not been called, or has been called but hasn’t returned its result yet. We are showing a CircularProgressIndicator.
    当未调用future,或已调用但尚未return其结果时。 我们j就展示一个 CircularProgressIndicator。

  3. When the future is done, we check to see if it returned with an error. If so, we show the text of the error
    当 future 完成后,我们检查它是否返回错误。 如果是这样,我们显示错误的text

  4. Otherwise, we show a ListTile that displays the name and price returned from the future
    否则,我们显示一个 ListTile,它显示从future返回的名称和价格

And that’s it, when you start the app, you can see the entries getting loaded, then shown on screen, and as you scroll down, the same will happen for more and more entries
就是这样,当你启动App时,你可以看到条目正在加载,然后显示在屏幕上,当你向下滚动时,越来越多的条目也会发生同样的情况

Not bad, but we have a few problems to fix here:
不错,但我们有一些问题需要解决:

The loader looks really ugly-这个loader看起来真的很丑

We can see that the progress indicator is too wide, because it is trying to fit all the available width. To solve that, we can simply wrap our indicator with an Align widget, and set its alignment property to center:
我们可以看到进度indicator太宽了,因为它试图填充所有可用的宽度。 为了解决这个问题,我们可以简单地用一个 Align widget包装我们的indicator,并将其对齐属性设置为 center:

return Align(
  alignment: Alignment.center,
  child: CircularProgressIndicator()
);

This will give us nicer-looking progress indicators
这将为我们提供更好看的进度indicator
Flutter: 创建一次加载一页的ListView_第2张图片

But the main problem, which is the main focus of this article, is to load each 20 of those at a time, instead of loading them one-by-one.
但主要问题,也就是本文的重点,是一次加载其中的 20 个,而不是一个一个地加载它们。

Loading entries one page at a time- 一次加载一页条目

To do that, we can create a ListView of ListViews! Each child of our ListView.builder, will be a ListView that contains the page’s entries.
为此,我们可以创建一个 ListView 的 ListView! 我们 ListView.builder 的每个子项都将是一个包含页面条目的 ListView。

Let’s assume that our _fetchEntry function does not fetch single entries anymore, rather full pages! so we will call it _fetchPage instead, and it will look like:
假设我们的 _fetchEntry 函数不再获取单个条目,而是获取整页! 所以我们将它命名为 _fetchPage,它看起来像:

_fetchPage(int pageNumber, int pageSize) async {
  await Future.delayed(Duration(seconds: 1));

  return List.generate(pageSize, (index) {
    return {
      'name': 'product $index of page $pageNumber',
      'price': Random().nextInt(100)
    };
  });
}

The function emulates fetching a page from the server, by generating a list of pageSize entries.
这个函数通过生成 pageSize 条目列表来模拟从服务器获取页面。

This emulated server takes 1 second to return your request
这里模拟服务器需要 1 秒才能返回你的请求

Our ListView.builder will now look like:
我们的 ListView.builder 现在看起来像:

ListView.builder(
  itemBuilder: (context, pageNumber) {
    return FutureBuilder(
      future: this._fetchPage(pageNumber, 20),
      builder: (context, snapshot) {
        switch (snapshot.connectionState) {
          case ConnectionState.none:
          case ConnectionState.waiting:
            return Align(
              alignment: Alignment.center,
              child: CircularProgressIndicator()
            );
          case ConnectionState.done:
            if (snapshot.hasError) {
              return Text('Error: ${snapshot.error}');
            } else {

              var pageData = snapshot.data;

              return this._buildPage(pageData);
            }
        }
      },
    );
   }
)

Our ListView.builder now calls _fetchPage in its constructor instead of _fetchEntry, and specifies that we want the page size to be 20 entries
我们的 ListView.builder 现在在其构造函数中调用 _fetchPage 而不是 _fetchEntry,并指定我们希望页面大小为 20 个条目

When the page is ready, instead of returning a ListTile as we used to do, we are calling a function named _buildPage , and providing it with the page data that we fetched from the server
当页面准备就绪时,我们不再像以前那样返回 ListTile,而是调用一个名为 _buildPage 的函数,并将我们从服务器获取的页面数据提供给它

But what does _buildPage do? It builds an inner ListView to represent the page!
但是 _buildPage 做了什么? 它构建了一个内部 ListView 来表示页面!

Widget _buildPage(List page) {
  return ListView(
    shrinkWrap: true,
    primary: false,
    children: page.map((productInfo) {
      return ListTile(
        leading: Icon(Icons.shopping_cart),
        title: Text(productInfo['name']),
        subtitle: Text('price: ${productInfo['price']}USD'),
      );
    }).toList()
  );
}

The function returns a ListView. That is, each child of the outer ListView is a ListView that holds the entries of the page.
这个函数返回一个 ListView。 也就是说,外部 ListView 的每个子项都是一个包含页面条目的 ListView。
We use the map method to transform the list of data that we have into a list of ListTiles. Each ListTile corresponds to a fetched entry.
我们使用 map 方法将我们拥有的数据列表转换为 ListTiles 列表。 每个 ListTile 对应一个获取的条目。
We set the primary property to false. This tells flutter that this ListView is not the primary scrolling target. Because the parent ListView is the actual scrolling target, not this one
我们将primary设置为 false。 这告诉flutter这个 ListView 不是主要的滚动目标。 因为父ListView是实际的滚动目标,不是这个
We set shrinkWrap property to true. This tells flutter that this ListView should not try to expand infinitely in the vertical direction. That is also the job of the parent.
我们将 shrinkWrap 属性设置为 true。 这告诉 Flutter 这个 ListView 不应该尝试在垂直方向无限扩展。 这也是父容器的工作。
With that, the following will happen:
这样,将发生以下情况:

That… is not what we expected! It did not really load one page, rather about fifteen!
那……不是我们所期望的! 它并没有真正加载一页,而是大约十五页!
But why? The problem is with the progress indicator. When we first start, progress indicators are shown, and these progress indicators are small! So the ListView.builder will decide that it can fit about 15 entries in the view, and will proceed accordingly showing us 15 pages.
但为什么? 问题在于进度indicator。 当我们第一次启动时,会显示进度indicator,而且这些进度indicator很小! 所以 ListView.builder 将决定它可以在视图中容纳大约 15 个条目,并将继续向我们显示 15 个页面。
So what can we do? We can, again, wrap our progress indicator with one more widget
所以,我们能做些什么? 我们可以再次用一个widget包装我们的进度指示器

SizedBox(
  height: MediaQuery.of(context).size.height * 2,
  child: Align(
      alignment: Alignment.topCenter,
      child: CircularProgressIndicator()
  ),
);

The SizedBox is a widget that allows us to give it fixed dimensions
SizedBox 是一个允许我们给它固定尺寸的widget

We give it a height that is 2 times as big as the viewport’s height. That way, the builder will only load one entry at a time. To get the viewport’s height, we used the MediaQuery.of function, which gives us information about the dimensions of the current media.
我们给它的高度是viewport高度的 2 倍。 这样,构建器一次只会加载一个条目。 为了获得viewport的高度,我们使用了 MediaQuery.of 函数,它为我们提供了有关当前media尺寸的信息。

We also changed the alignment property of the Align widget to topCenter, so that the indicator shows at the top of the SizedBox.
我们还将 Align widget的对齐属性更改为 topCenter,以便indicator显示在 SizedBox 的顶部。

Now, we are doing much better:
现在,我们做得更好:

And that’s it, we’re almost there!
就是这样,我们快到了!

One last problem - 最后一个问题

What’s left? Well, this implementation still has a small bug, it’s a bit hard to see at first, but if you scroll down a few pages, and then scroll back up, you would notice that the scrolling is kind of broken. It keeps throwing you around in a bizarre way.
还剩什么? 嗯,这个实现还有一个小bug,一开始有点难看,但是如果你向下滚动几页,然后再向上滚动,你会发现滚动有点坏了。 它总是以一种奇怪的方式让你四处游荡。
Flutter: 创建一次加载一页的ListView_第3张图片

Stuck at page 6
卡在第 6 页

The explanation of this behavior is a bit complicated: ListView, and ScrollViews in general, tend to dispose of the children that are not currently visible on the screen. When we try to scroll back to the child, the child is reinitialized from scratch. But in this case, our child is a FutureBuilder; re-initializing it creates a progress indicator again just for a part of a second, then creates the page once again. This confuses the scrolling mechanism, throwing us around in non-deterministic ways.
对这种行为的解释有点复杂:ListView 和一般的 ScrollViews 倾向于丢弃当前在屏幕上不可见的子项。 当我们尝试滚动回子项,子项会从头开始重新初始化。 但是在这种情况下,我们的子项是一个 FutureBuilder; 重新初始化它会在几秒钟内再次创建一个进度indicator,然后再次创建页面。 这混淆了滚动机制,让我们以不确定的方式四处游荡。

How to solve it? - 怎么解决这个问题

One way to solve this is to make sure that the progress indicator has the exact same size of the page, but in most cases, that is not too practical. So, we will resort to a method that is less efficient, but that will solve our problems; we will prevent ListView from disposing of the children. In order to do that, we need to wrap each child — that is, each FutureBuilder, with an AutomaticKeepAliveClientMixin. This mixin makes the children ask their parent to keep them alive even when off-screen, which will solve our problem. So:
解决此问题的一种方法是确保进度指示器具有与页面完全相同的大小,但在大多数情况下,这不太实用。 因此,我们将采用一种效率较低但可以解决我们的问题的方法; 我们将阻止 ListView 处理子项。 为了做到这一点,我们需要用 AutomaticKeepAliveClientMixin 包装每个孩子——也就是每个 FutureBuilder。 这个 mixin 让子项们要求他们的父容器让他们即使在屏幕外也能保持活力,这将解决我们的问题。 所以:

  1. Replace the FutureBuilder in your code with KeepAliveFutureBuilder.
    将代码中的 FutureBuilder 替换为 KeepAliveFutureBuilder。

  2. Create the KeepAliveFutureBuilder widget:
    创建 KeepAliveFutureBuilder widget:

class KeepAliveFutureBuilder extends StatefulWidget {

  final Future future;
  final AsyncWidgetBuilder builder;

  KeepAliveFutureBuilder({
    this.future,
    this.builder
  });

  @override
  _KeepAliveFutureBuilderState createState() => _KeepAliveFutureBuilderState();
}

class _KeepAliveFutureBuilderState extends State<KeepAliveFutureBuilder> with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: widget.future,
      builder: widget.builder,
    );
  }

  @override
  bool get wantKeepAlive => true;
}
  1. This widget is just a wrapper around the FutureBuilder. It is a StatefulWidget whose State extends the State class with the AutomaticKeepAliveClientMixin.
    这个widget只是 FutureBuilder 的一个wrapper。 它是一个 StatefulWidget,它的 State 使用 AutomaticKeepAliveClientMixin 扩展了 State 类。

  2. It implements the wantKeppAlive getter, and makes it simply return true, to denote to the ListView that we want this child to be kept alive.
    它实现了 wantKeppAlive getter,并使其简单地返回 true,以向 ListView 表示我们希望这个子项保持活动状态。

And that’s it! This time we’re really done! We have created a ListView that loads one page at a time. It is not the most efficient one, but it solves our problem.
就是这样! 这次我们真的完成了! 我们创建了一个 ListView 一次加载一个页面。 它不是最有效的,但它解决了我们的问题。

Let’s face it though, this code is too much boilerplate. I do recommend to abstract this whole logic in a widget of its own. Or -shamless plug- to use my flutter_pagewise package, which provides you with extendable, elegant widgets that solve this problem for both ListView and GridView.
让我们面对现实吧,这段代码的样板太多了。 我确实建议将整个逻辑抽象到它自己的widget中。 或者 -shamless 插件 - 使用我的 flutter_pagewise 包,它为你提供了可扩展的、优雅的widget,为 ListView 和 GridView 解决了这个问题。

你可能感兴趣的:(flutter资料,flutter,android,ios,dart)