skip to content

In Flutter, how do you build a ListView that loads the next page as the user nears the end and shows a loading footer?

level: juniorimportance: must knowfreq 58%

answer

  1. build rows on demand
  2. one extra index while more exists
  3. footer at index == items.length
  4. extentAfter below a pixel threshold
  5. hasMore false drops the footer

basics

~20 s

Use ListView.builder with itemCount set to the loaded items plus one footer row while more pages exist, and request the next page when the scroll position's extentAfter drops below a threshold of roughly one screen.

solid answer

~50 s

I keep the loaded items, the next page number or cursor, a `hasMore` flag and a `loading` flag in state, and render them with `ListView.builder`. `itemCount` is `items.length + (hasMore ? 1 : 0)`; when the builder is asked for `index == items.length` it returns the footer, a spinner while loading or a retry button after an error. A `ScrollController` listener checks `position.extentAfter`, the pixels of content still below the viewport, and calls `loadMore()` when it falls under about one screen height, so the next 25 rows usually arrive before the user sees the footer. `loadMore()` returns early when a load is running or `hasMore` is false, and a page shorter than the page size sets `hasMore` to false. Because a listener only fires on scrolling, I also re-check after each page lands, in case the first page does not fill the screen.

code

dart · 98 lines
dart
import 'package:flutter/material.dart';

class Job {
  const Job({required this.id, required this.title});
  final String id;
  final String title;
}

typedef JobPageLoader = Future<List<Job>> Function(int page, int pageSize);

class JobFeed extends StatefulWidget {
  const JobFeed({super.key, required this.loadPage});
  final JobPageLoader loadPage;

  @override
  State<JobFeed> createState() => _JobFeedState();
}

class _JobFeedState extends State<JobFeed> {
  static const _pageSize = 25;
  static const _threshold = 600.0;
  final _controller = ScrollController();
  final _jobs = <Job>[];
  int _nextPage = 0;
  bool _loading = false;
  bool _hasMore = true;
  Object? _error;

  @override
  void initState() {
    super.initState();
    _controller.addListener(_onScroll);
    _loadMore();
  }

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

  void _onScroll() {
    // No automatic retries after a failure: the footer's button retries.
    if (_error == null && _controller.position.extentAfter < _threshold) {
      _loadMore();
    }
  }

  Future<void> _loadMore() async {
    if (_loading || !_hasMore) return;
    setState(() {
      _loading = true;
      _error = null;
    });
    try {
      final page = await widget.loadPage(_nextPage, _pageSize);
      if (!mounted) return;
      setState(() {
        _jobs.addAll(page);
        _nextPage++;
        _hasMore = page.length == _pageSize;
        _loading = false;
      });
      // The first pages may not fill the screen, so nothing would scroll.
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (mounted && _controller.hasClients) _onScroll();
      });
    } catch (e) {
      if (!mounted) return;
      setState(() {
        _error = e;
        _loading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _controller,
      itemCount: _jobs.length + (_hasMore ? 1 : 0),
      itemBuilder: (context, index) {
        if (index < _jobs.length) {
          return ListTile(title: Text(_jobs[index].title));
        }
        if (_error != null) {
          return Center(
            child: TextButton(onPressed: _loadMore, child: const Text('Retry')),
          );
        }
        return const Padding(
          padding: EdgeInsets.all(16),
          child: Center(child: CircularProgressIndicator()),
        );
      },
    );
  }
}

go deeper

for a junior

Recall the shape: ListView.builder, itemCount of items plus one while more pages exist, and a footer row at index items.length that shows a spinner.

for a middle

Explain extentAfter as content below the viewport, why a threshold of about a screen hides latency, and how hasMore and a loading flag stop extra requests.

for a senior

Show you have shipped one: the first page that fits the screen, retry without a retry storm, and choosing the listener over a side effect in itemBuilder.

for a principal

Frame the threshold and page size as a cost trade-off between perceived latency, wasted fetches for rows never seen, and memory held by an ever-growing list.

