Crypto News

Scroll Smarter, Not Harder: Generic Pagination in Flutter Explained

Learn how to develop flutter pagination with generics.

Chapters:

  1. Learn data structures the fun way: Anagram game

  2. Dice of Scarne: A fun way to find out Flutter and Bloc

  3. Flutter Pagination with Generics and Bloc: Write once and use anywhere

  4. More to come …

In previous workshops, we worked on lists, hashmaps, and hashsets and learned state management with bloc. In this workshop, we will build flutter pagination.

TLDR: Code for the common pagination

Contents:

  1. Who is the target audience for this article?
  2. Why write in pagination?
  3. What is our end goal?
  4. Developing the main files for this project
  5. Developing UI
  6. Create scrollendmixin
  7. Developing bloc files
  8. Integration of bloc with UI
  9. Abstracting the bloc and the logic
  10. Let's combine everything

Who is the target audience for this article?

Easy peasy, this article aims to help newcomers and mediators. Experts already know this. Perhaps they moved to the next chapter.

This article is for those who still learn and are looking for better examples to learn from.

There is another faction that this article is for. Those who have developed something better. Help us all and paste your solution, and explain why your code is better.

There are already many articles and writing that explains pagination. So how does this do for me?

So this article aims to develop better protocols for writing the code and understanding why using generics.

Because it is in the continuation of our existing series, from Learn data structures the fun way: Anagram gameThis 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 would like to develop a solution that can be applied to any type of list and can load more data on the demand.

Developing the main files for this project

Let's create a basic file and some utility files I use for this workshop.

import 'package:equatable/equatable.dart';
// T is type of data
// H is type of error
sealed class DataField extends Equatable {
  const DataField();
}

class DataFieldInitial extends DataField {
  const DataFieldInitial();
  @override
  List
  • DataField is a sealed class we can use to store the data states that are stored. Increase Knowledge about class modifiers Here.
  • We defined 4 states for the farm, which could be initial , loading , success and error
  • We declared the DataField class with two types, T and H Where T is the type of data we can store and H Being an error type.
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 use it for developing our product model objects.
  • I hope the code comments are enough for both. 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: '
    );
  }
  final String id;
  final String name;
  final String image;

  @override
  List

These will be the models used in this workshop, thinking that the API response looks 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 also ignore these files.

import 'package:pagination_starter/core/models.dart';

class ApiClient {
// Used for mocking api response
  Future getProducts({int? page, int pageSize = 10}) async {
    await Future.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(),
    );
  }
}

Developing 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 createState() => _CounterViewState();
}

class _CounterViewState extends State {

    // 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(
        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 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 The operation builds a list item. It can be a card with an image or a loading indicator, depending on whether it is the last index and the list is reached.
  • _list The operation builds a list of items. We have added 1 to the number of item to accommodate the loading.
  • We also added a NotificationListener The widget that listens to scroll notifications to indicate if we reach the end of the list.
  • Because our UI class holds some data that can be loaded with a mixin, and add reuse to our code.

You can skip this part and continue if you Basy want to add some code to work your pagination.

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 reaches the bottom end.

  • We passed a callback, which, when called, indicated that the end was reached, could continue your action.

  • We can also change the cost of pagination -offsetting.

Now let's separate it 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 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(
        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,
        ),
    );
  }

  ...
}

The app looks like this now.

At this stage, our app works and pages too. But the call to paginate is nothing. Let's start with the logic. And products are not true either.

