Welcome back, everyone! Today, we will be learning about implementing pagination in Flutter using Bloc as our state management solution. To fetch products, we will be using a modified version of the fake-store APIs that includes pagination.
Let's begin with the basics before diving into Flutter-specific details.
Do not worry we are going to talk about bloc's basic
Why Pagination?
Pagination refers to the practice of dividing content into multiple pages to improve user experience and page load times. It is commonly used in websites and applications that feature long-form content such as articles, product listings, and search results.
Setup Project
-
Add the following dependency to your Flutter project(
pubspec.yaml
)flutter_bloc: ^8.1.2 http: ^0.13.5 dartz: ^0.10.1 shimmer: ^2.0.0 cached_network_image: ^3.2.3
-
darts
: In Flutter, Dartz is a library that provides functional programming tools such as the Either data type. Either data type represents a value that can be one of two possible types, typically used to represent the success or failure of a computation.
-
shimmer
: Shimmer library is being used for showing loading indicator link on LinkedIn, Twitter, Facebook etc. -
cached_network_image
: Flutter library, as its name suggests, is used for caching network images. -
http
: Flutter library for callingHTTP
requests.
Project file Structure:
-
models
: following folders will contain models that will be used in the application. -
presentation
: The following layer has been divided further intoblocs
,pages
, andwidgets
.-
widgets
: Widgets are the building blocks of the user interface in Flutter. They are reusable UI elements that can be combined to create complex layouts. Widgets can be stateless, meaning they do not change over time, or stateful, meaning they can change based on user interaction or other events. -
blocs
: Blocs are a pattern for managing state in Flutter applications. They stand for Business Logic Components and are responsible for managing the flow of data between the presentation layer and the data layer. -
pages
: Pages are the top-level UI elements in a Flutter application. Each page typically represents a separate screen or view.
-
-
repo
: Stands for the repository which has logic for calling APIs it passes data to models.
Parsing APIs Data to Dart Model-Classes
Don't know how to parse JSON data to Dart class?
- We are going to use a modified version of FakeStore APIs.
Base URL="https://flutter-pagination-api-djsmk123.vercel.app/api
-
Endpoint
/get-products?page=1
by defaultpage=1
. How the response will look like?{ "products": [ { "id": 1, "title": "Fjallraven - Foldsack No. 1 Backpack, Fits 15 Laptops", "price": 109.95, "description": "Your perfect pack for everyday use and walks in the forest. Stash your laptop (up to 15 inches) in the padded sleeve, your everyday", "category": "men's clothing", "image": "https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg", "rating": { "rate": 3.9, "count": 120 } }, { "id": 2, "title": "Mens Casual Premium Slim Fit T-Shirts ", "price": 22.3, "description": "Slim-fitting style, contrast raglan long sleeve, three-button henley placket, light weight & soft fabric for breathable and comfortable wearing. And Solid stitched shirts with round neck made for durability and a great fit for casual fashion wear and diehard baseball fans. The Henley style round neckline includes a three-button placket.", "category": "men's clothing", "image": "https://fakestoreapi.com/img/71-3HjGNDUL._AC_SY879._SX._UX._SY._UY_.jpg", "rating": { "rate": 4.1, "count": 259 } }, { "id": 3, "title": "Mens Cotton Jacket", "price": 55.99, "description": "great outerwear jackets for Spring/Autumn/Winter, suitable for many occasions, such as working, hiking, camping, mountain/rock climbing, cycling, traveling or other outdoors. Good gift choice for you or your family member. A warm hearted love to Father, husband or son in this thanksgiving or Christmas Day.", "category": "men's clothing", "image": "https://fakestoreapi.com/img/71li-ujtlUL._AC_UX679_.jpg", "rating": { "rate": 4.7, "count": 500 } }, { "id": 4, "title": "Mens Casual Slim Fit", "price": 15.99, "description": "The color could be slightly different between on the screen and in practice. / Please note that body builds vary by person, therefore, detailed size information should be reviewed below on the product description.", "category": "men's clothing", "image": "https://fakestoreapi.com/img/71YXzeOuslL._AC_UY879_.jpg", "rating": { "rate": 2.1, "count": 430 } }, { "id": 5, "title": "John Hardy Women's Legends Naga Gold & Silver Dragon Station Chain Bracelet", "price": 695, "description": "From our Legends Collection, the Naga was inspired by the mythical water dragon that protects the ocean's pearl. Wear facing inward to be bestowed with love and abundance, or outward for protection.", "category": "jewelery", "image": "https://fakestoreapi.com/img/71pWzhdJNwL._AC_UL640_QL65_ML3_.jpg", "rating": { "rate": 4.6, "count": 400 } } ], "current_page": 1, "reach_max": false }
Let's Create Dart Class Model
-
ProductModel
: create a new file in models namedproduct_model.dart
with the following content.import 'package:pagination_in_flutter/models/rating_model.dart'; class ProductModel { final int id; final String title; final String description; final String category; final double price; final String image; final RatingModel rating; ProductModel({ required this.id, required this.title, required this.description, required this.category, required this.price, required this.image, required this.rating, }); factory ProductModel.fromJson(Map<String, dynamic> json) { return ProductModel( id: json['id'], title: json['title'], description: json['description'], category: json['category'], price: (json['price'] as num).toDouble(), image: json['image'], rating: RatingModel.fromJson(json['rating']), ); } toJson() { return { "id": id, "title": title, "description": description, "category": category, "price": price, "image": image, "rating": rating.toJson(), }; } }
-
RatingModel
: same create a new dart file in the same folder with the namedrating_model.dart
and the content will be followed as. -
class RatingModel { final double rate; final int count; RatingModel({required this.rate, required this.count}); factory RatingModel.fromJson(Map<String, dynamic> json) { return RatingModel( rate: (json['rate'] as num).toDouble(), count: json['count']); } toJson() { return {"rate": rate, "count": count}; } }
-
ProductsListModel
: The following model requires for parsing JSON data.import 'package:pagination_in_flutter/models/product_model.dart'; class ProductsListModel { final List<ProductModel> products; final bool reachMax; final int currentPage; const ProductsListModel( {required this.products, required this.currentPage, required this.reachMax}); factory ProductsListModel.fromJson(Map<String, dynamic> json) { return ProductsListModel( products: parseProducts(json['products']), currentPage: json['current_page'], reachMax: json['reach_max']); } Map<String, dynamic> toJson() { return { 'products': products.map((e) => e.toJson()), 'current_page': currentPage, 'reach_max': reachMax }; } static List<ProductModel> parseProducts(List<dynamic> p) { return List.generate(p.length, (index) => ProductModel.fromJson(p[index])); } }
-
Failure
: The failure model will be used to throw errors inEither
and following inmodels/error_model.dart
class Failure { final String message; const Failure({required this.message}); }
Let's Call API
-
Create class
ProductRepo
for calling APIs which hasgetProducts
static function which will return EitherFailure
(error) orProductListModel
. -
import 'dart:convert'; import 'package:dartz/dartz.dart'; import 'package:http/http.dart' as http; import 'package:pagination_in_flutter/models/error_model.dart'; import 'package:pagination_in_flutter/models/product_list_model.dart'; class ProductRepo { static Future<Either<Failure, ProductsListModel>> getProducts( {required int page}) async { try { Map<String, String> query = {"page": page.toString()}; var uri = Uri.parse( "https://flutter-pagination-api-djsmk123.vercel.app/api/get-products") .replace(queryParameters: query); final response = await http.get(uri, headers: {"Content-Type": "application/json"}); if (response.statusCode == 200) { var json = jsonDecode(response.body); ProductsListModel products = ProductsListModel.fromJson(json); return Right(products); } else { return const Left(Failure(message: 'Failed to parse json response')); } } catch (e) { return const Left(Failure(message: 'Something went wrong')); } } }
Before creating Blocs states, event and their implementation, Let's talk about Bloc's Basics.
Bloc's:
Flutter Widgets that make it easy to implement the BLoC (Business Logic Component) design pattern. Built to be used with the bloc state management package.
Not going to cover Cubit in Flutter Bloc
Bloc has three different components state
, event
and emit
.
-
State
: represents the current state of the application. astate
is an immutable object that represents a snapshot of the application at a specific moment in time. When theState
changes and the UI is rebuilt with the new state. -
event
: an Event is a message or signal that triggers a change in the State of the application. Event objects represent user actions, system events, or any other external change that affects the state of the application. -
emit
: emit method is used to notify the Bloc or Cubit that a new State has been produced. The emit method accepts a single argument, which is the new State object.BlocBuilder:
a Flutter widget that requires a
bloc
and abuilder
function.BlocBuilder
handles building the widget in response to new states.BlocBuilder
is very similar toStreamBuilder
has a more simple API to reduce the amount of boilerplate code needed. Thebuilder
function will potentially be called many times and should be a pure function that returns a widget in response to the state.
Let's Back to where were we.
Creating Blocs for product
You can create your bloc classes but it is time-consuming so use plugins
-
Android Studio: https://plugins.jetbrains.com/plugin/12129-bloc
Keeping Bloc class as `
ProductsBloc`
Events
We have only one event that is ProductLoadEvent
so create a class for that
part of 'products_bloc.dart';
@immutable
abstract class ProductsEvent {}
class ProductsLoadEvent extends ProductsEvent {}
States
we could have multiple states like
- Initial loading: Initially we have to show a loading indicator while the first page is keep fetching from APIs.
-
Initial Error: While fetching items from API, there could be an error.
-
Empty List: it might happen that we got an empty list in response.
-
Product fetch success: When you have successfully fetched data from API and now load more while keeping earlier data so it has conditional value, isLoading or hasError.
part of 'products_bloc.dart'; @immutable abstract class ProductsState {} class ProductsInitial extends ProductsState {} //State for initial Loading when current page will be 1 class ProductsInitialLoading extends ProductsState { final String message; ProductsInitialLoading({required this.message}); } class ProductInitialError extends ProductsState { final String message; ProductInitialError({required this.message}); } class ProductsEmpty extends ProductsState {} class ProductsLoaded extends ProductsState { final ProductsListModel products; final LoadingMore? loading; final LoadMoreError? error; ProductsLoaded({ required this.products, this.loading, this.error, }); } // LoadingMore Model class LoadingMore { final String message; LoadingMore({required this.message}); } // LoadingMoreError Model class LoadMoreError { final String message; LoadMoreError({required this.message}); }
Let's implement Event and States
Add the following content in products_bloc.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pagination_in_flutter/models/product_list_model.dart';
import 'package:pagination_in_flutter/repo/products_repo.dart';
part 'products_event.dart';
part 'products_state.dart';
class ProductsBloc extends Bloc<ProductsEvent, ProductsState> {
ProductsListModel products = const ProductsListModel(
products: [],
currentPage: 1,
reachMax: false,
);
ProductsBloc() : super(ProductsInitial()) {
on<ProductsEvent>((event, emit) async {
if (event is ProductsLoadEvent) {
bool isInitial = products.currentPage == 1;
isInitial
? emit(ProductsInitialLoading(message: 'Fetching products....'))
: emit(ProductsLoaded(
products: products,
loading: LoadingMore(message: 'Fetching more products...')));
final response =
await ProductRepo.getProducts(page: products.currentPage);
response.fold(
(l) => isInitial
? emit(ProductInitialError(message: 'Failed to load products'))
: emit(ProductsLoaded(
products: products,
error: LoadMoreError(
message: 'Failed to load more products'))), (r) {
if (isInitial) {
products = ProductsListModel(
products: r.products,
currentPage: r.currentPage + 1,
reachMax: r.reachMax);
if (products.products.isEmpty) {
emit(ProductsEmpty());
}
} else {
//Adding products to existing list
products = ProductsListModel(
products: products.products + r.products,
currentPage: r.currentPage + 1,
reachMax: r.reachMax);
}
emit(ProductsLoaded(products: products));
});
}
});
}
}
Done with Flutter Bloc
Let's Implement UI
-
create a new
stateful
class namedProductPage
in/presentation/pages/
with the following content.import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pagination_in_flutter/colors.dart'; import 'package:pagination_in_flutter/models/product_model.dart'; import 'package:pagination_in_flutter/presentation/blocs/products_bloc.dart'; import '../widgets/widgets.dart'; class ProductsPage extends StatefulWidget { const ProductsPage({Key? key}) : super(key: key); @override State<ProductsPage> createState() => _ProductsPageState(); } class _ProductsPageState extends State<ProductsPage> { @override void initState() { BlocProvider.of<ProductsBloc>(context).add(ProductsLoadEvent()); super.initState(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( leading: Icon( Icons.menu, color: primaryColor, size: 30, ), title: Text( "Flutter Pagination", style: TextStyle( color: primaryColor, fontSize: 20, ), ), centerTitle: true, ), body: PaginationWidget<ProductModel>( loadMore: () { BlocProvider.of<ProductsBloc>(context).add(ProductsLoadEvent()); }, initialEmpty: const EmptyWidget(), initialLoading: const LoadingWidget(), initialError: const CustomErrorWidget(), child: (ProductModel productModel) { return ProductCard(product: productModel); }, )); } }
-
Create
PaginationWidget
stateless class inwidgets
directory.class PaginationWidget<t> extends StatelessWidget { final Function() loadMore; final Widget initialError; final Widget initialLoading; final Widget initialEmpty; final Widget Function(t p) child; final Widget? onLoadMoreError; final Widget? onLoadMoreLoading; const PaginationWidget( {Key? key, required this.loadMore, required this.initialError, required this.initialLoading, required this.initialEmpty, this.onLoadMoreError, this.onLoadMoreLoading, required this.child}) : super(key: key); @override Widget build(BuildContext context) { return BlocBuilder<ProductsBloc, ProductsState>( builder: (context, state) { if (state is ProductsLoaded) { List<ProductModel> products = state.products.products; return NotificationListener<ScrollEndNotification>( onNotification: (scrollInfo) { scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent && !state.products.reachMax ? loadMore() : null; return true; }, child: Column( children: [ Expanded( child: ListView.builder( itemCount: products.length, itemBuilder: (context, index) => child(products[index] as t))), const SizedBox( height: 20, ), //if error occured while loading more if (state.error != null) Expanded(child: onLoadMoreError ?? initialError), if (state.loading != null) Expanded(child: onLoadMoreLoading ?? initialLoading), ], )); } if (state is ProductsInitialLoading) { return initialLoading; } if (state is ProductsEmpty) { return initialEmpty; } if (state is ProductInitialError) { return initialError; } return const SizedBox.shrink(); }, ); } }
-
The PaginationWidget class is a Flutter stateless widget that renders a list of items with pagination support. It takes in several parameters through its constructor to configure its behavior and appearance.
-
The constructor for the PaginationWidget class has the following parameters:
-
loadMore: a function that is called when the user reaches the end of the list and needs to load more items.
-
initialError: a widget to display when an error occurs while loading the initial list of items.
-
initialLoading: a widget to display while the initial list of items is being loaded.
-
initialEmpty: a widget to display when the initial list of items is empty.
-
child: a function that takes an item of type t and returns a widget that represents that item in the list.
-
onLoadMoreError: a widget to display when an error occurs while loading more items.
-
onLoadMoreLoading: a widget to display while more items are being loaded.
-
-
-
-
Empty List item Widget: create a widget in
widgets/empty_widget
import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; class EmptyWidget extends StatelessWidget { const EmptyWidget({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CachedNetworkImage( imageUrl: "https://static.vecteezy.com/system/resources/previews/005/073/073/original/no-item-in-the-shopping-cart-add-product-click-to-shop-now-concept-illustration-flat-design-eps10-modern-graphic-element-for-landing-page-empty-state-ui-infographic-icon-vector.jpg", height: 200, ), const SizedBox( height: 30, ), Text("No products available", style: TextStyle(color: Colors.grey.shade500, fontSize: 24)), ], ), ); } }
-
CustomErrorWidget
: do the same with this, nameerror_widget.dart.
import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; class CustomErrorWidget extends StatelessWidget { const CustomErrorWidget({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CachedNetworkImage( imageUrl: "https://gcdnb.pbrd.co/images/5Rz9dEYkdYdm.png?o=1", height: 200, ), const SizedBox( height: 30, ), Text("Error Occured,try again", style: TextStyle(color: Colors.grey.shade500, fontSize: 24)), ], ), ); } }
-
LoadingWidget
: For showing Loading, We are usingshimmer
widget.
import 'package:flutter/material.dart';
import 'package:pagination_in_flutter/colors.dart';
import 'package:shimmer/shimmer.dart';
class LoadingWidget extends StatelessWidget {
const LoadingWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListView.builder(
itemBuilder: (c, i) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
child: Card(
elevation: 5,
surfaceTintColor: Colors.grey,
color: Colors.grey.shade300,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Stack(
children: [
Column(
children: [
Row(
children: [
Flexible(
child: shimmerBuilder(
const CircleAvatar(
radius: 40,
backgroundColor: Colors.grey,
),
)),
const SizedBox(
width: 20,
),
Flexible(
child: shimmerBuilder(
Container(
height: 20,
width: 150,
color: Colors.black,
),
),
)
],
),
const SizedBox(
height: 20,
),
Row(
children: [
shimmerBuilder(
const Icon(
Icons.favorite_border,
color: Colors.grey,
),
),
const SizedBox(
width: 10,
),
shimmerBuilder(
Container(
height: 10,
width: 100,
color: Colors.black,
),
),
],
),
],
),
Positioned(
right: 0,
bottom: 0,
child: shimmerBuilder(
Container(
width: 125,
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 5),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: primaryColor),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(
Icons.shopping_cart_outlined,
color: Colors.grey,
),
],
),
),
))
],
),
),
),
),
itemCount: 5,
);
}
Shimmer shimmerBuilder(child) {
return Shimmer(
gradient: LinearGradient(
colors: [
Colors.grey.shade300,
Colors.grey.shade100,
Colors.grey.shade50,
],
),
child: child,
);
}
}
Card widget:
create a dart file named product_card.dart
with the following content:
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:pagination_in_flutter/colors.dart';
import 'package:pagination_in_flutter/models/product_model.dart';
class ProductCard extends StatelessWidget {
final ProductModel product;
const ProductCard({Key? key, required this.product}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
child: Card(
elevation: 5,
surfaceTintColor: primaryColor,
color: Colors.blueAccent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Stack(
//crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Row(
children: [
Flexible(
child: CircleAvatar(
radius: 40,
backgroundColor: Colors.white,
child: ClipOval(
child: CachedNetworkImage(
width: double.maxFinite,
height: 130.0,
fit: BoxFit.scaleDown,
imageUrl: product.image,
placeholder: (context, url) => const SizedBox(
width: 10,
height: 10,
),
errorWidget: (context, url, error) => Image.asset(
'assets/images/sinimagen.png',
height: 30,
fit: BoxFit.cover,
),
),
),
),
),
const SizedBox(
width: 20,
),
Flexible(
child: Text(
product.title,
maxLines: 2,
style:
const TextStyle(color: Colors.white, fontSize: 14),
))
],
),
const SizedBox(
height: 20,
),
Row(
children: [
const Icon(
Icons.favorite_border,
color: Colors.white,
),
const SizedBox(
width: 10,
),
Text(
product.rating.count.toString(),
maxLines: 2,
style:
const TextStyle(color: Colors.white, fontSize: 14),
),
],
),
],
),
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 125,
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: primaryColor),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(product.price.toString()),
const SizedBox(
width: 10,
),
const Icon(Icons.shopping_cart_outlined),
],
),
),
)
],
),
),
),
);
}
}
- Add the following content in
main.dart
-
import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pagination_in_flutter/presentation/blocs/products_bloc.dart'; import 'package:pagination_in_flutter/presentation/pages/products_page.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [BlocProvider(create: (context) => ProductsBloc())], child: MaterialApp( title: 'Pagination Example with Flutter', debugShowCheckedModeBanner: false, theme: ThemeData(useMaterial3: true), home: const ProductsPage(), )); } }
-
Create
colors.dart
import 'package:flutter/material.dart'; Color primaryColor = Colors.blueAccent;
OUTPUT
Fetching Items when No Error Occurred
Fetching items when the error occurred while loading more:
Hurry we successfully implemented Pagination in Flutter using Blocs.
Source code:
-
Flutter Github Repo: https://github.com/Djsmk123/flutter_pagination
-
Backend Repo: https://github.com/Djsmk123/flutter_pagination_api
Follow me:
Top comments (2)
Hello. Maybe after emit(ProductsEmpty()); will you set return, if you don't set return after emit ProductsEmpty, it will be never state. It will return always emit(ProductsLoaded(products: products));
Good Catch!!, My Bad will update this.