Skip to content

Repository files navigation

pub package downloads likes pub points license MIT

portfolio publisher


Easier to display items in a list/grid view from your controllers directly or handling state internally with support for pagination. Saves the results in state to avoid unnecessary api calls everytime screen is pushed.


🗂️ Table of Contents


📷 Screenshots

Loading Shimmer Animation Loaded List View

✨ Features

  • Easy Pagination Handling: Effortlessly display paginated data with built-in support for handling API pagination.
  • State Management: Manage state internally within the widget or externally through controllers, ensuring optimized API calls by caching results.
  • List or Grid View: Customize how your items are displayed by toggling between list or grid views.
  • Shimmer Loader: Built-in shimmer loader for smoother UX during data loading, with customizable shimmer animation colors and duration.
  • Automatic Refresh: Includes pull-down refresh and error refresh functionalities to reload data dynamically.
  • Seamless UI Updates: On data refresh, update only the relevant items without unnecessary UI flickering or reloading.
  • Error Handling: Built-in handling of errors during API calls, with the ability to refresh on error.

❓ Usage

  1. Add paginated_items_builder as a dependency in your pubspec.yaml file.
dependencies:
  flutter:
    sdk: flutter

  paginated_items_builder:
  1. Add PaginatedItemsBuilder widget to your widget tree. Here, let's consider a list of posts.

In the controller, let's define a variable for handling the posts response (typically inside the specific controller) and a public getter to access it in the UI.

PaginatedItemsResponse<Post>? _postsResponse;

PaginatedItemsResponse<Post>? get postsResponse => _postsResponse;

Define a function to handle the state of the list, function that handles calling the api, getting the results and return the response handler from it.

Future<PaginatedItemsResponse<Post>?> updatePosts({bool reset = false}) async {
  final res = await apiFunction(
    // startKey is optional and only required when you have pagination support in api
    startKey: reset ? null : _postsResponse?.paginationKey,
  );

  // `merge` adopts the new response when there is nothing to merge into, or
  // when reset is true (a pull-down refresh), and merges the page in otherwise.
  _postsResponse = _postsResponse.merge(res, reset: reset);

  notifyListeners();
  return _postsResponse;
}

The apiFunction can be defined as:

Future<PaginatedItemsResponse<Post>> apiFunction({
  // can be string or int (page number) or any other type.
  dynamic startKey,
}) async {
    // startKey necessary if pagination support
    final res = await _api.getPosts(startKey: startKey);

    return PaginatedItemsResponse<Post>(

      // list of items
      listItems: res.data?.posts,

      // required. pass null if the API has no pagination. (can be of any type)
      // leaving it out is not allowed: a missing key silently means
      // "no more data", which would stop the list after the first page.
      paginationKey: res.data?.paginationKey,

      // required. unique id, should only be passed in the repository function.
      // used by `updateItem`, `findByUid`, and to avoid duplicating items
      // that arrive again in a later page (compares uid)
      idGetter: (post) => post.id.toString(),
    );
}

If you need a response before the first page has been fetched — say you keep a non-nullable response on a controller — build an empty one and merge pages into it:

final postsResponse = PaginatedItemsResponse<Post>.empty(
  idGetter: (post) => post.id.toString(),
);

APIs that page by size instead of by cursor

If your API has no end-of-list cursor and you instead know the page size, pass itemsPerPage. A page that comes back shorter than a full one is then treated as the last, and paginationKey stops being what decides it:

return PaginatedItemsResponse<Post>(
  listItems: res.data?.posts,
  paginationKey: nextOffset,
  itemsPerPage: 20,
  idGetter: (post) => post.id.toString(),
);

Now, can use this widget like shown in the widget tree: (No need to handle a refresh indicator separately. It is already present.)

When the reset from fetchPageData fn is true, your code should handle the logic to update and replace the existing contents. Basically update all items. Much like a pull-down refresh.

