Pagination In Flutter(Lazy Loading, Truncated): Shopping App -Part 6

Sanjib Maharjan
12 min readJun 27, 2023

--

This is the sixth part of our shopping app tutorial and in this section, we’ll be looking into the pagination and different ways we can achieve it. Pagination becomes very important when we are displaying a large amount of data both on servers as well as on the client side. Fetching all data at once from servers becomes very slow and inefficient as it takes server time especially when there are a large amount of data that may further scale in the future. Similarly, on the client side, all of the data fetched should be rendered, which consumes a lot of memory space if we do not implement it properly. Instead, we can show the user a certain amount of data first and present the rest as per the demand.

We’ll be starting from where we had left in our previous part and If you have not been following along with the series you can check them out in the links at the end of the tutorial or clone the project at the link to get started.

There are different ways one might implement pagination.

  • Infinite Scroll(Lazy load, Auto): As the user reaches the end of the page, new data are fetched from the server and presented to the user. We may enhance this by adding an offset i.e. make the API call before the user reaches a height equal to or less thanoffset from the bottom of the page so that the user doesn't have to wait much.
  • Infinite Scroll(Lazy load, Manual): A load more button is displayed at the end of the page to load more data.
  • Paginated Display: Users are presented with a numbered list of pages that the user clicks to go to the specific page.
  • There are other ways like pull up, pull down, etc., which we’ll be not going through in this tutorial.

We can make use of different flags to make the implementation better. For eg., the has_next flag gives the information if we have data left to be fetched, a page flag to track the current page, a count flag to store the number of data to be fetched in an API call, etc. For the paginated display, we’ll also be needing has_prev and total_size flags.

Create a class Paginate to hold the above information.

import 'package:json_annotation/json_annotation.dart';

abstract class Paginate<T> {
static const DEFAULT_ITEM_PER_PAGE = 10;

final List<T> data;

@JsonKey(name: 'has_next')
final bool hasNext;

@JsonKey(name: 'has_prev')
final bool hasPrev;

final int page;

@JsonKey(name: 'data_count')
final int dataCount;

@JsonKey(name: 'total_pages')
final int totalPages;
@JsonKey(name: 'per_page')
final int perPage;
Paginate(
{this.data,
this.hasNext,
this.hasPrev,
this.page,
this.totalPages,
this.perPage,
this.dataCount});
}

Also, we have to consider different cases like loading, error, etc. which need to be handled differently. For example, we will not be fetching the next page in lazy loading infinite scroll unless we’re done fetching the current page.

We need a lot of data to demonstrate the working of the pagination and for that, we’ll be replicating the data we already have multiple times. Check out the data in this link.

Now let's create a model to store the paginated items' data.

@immutable
@JsonSerializable()
class ProductItemPaged extends Paginate<ProductItem> {
ProductItemPaged(
{List<ProductItem> data,
bool hasNext,
bool hasPrev,
int perPage,
int page,
int dataCount,
int totalPages})
: super(
data: data,
hasPrev: hasPrev,
hasNext: hasNext,
totalPages: totalPages,
dataCount: dataCount,
page: page,
perPage: perPage);

factory ProductItemPaged.fromJson(Map<String, dynamic> json) =>
_$ProductItemPagedFromJson(json);

factory ProductItemPaged.initial() => ProductItemPaged(
data: [],
hasNext: true,
hasPrev: false,
page: 0,
totalPages: 1,
);

Map<String, dynamic> toJson() => _$ProductItemPagedToJson(this);

ProductItemPaged update(
{List<ProductItem> data,
hasNext,
page,
hasPrev,
totalPages,
dataCount,
perPage}) {
return ProductItemPaged(
data: data ?? this.data,
hasNext: hasNext ?? this.hasNext,
page: page ?? this.page,
totalPages: totalPages ?? this.totalPages,
hasPrev: hasPrev ?? this.hasPrev,
perPage: perPage ?? this.perPage,
dataCount: dataCount ?? this.dataCount,
);
}

@override
String toString() {
return '''ProductItemPaged {
data: $data,
hasNext: $hasNext,
page: $page,
totalPages: $totalPages,
dataCount: $dataCount,
hasPrev: $hasPrev,
perPage: $perPage,
}''';
}
}

Then we have to change the mock implementation for getting trending items in the product_item_provider.dart file as:

