Learn how to build pagination in Flutter with Generics.

Chapters:

  1. Learn Data Structures the fun way: Anagram Game

  2. Scarne’s Dice: A fun way to learn Flutter and Bloc

  3. Pagination in Flutter with Generics and Bloc: Write Once and use Anywhere

  4. 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.

TLDR: Code for Generic Pagination

Contents:

  1. Who is the target Audience for this article?
  2. Why write on Pagination?
  3. What is our end goal?
  4. Building core files for this project
  5. Building the UI
  6. Create ScrollEndMixin
  7. Building the bloc files
  8. Integrating the bloc into UI
  9. Abstracting the bloc and the logic
  10. 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 Learn Data Structures the fun way: Anagram Game, this article assumes you know how to set up a project, how to use Bloc.

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];
}

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
  }
}

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),
        ],
      ),
    );
  }
}

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;
  }
}

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];
}

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];
}

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),
      ),
    );
  }
}

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),
              },
            );
          },
        ),
      ),
    );
  }
  ...
}

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];
}

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];
}

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});
}

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);
  }
}

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),
              },
            );
          },
        ),
      ),
    );
  }
  ...
}