Developing 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
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, String> products;
  final bool canLoadMore;

  ProductsState copyWith({
    int? page,
    DataField, String>? products,
    bool? canLoadMore,
  }) {
    return ProductsState(
      page: page ?? this.page,
      products: products ?? this.products,
      canLoadMore: canLoadMore ?? this.canLoadMore,
    );
  }

  @override
  List
  • ProductsState The class holds the state of pagination.

  • page Determines the current page number. If your pagination uses a string, you can use a string here for pagination.

  • products Holding the state of the products, whether they are in a state of loading, initial, success or error.

  • canLoadMore The breaker is determined 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 {
  ProductsBloc(this.apiClient): super(ProductsState.initial()) {
    on(_fetchProducts);
  }

  final ApiClient apiClient;

  FutureOr _fetchProducts(GetProducts event, Emitter 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, String>() => [],
      DataFieldLoading, String>(:final data) => data,
      DataFieldSuccess, String>(:final data) => data,
      DataFieldError, String>(:final data) => data,
    };

    // start loading state
    emit(
      state.copyWith(
        products: DataFieldLoading, 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 using ApiClient Which we created earlier in the main files. This is a hope for mockery for getting products.
  • We registered GetProducts The event handler to specify logic for initial and subsequent calls.
  • We prevent pagination if we are already taking the data or if we receive a breaker for stopping the pagination.
  • We started the state of loading to display the loader to the UI.
  • Get the results using the ApiClient And if the products return are empty, it means that calling for pagination is no longer needed, so we can end the pagination here.
  • When all the data is obtained, we will update the state with success. For now holding the state of the error for the initial and subsequent pagination, I left that to you. If you still like the solution, hit me in the comments. Will add it there.

Integration of bloc with 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 with BottomEndScrollMixin {

  ...

  @override
  Widget build(BuildContext context) {
    final l10n = context.l10n;
    final bloc = context.read();
    return Scaffold(
      appBar: AppBar(title: Text(l10n.counterAppBarTitle)),
      body: NotificationListener(
        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(
          builder: (context, state) {
            return Center(
              child: switch (state.products) {
                DataFieldInitial, String>() =>
                  const CircularProgressIndicator(),
                DataFieldLoading, String>(:final data) =>
                  _list(data, true),
                DataFieldSuccess, String>(:final data) =>
                  data.isEmpty
                      ? const Text('No more products found')
                      : _list(data, false),
                DataFieldError, String>(
                  :final error,
                  :final data
                ) =>
                  data.isEmpty ? Text(error) : _list(data, false),
              },
            );
          },
        ),
      ),
    );
  }
  ...
}
  • Now integration ProductsBloc In the UI it seems easy. We added the GetProducts to bloc to obtain the first group of items.
  • And a second call for GetProducts In onEndReached Callback with the addition of 1 to the number of pages required for supporting this type of pagination.
  • And then we use the sealed Classes of the complete mapping feature to restore a widget depending on the state of the product field.
  • All of the remaining code is the same.

Abstracting the bloc and the logic

We reached the end of our story, developing a common solution to pagination. Since we have created logic for pagination, the next steps will be easy.

We create 3 more bloc files. Both types of files we created earlier. And we just copy and paste the code and change it a little to handle all types 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 extends Equatable {
  const PaginationEvent();

  @override
  List
  • This is a new class. PaginationEvent class. Copy the code from ProductEvent Class and paste here.

  • You will notice that we only added a ID Type your type of data used for pagination. It can be any kind.

  • Delete your ProductEvent class.

part of 'pagination_bloc.dart';

class PaginationState extends Equatable {
  const PaginationState({
    required this.canLoadMore,
    required this.itemState,
    required this.page,
  });

  factory PaginationState.initial(
    ID id, {
    bool canLoadMore = true,
    DataField, 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, 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 copyWith({
    ID? page,
    DataField, E>? itemState,
    bool? canLoadMore,
  }) {
    return PaginationState(
      page: page ?? this.page,
      itemState: itemState ?? this.itemState,
      canLoadMore: canLoadMore ?? this.canLoadMore,
    );
  }

  @override
  List
  • A similar class to ProductsState

  • Just change the data type and make it generic. We used ID For data type for pagination, ITEM For the data type we will hold a list of, and page that represents 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
    extends Bloc, PaginationState> {

  PaginationBloc({required ID page}) : super(PaginationState.initial(page)) {
    on>((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, E>() => [],
      DataFieldLoading, E>(:final data) => data,
      DataFieldSuccess, E>(:final data) => data,
      DataFieldError, E>(:final data) => data,
    };

    // start loading state
    emit(
      state.copyWith(
        itemState: DataFieldLoading, 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, E?)> fetchNext({ID? page});
}
  • It is also almost all code copied-pasted ProductsBloc.
  • As you can see almost all code is the same except data types.
  • We have also added a new abstract technique, fetchNextto be implemented by bloc implementation.
  • This procedure returns a list of newly obtained items and an inevitable error.

Let's combine everything

Now that we are done with generics and abstract in our logic of pagination, we can start by making changes to 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 {
  ProductsBloc(this.apiClient) : super(page: 1);
  final ApiClient apiClient;

  @override
  FutureOr<(List, String?)> fetchNext({int? page}) async {
    // define: how to parse the products
    return ((await apiClient.getProducts(page: page)).products, null);
  }
}
  • ProductsBloc The class now extends PaginationBloc Representing, int as page, product as a list of items and strings as an error type.

  • We just have to implement the abstract fetchNext Method that determines how pages are obtained.

  • Our code today is noticeably reduced for writing 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(1)),
      child: const CounterView(),
    );
  }
}

...

class _CounterViewState extends State with BottomEndScrollMixin {

  ...  

  @override
  Widget build(BuildContext context) {
    final l10n = context.l10n;
    final bloc = context.read();
    return Scaffold(
      appBar: AppBar(title: Text(l10n.counterAppBarTitle)),
      body: NotificationListener(
        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>(
          builder: (context, state) {
            return Center(
              child: switch (state.itemState) {
                DataFieldInitial, String>() =>
                  const CircularProgressIndicator(),
                DataFieldLoading, String>(:final data) =>
                  _list(data, true),
                DataFieldSuccess, String>(:final data) =>
                  data.isEmpty
                      ? const Text('No more products found')
                      : _list(data, false),
                DataFieldError, String>(
                  :final error,
                  :final data
                ) =>
                  data.isEmpty ? Text(error) : _list(data, false),
              },
            );
          },
        ),
      ),
    );
  }
  ...
}
  • We just have to make 2 changes to include the new ProductsBloc .

  • Change the event type for data acquisition. The type has to change from GetProducts In PaginateFetchEvent.

  • Change BlocBuilder To use the new PaginationState .


Related Articles

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top button

Adblocker Detected

Please consider supporting us by disabling your ad blocker