@override
Future<ProductItemPaged> getTrendingProducts(int page, {perPage}) async {
//add some delay to give the feel of api call
await Future.delayed(Duration(seconds: 3));
final List _data = MockUtil.getAllItems()["data"];
final _filtered =
_data.where((d) => d['trending'] != null && d['trending']).toList();
final int _perPage = perPage ?? Paginate.DEFAULT_ITEM_PER_PAGE;
final int _offset = (page - 1) * _perPage;
final int _totalSize = (_filtered.length / _perPage).ceil();

return Future.value(ProductItemPaged(
page: page,
data: _filtered
.sublist(_offset, min(_offset + _perPage, _filtered.length))
?.map((e) => ProductItem.fromJson(e))
?.toList(),
hasNext: page < _totalSize,
hasPrev: page > 1,
perPage: _perPage,
dataCount: _filtered.length,
totalPages: _totalSize));
}

Also, make changes to the corresponding implementation in the product_item_repository.dart file. Also, make changes to the mock implementation of getting top-selling and featured products.

Now make corresponding changes to the product_item_state.dart file as:

part of 'product_item_cubit.dart';

@immutable
class ProductItemState extends Equatable {
final bool loading;
final bool init;
final String error;

final ProductItemPaged pagedItem;

List<ProductItem> get items => pagedItem.data;

bool get hasError => error != null && error.isNotEmpty;

bool get hasData => init && items.isNotEmpty;

bool get hasNoData => init && items.isEmpty;

int get size => items?.length ?? 0;

const ProductItemState({this.loading, this.error, this.init, this.pagedItem});

@override
List<Object> get props => [loading, error, init, error, pagedItem];

factory ProductItemState.initial({loading}) {
return ProductItemState(
loading: loading ?? true,
error: "",
init: false,
pagedItem: ProductItemPaged.initial());
}

ProductItemState update(
{bool loading, bool init, ProductItemPaged pagedItem, error}) {
return ProductItemState(
loading: loading ?? this.loading,
pagedItem: pagedItem ?? this.pagedItem,
init: init ?? this.init,
error: error ?? "",
);
}

@override
String toString() =>
'ProductItemState { loading: $loading, error: $error, init: $init }';
}

Here we are storing all paginated flags along with the data list so we can use them later. Next change the implementation of getting product items in the product_item_cubit.dart file as:

void loadProducts({ITEM_TYPE type, appendData: true}) async {
try {
emit(state.update(loading: true));
final int _page = state.pagedItem.page + 1;
ProductItemPaged productsPaged =
await _productRepository.getProductList(_page, type: type);

//appendData is for appending previous data->lazy loading cases
//for paged display appendData is false -> paged display
final ProductItemPaged _updatedPagedData = appendData ? productsPaged
.update(data: [...state.pagedItem.data, ...productsPaged.data]) : productsPaged;

emit(state.update(
loading: false,
pagedItem: _updatedPagedData,
init: true));
} catch (e) {
final errorMsg = e.toString();
emit(state.update(loading: false, error: errorMsg));
}
}

The parameter appendData is set true for the lazy loading cases where we have to present all data to the user while in the case in which we have to show only paged result of the particular page we do not append the previous values.

//this is the result of first page getting 10 data in each call
{
"data": [
...
],
"hasNext": true,
"page": 1,
"totalPages": 3,
"dataCount": 24,
"hasPrev": false,
}
This is the result of second page{
"data": [
...
],
"hasNext": true,
"page": 2,
"totalPages": 3,
"dataCount": 24,
"hasPrev": true,
}
This is the result of third page{
"data": [
...
],
"hasNext": false,
"page": 3,
"totalPages": 3,
"dataCount": 24,
"hasPrev": true,
}

The above result shows the response for each of the pages. We are fetching 10 data at a time with a total of three pages to fetch since we have only 24 items in total with 10 items each in the first 2 pages and the remaining 4 items on the last one. Also, the hasPrev flag is false in the first API call, so we won't be making an unnecessary API call to get the previous list. Similarly, the hasNext flag is false in the last API call and hence we won’t be making further API calls since we know that there are no more data available.

Next, we’ll be making changes to the UI to fetch the data once we reach the page's end. One way is to add a button at the end of the list and trigger getting the data once the user clicks on it. But we’ll be working on an approach where the data are fetched automatically as soon as the user reaches the end.

