Learn how to build pagination in Flutter with Generics.
Chapters:
-
Pagination in Flutter with Generics and Bloc: Write Once and use Anywhere
-
More to come …
In the previous workshops, we worked on Lists, HashMaps, and HashSets and learned State Management with Bloc. In this workshop, we will build Pagination in Flutter.
Contents:
Who is the target Audience for this article? Why write on Pagination? What is our end goal? Building core files for this project Building the UI Create ScrollEndMixin Building the bloc files Integrating the bloc into UI Abstracting the bloc and the logic Let’s put everything together
Who is the target audience for this article?
Easy peasy, this article aims to help newcomers and intermediates. Experts already know this. They have probably moved on to the next chapter.
This article is for those who are still learning and looking for better examples to learn from.
There is one more faction that this article is for. The ones who have developed something far more efficient. Help us all and paste your solution, and explain why your code is better.
Why write on Pagination?
There are already many articles and write-ups explaining pagination. So how does this do anything for me?
So this article aims to build better protocols for writing code and understanding why to use generics.
Since this is in continuation of our existing series, from
What is our end goal?
Our end goal is simple. We want to build a solution that can be applied to any kind of list and can load more data on demand.
Building core files for this project
Let’s build a core file and some utility files that I am using for this workshop.
import 'package:equatable/equatable.dart';
// T is type of data
// H is type of error
sealed class DataField<T,H> extends Equatable {
const DataField();
}
class DataFieldInitial<T, H> extends DataField<T, H> {
const DataFieldInitial();
@override
List<Object?> get props => [];
}
class DataFieldLoading<T, H> extends DataField<T, H> {
const DataFieldLoading(this.data);
final T data;
@override
List<Object?> get props => [data];
}
class DataFieldSuccess<T, H> extends DataField<T, H> {
const DataFieldSuccess(this.data);
final T data;
@override
List<Object?> get props => [data];
}
class DataFieldError<T, H> extends DataField<T, H> {
const DataFieldError(this.error, this.data);
final H error;
final T data;
@override
List<Object?> get props => [error, data];
}
DataField
is a sealed class that we can use to store the states of the data being stored. Learn more about class modifiershere .- We have defined 4 states for the field, which can be
initial
,loading
,success
anderror
- We have declared the
DataField
class with two types,T
andH
whereT
is the type of data that we will be storing andH
being the type of error.
import 'package:flutter/material.dart';
extension ColorToHex on Color {
/// Converts the color to a 6-digit hexadecimal string representation (without alpha).
String toHex({bool leadingHash = false}) {
return '${leadingHash ? '#' : ''}'
'${red.toRadixString(16).padLeft(2, '0')}'
'${green.toRadixString(16).padLeft(2, '0')}'
'${blue.toRadixString(16).padLeft(2, '0')}';
}
}
class ColorGenerator {
/// Generates a pseudo-random color based on the provided index.
///
/// This function uses a simple hash function to map the index to a unique
/// color. It's not cryptographically secure, but it's sufficient for
/// generating distinct colors for UI elements.
///
/// The generated color will always have full opacity (alpha = 0xFF).
static Color generateColor(int index) {
// A simple hash function to distribute the index across the color space.
int hash = index * 0x9E3779B9; // A large prime number
hash = (hash ^ (hash >> 16)) & 0xFFFFFFFF; // Ensure 32-bit unsigned
// Extract color components from the hash.
int r = (hash >> 16) & 0xFF;
int g = (hash >> 8) & 0xFF;
int b = hash & 0xFF;
return Color(0xFF000000 + (r << 16) + (g << 8) + b); // Full opacity
}
}
- This is an optional file, and we are using this for building our Product Model Objects.
- I hope the comments in the code are sufficient for the same. If not, please comment.
import 'package:equatable/equatable.dart';
import 'package:pagination_starter/core/color_generator.dart';
// This is how the model will look like
class Product extends Equatable {
const Product({required this.id, required this.name, required this.image});
factory Product.fromInteger(int index) {
final randomColorHexCode = ColorGenerator.generateColor(index).toHex();
return Product(
id: index.toString(),
name: 'Item number: $index',
image: 'https://dummyjson.com/image/400x200/${randomColorHexCode}/?type=webp&text=With+Id+$index&fontFamily=pacifico',
);
}
final String id;
final String name;
final String image;
@override
List<Object?> get props => [id];
}
class ProductResponse extends Equatable {
const ProductResponse({required this.products, required this.time});
final List<Product> products;
final DateTime time;
@override
List<Object?> get props => [products, time];
}
These will be the models used in this workshop, assuming the response to the API looks something like this:
{
"statusCode": 200,
"time": DateTime.now(),
"products": [
{
"id": 1,
"name": "Index : 1",
"image": "image_1.webp"
},
{
"id": 2,
"name": "Index : 2",
"image": "image_2.webp"
},
...
],
},
You can ignore these files, too.
import 'package:pagination_starter/core/models.dart';
class ApiClient {
// Used for mocking api response
Future<ProductResponse> getProducts({int? page, int pageSize = 10}) async {
await Future<void>.delayed(const Duration(seconds: 2));
final start = ((page ?? 1) - 1) * pageSize + 1;
return ProductResponse(
products: List.generate(
pageSize,
(index) {
return Product.fromInteger(start + index);
},
),
time: DateTime.now(),
);
}
}
Building the UI
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pagination_starter/core/api_client.dart';
import 'package:pagination_starter/core/bloc/pagination_bloc.dart';
import 'package:pagination_starter/core/models.dart';
import 'package:pagination_starter/counter/bloc/products_bloc.dart';
import 'package:pagination_starter/l10n/l10n.dart';
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return const CounterView();
}
}
class CounterView extends StatefulWidget {
const CounterView({super.key});
@override
State<CounterView> createState() => _CounterViewState();
}
class _CounterViewState extends State<CounterView> {
// This function takes in scroll notifications and notifies
// when the scroll has reached
void onScroll(ScrollNotification notification, {VoidCallback? onEndReached}) {
if (_isAtBottom(notification)) {
// you can paginate
onEndReached?.call();
}
}
bool _isAtBottom(ScrollNotification notification) {
final maxScrollExtent = notification.metrics.maxScrollExtent;
final currentScrollExtent = notification.metrics.pixels;
// you can play around with this number
const paginationOffset = 200;
return currentScrollExtent >= maxScrollExtent - paginationOffset;
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(title: Text(l10n.counterAppBarTitle)),
body: NotificationListener<ScrollNotification>(
onNotification: (notification) {
onScroll(
notification,
onEndReached: () {
// send a call to fetch the new set of items
},
);
return false;
},
child: _list(
// fake products
List.generate(10, Product.fromInteger),
true,
),
),
);
}
Widget _list(List<Product> data, bool isPaginating) {
return ListView.builder(
itemBuilder: (context, index) => _buildItem(
isPaginating,
index == data.length,
index >= data.length? null: data[index],
),
itemCount: data.length + (isPaginating ? 1 : 0),
);
}
Widget _buildItem(bool isPaginating, bool isLastItem, Product? product) {
if (isPaginating && isLastItem) {
return const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: CircularProgressIndicator(),
),
);
}
return Card(
child: Column(
children: [
Image.network(product!.image),
],
),
);
}
}
_buildItem
function builds the item of the list. It can be either a card with an Image or a loading indicator, depending on whether it is the last index and the list is paginating._list
function builds the list of items. We add 1 to the item count to accommodate for paginating loader.- We have also added a
NotificationListener
widget that listens to the scroll notifications to indicate when we have reached the end of the list. - Since our UI class holds some data that can be offloaded to a mixin, and will add re-usability in our code.
Create ScrollEndMixin
You can skip this part and move ahead if you just want to add some code to get your pagination working.
import 'package:flutter/widgets.dart';
mixin BottomEndScrollMixin {
// you can play around with this number
double _paginationOffset = 200;
void onScroll(ScrollNotification notification, {VoidCallback? onEndReached}) {
if (_isAtBottom(notification)) {
// you can paginate
onEndReached?.call();
}
}
set paginationOffset(double value) => _paginationOffset = value;
bool _isAtBottom(ScrollNotification notification) {
final maxScrollExtent = notification.metrics.maxScrollExtent;
final currentScrollExtent = notification.metrics.pixels;
return currentScrollExtent >= maxScrollExtent - _paginationOffset;
}
}
-
In this mixin, we find when the scroll has reached the bottom end.
-
We pass in a callback, which, when called, indicates that the end has been reached, can proceed with your action.
-
We can also change the value of the pagination offset.
Now let’s piece this together with the UI
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pagination_starter/core/api_client.dart';
import 'package:pagination_starter/core/bloc/pagination_bloc.dart';
import 'package:pagination_starter/core/bottom_end_scroll_mixin.dart';
import 'package:pagination_starter/core/models.dart';
import 'package:pagination_starter/counter/bloc/products_bloc.dart';
import 'package:pagination_starter/l10n/l10n.dart';
...
class _CounterViewState extends State<CounterView> with BottomEndScrollMixin {
@override
void initState() {
// set scroll offset in pixels before which scroll should happen
paginationOffset = 200;
super.initState();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(title: Text(l10n.counterAppBarTitle)),
body: NotificationListener<ScrollNotification>(
onNotification: (notification) {
onScroll(
notification,
onEndReached: () {
// send a call to fetch the new set of items
},
);
return false;
},
child: _list(
List.generate(10, Product.fromInteger),
true,
),
);
}
...
}
This is how the app looks right now.
At this stage, our app works and paginates too. But the paginate call does not do anything. Let’s start with the logic. And neither are the products real.
Building the Bloc Files
part of 'products_bloc.dart';
abstract class ProductEvent extends Equatable {
const ProductEvent();
}
class GetProducts extends ProductEvent {
const GetProducts(this.page);
final int page;
@override
List<Object?> get props => [page];
}
-
We have created only one event for now,
GetProducts
which takes in the page number to load. This page number can be anything.
part of 'products_bloc.dart';
class ProductsState extends Equatable {
const ProductsState({
required this.page,
required this.products,
required this.canLoadMore,
});
factory ProductsState.initial() => const ProductsState(
// page is always incremented when
// it's sent, so starting from 0.
page: 0,
products: DataFieldInitial(),
canLoadMore: true,
);
final int page;
final DataField<List<Product>, String> products;
final bool canLoadMore;
ProductsState copyWith({
int? page,
DataField<List<Product>, String>? products,
bool? canLoadMore,
}) {
return ProductsState(
page: page ?? this.page,
products: products ?? this.products,
canLoadMore: canLoadMore ?? this.canLoadMore,
);
}
@override
List<Object?> get props => [products, page, canLoadMore];
}
-
ProductsState
class holds the state of the pagination. -
page
defines the current page number. If your pagination uses a String, you can use a String here for pagination. -
products
holds the state of the products, whether they are in a loading state, initial, success or error. -
canLoadMore
defines the breaker for pagination.
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pagination_starter/core/api_client.dart';
import 'package:pagination_starter/core/data_field.dart';
import 'package:pagination_starter/core/models.dart';
part 'product_event.dart';
part 'products_state.dart';
class ProductsBloc extends Bloc<ProductEvent, ProductsState> {
ProductsBloc(this.apiClient): super(ProductsState.initial()) {
on<GetProducts>(_fetchProducts);
}
final ApiClient apiClient;
FutureOr<void> _fetchProducts(GetProducts event, Emitter<ProductsState> emit) async {
// check if it is already loading, if it is, return
if (state.products is DataFieldLoading) return;
// check if we can load more results
if (!state.canLoadMore) return;
final fetchedProducts = switch (state.products) {
DataFieldInitial<List<Product>, String>() => <Product>[],
DataFieldLoading<List<Product>, String>(:final data) => data,
DataFieldSuccess<List<Product>, String>(:final data) => data,
DataFieldError<List<Product>, String>(:final data) => data,
};
// start loading state
emit(
state.copyWith(
products: DataFieldLoading<List<Product>, String>(fetchedProducts),
),
);
// fetch results
final results = await apiClient.getProducts(page: event.page, pageSize: 10);
// check if products are returned empty
// if they are, stop pagination
if (results.products.isEmpty) {
emit(
state.copyWith(
canLoadMore: false,
),
);
}
final products = [...fetchedProducts, ...results.products];
// increment the page number and update data
emit(
state.copyWith(
page: event.page,
products: DataFieldSuccess(products),
),
);
}
}
ProductsBloc
usesApiClient
that we created earlier in the core files. This is a mock dependency for fetching products.- We register the
GetProducts
event handler to define the logic for initial and subsequent calls. - We stop the pagination if we are already fetching the data or if we have received a breaker for stopping the pagination.
- We start the loading state to show the loader in the UI.
- Fetch the results using the
ApiClient
and if the products returned are empty, this means no more calls to pagination are required, so we can end the pagination here. - Once all the data is fetched, we update the state with Success. For now handling the error state for initial and subsequent pagination, I leave that up to you. If you still want the solution, hit me up in the comments. Will add it there.
Integrating the Bloc into UI
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pagination_starter/core/api_client.dart';
import 'package:pagination_starter/core/bottom_end_scroll_mixin.dart';
import 'package:pagination_starter/core/data_field.dart';
import 'package:pagination_starter/core/models.dart';
import 'package:pagination_starter/counter/bloc/products_bloc.dart';
import 'package:pagination_starter/l10n/l10n.dart';
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) =>
ProductsBloc(ApiClient())..add(const GetProducts(1)),
child: const CounterView(),
);
}
}
...
class _CounterViewState extends State<CounterView> with BottomEndScrollMixin {
...
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final bloc = context.read<ProductsBloc>();
return Scaffold(
appBar: AppBar(title: Text(l10n.counterAppBarTitle)),
body: NotificationListener<ScrollNotification>(
onNotification: (notification) {
onScroll(
notification,
onEndReached: () {
// send a call to fetch the new set of items
bloc.add(GetProducts(bloc.state.page + 1));
},
);
return false;
},
child: BlocBuilder<ProductsBloc, ProductsState>(
builder: (context, state) {
return Center(
child: switch (state.products) {
DataFieldInitial<List<Product>, String>() =>
const CircularProgressIndicator(),
DataFieldLoading<List<Product>, String>(:final data) =>
_list(data, true),
DataFieldSuccess<List<Product>, String>(:final data) =>
data.isEmpty
? const Text('No more products found')
: _list(data, false),
DataFieldError<List<Product>, String>(
:final error,
:final data
) =>
data.isEmpty ? Text(error) : _list(data, false),
},
);
},
),
),
);
}
...
}
- Now integrating
ProductsBloc
into the UI seems easy. We add theGetProducts
to the bloc to fetch the first batch of items. - And a second call for
GetProducts
in theonEndReached
callback with adding 1 to the page count required for supporting this type of pagination. - And then we use the
sealed
class exhaustive mapping feature to return a Widget depending on the state of the product's field. - All the rest of the code is the same.
Abstracting the Bloc and the logic
We have reached the end of our story, building a Generic Pagination Solution. Since we have already created the logic for pagination, the next steps are going to be easy.
Let’s create 3 more bloc files. Same kind of files that we created earlier. And we will just copy and paste the code and change it a little to handle all kinds of data. And let’s delete some files too.
part of 'pagination_bloc.dart';
// Base event for triggering pagination fetches.
// The ID refers to the current page, or last item id
sealed class PaginationEvent<ID> extends Equatable {
const PaginationEvent();
@override
List<Object?> get props => [];
}
class PaginateFetchEvent<ID> extends PaginationEvent<ID> {
const PaginateFetchEvent(this.id);
final ID id;
@override
List<Object?> get props => [id];
}
-
This is a new class.
PaginationEvent
class. Copy the code from theProductEvent
class and paste it here. -
You will notice we have only added an
ID
type that will be your type of data used for pagination. This can be any type. -
Delete your
ProductEvent
class.
part of 'pagination_bloc.dart';
class PaginationState<ID, ITEM, E> extends Equatable {
const PaginationState({
required this.canLoadMore,
required this.itemState,
required this.page,
});
factory PaginationState.initial(
ID id, {
bool canLoadMore = true,
DataField<List<ITEM>, E> state = const DataFieldInitial(),
}) =>
PaginationState(
canLoadMore: canLoadMore,
itemState: state,
page: id,
);
// This variable will hold all the states of initial and future fetches
final DataField<List<ITEM>, E> itemState;
final bool canLoadMore;
// this variable is used to fetch the new batch of items
// this can be anything from page, last item's id as offset
// or anything or adjust as you see fit
final ID page;
PaginationState<ID, ITEM, E> copyWith({
ID? page,
DataField<List<ITEM>, E>? itemState,
bool? canLoadMore,
}) {
return PaginationState(
page: page ?? this.page,
itemState: itemState ?? this.itemState,
canLoadMore: canLoadMore ?? this.canLoadMore,
);
}
@override
List<Object?> get props => [page, canLoadMore, itemState];
}
-
A very similar class to
ProductsState
-
Just change the type of the data and make it generic. We have used
ID
for the type of data for pagination,ITEM
for the type of data that we will be holding a list of, andpage
representing the current page of the state. -
Delete your
ProductsState
class.
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:pagination_starter/core/data_field.dart';
part 'pagination_event.dart';
part 'pagination_state.dart';
abstract class PaginationBloc<ID, ITEM, E>
extends Bloc<PaginationEvent<ID>, PaginationState<ID, ITEM, E>> {
PaginationBloc({required ID page}) : super(PaginationState.initial(page)) {
on<PaginateFetchEvent<ID>>((event, emit) async {
// check if it is already loading, if it is, return
if (state.itemState is DataFieldLoading) return;
// check if we can load more results
if (!state.canLoadMore) return;
final fetchedProducts = switch (state.itemState) {
DataFieldInitial<List<ITEM>, E>() => <ITEM>[],
DataFieldLoading<List<ITEM>, E>(:final data) => data,
DataFieldSuccess<List<ITEM>, E>(:final data) => data,
DataFieldError<List<ITEM>, E>(:final data) => data,
};
// start loading state
emit(
state.copyWith(
itemState: DataFieldLoading<List<ITEM>, E>(fetchedProducts),
),
);
// fetch results
final results = await fetchNext(page: event.id);
// check if products are returned empty
// if they are, stop pagination
if (results.$1.isEmpty) {
emit(
state.copyWith(
canLoadMore: false,
),
);
}
final products = [...fetchedProducts, ...results.$1];
// increment the page number and update data
emit(
state.copyWith(
page: event.id,
itemState: DataFieldSuccess(products),
),
);
});
}
// Abstract method to fetch the next page of data. This is where the
// data-specific logic goes. The BLoC doesn't know *how* to fetch the data,
// it just knows *when* to fetch it.
FutureOr<(List<ITEM>, E?)> fetchNext({ID? page});
}
- This is also almost all copy-pasted code from
ProductsBloc
. - As you can see almost all the code is the same except for the types of the data.
- We have also added a new abstract method,
fetchNext
, to be implemented by the implementing Bloc. - This method returns a list of newly fetched items and a nullable error.
Let’s put everything together
Now that we are done with the generics and abstracting our pagination logic, we can start by making changes in our UI.
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:pagination_starter/core/api_client.dart';
import 'package:pagination_starter/core/bloc/pagination_bloc.dart';
import 'package:pagination_starter/core/data_field.dart';
import 'package:pagination_starter/core/models.dart';
part 'product_event.dart';
part 'products_state.dart';
class ProductsBloc extends PaginationBloc<int, Product, String> {
ProductsBloc(this.apiClient) : super(page: 1);
final ApiClient apiClient;
@override
FutureOr<(List<Product>, String?)> fetchNext({int? page}) async {
// define: how to parse the products
return ((await apiClient.getProducts(page: page)).products, null);
}
}
-
ProductsBloc
class now extendsPaginationBloc<int, Product, String>
representing, int as page, Product as the list of items and String as the type of error. -
We just need to implement the abstract
fetchNext
method that defines how the pages are fetched. -
Our code is now dramatically reduced for writing the pagination for a list of items.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pagination_starter/core/api_client.dart';
import 'package:pagination_starter/core/bloc/pagination_bloc.dart';
import 'package:pagination_starter/core/bottom_end_scroll_mixin.dart';
import 'package:pagination_starter/core/data_field.dart';
import 'package:pagination_starter/core/models.dart';
import 'package:pagination_starter/counter/bloc/products_bloc.dart';
import 'package:pagination_starter/l10n/l10n.dart';
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) =>
ProductsBloc(ApiClient())..add(const PaginateFetchEvent<int>(1)),
child: const CounterView(),
);
}
}
...
class _CounterViewState extends State<CounterView> with BottomEndScrollMixin {
...
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final bloc = context.read<ProductsBloc>();
return Scaffold(
appBar: AppBar(title: Text(l10n.counterAppBarTitle)),
body: NotificationListener<ScrollNotification>(
onNotification: (notification) {
onScroll(
notification,
onEndReached: () {
// send a call to fetch the new set of items
bloc.add(PaginateFetchEvent(bloc.state.page + 1));
},
);
return false;
},
child: BlocBuilder<ProductsBloc, PaginationState<int, Product, String>>(
builder: (context, state) {
return Center(
child: switch (state.itemState) {
DataFieldInitial<List<Product>, String>() =>
const CircularProgressIndicator(),
DataFieldLoading<List<Product>, String>(:final data) =>
_list(data, true),
DataFieldSuccess<List<Product>, String>(:final data) =>
data.isEmpty
? const Text('No more products found')
: _list(data, false),
DataFieldError<List<Product>, String>(
:final error,
:final data
) =>
data.isEmpty ? Text(error) : _list(data, false),
},
);
},
),
),
);
}
...
}
-
We only had to make 2 changes to incorporate the new
ProductsBloc
. -
Change the type of event for fetching the data. The type had to change from
GetProducts
toPaginateFetchEvent
. -
Change the
BlocBuilder
to use the newPaginationState
.