The fetchPageData provides the reset flag(boolean). If that is true, that means an action was triggered which requires to force reload the items of the list.

The reset flag will be true only when the itemsFetchScope is either ItemsFetchScope.noItemsRefresh i.e. no items were found, and user clicked the refresh icon OR ItemsFetchScope.pullDownToRefresh i.e. the user wants to refresh the list contents with pull-down action OR ItemsFetchScope.onErrorRefresh if an error occurs.

PaginatedItemsBuilder<Post>(
  response: controller.postsResponse,
  fetchPageData: (reset) => controller.updatePosts(reset: reset),
  
  // whether to turn all the existing cards into loaders or not.
  // If true, all the already displayed items will convert into
  // loaders, and then the new list will be rendered.
  
  // If false, then nothing will change on the screen while the data
  // is being fetched, when the data arrives, the content in the
  // cards will replace.
  showLoaderOnResetGetter: (itemsFetchScope) => [
    ItemsFetchScope.noItemsRefresh,
    ItemsFetchScope.onErrorRefresh,
    ItemsFetchScope.pullDownToRefresh,
  ].contains(itemsFetchScope),

  /// whether to display items in a list or grid view.
  itemsDisplayType: ItemsDisplayType.list,
  
  /// there are params to customize your list / grid view even further.
  /// Read more below...
  itemBuilder: (context, index, item) => Text('Item$index : $item'),
),

If the state is handled using PaginationItemsStateHandler, then response and fetchPageData is handled internally and is provided in the builder callback. Use it as follows:

/// function which calls the API and returns `PaginatedItemsResponse`.
Future<PaginatedItemsResponse<Post>> updatePosts(dynamic paginationKey) async {
  return await PostsRepository.getPosts(startKey: paginationKey);
}

PaginationItemsStateHandler<Post>(
  fetchPageData: updatePosts,
  builder: (response, fetchPageData) {
    return PaginatedItemsBuilder<Post>(
      response: response,
      fetchPageData: fetchPageData,
      itemBuilder: (context, idx, post) => PostCard(post),
      loaderItemsCount: 12,
    );
  },
),

You can also log the result directly by using the log() function on the PaginatedItemsResponse directly...

final response = PaginatedItemsResponse<Post>(
  listItems: res.data?.posts,
  paginationKey: res.data?.paginationKey,
  idGetter: (post) => post.id.toString(),
);

response.log();

Shimmer loader

Want to use the shimmer loader somewhere else?

What if you have multiple PaginatedItemsBuilder widgets in a single view, then every builder has it's own loader, and you want a pull down refresh handler on the main page, and at the same time don't want every widget to render it's own loader, instead, have a common global loader for the entire page.

Then you can use LoaderShimmer, which is basically shimmer with the ShimmerConfig properties as defaults, that can also be changed(if required)...

LoaderShimmer(

  baseColor: Colors.grey, // defaults to `ShimmerConfig.baseColor`

  // ... and more properties

  child: ListView(
    children: [
      // disable individual loaders for these builders by passing false 
      // in the showLoaderOnReset flag in the updateX methods..
      PaginatedItemsBuilder1(),
      PaginatedItemsBuilder2(),
      PaginatedItemsBuilder3(),
    ],
  ),
);

PaginatedItemsBuilder Config

To see the shimmer loader in play, you need to provide a mock items getter.. What basically happens is that this 'MockItem' is basically an object of the class T which is passed in the PaginatedItemsBuilder class.

Generate a class like shown:

class MockItems {
  static dynamic getByType<T>([String? mockItemKey]) {
    final key = mockItemKey ?? T.toString();
    switch (key) {
      case 'Category':
        // a widget can also be returned from here, instead of an object...
        // if a widget is returned, then widget is rendered directly...
        return _category;
    }
  }

  static final _category = Category.fromJson({
    'id': 'id',
    'name': '■■■■■■',
  });
}

and then pass the reference to the getByType function to the PaginatedItemsBuilderConfig.

PaginatedItemsBuilder.config = PaginatedItemsBuilderConfig(
  mockItemGetter: MockItems.getByType,
);

In the PaginatedItemsBuilderConfig, you can also customize the shimmer loader colors etc.

PaginatedItemsBuilder.config = PaginatedItemsBuilderConfig(
  mockItemGetter: MockItems.getByType,
  shimmerConfig: ShimmerConfig(
    baseColor: Colors.grey[300],
    highlightColor: Colors.grey[200],
  ),
  // ...and a lot more params
);

Supporting multiple themes

The config can be initialized in the MaterialApp's builder property. It is also possible to pass different colors for different themes as shown:

MaterialApp(
    title: 'PaginatedItemsBuilder Demo',
    builder: (context, child) {
        late final Color shimmerBaseColor;
        late final Color shimmerHighlightColor;
        
        switch (Theme.of(context).brightness) {
            case Brightness.light:
                shimmerBaseColor = Colors.grey[300]!;
                shimmerHighlightColor = Colors.grey[100]!;
                break;
            case Brightness.dark:
                shimmerBaseColor = const Color(0xFF031956);
                shimmerHighlightColor = const Color(0x80031956);
                break;
        }
        
        PaginatedItemsBuilder.config = PaginatedItemsBuilderConfig(
            mockItemGetter: MockItems.getByType,
            shimmerConfig: ShimmerConfig(
                baseColor: shimmerBaseColor,
                highlightColor: shimmerHighlightColor,
                duration: const Duration(seconds: 1),
            ),
        );
        
        return child!;
    },
);

Want to show items as a grid? Change the cross axis count? Pass in a custom scroll controller? Well, there are a lot of parameters that can be customized in PaginatedItemsBuilder


🛫 Migration Guides

Migration Guide from v1.x to v2.x+

1. Minimum SDK versions raised

  • Flutter 3.41.0 or newer, Dart 3.9.0 or newer.
  • Stay on 1.2.5 if you cannot upgrade the SDK yet.

2. PaginatedItemsResponse parameters are now all required

idGetter, listItems and paginationKey were optional. Each failed silently when left out — without idGetter a page could duplicate items already in the list, and without paginationKey the list quietly stopped after the first page, because a missing key reads as "no more data".

Pass null explicitly where a value does not apply:

return PaginatedItemsResponse<Post>(
  listItems: res.data?.posts,
- // paginationKey omitted
+ paginationKey: null, // or the cursor, if the API paginates
  idGetter: (post) => post.id.toString(),
);

Note

The analyzer flags every call site that needs updating, so this migration is mechanical.

3. items is no longer nullable

PaginatedItemsResponse.items changed from List<T>? to a final List<T>. A response that exists always has a list, even when empty. Drop the ! and the null checks:

- final count = response.items!.length;
- if (response.items != null) { ... }
+ final count = response.items.length;

"Nothing fetched yet" is now expressed by a null response, not by null items inside one — which is what PaginatedItemsBuilder.response already did.

clear() now empties the list instead of setting it to null, and the hasData getter is gone with nothing left to distinguish. Use isEmpty / isNotEmpty, or check the response itself for null:

- if (response.hasData) { ... }
+ if (response != null && response.isNotEmpty) { ... }

4. operator [] returns a non-nullable item

Indexing now behaves like a plain List, returning T and throwing a RangeError on a bad index rather than returning null:

- final post = response[0]!;
+ final post = response[0];

5. Config builders that never worked are now settable

noItemsWidgetBuilder, errorWidgetBuilder and refreshIconBuilder on PaginatedItemsBuilderConfig were declared but could never be assigned, and reading them threw a LateInitializationError. They are now constructor parameters, used as the fallback when the widget does not override them:

PaginatedItemsBuilder.config = PaginatedItemsBuilderConfig(
  errorWidgetBuilder: (error, refreshOnTap) => MyErrorView(onRetry: refreshOnTap),
);