Add a scroll controller in the item_list.dart file and make the necessary changes as follows:

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_shopping_app/constant/enum.dart';
import 'package:flutter_shopping_app/helper/responsive_helper.dart';
import 'package:flutter_shopping_app/ui/common/item/bloc/product_item_cubit.dart';
import 'package:flutter_shopping_app/ui/common/item/item_card.dart';
import 'package:flutter_shopping_app/ui/common/item/item_loader.dart';
import 'package:flutter_shopping_app/ui/common/top_title.dart';
import 'package:get_it/get_it.dart';

class ItemList extends StatelessWidget {
final ITEM_TYPE type;
final String title;

const ItemList(this.type, {Key key, this.title: ""}) : super(key: key);

@override
Widget build(BuildContext context) {
final ResponsiveHelper _respHelper = ResponsiveHelper(context: context);
return Column(
children: [
TopTitle(
_respHelper,
title: title ?? "Items",
onPressed: _seeAll,
),
BlocProvider<ProductItemCubit>(
create: (context) => GetIt.instance.get<ProductItemCubit>()
..loadProducts(type: type),
child: _ItemList(type)),
],
);
}

_seeAll() {}
}

class _ItemList extends StatefulWidget {
final ITEM_TYPE type;

const _ItemList(this.type, {Key key}) : super(key: key);

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

class _ItemListState extends State<_ItemList> {
final int _pageOffset = 100;

ScrollController _controller;
ProductItemCubit _bloc;

@override
void initState() {
super.initState();
_controller = ScrollController()..addListener(_scrollListener);
_bloc = context.read<ProductItemCubit>();
}

_scrollListener() {
//load the next page only if we have reached the end of the list minus offset
//if we are not in loading state or error state
//and if the list have next data to be fetched
if (_controller.position.maxScrollExtent <=
_controller.offset + _pageOffset &&
!_bloc.state.loading &&
!_bloc.state.hasError &&
_bloc.state.pagedItem.hasNext) {
_loadProducts();
}
}


@override
Widget build(BuildContext context) {
final ResponsiveHelper _respHelper = ResponsiveHelper(context: context);
return SizedBox(
height: _respHelper.value<double>(mobile: 164, desktop: 224, tablet: 194),
child: BlocBuilder<ProductItemCubit, ProductItemState>(
builder: (context, state) {
if (state.hasNoData)
return EmptyCard(
responsiveHelper: _respHelper,
);
return ListView.builder(
controller: _controller,
itemBuilder: (context, index) => index >= state.items.length
? ItemLoaderCard(
errorMsg: state.error,
retry: () => _retry(context),
responsiveHelper: _respHelper,
)
: ItemCard(
item: state.items[index],
responsiveHelper: _respHelper,
),
itemCount: !state.init
? 4
: state.hasError
? state.items.length + 1
: state.loading
? state.items.length + 2
: state.items.length,
scrollDirection: Axis.horizontal,
);
}),
);
}

_loadProducts() {
_bloc.loadProducts(type: widget.type);
}

_retry(BuildContext context) {
_loadProducts();
}
}

Here we have successfully implemented pagination in the horizontal view. Next, we’ll be working on another type of pagination where data are displayed according to the page number. For that, we’ll implement a view all page for displaying product lists in a grid.

Create a file item_list_page.dart with the following contents:

import 'package:beamer/beamer.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_shopping_app/constant/enum.dart';
import 'package:flutter_shopping_app/helper/responsive_helper.dart';
import 'package:flutter_shopping_app/ui/common/base/app_wrapper.dart';
import 'package:flutter_shopping_app/ui/common/item/bloc/product_item_cubit.dart';
import 'package:flutter_shopping_app/ui/common/item/item_card.dart';
import 'package:flutter_shopping_app/ui/common/item/item_loader.dart';
import 'package:get_it/get_it.dart';

class ItemListLocation extends BeamLocation {
ItemListLocation({BeamState state}) : super(state);

@override
List<String> get pathBlueprints => ['/trending', '/featured', '/topSelling'];

@override
List<BeamPage> buildPages(BuildContext context, BeamState state) {
final ITEM_TYPE _itemListType =
state.pathBlueprintSegments.contains('trending')
? ITEM_TYPE.trending
: state.pathBlueprintSegments.contains('featured')
? ITEM_TYPE.featured
: ITEM_TYPE.topSelling;
return [
BeamPage(
key: ValueKey('item-${describeEnum(_itemListType)}'),
title: 'Items',
child: ItemListPage(_itemListType),
)
];
}
}

class ItemListPage extends StatelessWidget {
final ITEM_TYPE type;

const ItemListPage(this.type, {Key key}) : super(key: key);

@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: SafeArea(
child: Scaffold(
appBar: AppBar(
centerTitle: false,
title: Text(describeEnum(type)),
leading: IconButton(
icon: Icon(Icons.arrow_back_ios),
onPressed: () => _goBack(context),
),
elevation: 0),
body: BlocProvider<ProductItemCubit>(
create: (context) =>
GetIt.I.get<ProductItemCubit>()..loadProducts(type: type),
child: AppWrapper(child: _ItemListPage(type)))),
),
);
}

