Implementing Pagination/Infinite Scrolling in Flutter using Provider Pattern (Stacked Architecture)

Implementing Pagination/Infinite Scrolling in Flutter using Provider Pattern (Stacked Architecture)

ยท

11 min read

When loading list views with data in flutter, we load tons of data at a go and this can affect the performance of your application negatively in the long run.

Loading list views with data in most cases happens asynchronously (might take a while to get the actual data). During this, applications use time and resources to get this data.

Pagination is the process of dividing a document (could be a list) anything that can be divided into sections, into discrete pages. This how your search engines which I think should be the most common example, supplies you with tons of data from pages 1 to โˆž. You obviously don't want to load everything at once, who's trying to cry, definitely not me ๐Ÿ‘€.

What you would want to do here is to get this data bit by bit (lazily) to avoid slowing down the performance of the app.

Lazy loading is a software design pattern where the initialization/creation of an object occurs only when it is actually needed. Using this approach you increase your apps performance and user experience, also the device's memory is properly conserved for other functionality in the app.

Let's dive into the implementation!

patrick-mahomes-kansas-city.gif

BTW, this is my first time writing an article, show this junior developer some love. ๐Ÿ˜

NB: There are many ways to implement this using different architectures and state management techniques, do your research, learn and try to implement it however you can to fit your project's needs.

Let's start by creating the project

Screenshot 2021-12-31 at 04.22.52.png

You would need to add some dependencies to the pubspec.yaml file:

Stacked : MVVM inspired architecture in Flutter. It provides common functionalities required to build a large application in an understandable manner.

Stacked Services : A package that contains some default implementations of services required for a cleaner implementation of the Stacked Architecture.

Logger : Small, easy to use and extensible logger which prints beautiful logs.

Add this to your dev_dependencies in your pubspec.yaml file:

Build runner : A build system for Dart code generation and modular compilation.

Stacked generator : stacked Generator is a package dedicated to reduce the boilerplate required to setup a stacked application.

These are the latest versions of the packages added as at the time of this writing.

name: flutter_list_pagination
description: A new Flutter project.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

version: 1.0.0+1

environment:
  sdk: ">=2.15.1 <3.0.0"

dependencies:
  flutter:
    sdk: flutter

  # Our dependencies HERE
  stacked: ^2.2.7+1
  stacked_services: ^0.8.16
  logger: ^1.1.0

  cupertino_icons: ^1.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^1.0.0

  # Our dependencies HERE
  build_runner: ^2.1.7
  stacked_generator:

flutter:

  uses-material-design: true

After you have added all the necessary packages you'll need to run the "flutter pub get" command from the root directory to get the dependencies.

Run this in your terminal from the project's root directory.

flutter pub get

App architecture and folder structure

Screenshot 2021-12-31 at 15.44.17.png

I have created folders to separate files relating to each other ensuring the project stays clean and readable (you don't want new people to hate you when they open your codebase ๐Ÿ˜ฌ), I'll explain what each folder contains.

app : This contains the apps router files for navigation, service locators (DI), logger. Generally these are files created by stacked.

core : This contains those files (i.e. constants and enums) that are used all over the application.

models : This contains object models used to represent/structure data using fields and methods.

services : This contains classes used specifically for a single functionality.

ui : This contains your UI layouts (your stateless and stateful widgets) and widgets.

Let's create our UI

In the UI folder create another folder called views, in views, a folder called paginated_list.

In paginated_list folder, create a dart file called paginated_list_view and another dart file called paginated_list_viewmodel

Screenshot 2021-12-31 at 17.02.22.png

Inside paginated_list_view create a stateless widget, return a reactive ViewModelBuilder widget. Inside the ViewModelBuilder widget return a ListViewBuilder widget.

import 'package:flutter/material.dart';
import 'package:flutter_list_pagination/ui/views/paginated_list/paginated_list_view.dart';

void main() {
  runApp(
    const MyApp(),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Flutter List Pagination Example',
      home: PaginatedListView(),
    );
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_list_pagination/ui/views/paginated_list/paginated_list_viewmodel.dart';
import 'package:stacked/stacked.dart';

class PaginatedListView extends StatelessWidget {
  const PaginatedListView({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ViewModelBuilder<PaginatedListViewModel>.reactive(
      builder: (context, model, child) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Flutter Paginated List Demo'),
          ),
          body: ListView.builder(
            itemCount: 1,
            itemBuilder: (context, index) {
              return ListTile(
                leading: Text(
                  '$index',
                  style: Theme.of(context).textTheme.bodyText2,
                ),
                title: Text(
                  'Hellooooooooo',
                  style: Theme.of(context).textTheme.headline6,
                ),
                trailing: const Text('From Demmss'),
              );
            },
          ),
        );
      },
      viewModelBuilder: () => PaginatedListViewModel(),
    );
  }
}

Simulator Screen Shot - iPhone 11 Pro Max - 2021-12-31 at 18.15.19.png

Let's get into the main parts; the Service, the Model and Lazy loading.

Create a model class called Comments, with two fields for user name, comment and finally create the constructor for the object.

class Comments {
  String? userName;
  String? userComment;

  Comments({this.userName, this.userComment});
}

Create a class called CommentsService.

import 'dart:math';
import 'package:flutter_list_pagination/app/app.logger.dart';
import 'package:flutter_list_pagination/models/comments_model.dart';

class CommentsService {
  final log = getLogger('CommentsService');

  final _commentsList = [
    Comments(userName: 'Red', userComment: 'Nice'),
    Comments(userName: 'Gabe', userComment: 'Very good'),
    Comments(userName: 'Mark', userComment: 'Bad'),
    Comments(userName: 'Michael', userComment: 'Nonsense'),
    Comments(userName: 'Odu', userComment: 'Crap'),
    Comments(userName: 'Harry', userComment: 'Excellent'),
    Comments(userName: 'Hannah', userComment: 'Lovely'),
    Comments(userName: 'Hailey', userComment: 'Mad o'),
    Comments(userName: 'Harriet', userComment: 'Wow'),
    Comments(userName: 'Mustapha', userComment: 'You dey vex'),
    Comments(userName: 'Monsey', userComment: 'Awesome'),
    Comments(userName: 'Mario', userComment: 'Hmmmm'),
    Comments(userName: 'Matteo', userComment: 'Needs work'),
    Comments(userName: 'James', userComment: 'Wow'),
    Comments(userName: 'Mariam', userComment: 'Opor'),
  ];

  Future<List<Comments>?> getComments({required int currentPage}) async {
    int itemsPerPage = 15;
    final fetchedList = <Comments>[];
    final n =
        min(itemsPerPage, _commentsList.length - currentPage * itemsPerPage);
    log.i('Now on page $currentPage');
    await Future.delayed(const Duration(seconds: 2));
    for (int i = 0; i < n; i++) {
      fetchedList.add(_commentsList[i]);
    }
    log.i(
        'items per page: $itemsPerPage Current page: $currentPage, Count: ${_commentsList.length}');
    return fetchedList;
  }
}

Create a private list of mock Comments object (global variable) in the CommentsService class called commentsList.

Define a method called getComments() with a Future return type of List?. It will take in a required parameter called currentPage of type int.

Future<List<Comments>?> getComments({required int currentPage}) async {}

We define a variable of type int called itemsPerPage. If you're working on a real life project you will come across paginated list in endpoints and each page has a number of items per page. This is a mock normally you won't need to define this.

Create an empty list (local variable) in getComments() of type Comments called fetchedList

final fetchedList = <Comments>[];

We define a final variable called itemsLeft which we will use to know the lesser of two numbers. We're checking against the number on the left itemsPerPage => 20 and the result of the operation on the right _commentsList.length - currentPage itemsPerPage*.

final itemsLeft =
        min(itemsPerPage, _commentsList.length - currentPage * itemsPerPage);

If : The itemsPerPage => 13 The _commentsList.length => 93 The currentPage => 1

The result would be min(13, 80) on the first iteration (page 1). The result would be min(13, 67) on the second iteration (page 2). The result would be min(13, 54) on the third iteration (page 3). The result would be min(13, 41) on the fourth iteration (page 4). The result would be min(13, 28) on the fifth iteration (page 5). The result would be min(13, 15) on the sixth iteration (page 6). The result would be min(13, 2) on the seventh iteration (page 7).

Note that this is returning the lesser of two numbers, the number on the right is the items left in the list and the number on the left is used to check and return the next 13 items if it's no longer up to 13 you have the number on the right returned.

The result would be min(13, 0) on the eigth iteration (page 8).

This last result, if it wasn't a mock the result would be an exception but instead we will check in our viewmodel if the list is empty, we will see that very soon.

Again this is because it's mocked like a real server, you won't have to do this with an already paginated endpoint.

We're going to mock a real API call using Future method delayed and then loop through the already defined list commentsList. i index is checked against the itemsLeft in the list and the next 13 items are added to the fetchedList.

    log.i('Now on page $currentPage');
    await Future.delayed(const Duration(seconds: 2));
    for (int i = 0; i < itemsLeft; i++) {
      fetchedList.add(_commentsList[i]);
    }
    log.i(
        'items per page: $itemsPerPage Current page: $currentPage, Count: ${_commentsList.length}');

    return fetchedList;

The last part, UI (view and viewmodel)

In the viewmodel

import 'package:flutter_list_pagination/app/app.logger.dart';
import 'package:stacked/stacked.dart';

class PaginatedListViewModel extends BaseViewModel {
  final log = getLogger('PaginatedListViewModel');
}

We would need to get this class/dependency CommentsService to the ViewModel, using Service locator pattern and the get_it package wrapped into stacked you can get this dependency without creating a tight dependence between this two classes when paired with abstraction (abstract classes) which I'm currently not doing.

To do this, create a file named app.dart in the app folder mentioned earlier, inside this file add this

import 'package:flutter_list_pagination/services/comments/comments_service.dart';
import 'package:stacked/stacked_annotations.dart';

@StackedApp(
  routes: [],
  dependencies: [
    Singleton(classType: CommentsService),
  ],
  logger: StackedLogger(),
)
class AppRouter {}

In your main.dart file, add setupLocator(); to the main() method

void main() {
  setupLocator();
  runApp(
    const MyApp(),
  );
}

Now run,

flutter pub run build_runner build --delete-conflicting-outputs

This generates routes, registers services and creates the logger.

You'll understand this better when you learn more about stacked architecture.

Back in the viewmodel, you can now get the class/dependency to your viewmodel using the locator

import 'package:flutter_list_pagination/app/app.locator.dart';
import 'package:flutter_list_pagination/app/app.logger.dart';
import 'package:flutter_list_pagination/models/comments_model.dart';
import 'package:flutter_list_pagination/services/comments/comments_service.dart';
import 'package:stacked/stacked.dart';

const commentsBusyKey = 'comments';

class PaginatedListViewModel extends BaseViewModel {
  final log = getLogger('PaginatedListViewModel');
  final commentsService = locator<CommentsService>();

  int currentPage = 1;

  bool _hasMore = true;

  bool get hasMore => _hasMore;

  final List<Comments> _commentsList = [];

  List<Comments> get commentsList => _commentsList;

  void setup() async {
    await loadMoreComments();
  }

  Future<void> loadMoreComments() async {
    await commentsService.getComments(currentPage: currentPage).then(
      (List<Comments>? fetchedCommentsList) {
        // check if there's still data
        if (fetchedCommentsList!.isEmpty) {
          _hasMore = false;
          setBusyForObject(commentsBusyKey, false);
        } else {
          currentPage++;
          _commentsList.addAll(fetchedCommentsList);
          setBusyForObject(commentsBusyKey, false);
        }
      },
    );
    log.i('Has more $_hasMore');
  }
}

We define a variable of type int called currentPage and give it a starting value of 1 as in normal paginated lists. This will be incremented on each iteration mocking an actual paginated list.

We define another variable of type bool called hasMore and give it a default value of true. This is what we use to keep track when the list has no more items.

Also define a List of type Comments to store fetched comments and supply to the ListView.builder.

The method setup() is fired once the viewmodel is created and when this happens we want to fetch the first batch of items from the server.

The loadMoreComments() method has a return type Future void has we aren't returning anything but we need to await this method the first time the viewmodel is created.

await commentsService.getComments(currentPage: currentPage).then(
      (List<Comments>? fetchedCommentsList) {
        // check if there's still data
        if (fetchedCommentsList!.isEmpty) {
          _hasMore = false;
          setBusyForObject(commentsBusyKey, false);
        } else {
          currentPage++;
          _commentsList.addAll(fetchedCommentsList);
          setBusyForObject(commentsBusyKey, false);
        }
      },
    );

We get the comments and then check if the list is empty and then set the hasMore flag to false. setBusyForObject we set the process to busy when we're fetching more data.

If the list isn't empty we want to add all the items in the fetchedCommentsList into our commentsList and also increment the current page by 1.

Finally, Now in the view

import 'package:flutter/material.dart';
import 'package:flutter_list_pagination/ui/views/paginated_list/paginated_list_viewmodel.dart';
import 'package:stacked/stacked.dart';

class PaginatedListView extends StatelessWidget {
  const PaginatedListView({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ViewModelBuilder<PaginatedListViewModel>.reactive(
      builder: (context, model, child) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Flutter Paginated List Demo'),
          ),
          body: ListView.builder(
            // if hasMore is true, we need to add an extra space for a loading widget
            itemCount: model.hasMore
                ? model.commentsList.length + 1
                : model.commentsList.length,
            itemBuilder: (context, index) {
              debugPrint('ListView.builder is building index $index');
              if (index >= model.commentsList.length) {
                // Don't trigger if one async loading is already under way
                if (!model.busy(commentsBusyKey)) {
                  model.loadMoreComments();
                }
                return Center(
                  child: Container(
                    padding: const EdgeInsets.only(top: 32, bottom: 32),
                    child: const SizedBox(
                      child: CircularProgressIndicator(),
                      height: 24,
                      width: 24,
                    ),
                  ),
                );
              }
              return ListTile(
                leading: Text(
                  '$index',
                  style: Theme.of(context).textTheme.bodyText2,
                ),
                title: Text(
                  '${model.commentsList[index].userComment}',
                  style: Theme.of(context).textTheme.headline6,
                ),
                trailing: Text('From ${model.commentsList[index].userName}'),
              );
            },
          ),
        );
      },
      viewModelBuilder: () => PaginatedListViewModel(),
    );
  }
}

In the ListView.builder widget we want to check if there are still more comments and then if this is true we add an extra space for a loading (widget) indicator.

Inside the itemBuilder's lambda function, we want to check if the first or last index is greater than or equal to the commentsList length and also if the process is currently not busy. If these conditions are both true we want to load more comments and display a loading indicator.

If these conditions are false then return ListTile() widget containing more comments if available.

ezgif-7-1b06eb75e9.gif

I think I'm done.

tumblr_02a9936a954aa86a180856a58b5c23a6_9fbd4bc9_500.gif

Conclusion

This is not a tutorial for you if you're just starting to learn flutter/state management techniques as I didn't really go in depth into understanding everything, maybe in my next article ๐Ÿค”.

Let me add this, If you know you're getting better at coding and you're solving problems other developers might face along the line please try to share your knowledge however you can. Don't leave those jewels in production only ๐Ÿ˜ค.

Link to project source code on Github

ย