cursor_pagination 1.0.0
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.
✨ 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 #
Option A: Using CubitPaginationController (Recommended)
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 #
With CubitPaginationController (Recommended)
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:
- ItemType - The type of items being paginated (e.g.,
User,Post) - CursorType - The type of cursor (e.g.,
String,int,DateTime) - 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 #
- Always handle errors in your
getPageFunc - Update cursor correctly after each successful fetch
- Use appropriate cursor type for your API (String tokens, int offsets, DateTime, etc.)
- Dispose controllers in StatefulWidget's dispose method
- Reuse scroll controller if you need custom scroll behavior
- 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.