Pagination Problem in Flutter (You have skipped it)

Kanan Yusubov
7 min readDec 10, 2024

Pagination is one of the most important features for large data collection visualization. For instance, if we have a list of products with a thousand items, you should not load all of the items at once. That is why, we need pagination to load them partially.

Define Mock classes for pagination

Initially, I created a product data class as follows:

class Product {
const Product({
required this.id,
required this.name,
required this.description,
required this.price,
});

final int id;
final String name;
final String description;
final double price;

@override
String toString() {
return 'Product(id: $id, name: $name, price: $price)';
}
}

To mock the pagination process, I wrote a simple Singleton class:

import 'dart:math';

import 'package:pagination_test_app/product.dart';

class PaginatedProductGenerator {
// Private constructor
PaginatedProductGenerator._internal() : _random = Random();

// Singleton instance
static PaginatedProductGenerator? _instance;

static PaginatedProductGenerator get instance {
_instance ??= PaginatedProductGenerator._internal();
return _instance!;
}

final Random _random;

List<Product> getPage(int offset, int limit) {
final products = List.generate(limit, (index) {
final id = offset + index + 1;
final name = 'Product $id';
final description = 'Description for Product $id';
final price = (100 + _random.nextDouble() * 900)
.toStringAsFixed(2); // Prices between 100-1000
return Product(
id: id,
name: name,
description: description,
price: double.parse(price),
);
});

return products;
}
}

As you can see, when we call getNextPage, it will give us new N (limit in this case) items.

What is the problem?

Now, assume that we have 10 pages, and for each page, we have 10 products (10 limit). So, we have 100 items maximum (end of pagination). The basic setup for pagination in Flutter is like the follows:

class ProductsPage extends StatefulWidget {
const ProductsPage({super.key});

@override
State<ProductsPage> createState() => _ProductsPageState();
}

class _ProductsPageState extends State<ProductsPage> {
final List<Product> _products = [];
final ScrollController _controller = ScrollController();
bool _isLoading = false;
bool _hasMore = true;
int _offset = 0;
final _limit = 10;

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

_loadMoreProducts(); // Initial load
_controller.addListener(_onScroll);
}

void _onScroll() {
if (_controller.offset >= _controller.position.maxScrollExtent &&
!_controller.position.outOfRange &&
!_isLoading &&
_hasMore) {
_loadMoreProducts();
}
}

Future<void> _loadMoreProducts() async {
if (_isLoading || !_hasMore) return;

setState(() {
_isLoading = true;
});

// Simulate network delay
await Future.delayed(const Duration(seconds: 1));

final newProducts = PaginatedProductGenerator.instance.getPage(
_offset,
_limit,
);

setState(() {
_products.addAll(newProducts);
_isLoading = false;
_offset += _limit;

/// mock end point, it is coming from API in normal case
/// generally, we have a parameter in response of server like
/// nextPage, or hasNext, etc.
if (_products.length == 100) {
_hasMore = false;
}
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
controller: _controller,
itemBuilder: (context, index) {
if (index < _products.length) {
final product = _products[index];

return Card(
child: Column(
children: [
Text(product.name),
const SizedBox(height: 8),
Text(product.description),
const SizedBox(height: 8),
Text(product.price.toString()),
],
),
);
}

// Loading indicator at the bottom
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
);
},
itemCount: _products.length + (_isLoading ? 1 : 0),
),
);
}

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

Here we have 2 cases. For a better understanding, I will explain it using Flutter Web.

The first case is the best. If the first 10 items cover all space on the page, everything works fine and pagination will work.

normal working case

The second case is the problem. If 10 items will not cover all space (it is unpredictable, because the device or resolution can be bigger than your testing case), pagination will not work, because ScrollView does not understand if you are scrolling to load items or not.

problematic case

How I solved it? (First part)

You might think that we can calculate one item height, and according to screen size, we can generate the item count we need to get on each page.

But this solution can be hard to implement.

For this reason, I have created a simple PaginatedListView (the same thing is for grids or slivers), that is like the following:

import 'package:flutter/material.dart';
import 'package:pagination_test_app/responsive_layout.dart';

class PaginatedListView<T> extends StatefulWidget {
const PaginatedListView({
super.key,
this.itemCount = 0,
required this.itemBuilder,
this.isLoading = false,
this.hasMore = true,
this.loadMore,
});

final int itemCount;
final IndexedWidgetBuilder itemBuilder;
final bool isLoading;
final bool hasMore;
final VoidCallback? loadMore;

@override
State<PaginatedListView> createState() => _PaginatedListViewState();
}

class _PaginatedListViewState extends State<PaginatedListView> {
final _controller = ScrollController();

@override
void initState() {
super.initState();
_controller.addListener(_scrollListener);
WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitialFill());
}

@override
void didChangeDependencies() {
context.windowSize;
super.didChangeDependencies();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitialFill());
}