_goBack(BuildContext context) {
if (context.canBeamBack) {
context.beamBack();
} else {
context.beamToNamed('/');
}
}
}

class _ItemListPage extends StatefulWidget {
final ITEM_TYPE type;

const _ItemListPage(this.type, {Key key}) : super(key: key);

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

class _ItemListPageState extends State<_ItemListPage> {
final int _pageOffset = 100;

ScrollController _controller;
ProductItemCubit _bloc;

@override
void initState() {
super.initState();
_controller = ScrollController()..addListener(_scrollListener);
_bloc = context.read<ProductItemCubit>();
}

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

_scrollListener() {
//load the next page only if we have reached the end of the list minus offset
//if we are not in loading state or error state
//and if the list have next data to be fetched
if (_controller.position.maxScrollExtent <=
_controller.offset + _pageOffset &&
!_bloc.state.loading &&
!_bloc.state.hasError &&
_bloc.state.pagedItem.hasNext) {
_loadProducts();
}
}

@override
Widget build(BuildContext context) {
final ResponsiveHelper _respHelper = ResponsiveHelper(context: context);
return BlocBuilder<ProductItemCubit, ProductItemState>(
builder: (context, state) {
if (state.hasNoData)
return EmptyCard(
responsiveHelper: _respHelper,
);
return GridView.builder(
padding: EdgeInsets.all(_respHelper.defaultGap),
controller: _controller,
itemCount: state.hasError
? state.size + 1
: !state.init
? _getOptimalCrossAxisCount(context, _respHelper)
: state.loading
? state.size + 2
: state.size,
itemBuilder: (BuildContext context, int index) {
if (index >= state.size)
return ItemLoaderCard(
errorMsg: state.error,
retry: () => _retry(context),
responsiveHelper: _respHelper,
);
else
return ItemCard(
item: state.items[index],
responsiveHelper: _respHelper,
);
},
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
childAspectRatio: _respHelper.isDesktop
? 1.1
: _respHelper.isTablet()
? 1
: 1.2,
crossAxisCount: _getOptimalCrossAxisCount(context, _respHelper),
mainAxisSpacing: _respHelper.defaultSmallGap,
crossAxisSpacing: 0,
));
});
}

int _getOptimalCrossAxisCount(
BuildContext context, ResponsiveHelper _respHelper) {
return _respHelper.isDesktop
? 4
: _respHelper.isTablet()
? 3
: 2;
}

_loadProducts() {
_bloc.loadProducts(type: widget.type);
}

_retry(BuildContext context) {
_loadProducts();
}
}

This implementation is similar to the previous implementation except we’ve used GridView to display the list of items. Also, add this new location to our router delegate. And add the trigger to this page in the item_list.dart file as:

_seeAll(BuildContext context) {
context.beamToNamed('/${describeEnum(type)}');
}

Next, we’ll be implementing the paged style of pagination. Add the following paginator.dart file with the following contents:

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_shopping_app/constant/color.dart';
import 'package:flutter_shopping_app/data/models/paginate.dart';

class CustomPaginator<T> extends StatelessWidget {
final Paginate<T> data;
final Function(int page) onPageChanged;
final bool disabled;

bool get canGoNext => data.hasNext && !disabled;

bool get canGoPrev => data.hasPrev && !disabled;

bool _isCurrentPage(page) => page == data.page;

const CustomPaginator(this.data, {Key key, this.onPageChanged, this.disabled})
: super(key: key);

@override
Widget build(BuildContext context) {
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
IconButton(
icon: Icon(
Icons.arrow_back_ios,
size: 16,
color: ThemeColor,
),
onPressed: canGoPrev ? _prev : null),
...List<Widget>.generate(
data.totalPages, (page) => _numberButton(page + 1)),
IconButton(
icon: Icon(
Icons.arrow_forward_ios,
size: 16,
color: ThemeColor,
),
onPressed: canGoNext ? _next : null),
],
);
}

_changePage(page) {
if (page != data.page && onPageChanged != null) {
onPageChanged(page);
}
}

