Infinite Scrolling and Pagination

Modern mobile applications deal with large datasets — news feeds, product listings, chat messages, search results, social media timelines. Loading all of this data at once is inefficient and leads to poor performance. Instead, developers use infinite scrolling and pagination techniques.

With infinite scrolling, the app fetches more data when the user scrolls to the bottom of the list. Pagination, on the other hand, organizes data into chunks or pages, fetched as needed.

In this post, we’ll cover:

  1. Why infinite scrolling is important.
  2. How to load data lazily in Flutter.
  3. Implementing the “Load More” pattern.
  4. Handling API-driven pagination.
  5. Best practices and performance tips.

By the end, you’ll know how to build smooth, scalable lists that handle thousands of items efficiently.


Why Infinite Scrolling and Pagination Are Important

  • Performance: Large datasets cannot be loaded at once without causing lags.
  • Better UX: Users see content immediately, instead of waiting for everything to load.
  • Network Efficiency: Fetching smaller chunks of data reduces bandwidth usage.
  • Real-world Scenarios: E-commerce product catalogs, chat apps, social feeds, or news apps rely on incremental loading.

Basics of Lazy Loading in Flutter

Flutter provides powerful scrolling widgets like ListView.builder, which already supports lazy building of widgets. Unlike ListView(children: []), the builder only constructs items when they are visible, making it ideal for long lists.

Example of a lazy-loaded list:

ListView.builder(
  itemCount: 1000,
  itemBuilder: (context, index) {
return ListTile(
  title: Text("Item $index"),
);
}, );

Even though itemCount is 1000, Flutter only builds items visible on screen plus a small buffer. This is lazy widget building, but not yet infinite scrolling — since all 1000 items exist in memory.

For infinite scrolling, we need dynamic data fetching as the user scrolls.


Detecting Scroll End with ScrollController

To implement infinite scroll, we must detect when the user reaches the bottom of the list. This is done using a ScrollController.

class InfiniteScrollDemo extends StatefulWidget {
  @override
  _InfiniteScrollDemoState createState() => _InfiniteScrollDemoState();
}

class _InfiniteScrollDemoState extends State<InfiniteScrollDemo> {
  final ScrollController _controller = ScrollController();
  List<int> _items = List.generate(20, (index) => index);

  @override
  void initState() {
super.initState();
_controller.addListener(() {
  if (_controller.position.pixels == _controller.position.maxScrollExtent) {
    _loadMore();
  }
});
} void _loadMore() {
setState(() {
  final nextItems = List.generate(20, (index) =&gt; _items.length + index);
  _items.addAll(nextItems);
});
} @override Widget build(BuildContext context) {
return ListView.builder(
  controller: _controller,
  itemCount: _items.length,
  itemBuilder: (context, index) {
    return ListTile(
      title: Text("Item ${_items&#91;index]}"),
    );
  },
);
} }

Here’s what’s happening:

  1. ScrollController detects scrolling.
  2. When reaching the end (position.pixels == maxScrollExtent), _loadMore() is called.
  3. New items are appended to _items, and the list updates.

This is the foundation of infinite scrolling.


Adding a Loading Indicator

Users expect feedback when data is being fetched. We can add a loading spinner at the end of the list.

class InfiniteScrollWithLoader extends StatefulWidget {
  @override
  _InfiniteScrollWithLoaderState createState() => _InfiniteScrollWithLoaderState();
}

class _InfiniteScrollWithLoaderState extends State<InfiniteScrollWithLoader> {
  final ScrollController _controller = ScrollController();
  List<int> _items = [];
  bool _isLoading = false;

  @override
  void initState() {
super.initState();
_loadMore();
_controller.addListener(() {
  if (_controller.position.pixels == _controller.position.maxScrollExtent &amp;&amp; !_isLoading) {
    _loadMore();
  }
});
} Future<void> _loadMore() async {
setState(() =&gt; _isLoading = true);
await Future.delayed(Duration(seconds: 2)); // simulate API delay
setState(() {
  final nextItems = List.generate(10, (index) =&gt; _items.length + index);
  _items.addAll(nextItems);
  _isLoading = false;
});
} @override Widget build(BuildContext context) {
return ListView.builder(
  controller: _controller,
  itemCount: _items.length + (_isLoading ? 1 : 0),
  itemBuilder: (context, index) {
    if (index == _items.length) {
      return Center(child: CircularProgressIndicator());
    }
    return ListTile(title: Text("Item ${_items&#91;index]}"));
  },
);
} }

Now, when the user scrolls to the bottom:

  • A loading spinner appears.
  • Data loads asynchronously.
  • Spinner disappears once new data arrives.

This provides better UX and simulates real-world API fetching.


Implementing the “Load More” Button

Sometimes infinite scrolling isn’t the best choice. Instead, apps use a Load More button at the end of the list.

class LoadMoreList extends StatefulWidget {
  @override
  _LoadMoreListState createState() => _LoadMoreListState();
}

class _LoadMoreListState extends State<LoadMoreList> {
  List<int> _items = List.generate(20, (index) => index);
  bool _isLoading = false;

  void _loadMore() async {
setState(() =&gt; _isLoading = true);
await Future.delayed(Duration(seconds: 2));
setState(() {
  final nextItems = List.generate(10, (index) =&gt; _items.length + index);
  _items.addAll(nextItems);
  _isLoading = false;
});
} @override Widget build(BuildContext context) {
return ListView.builder(
  itemCount: _items.length + 1,
  itemBuilder: (context, index) {
    if (index == _items.length) {
      return _isLoading
          ? Center(child: CircularProgressIndicator())
          : ElevatedButton(
              onPressed: _loadMore,
              child: Text("Load More"),
            );
    }
    return ListTile(title: Text("Item ${_items&#91;index]}"));
  },
);
} }

This approach is great when:

  • You want more control over loading.
  • Users prefer batches instead of infinite scrolling.
  • APIs use page-based data fetching.

Pagination with API Data

Most APIs don’t return all data at once; they provide it in pages. Each page may contain a limit (items per page) and a page or offset value.

A typical API response might look like:

{
  "page": 2,
  "limit": 10,
  "totalPages": 50,
  "data": [
{"id": 21, "title": "Item 21"},
{"id": 22, "title": "Item 22"}
] }

In Flutter, you can fetch these pages dynamically:

Future<void> _fetchPage(int page) async {
  final response = await http.get(Uri.parse("https://api.example.com/items?page=$page&limit=10"));
  final data = jsonDecode(response.body);
  setState(() {
_items.addAll(data&#91;'data']);
}); }

This integrates perfectly with ScrollController or Load More buttons, allowing you to build a scalable feed.


Best Practices for Infinite Scrolling and Pagination

  1. Use ListView.builder, not ListView(children): Avoid building all items upfront.
  2. Show feedback: Always display a loader or button so users know more content is loading.
  3. Handle API errors: Show retry buttons if fetching fails.
  4. Debounce scroll events: Prevent multiple fetch requests when hitting the bottom.
  5. Cache results: Don’t refetch the same data if the user scrolls back up.
  6. Use Slivers for advanced layouts: SliverList and SliverGrid offer more flexibility.

UX Considerations

  • End-of-List Indicator: Tell users when no more data is available.
  • Avoid Endless Feeds: Sometimes, users appreciate boundaries (e.g., Load More button).
  • Optimize Images: Lazy-load thumbnails and cache them to prevent lag.
  • Preload Data: Fetch the next page slightly before the end of the list for a smoother experience.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *