Flutter ListView lazy loading
Asked Answered
T

13

111

How can I realize items lazy loading for endless listview? I want to load more items by network when user scroll to the end of listview.

Tegument answered 27/3, 2018 at 8:49 Comment(4)
Use ListView.builder() and provide a number of items larger than what is already loaded. When the user scrolls to an index that's not yet loaded, you load the data and re-render the list.Pyemia
@GünterZöchbauer I don't like this approach as it's much harder to work with pagination. You don't know inside an unknown index if it's inside page n+1 or n+2 for example.Mckissick
It might depend on the use case. It worked well in our project. You can also combine it with the scroll position. What I like is that I can have a much bigger number than loaded items and the scrollbar reflects that.Pyemia
@GünterZöchbauer Can you give me an example how to paginate in your way?Dairy
H
133

You can listen to a ScrollController.

ScrollController has some useful information, such as the scrolloffset and a list of ScrollPosition.

In your case the interesting part is in controller.position which is the currently visible ScrollPosition. Which represents a segment of the scrollable.

ScrollPosition contains informations about it's position inside the scrollable. Such as extentBefore and extentAfter. Or it's size, with extentInside.

Considering this, you could trigger a server call based on extentAfter which represents the remaining scroll space available.

Here's an basic example using what I said.

class MyHome extends StatefulWidget {
  @override
  _MyHomeState createState() => _MyHomeState();
}

class _MyHomeState extends State<MyHome> {
  ScrollController controller;
  List<String> items = List.generate(100, (index) => 'Hello $index');

  @override
  void initState() {
    super.initState();
    controller = ScrollController()..addListener(_scrollListener);
  }

  @override
  void dispose() {
    controller.removeListener(_scrollListener);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Scrollbar(
        child: ListView.builder(
          controller: controller,
          itemBuilder: (context, index) {
            return Text(items[index]);
          },
          itemCount: items.length,
        ),
      ),
    );
  }

  void _scrollListener() {
    print(controller.position.extentAfter);
    if (controller.position.extentAfter < 500) {
      setState(() {
        items.addAll(List.generate(42, (index) => 'Inserted $index'));
      });
    }
  }
}

You can clearly see that when reaching the end of the scroll, it scrollbar expends due to having loaded more items.

Haworth answered 27/3, 2018 at 9:37 Comment(9)
Yep, it looks like something good and I will try it. But I have question. Will itemCount (itemBuilder parameter) changes dynamically when items.length will be changed?Tegument
Btw, should I always call 'super' call of overrided function at the end of function body? It's some offtop but i'm very interested)Tegument
ItemCount will update over time, yes. calling super is not always needed. But some functions ask for it.Mckissick
@RémiRousselet One small tip, super.initState() should always be the first line in initState() method. Let me update your answer.Psychophysics
Would this not in fact, rebuild the ListView every time you load new items / overscroll and therefore also rebuild the widgets of already loaded items? Since setState is called, build should be triggered again, rebuilding ListView.build?Underlinen
@RémiRousselet Will this condition -> controller.position.extentAfter < 500 not be true multiple times after extentAfter becomes less than 500.Mosa
@RémiRousselet May be you can help me with this question #64187098Neaten
This only works because you put enough in the list to fill the screen. What if you had only added 1 item? How do you detect that there's space at the bottom to fill?Makassar
@SayeedHussain, controler.position.extentAfter < 500 will be true multiple times like you said, but once it is true the first time, you can set a state that indicates that items are loading. So as long as items continue to load, and controler.position.extentAfter < 500 continues to be true, do nothing.Skied
P
41

Thanks for Rémi Rousselet's approach, but it does not solve all the problem. Especially when the ListView has scrolled to the bottom, it still calls the scrollListener a couple of times. The improved approach is to combine Notification Listener with Remi's approach. Here is my solution:

bool _handleScrollNotification(ScrollNotification notification) {
  if (notification is ScrollEndNotification) {
    if (_controller.position.extentAfter == 0) {
      loadMore();
    }
  }
  return false;
}