Important

If you were working around this by passing the same builder to every PaginatedItemsBuilder, you can now set it once on the config.

6. Optional: adopt the new conveniences

None of these are required, but they replace patterns you may have written by hand:

  • response.merge(res, reset: reset) replaces the if (reset || response == null) … else response.update(res) branch.
  • PaginatedItemsResponse.empty(idGetter: …) for a response held before the first fetch.
  • updateItems(...) is the bulk form of updateItem, and indexes ids once instead of rescanning per item.
  • itemsPerPage derives hasMoreData from page size, for APIs with no end-of-list cursor.
  • length, isEmpty, isNotEmpty on the response.
  • scrollCacheExtent, hitTestBehavior and findItemIndexCallback on the builder.

For more details, refer to the CHANGELOG.


🎯 Sample Usage

See the example app for a complete app.

Check out the full API reference for the widget here and reference for the response here.

import 'package:flutter/material.dart';
import 'package:paginated_items_builder/paginated_items_builder.dart';
import 'package:paginated_items_builder_demo/controllers/posts_controller.dart';
import 'package:paginated_items_builder_demo/models/post.dart';
import 'package:paginated_items_builder_demo/repositories/posts_repository.dart';
import 'package:paginated_items_builder_demo/widgets/post_card.dart';

class HomeScreen extends StatelessWidget {
  static const id = 'HomeScreen';

  const HomeScreen({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return const PostsListWithStateHandledExternally();
    // return const PostsListWithStateHandledInternally();
  }
}

//******************************************************************
// WITH CONTROLLER
//******************************************************************

class PostsListWithStateHandledExternally extends StatelessWidget {
  const PostsListWithStateHandledExternally({super.key});

  @override
  Widget build(BuildContext context) {
    final postsCon = PostsController.of(context);

    return SafeArea(
      child: Scaffold(
        body: PaginatedItemsBuilder<Post>(
          response: postsCon.postsResponse,
          // whether to turn all the existing cards into loaders or not.
          // If true, all the already displayed items will convert into
          // loaders, and then the new list will be rendered.

          // If false, then nothing will change on the screen while the data
          // is being fetched, when the data arrives, the content in the
          // cards will replace.
          showLoaderOnResetGetter: (itemsFetchScope) => [
            ItemsFetchScope.noItemsRefresh,
            ItemsFetchScope.onErrorRefresh,
            ItemsFetchScope.pullDownToRefresh,
          ].contains(itemsFetchScope),
          fetchPageData: (reset) => postsCon.updatePosts(reset: reset),
          itemBuilder: (context, idx, post) => PostCard(post),
          loaderItemsCount: 12,
        ),
      ),
    );
  }
}

//******************************************************************
// WITHOUT CONTROLLER
//******************************************************************

class PostsListWithStateHandledInternally extends StatelessWidget {
  const PostsListWithStateHandledInternally({super.key});

  /// function which calls the API and returns [PaginatedItemsResponse]
  Future<PaginatedItemsResponse<Post>> updatePosts(
    dynamic paginationKey,
  ) async {
    return await PostsRepository.getPosts(startKey: paginationKey);
  }

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(
        body: PaginationItemsStateHandler<Post>(
          fetchPageData: updatePosts,
          builder: (response, fetchPageData) {
            return PaginatedItemsBuilder<Post>(
              response: response,
              fetchPageData: fetchPageData,
              itemBuilder: (context, _, post) => PostCard(post),
              loaderItemsCount: 12,
            );
          },
        ),
      ),
    );
  }
}

👤 Author

Built and maintained by Rithik Bhandari, a mobile developer building cross-platform apps with Flutter.

About

Easier to display items in a list/grid view from your controllers directly or handling state internally with support for pagination. Saves the results in state to avoid unnecessary api calls everytime screen is pushed.

Topics

Resources

Stars

4 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages