Flutter Loading next data when reaching the bottom of ListView

eye-catchDart and Flutter
Sponsored links

Many applications have a lazy data loading feature. For example, it loads the first 20 data and if a user scrolls down to the bottom of the list, the application loads the next 20 data. I will show how to implement it in this article.
The following video shows the behavior.

Sponsored links

Data list to fetch

In this article, we don’t access a database for brevity. We use instead just a function that returns Future. We prepare 200 items and a function that returns desired number of items.

List<int> _data = [for (var i = 0; i < 200; i++) i];
Future<List<int>> _fetch(int count) {
  return Future.delayed(
    Duration(seconds: 2),
    () => _data.where((element) => element < count).toList(),
  );
}

The reason why we use Future type is to emulate database access or REST API. Those basically return Future type because it takes a while to complete the work. The delay time is 2 seconds to make it easy to see the loading icon.

We set 50, 100, 150 and 200 to the count parameter to load next data. If we need to fetch data by SQL, set a value to LIMIT cause. See here for the SQLite.

Sponsored links

Show the list by FutureBuilder

Let’s start with FutureBuilder. We need it because _fetch function returns Future type.

class LoadingNext extends StatefulWidget {
  @override
  _LoadingNext createState() => _LoadingNext();
}

class _LoadingNext extends State<LoadingNext> {
  int _count = 50;
  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(
        appBar: AppBar(
          title: Text("Loading next data"),
        ),
        body: _createBody(context),
      ),
    );
  }

  Widget _createBody(BuildContext context) {
    return FutureBuilder(
      future: _fetch(_count),
      builder: (BuildContext context, AsyncSnapshot<List<int>?> snapshot) {
        final data = snapshot.data;
        if (data == null) {
          return Center(child: CircularProgressIndicator());
        }

        return ListView.builder(
          itemCount: data.length,
          itemBuilder: (BuildContext context, int index) {
            return ListTile(title: Text("Item number - ${data[index]}"));
          },
        );
      },
    );
  }
}

It shows a progress indicator while _fetch function is processing.

indicator
list

Detect the current position by ScrollController

We need to fetch the next data when a user scrolls down to the bottom of the list view. The important thing is when to start the process. In this example, we will start fetching the next data if the current position reaches 80% of the view.
To get the scroll position, we can set ScrollController to ListView.controller.

final controller = ScrollController();
controller.addListener(() {
  final position =
      controller.offset / controller.position.maxScrollExtent;
  if (position >= 0.8) {
    setState(() {
      _count += 50;
    });
  }
});
return ListView.builder(
  controller: controller,   // set controller here
  itemCount: data.length,
  itemBuilder: (BuildContext context, int index) {
    return ListTile(title: Text("Item number - ${data[index]}"));
  },
);

Did you recognize that data is shown to 149 after the first loading when reaching the bottom of the list view? The code above is not good because setState is called many times.

setState should not be called while loading the next data. Let’s add conditions to improve it.

final controller = ScrollController();
controller.addListener(() {
  final position =
      controller.offset / controller.position.maxScrollExtent;
  if (position >= 0.8) {
    // Add conditions here
    if (data.length == _count && _count < _data.length) {
      setState(() {
        _count += 50;
      });
    }
  }
});
return ListView.builder(
  controller: controller,
  itemCount: data.length,
  itemBuilder: (BuildContext context, int index) {
    return ListTile(title: Text("Item number - ${data[index]}"));
  },
);

When setState is called, build method is called again for the parent widget. FutureBuilder.builder passes the old data while loading the new data. Namely, snapshot.data.length, which is data.length above, is 50 for the first time whereas the _count is 100.
Once the loading process is completed, snapshot.data.length is 100 and position is 0.5.

Don’t forget to call controller.dispose() if you implement the logic in the same class.

Extract the ScrollController logic into a Widget

If we need to have the same feature for the different lists, we need to implement it multiple times. Let’s extract the logic to make it easier.

class ScrollListener extends StatefulWidget {
  final Widget Function(BuildContext, ScrollController) builder;
  final VoidCallback loadNext;
  final double threshold;
  ScrollListener({
    required this.threshold,
    required this.builder,
    required this.loadNext,
  });

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

class _ScrollListener extends State<ScrollListener> {
  ScrollController _controller = ScrollController();

  @override
  void initState() {
    super.initState();
    _controller.addListener(() {
      final rate = _controller.offset / _controller.position.maxScrollExtent;
      if (widget.threshold <= rate) {
        widget.loadNext();
      }
    });
  }

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

  @override
  Widget build(BuildContext context) {
    return widget.builder(context, _controller);
  }
}

We can replace the previous code with the following code.

return ScrollListener(
  threshold: 0.8,
  builder: (context, controller) {
    return ListView.builder(
      controller: controller,
      itemCount: data.length,
      itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("Item number - ${data[index]}"));
      },
    );
  },
  loadNext: () {
    if (data.length == _count && _count < _data.length) {
      setState(() {
        _count += 50;
      });
    }
  },
);

Show CircularProgressIndicator while loading the next data

The last thing that we want to do is to show a progress indicator while loading the next data. Let’s show CircularProgressIndicator. The following code is what I implemented at first.

return ScrollListener(
  threshold: 0.8,
  builder: (context, controller) {
    final listView = ListView.builder(
      controller: controller,
      itemCount: data.length,
      itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("Item number - ${data[index]}"));
      },
    );

    if (data.length != _count) {
      return Stack(
        children: [
          listView,
          Center(child: CircularProgressIndicator()),
        ],
      );
    } else {
      return listView;
    }
  },
  loadNext: () {
    if (data.length == _count && _count < _data.length) {
      setState(() {
        _count += 50;
      });
    }
  },
);

But this code doesn’t work as expected! The position goes back to the top when start loading the next data.

I don’t know why the controller.position is initialized but it can be solved if we always return the same widget tree.

return ScrollListener(
  threshold: 0.8,
  builder: (context, controller) {
    final listView = ListView.builder(
      controller: controller,
      itemCount: data.length,
      itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("Item number - ${data[index]}"));
      },
    );

    return Stack(
      children: [
        listView,
        Opacity(
          opacity: data.length != _count ? 1 : 0,
          child: Center(child: CircularProgressIndicator()),
        ),
      ],
    );
  },
  loadNext: () {
    if (data.length == _count && _count < _data.length) {
      setState(() {
        _count += 50;
      });
    }
  },
);

Stack Widget allows us to show a widget on a widget. The bottom widget is shown at the top.
Opacity Widget controls the transparency. If the opacity is 0, the widget is fully transparent which means invisible. It can be replaceable with Visibility in this case.

Visibility(
  visible: data.length != _count,
  child: Center(child: CircularProgressIndicator()),
),

If we want to show the translucent progress indicator, use Opacity.

Related articles

Comments

Copied title and URL