@override
Widget build(BuildContext context) {
    final Widget gridWithScrollNotification = NotificationListener<
            ScrollNotification>(
        onNotification: _handleScrollNotification,
        child: GridView.count(
            controller: _controller,
            padding: EdgeInsets.all(4.0),
          // Create a grid with 2 columns. If you change the scrollDirection to
          // horizontal, this would produce 2 rows.
          crossAxisCount: 2,
          crossAxisSpacing: 2.0,
          mainAxisSpacing: 2.0,
          // Generate 100 Widgets that display their index in the List
          children: _documents.map((doc) {
            return GridPhotoItem(
              doc: doc,
            );
          }).toList()));
    return new Scaffold(
      key: _scaffoldKey,
      body: RefreshIndicator(
       onRefresh: _handleRefresh, child: gridWithScrollNotification));
}
Porterfield answered 9/10, 2018 at 9:58 Comment(3)
ScrollNotification has notification.metrics.extentAfter, so you don't need to use ScrollController. In addition, when I used both at first time, It never work until I removed _controller out of ListView.Ellamaeellan
I was tired of my function getting called couple of times. Thanks for the solutionAirminded
May be due to the version difference you don't need the scroll controller with this code. bool _handleScrollNotification(ScrollNotification notification) { if (notification is ScrollEndNotification && notification.metrics.extentAfter == 0) { loadMore(); } return false; }Affirm
P
13

The solution use ScrollController and I saw comments mentioned about page.
I would like to share my finding about package incrementally_loading_listview https://github.com/MaikuB/incrementally_loading_listview.
As packaged said : This could be used to load paginated data received from API requests.

Basically, when ListView build last item and that means user has scrolled down to the bottom.
Hope it can help someone who have similar questions.

For purpose of demo, I have changed example to let a page only include one item and add an CircularProgressIndicator.

enter image description here

...
bool _loadingMore;
bool _hasMoreItems;
int  _maxItems = 30;
int  _numItemsPage = 1;
...
_hasMoreItems = items.length < _maxItems;    
...
return IncrementallyLoadingListView(
              hasMore: () => _hasMoreItems,
              itemCount: () => items.length,
              loadMore: () async {
                // can shorten to "loadMore: _loadMoreItems" but this syntax is used to demonstrate that
                // functions with parameters can also be invoked if needed
                await _loadMoreItems();
              },
              onLoadMore: () {
                setState(() {
                  _loadingMore = true;
                });
              },
              onLoadMoreFinished: () {
                setState(() {
                  _loadingMore = false;
                });
              },
              loadMoreOffsetFromBottom: 0,
              itemBuilder: (context, index) {
                final item = items[index];
                if ((_loadingMore ?? false) && index == items.length - 1) {
                  return Column(
                    children: <Widget>[
                      ItemCard(item: item),
                      Card(
                        child: Padding(
                          padding: const EdgeInsets.all(16.0),
                          child: Column(
                            children: <Widget>[
                              Row(
                                crossAxisAlignment:
                                    CrossAxisAlignment.start,
                                children: <Widget>[
                                  Container(
                                    width: 60.0,
                                    height: 60.0,
                                    color: Colors.grey,
                                  ),
                                  Padding(
                                    padding: const EdgeInsets.fromLTRB(
                                        8.0, 0.0, 0.0, 0.0),
                                    child: Container(
                                      color: Colors.grey,
                                      child: Text(
                                        item.name,
                                        style: TextStyle(
                                            color: Colors.transparent),
                                      ),
                                    ),
                                  )
                                ],
                              ),
                              Padding(
                                padding: const EdgeInsets.fromLTRB(
                                    0.0, 8.0, 0.0, 0.0),
                                child: Container(
                                  color: Colors.grey,
                                  child: Text(
                                    item.message,
                                    style: TextStyle(
                                        color: Colors.transparent),
                                  ),
                                ),
                              )
                            ],
                          ),
                        ),
                      ),
                      Center(child: CircularProgressIndicator())
                    ],
                  );
                }
                return ItemCard(item: item);
              },
            );

