cursor_pagination 1.0.0 copy "cursor_pagination: ^1.0.0" to clipboard
cursor_pagination: ^1.0.0 copied to clipboard

A flexible cursor-based pagination library for Flutter with full generic type support. Works with ChangeNotifier, BLoC/Cubit, and supports any cursor type.

Cursor Pagination #

A flexible and powerful cursor-based pagination library for Flutter with full generic type support.

Pub Version License: MIT

✨ Features #

  • 🎯 Full Generic Type Support - Use any cursor type (String, int, DateTime, custom types)
  • 🔄 Multiple State Management - Works with ChangeNotifier, BLoC/Cubit
  • 📜 Auto-loading - Automatically loads next page when scrolling near bottom
  • 🎨 Flexible State Handling - Separate states for data, empty, and error
  • 🛠️ Item Manipulation - Update or remove items without refetching
  • 🚀 Production Ready - Well-tested and documented
  • 💪 Type Safe - Leverages Dart's strong typing system

📦 Installation #

Add to your pubspec.yaml:

dependencies:
  cursor_pagination: ^1.0.0
  flutter_bloc: ^8.1.0 # Only if using CubitPaginationController

🚀 Quick Start #

1. Define Your Models #

/// Product model
class Product {
  final String id;
  final String title;
  final String description;

  const Product({
    required this.id,
    required this.title,
    required this.description,
  });
}

/// Custom cursor for pagination
class ProductCursor {
  final String? lastSeenId;
  final int limit;

  const ProductCursor({this.lastSeenId, this.limit = 10});

  ProductCursor updateCursor(String id) {
    return ProductCursor(lastSeenId: id, limit: limit);
  }
}

2. Create a Controller #

final controller = CubitPaginationController<Product, ProductCursor, String>(
  firstPagePointer: CursorPagination<ProductCursor>(
    cursor: ProductCursor(limit: 10),
    limit: 10,
  ),
  getPageFunc: (pagination) async {
    try {
      // Extract cursor
      final cursor = pagination.cursor ?? ProductCursor(limit: pagination.limit);

      // Load data via API/Repository
      final products = await repository.getProducts(cursor);

      // Determine cursor for next page
      final nextCursor = products.isNotEmpty
          ? cursor.updateCursor(products.last.id)
          : cursor;

      return SuccessPaginationResult(
        itemList: products,
        pagination: CursorPagination<ProductCursor>(
          cursor: nextCursor,
          limit: pagination.limit,
        ),
      );
    } catch (e) {
      return ErrorPaginationResult(
        pagination: pagination,
        error: e.toString(),
      );
    }
  },
);

Option B: Using FlutterPaginationController (ChangeNotifier)

final controller = FlutterPaginationController<Product, ProductCursor, String>(
  firstPagePointer: CursorPagination<ProductCursor>(
    cursor: ProductCursor(limit: 10),
    limit: 10,
  ),
  getPageFunc: (pagination) async {
    // Same as above
  },
);

3. Build Your UI #

class ProductsScreen extends StatefulWidget {
  const ProductsScreen({super.key});

  @override
  State<ProductsScreen> createState() => _ProductsScreenState();
}

class _ProductsScreenState extends State<ProductsScreen> {
  late final CubitPaginationController<Product, ProductCursor, String> controller;

  @override
  void initState() {
    super.initState();
    controller = CubitPaginationController(
      firstPagePointer: CursorPagination<ProductCursor>(
        cursor: ProductCursor(limit: 10),
        limit: 10,
      ),
      getPageFunc: _fetchProducts,
    );
    controller.getFirst();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Products')),
      body: CubitPaginatedListBuilder<Product, ProductCursor, String>(
        controller: controller,

        // 📊 Data state
        dataBuilder: (context, dataState, isProcessing) {
          final products = dataState.itemList;
          final isLastPage = dataState.isLastItems;

          return RefreshIndicator(
            onRefresh: () async => controller.getFirst(),
            child: ListView.separated(
              controller: controller.scrollController,
              padding: const EdgeInsets.all(16),
              itemCount: products.length + (isLastPage ? 0 : 1),
              separatorBuilder: (_, __) => const SizedBox(height: 16),
              itemBuilder: (context, index) {
                // Shimmer loading indicator
                if (index >= products.length) {
                  return const Center(child: CircularProgressIndicator());
                }

                // Product card
                final product = products[index];
                return Card(
                  child: ListTile(
                    title: Text(product.title),
                    subtitle: Text(
                      product.description,
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                    ),
                  ),
                );
              },
            ),
          );
        },

        // 📭 Empty state
        emptyBuilder: (context, _, __) => const Center(
          child: Text('No products found'),
        ),

        // ❌ Error state
        errorBuilder: (context, errorState, __) => Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('Error: ${errorState.description}'),
              ElevatedButton(
                onPressed: controller.getFirst,
                child: const Text('Retry'),
              ),
            ],
          ),
        ),
      ),
    );
  }

  @override
  void dispose() {
    controller.close();
    super.dispose();
  }
}

With FlutterPaginationController

class ProductsScreen extends StatefulWidget {
  @override
  State<ProductsScreen> createState() => _ProductsScreenState();
}

class _ProductsScreenState extends State<ProductsScreen> {
  late final FlutterPaginationController<Product, ProductCursor, String> controller;

  @override
  void initState() {
    super.initState();
    controller = FlutterPaginationController(
      firstPagePointer: CursorPagination<ProductCursor>(
        cursor: ProductCursor(limit: 10),
        limit: 10,
      ),
      getPageFunc: _fetchProducts,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Products')),
      body: AnimatedBuilder(
        animation: controller,
        builder: (context, _) {
          final state = controller.state;

          return switch (state) {
            DataListPCState(:final itemList, :final isLastItems) => ListView.builder(
              controller: controller.scrollController,
              itemCount: itemList.length + (isLastItems ? 0 : 1),
              itemBuilder: (context, index) {
                if (index >= itemList.length) {
                  return const Center(child: CircularProgressIndicator());
                }
                final product = itemList[index];
                return Card(
                  child: ListTile(
                    title: Text(product.title),
                    subtitle: Text(product.description),
                  ),
                );
              },
            ),
            EmptyListPCState() => const Center(
              child: Text('No products found'),
            ),
            ErrorListPCState(:final description) => Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text('Error: $description'),
                  ElevatedButton(
                    onPressed: controller.getFirst,
                    child: const Text('Retry'),
                  ),
                ],
              ),
            ),
          };
        },
      ),
    );
  }

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

📚 Advanced Usage #

Different Cursor Types #

String Cursor (Token-based)

final controller = CubitPaginationController<Product, String, String>(
  firstPagePointer: const CursorPagination<String>(limit: 20),
  getPageFunc: (pagination) async {
    final products = await api.getProducts(
      cursor: pagination.cursor,
      limit: pagination.limit,
    );

    return SuccessPaginationResult(
      itemList: products.items,
      pagination: pagination.updateCursor(products.nextCursor),
    );
  },
);

Integer Cursor (Offset-based)

final controller = CubitPaginationController<Product, int, String>(
  firstPagePointer: const CursorPagination<int>(cursor: 0, limit: 10),
  getPageFunc: (pagination) async {
    final products = await api.getProducts(
      offset: pagination.cursor ?? 0,
      limit: pagination.limit,
    );

    return SuccessPaginationResult(
      itemList: products,
      pagination: pagination.updateCursor(
        (pagination.cursor ?? 0) + products.length,
      ),
    );
  },
);

DateTime Cursor (Time-based)

final controller = CubitPaginationController<Message, DateTime, String>(
  firstPagePointer: CursorPagination<DateTime>(
    cursor: DateTime.now(),
    limit: 50,
  ),
  getPageFunc: (pagination) async {
    final messages = await api.getMessages(
      before: pagination.cursor,
      limit: pagination.limit,
    );

    return SuccessPaginationResult(
      itemList: messages,
      pagination: pagination.updateCursor(messages.last.timestamp),
    );
  },
);

Custom Cursor Type (Composite)

/// Custom cursor with multiple fields
class ProductCursor {
  final String? lastSeenId;
  final int limit;

  const ProductCursor({this.lastSeenId, this.limit = 10});

  ProductCursor updateCursor(String id) {
    return ProductCursor(lastSeenId: id, limit: limit);
  }
}

final controller = CubitPaginationController<Product, ProductCursor, String>(
  firstPagePointer: CursorPagination<ProductCursor>(
    cursor: const ProductCursor(limit: 10),
    limit: 10,
  ),
  getPageFunc: (pagination) async {
    final cursor = pagination.cursor ?? ProductCursor(limit: pagination.limit);
    final products = await repository.getProducts(cursor);

    final nextCursor = products.isNotEmpty
        ? cursor.updateCursor(products.last.id)
        : cursor;

    return SuccessPaginationResult(
      itemList: products,
      pagination: CursorPagination<ProductCursor>(
        cursor: nextCursor,
        limit: pagination.limit,
      ),
    );
  },
);

Manual Operations #

// Load first page (reset)
controller.getFirst();

// Load next page manually
controller.getNext();

// Refresh current page
controller.refreshCurrent();

// Update specific item
controller.updateItemAt(index, updatedProduct);

// Remove specific item
controller.removeItemAt(index);

Listen to Processing State #

ValueListenableBuilder<bool>(
  valueListenable: controller.isProcessing,
  builder: (context, isProcessing, child) {
    return ElevatedButton(
      onPressed: isProcessing ? null : controller.getFirst,
      child: isProcessing
        ? CircularProgressIndicator()
        : Text('Refresh'),
    );
  },
);

Custom Scroll Threshold #

// The default threshold is 200px from bottom
// To customize, you can extend the controller or adjust the scroll listener

🏗️ Architecture #

Type Parameters #

All controllers use three generic types:

  1. ItemType - The type of items being paginated (e.g., User, Post)
  2. CursorType - The type of cursor (e.g., String, int, DateTime)
  3. ErrorType - The type for error information (e.g., String, custom error class)

States #

The controller can be in one of three states:

  • DataListPCState - Contains loaded items, pagination info, and last page flag
  • EmptyListPCState - No items found (first page returned empty)
  • ErrorListPCState - An error occurred during pagination

Results #

Your getPageFunc should return one of:

  • SuccessPaginationResult - Contains items and updated pagination
  • ErrorPaginationResult - Contains error information

🎯 Best Practices #

  1. Always handle errors in your getPageFunc
  2. Update cursor correctly after each successful fetch
  3. Use appropriate cursor type for your API (String tokens, int offsets, DateTime, etc.)
  4. Dispose controllers in StatefulWidget's dispose method
  5. Reuse scroll controller if you need custom scroll behavior
  6. Test pagination with different data sizes (empty, single page, multiple pages)

🤝 Contributing #

Contributions are welcome! Please feel free to submit a Pull Request.

📄 License #

This project is licensed under the MIT License - see the LICENSE file for details.

🙏 Credits #

Created and maintained by the Flutter community.

cursor_pagination #

5
likes
150
points
59
downloads

Publisher

unverified uploader

Weekly Downloads

A flexible cursor-based pagination library for Flutter with full generic type support. Works with ChangeNotifier, BLoC/Cubit, and supports any cursor type.

Repository (GitHub)
View/report issues

Topics

#pagination #cursor #infinite-scroll #flutter #bloc

Documentation

Documentation
API reference

License

MIT (license)

Dependencies

flutter, flutter_bloc

More

Packages that depend on cursor_pagination