_numberButton(pageNum) {
return TextButton(
onPressed: disabled ? null : () => _changePage(pageNum),
child: Text(
"$pageNum",
style: TextStyle(
color: _isCurrentPage(pageNum)
? ThemeTextColorLightest
: disabled
? ThemeTextColorLighter
: ThemeColor),
),
style: TextButton.styleFrom(
// shape: CircleBorder(side: BorderSide(width: 1, color: ThemeColor)),
padding: EdgeInsets.all(4),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: Size(40, 40),
backgroundColor:
_isCurrentPage(pageNum) ? ThemeColor : Colors.transparent),
);
}

_prev() {
_changePage(data.page - 1);
}

_next() {
_changePage(data.page + 1);
}
}

Add the above widget to the item_list_page.dart file and make the necessary changes as below.

...if (!state.loading && state.hasNoData)
return EmptyCard(
responsiveHelper: _respHelper,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: GridView.builder(
padding: EdgeInsets.all(_respHelper.defaultGap),
// controller: _controller, ->remove controller
....)
),//Expanded
CustomPaginator(
state.pagedItem,
onPageChanged: _onPageChanged,
disabled: state.loading,
)
....
_onPageChanged(int page) {
_loadProducts(page: page);
}
_loadProducts({page}) {
_bloc.loadProducts(type: widget.type, page: page, appendData: false);
}
...

For the full changes on this page, you can check this link. Similarly on the item_list_cubit.dart file, make the following changes:

...
void loadProducts({ITEM_TYPE type, appendData: true, int page}) async {
try {
//appendData is for appending previous data->lazy loading cases
//for paged display appendData is false -> paged display
emit(state.update(
loading: true,
pagedItem: appendData
? state.pagedItem
: state.pagedItem.update(data: [], page: page)));
final int _page = page ?? state.pagedItem.page + 1;
ProductItemPaged productsPaged =
await _productRepository.getProductList(_page, type: type);

final ProductItemPaged _updatedPagedData = productsPaged
.update(data: [...state.pagedItem.data, ...productsPaged.data]);

emit(state.update(
loading: false, pagedItem: _updatedPagedData, init: true));
} catch (e) {
final errorMsg = e.toString();
emit(state.update(loading: false, error: errorMsg));
}
}
...

This is the result of the paged pagination we’ve completed so far which looks quite good when we have fewer pages and starts to look messy as the number of pages increases. The former implementation of lazy loading is better than this paged implementation but in some cases, it might also be useful. Next, we will try to truncate the page numbers so that the pages do not look crowded and we’ll do this by hiding the pages that are far away from the currently selected page.

Add the following method in thecustom_paginator.dart file to truncate pages in case there are a high number of pages.

List _truncatePagination(currentPage, totalPage,
{int neighbours: 2, wildCard: '...'}) {
if (currentPage > totalPage) {
throw Exception("currentPage cannot be greater than totalPage");
}

if (totalPage <= 3 + neighbours) {
//adding wildcard is not required in this case
return List<int>.generate(totalPage, (index) => index + 1);
}

var _pages = [1], _pagesUpdated = [], _prevPage;

for (int i = currentPage - neighbours; i <= currentPage + neighbours; i++) {
//make sure first and last pages are not repeated
if (i < totalPage && i > 1) {
_pages.add(i);
}
}
_pages.add(totalPage);

_pages.forEach((int page) {
if (_prevPage != null) {
if (page - _prevPage == 2) {
//if the gap is 2 instead of showing ... show the actual number
//eg. [1, 2, 3, 4, 5, ..., 20] is better than [1, ..., 3, 4, 5, ..., 20]
//where 4 is the selected page
_pagesUpdated.add(_prevPage + 1);
} else if (page - _prevPage != 1) {
_pagesUpdated.add(wildCard);
}
}
_pagesUpdated.add(page);
_prevPage = page;
});

return _pagesUpdated;
}

Also, render the new page numbers by using the _truncatePagination function we’ve just added.

Widget build(BuildContext context) {
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
IconButton(
icon: Icon(
Icons.arrow_back_ios,
size: 16,
color: ThemeColor,
),
onPressed: canGoPrev ? _prev : null),
..._truncatePagination(data.page, data.totalPages)
.map((num) => _numberButton(num))
.toList(),

IconButton(
icon: Icon(
Icons.arrow_forward_ios,
size: 16,
color: ThemeColor,
),
onPressed: canGoNext ? _next : null),
],
);
}

--

--