full example https://github.com/MaikuB/incrementally_loading_listview/blob/master/example/lib/main.dart

Package use ListView index = last item and loadMoreOffsetFromBottom to detect when to load more.

    itemBuilder: (itemBuilderContext, index) {    
              if (!_loadingMore &&
              index ==
                  widget.itemCount() -
                      widget.loadMoreOffsetFromBottom -
                      1 &&
              widget.hasMore()) {
            _loadingMore = true;
            _loadingMoreSubject.add(true);
          }
Protocol answered 3/5, 2019 at 1:43 Comment(1)
perfect solution for my problem, does network example available for this library?Yorktown
H
9

here is my solution for find end of listView

_scrollController.addListener(scrollListenerMilli);


if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
      getMoreData();
    }

If you want to load more data when 1/2 or 3/4 of a list view size, then use this way.

if (_scrollController.position.pixels == (_scrollController.position.maxScrollExtent * .75)) {//.5
      getMoreData();
    }

Additional -> Make sure you called getMore API only one time when reaching to the bottom. You can solve this in many ways, This is one of the ways to solve this by boolean variable.

bool loadMore = false;

if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent && !loadMore) {
     loadMore = true;
     getMoreData().then(() => loadMore = false);
}
Hartmunn answered 1/10, 2020 at 6:54 Comment(0)
C
4

Use lazy_load_scrollview: 1.0.0 package that use same concept behind the scenes that panda world answered here. The package make it easier to implement.

Clew answered 14/6, 2019 at 5:37 Comment(1)
I am using the same package and it's working fine. I just wondered how can we show CircularProgressIndicator at the end of List when data is still loading like when we hit the end of the List we are going fetch some data from the API and till the we get the data, we just show the CircularProgressIndicator.Hols
R
4

here is my approach which is inspired by answers above,

NotificationListener(onNotification: _onScrollNotification, child: GridView.builder())

bool _onScrollNotification(ScrollNotification notification) {
    if (notification is ScrollEndNotification) {
      final before = notification.metrics.extentBefore;
      final max = notification.metrics.maxScrollExtent;

      if (before == max) {
        // load next page
        // code here will be called only if scrolled to the very bottom
      }
    }
    return false;
  }
Ramose answered 10/5, 2020 at 17:19 Comment(1)
What is the best way to modify this so I can lazy load previous pages? So, new items at the very bottom of my list & scroll up to see old items.Jaguar
C
2

There is a much simpler solution than working with Scroll Controllers and Notifications. Just use the built in lazy loading feature of ListView Builders:

I suggest (and tested) to just wrap two FutureBuilders within each other and let them handle everything for you. Alternatively, the outer FutureBuilder can be replaced by loading the values in the initState.

  1. Create FutureBuilder to retrieve the most compact version of your data. Best a url or an id of the data items to be displayed

  2. Create a ListView.builder, which according to the flutter doc Flutter Lists Codebook, already takes care of the lazy loading part

    The standard ListView constructor works well for small lists. To work with lists that contain a large number of items, it’s best to
    use the ListView.builder constructor.

    In contrast to the default ListView constructor, which requires creating all items at once, the ListView.builder() constructor
    creates items as they’re scrolled onto the screen.

  3. Within the ListView builder, add another FutureBuilder, which fetches the individual content.

  4. You're done

Have a look at this example code.

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
        future: <get a short list of ids to fetch from the web>,
        builder: (BuildContext context, AsyncSnapshot<List<int>> snapshot) {
          if (snapshot.hasData) {
            return Expanded(
              child: ListView.builder(
                  itemCount: snapshot.data!.length,
                  itemBuilder: (BuildContext context, final int index) {
                    final int recordId = snapshot.data![index];
                    return FutureBuilder(
                        future: <get the record content from the web>,
                        builder: (BuildContext context,
                            AsyncSnapshot<Issue?> snapshot) {
                          if (snapshot.hasData) {
                            final Record? record = snapshot.data;
                            if (issue != null) {
                              return ListTile(
                                isThreeLine: true,
                                horizontalTitleGap: 0,
                                title: <build record widget>,
                              );
                            }
                          }
                          return ListTile(
                              isThreeLine: true,
                              horizontalTitleGap: 0,
                              title: const Text("Loading data..."));
                        });
                  }),
            );
          }
          return const Text("Loading data...",
              style: TextStyle(color: Colors.orange));
        });

Let me know what you think. Performance was great when I've tried it, I'm wondering what you experienced with this. Sure, this needs some clean up, I know :D

Calabar answered 26/7, 2022 at 9:49 Comment(1)
This is the correct answer with respect to the current version of Flutter 3.3.7. Also, works with GridView.builder having Futures being returned from the itemBuilder method..Vilipend
S
1

The solutions posted don't solve the issue if you want to achieve lazy loading in up AND down direction. The scrolling would jump here, see this thread.

If you want to do lazy loading in up and down direction, the library bidirectional_listview could help.

Example (Source):

static const double kItemHeight = 30.0;
BidirectionalScrollController controller;
double oldScrollPosition = 0.0;

@override
void initState() {
  super.initState();

  for (int i = -10; i <= 10; i++) {
    items[i] = "Item " + i.toString();
  }

  controller = new BidirectionalScrollController()
    ..addListener(_scrollListener);
}
@override
void dispose() {
  controller.removeListener(_scrollListener);
  super.dispose();
}

@override
void build() {
// ...
  List<int> keys = items.keys.toList();
  keys.sort();

  return new BidirectionalListView.builder(
    controller: controller,
    physics: AlwaysScrollableScrollPhysics(),
    itemBuilder: (context, index) {
      return Container(
          child: Text(items[index]),
          height: kItemHeight,
    },
    itemCount: keys.first,
    negativeItemCount: keys.last.abs(),
  );
// ...
}

// Reload new items in up and down direction and update scroll boundaries
void _scrollListener() {
  bool scrollingDown = oldScrollPosition < controller.position.pixels;
  List<int> keys = items.keys.toList();
  keys.sort();
  int negativeItemCount = keys.first.abs();
  int itemCount = keys.last;

  double positiveReloadBorder = (itemCount * kItemHeight - 3 * kItemHeight);
  double negativeReloadBorder =
      (-(negativeItemCount * kItemHeight - 3 * kItemHeight));

  // reload items
  bool rebuildNecessary = false;
  if (scrollingDown && controller.position.pixels > positiveReloadBorder) 
  {
    for (int i = itemCount + 1; i <= itemCount + 20; i++) {
      items[i] = "Item " + i.toString();
    }
    rebuildNecessary = true;
  } else if (!scrollingDown &&
      controller.position.pixels < negativeReloadBorder) {
    for (int i = -negativeItemCount - 20; i < -negativeItemCount; i++) {
      items[i] = "Item " + i.toString();
    }
    rebuildNecessary = true;
  }

  // set new scroll boundaries
  try {
    BidirectionalScrollPosition pos = controller.position;
    pos.setMinMaxExtent(
        -negativeItemCount * kItemHeight, itemCount * kItemHeight);
  } catch (error) {
    print(error.toString());
  }
  if (rebuildNecessary) {
    setState(({});
  }

  oldScrollPosition = controller.position.pixels;
}

I hope that this helps a few people :-)

Subjoin answered 3/5, 2020 at 17:54 Comment(0)
B
1

The accepted answer is correct but you can also do as follows,

Timer _timer;

  Widget chatMessages() {
    _timer = new Timer(const Duration(milliseconds: 300), () {
      _scrollController.animateTo(
        _scrollController.position.maxScrollExtent,
        curve: Curves.easeOut,
        duration: const Duration(milliseconds: 300),
      );
    });
    return StreamBuilder(
      stream: chats,
      builder: (context, snapshot) {
        return snapshot.hasData
            ? ListView.builder(
                // physics: NeverScrollableScrollPhysics(),
                controller: _scrollController,
                shrinkWrap: true,
                reverse: false,
                itemCount: snapshot.data.documents.length,
                itemBuilder: (context, index) {
                  return MessageTile(
                    message: snapshot.data.documents[index].data["message"],
                    sendByMe: widget.sendByid ==
                        snapshot.data.documents[index].data["sendBy"],
                  );
                })
            : Container();
      },
    );
  }
Borzoi answered 21/5, 2021 at 8:35 Comment(0)
B
1

There is also this package, taking away the boilerplate: https://pub.dev/packages/lazy_load_scrollview

Buyer answered 1/6, 2021 at 7:37 Comment(0)
V
1

This is an old question and the current answer is to use the ListView.builder method.

Same is true for the GridView.builder, please refer to the example below.

GridView.builder(
    // ask GridView to cache and avoid redundant callings of Futures
    cacheExtent: 100,
    
    shrinkWrap: true,
  
    itemCount: c.thumbnails.length,
  
    // Define this as you like
    gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
      crossAxisCount: 3,
      mainAxisSpacing: 0.0,
      crossAxisSpacing: 0.0,
      childAspectRatio: 1.0,
    ),
    itemBuilder: (BuildContext context, int index) {

      return FutureBuilder<Image>(builder: (ctx, snap) {
        if (!snap.hasData) {
          return const SizedBox.expand(); // show nothing
        }
        if (snap.hasError) {
          return Text('An error occured ${snap.error}');
        }
        return snap.data!;
      },
      future: <YOUR THUMBNAIL FUTURE>,
    );
  }
);
Vilipend answered 6/11, 2022 at 22:24 Comment(0)
H
0

You can handle it by knowing the current page and the last page By using listview builder

itemBuilder: (context, index) {
      if(list.length - 1 == index && currentPage! < lastPage!){
               currentPage = currentPage! + 1;
               /// Call your api here to update the list
               return Progress();
             }
      return ///element widget here.
},
Hipped answered 26/11, 2022 at 9:34 Comment(0)
M
0

If I had to implement the pagination myself, I would either go with infinite_scroll_pagination library (ListView, GridView, PageView support) or, if the project was small and I just wanted it in one place and didn't want to add yet another dependency, I would just wrap my ListView with NotificationListener<ScrollNotification> like below:

NotificationListener<ScrollNotification>(
  onNotification: (scrollNotification) {
  if (scrollNotification is ScrollEndNotification) {
    onScrollListener();
  }

  return true;
}
child: CustomScrollView(
      slivers: [
        SliverAppBar(
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: const Text('Infinite scrolling SliverList'),
        ),
        SliverList.builder(
          itemBuilder: (context, index) {
            // Show progress indicator on the bottom
            if (allFlowers.value.length == index) {
              return const Padding(
                padding: EdgeInsets.only(top: 16, bottom: 16),
                child: Center(child: CircularProgressIndicator()),
              );
            }

            final flowerName = allFlowers.value[index];

            return Padding(
              padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
              child: Text(
                flowerName,
                style: TextStyle(
                  color: Theme.of(context).colorScheme.primary,
                  fontSize: 20,
                  fontWeight: FontWeight.w500,
                ),
              ),
            );
          },
          itemCount: isPageLoading.value ? allFlowers.value.length + 1 : allFlowers.value.length,
        ),....

void onScrollListener() {
  if (reachedEnd.value) {
    return;
  }

  if (!isPageLoading.value) {
    isPageLoading.value = true;
    Future.microtask(() async {
      final newFlowers = await getFlowersUseCase.getFlowers(page: nextPage.value);
      if (newFlowers.length < pageSize) {
        reachedEnd.value = true;
      } else {
        nextPage.value++;
      }

      allFlowers.value.addAll(newFlowers);
      isPageLoading.value = false;
    });
  }
}

I've added a complete code example here.

Mizzle answered 31/1 at 10:5 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.