## What an infinite list is An **infinite list** (also called a load-more or paged list) shows the first page of data, then fetches the next page as the user scrolls toward the end, so the list feels endless while the app only holds what has been loaded. In Flutter it is built from three parts: - a **lazy list**, `ListView.builder`, which builds only the rows near the viewport; - a **footer row** that stands for 'more is coming' (a spinner), 'loading failed' (a retry button) or nothing once the last page has arrived; - a **trigger** that decides when to request the next page. The state behind it is small: the loaded items, a page number or cursor for the next request, a `hasMore` flag, a `loading` flag and an optional error. ## The builder and the footer row `ListView.builder` asks its `itemBuilder` for a widget at each index from `0` to `itemCount - 1`. The trick is to ask for one index more than there are items while more pages exist: `itemCount: items.length + (hasMore ? 1 : 0)`. When the builder is called with `index == items.length`, that index is the footer. | State | What the footer shows | |---|---| | a page is loading | a `CircularProgressIndicator` | | the last load failed | a short message and a retry button | | `hasMore` is false | no footer at all; `itemCount` equals `items.length` | Putting the spinner inside the list, rather than in a `Column` under the `ListView`, matters: the footer scrolls with the content, appears exactly where the next rows will, and never takes height away from the viewport. ## Deciding when to load: extentAfter Every scroll view has a `ScrollPosition`, reachable as `controller.position` once a `ScrollController` is attached. It exposes `pixels` (the current offset) and three derived measures from `ScrollMetrics`: `extentBefore` (content above the viewport), `extentInside` (content visible) and **`extentAfter`**, the content still below the viewport's trailing edge, computed as `max(maxScrollExtent - pixels, 0.0)`. It is clamped, so it never goes negative. A load-more listener compares `extentAfter` with a threshold, often about one screen height (500 to 1000 logical pixels), and requests the next page when it drops below. Starting early hides network latency. Waiting for `pixels == maxScrollExtent` instead means the user always reaches the bottom and then waits on a spinner. ## Two ways to trigger | Trigger | How it works | Watch out for | |---|---|---| | scroll listener | `controller.addListener` checks `position.extentAfter < threshold` | fires on every offset change, so it needs an in-flight guard; never fires if nothing scrolls | | footer build | the `itemBuilder` starts a load when it builds `index == items.length` | runs while the list lays out its children, so a synchronous `setState` on the screen's state fails with 'setState() or markNeedsBuild() called during build.'; re-runs whenever the footer rebuilds | Both work, and both must be guarded so that one page is requested once. The listener version gives a threshold you can tune in pixels. The footer version starts loading when the footer enters the viewport's cache area, 250 pixels beyond the visible edge by default (set through `scrollCacheExtent`, which replaced the deprecated `cacheExtent` in 3.44). ## The first page that does not fill the screen A listener runs only when the offset changes. If 25 short rows fit on a tablet, the user cannot scroll, the listener never fires and the feed stops at page one. After each page lands, re-check the threshold once the new frame is laid out (`WidgetsBinding.instance.addPostFrameCallback` is the usual hook) and load again while `extentAfter` is still under the threshold and `hasMore` is true. With content shorter than the viewport, `maxScrollExtent` is `0.0` and so is `extentAfter`, so the same check covers it. ## Walk-through: a job feed loaded 25 at a time 1. `initState` attaches the listener and requests page 0. 2. The response adds 25 postings; `hasMore` stays true because the page was full. 3. The user scrolls; when fewer than about 600 pixels remain below the viewport, the listener requests page 1 while the footer spinner is still off-screen. 4. A page with fewer than 25 postings sets `hasMore` to false, `itemCount` drops the footer, and further scrolling requests nothing. 5. If a request fails, the footer shows a retry button, and the automatic trigger stays quiet until the user taps it. How the page number or cursor is designed on the server, and how the HTTP call is made, are separate topics; the list only needs 'give me the next page' and 'was that the last one'.

  • Why not wait until the position's pixels equal maxScrollExtent before loading?
    Because the user then always reaches the very bottom first and waits for the whole network round trip with a spinner in view. A threshold on `extentAfter` of about one screen starts the request while there is still content to read, so the next page usually lands before the footer is visible. The exact value is a trade-off: larger hides more latency but fetches pages the user may never scroll to.
  • How does the list know the last page has arrived?
    Either the page comes back shorter than the requested page size, or the server says so explicitly, for example with no next cursor. The short-page rule costs one extra, empty request when the total is an exact multiple of 25. Either way `hasMore` becomes false, `itemCount` drops the footer and the trigger stops asking.
  • Why is starting the load from the itemBuilder, when the footer is built, riskier than a listener?
    The builder runs while the list lays out its children, inside a build scope, so calling `setState` on the screen's state synchronously there fails with 'setState() or markNeedsBuild() called during build.'. It also re-runs whenever the footer rebuilds. It works if the call only starts async work behind an in-flight guard and changes state after an await or a post-frame callback.

saying these in an interview costs you the question

  • Build the feed with a plain ListView and a children list, appending rows as pages arrive.
  • Put the loading spinner in a Column below the ListView instead of as a footer row.
  • Wait until pixels equals maxScrollExtent exactly before requesting the next page.
  • The scroll listener will fire eventually even if the first page fits on screen.
  • extentAfter goes negative when the content is shorter than the viewport.