@override
void didUpdateWidget(covariant PaginatedListView oldWidget) {
super.didUpdateWidget(oldWidget);
WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitialFill());
}

bool get skipPagination => widget.isLoading || !widget.hasMore;

void _checkInitialFill() {
if (skipPagination) return;

print('extent: ${_controller.position.maxScrollExtent}');

if (_controller.position.maxScrollExtent == 0) {
widget.loadMore?.call();
}
}

void _scrollListener() {
if (skipPagination) return;

if (_controller.offset >= _controller.position.maxScrollExtent &&
!_controller.position.outOfRange) {
widget.loadMore?.call();
}
}

@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _controller,
itemBuilder: (context, index) {
if (index < widget.itemCount) {
return widget.itemBuilder(context, index);
}

return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
);
},
itemCount: widget.itemCount + (widget.hasMore ? 1 : 0),
);
}
}

Explanation:

  • skipPagination — this function restricts the loading of items again if one is already in progress or all items load is completed
  • _checkInitialFill—When we call this function in the initState, it checks whether the list is filled with all available space. maxScrollExtent returns 0 if there is no scrollable content (the list does not fill all the space).
  • _scrollListener — when the user scrolls the list, this function will handle load more processes automatically.
  • didUpdateWidget — It will called periodically until it fills all available space because our state is coming from the upper layer, in every pagination, the list will be reloaded.

Now, our UI handles the initial fill process successfully!

But resolution, resize? (Part 2)

However, when the user resizes the browser and the resolution of the device changes, the code example is not working. How can we solve it?

First of all, we write a simple Inherited Model to listen to size and layout changes:

import 'package:flutter/widgets.dart';

enum Layout { mobile, desktop, tablet }

enum LayoutAspect { layout, size }

class ResponsiveLayoutScopeWrapper extends StatelessWidget {
const ResponsiveLayoutScopeWrapper({
super.key,
required this.child,
});

final Widget child;

@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final width = size.width;

final Layout layout;

if (width > 1024) {
layout = Layout.desktop;
} else if (width >= 738) {
layout = Layout.tablet;
} else {
layout = Layout.mobile;
}

return ResponsiveLayoutScope(
layout: layout,
size: size,
child: child,
);
}
}

class ResponsiveLayoutScope extends InheritedModel<LayoutAspect> {
const ResponsiveLayoutScope({
super.key,
required super.child,
required this.layout,
required this.size,
});

final Layout layout;
final Size size;

static ResponsiveLayoutScope? of(BuildContext context, LayoutAspect aspect) {
return context.dependOnInheritedWidgetOfExactType<ResponsiveLayoutScope>(
aspect: aspect,
);
}

static Size sizeOf(BuildContext context) =>
of(context, LayoutAspect.size)!.size;

static Layout layoutOf(BuildContext context) =>
of(context, LayoutAspect.layout)!.layout;

@override
bool updateShouldNotify(ResponsiveLayoutScope oldWidget) {
return oldWidget.layout != layout || oldWidget.size != size;
}

@override
bool updateShouldNotifyDependent(
covariant InheritedModel<LayoutAspect> oldWidget,
Set<LayoutAspect> dependencies,
) {
if (oldWidget is! ResponsiveLayoutScope) return false;

if (oldWidget.layout != layout &&
dependencies.contains(LayoutAspect.layout)) {
return true;
} else if (oldWidget.size != size &&
dependencies.contains(LayoutAspect.size)) {
return true;
}

return false;
}
}

extension LayoutExt on BuildContext {
Layout get windowLayout => ResponsiveLayoutScope.layoutOf(this);

Size get windowSize => ResponsiveLayoutScope.sizeOf(this);

bool get isMobile => windowLayout == Layout.mobile;

bool get isDesktop => windowLayout == Layout.desktop;

bool get isTablet => windowLayout == Layout.tablet;
}

You can listen to only the size or layout of the current browser!

And, we should provide our provider above material app for the app:

class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return ResponsiveLayoutScopeWrapper(
child: MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.white),
useMaterial3: true,
),
home: const ProductsPage(),
),
);
}
}

The last step will be in the pagination view. So:

  @override
void didChangeDependencies() {
context.windowSize;
super.didChangeDependencies();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitialFill());
}

We should add didChangedDependencies here. The reason is when the size of the app changes it will trigger listview to resize itself, and in this case, if there is available space for a list item, it will call load more again.

And, that is it! now our code is working!

size-aware pagination

It is working for different resolutions too!

other resolution-aware pagination

We have done it! Thank you for reading my article. If you like it, don’t forget to clap!

--

--

Kanan Yusubov
Kanan Yusubov

Written by Kanan Yusubov

Mobile Platforms Expert (Flutter) from Azerbaijan | Founder of Azerbaijan Flutter Users Community

Responses (2)