Endless Scroll Pagination
A powerful Flutter package for endless scrolling with cursor-based pagination, pull-to-refresh, and customizable loading states.
Features
โจ Cursor-based pagination - Perfect for modern APIs
๐ Pull-to-refresh - Built-in refresh functionality
โก Smart loading triggers - Scroll distance or invisible items threshold
๐จ Fully customizable - Loading, error, and empty state widgets
๐งช Well tested - Comprehensive unit and widget tests
๐ฑ Real-time updates - Add, remove, and update items dynamically
๐ง Error handling - Automatic retry with backoff
Installation
Add this to your pubspec.yaml:
dependencies:
endless_scroll_pagination: ^0.1.0
Then run:
flutter pub get
Quick Start
import 'package:endless_scroll_pagination/endless_scroll_pagination.dart';
// 1. Create a controller
final controller = PaginationController<Post>(
loadMore: (cursor) async {
final response = await api.getPosts(after: cursor, limit: 20);
return PaginationResult(
items: response.posts,
nextCursor: response.nextCursor,
hasMore: response.hasMore,
);
},
cursorExtractor: (post) => post.id,
);
// 2. Use the widget
EndlessScrollView<Post>(
controller: controller,
itemBuilder: (context, post, index) {
return PostWidget(post: post);
},
)
Advanced Usage
Custom Configuration
final controller = PaginationController<Post>(
loadMore: loadMorePosts,
cursorExtractor: (post) => post.id,
config: const PaginationConfig(
pageSize: 15, // Items per page
scrollThreshold: 300.0, // Pixels from bottom to trigger
enablePullToRefresh: true, // Enable pull-to-refresh
enableInvisibleItemsThreshold: true, // Use invisible items instead of pixels
invisibleItemsThreshold: 5, // Items before triggering load
maxRetries: 3, // Max retry attempts on error
retryDelay: Duration(seconds: 2), // Delay between retries
),
);
Custom Builders
EndlessScrollView<Post>(
controller: controller,
itemBuilder: (context, post, index) => PostWidget(post),
// Custom loading indicator
loadingBuilder: (context) {
return Container(
padding: EdgeInsets.all(20),
child: Column(
children: [
CircularProgressIndicator(),
SizedBox(height: 8),
Text('Loading amazing content...'),
],
),
);
},
// Custom error widget
errorBuilder: (context, error, retry) {
return Card(
color: Colors.red[50],
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
Icon(Icons.error, color: Colors.red),
Text('Oops! $error'),
ElevatedButton(
onPressed: retry,
child: Text('Try Again'),
),
],
),
),
);
},
// Custom empty state
emptyBuilder: (context) {
return Center(
child: Column(
children: [
Icon(Icons.inbox, size: 64),
Text('No posts yet!'),
Text('Pull down to refresh'),
],
),
);
},
)
Real-time Updates
// Add new item (e.g., when user creates a post)
controller.addItem(newPost);
// Remove item (e.g., when user deletes a post)
controller.removeItem(deletedPost);
// Update item (e.g., when user likes a post)
controller.updateItem(oldPost, updatedPost);
// Refresh all data
await controller.refresh();
Grid Layout Example
EndlessScrollView<Photo>(
controller: photoController,
itemBuilder: (context, photo, index) {
// Create 2-column grid manually
if (index % 2 == 0) {
final nextPhoto = index + 1 < controller.state.items.length
? controller.state.items[index + 1]
: null;
return Row(
children: [
Expanded(child: PhotoCard(photo)),
if (nextPhoto != null) ...[
SizedBox(width: 8),
Expanded(child: PhotoCard(nextPhoto)),
],
],
);
}
return SizedBox.shrink(); // Odd indices handled above
},
)
Social Media Feed Example
Perfect for building feeds like Instagram, Twitter, or Facebook:
class SocialFeed extends StatefulWidget {
@override
_SocialFeedState createState() => _SocialFeedState();
}
class _SocialFeedState extends State<SocialFeed> {
late PaginationController<Post> _controller;
@override
void initState() {
super.initState();
_controller = PaginationController<Post>(
loadMore: (cursor) async {
final response = await apiService.getFeed(
userId: currentUser.id,
after: cursor,
limit: 20,
);
return PaginationResult<Post>(
items: response.posts,
nextCursor: response.nextCursor,
hasMore: response.hasMore,
);
},
cursorExtractor: (post) => post.id,
config: const PaginationConfig(
enablePullToRefresh: true,
invisibleItemsThreshold: 3,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: EndlessScrollView<Post>(
controller: _controller,
itemBuilder: (context, post, index) {
return PostCard(
post: post,
onLike: () => _handleLike(post),
onComment: () => _handleComment(post),
onShare: () => _handleShare(post),
);
},
),
);
}
void _handleLike(Post post) {
// Update like count immediately for smooth UX
final updatedPost = post.copyWith(
isLiked: !post.isLiked,
likeCount: post.isLiked ? post.likeCount - 1 : post.likeCount + 1,
);
_controller.updateItem(post, updatedPost);
// Send API request in background
apiService.toggleLike(post.id);
}
}
API Integration Examples
REST API with Cursor Pagination
class ApiService {
Future<FeedResponse> getFeed({
required String userId,
String? after,
int limit = 20,
}) async {
final response = await dio.get('/feed', queryParameters: {
'user_id': userId,
if (after != null) 'after': after,
'limit': limit,
});
return FeedResponse.fromJson(response.data);
}
}
// Usage with controller
final controller = PaginationController<Post>(
loadMore: (cursor) async {
final response = await apiService.getFeed(
userId: currentUser.id,
after: cursor,
limit: 20,
);
return PaginationResult<Post>(
items: response.posts,
nextCursor: response.nextCursor,
hasMore: response.hasMore,
);
},
cursorExtractor: (post) => post.id,
);
GraphQL with Relay-style Pagination
final controller = PaginationController<Post>(
loadMore: (cursor) async {
final result = await graphQLClient.query(QueryOptions(
document: gql('''
query GetPosts($after: String, $first: Int!) {
posts(after: $after, first: $first) {
edges {
node { id title content author createdAt }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
'''),
variables: {
'after': cursor,
'first': 20,
},
));
final edges = result.data['posts']['edges'] as List;
final pageInfo = result.data['posts']['pageInfo'];
return PaginationResult<Post>(
items: edges.map((edge) => Post.fromJson(edge['node'])).toList(),
nextCursor: pageInfo['endCursor'],
hasMore: pageInfo['hasNextPage'],
);
},
cursorExtractor: (post) => post.id,
);
Performance Tips
- Use const constructors where possible
- Implement
==andhashCodefor your models to optimize updates - Consider using
AutomaticKeepAliveClientMixinfor complex items - Use
RepaintBoundaryfor expensive item widgets - Optimize image loading with proper caching
Migration Guide
From other pagination packages
The main differences:
- Controller-based approach instead of
PagingController - Built-in cursor extraction
- Simplified configuration
- Better error handling
// Old way
final PagingController<String?, Post> pagingController = PagingController(firstPageKey: null);
// New way
final controller = PaginationController<Post>(
loadMore: loadMorePosts,
cursorExtractor: (post) => post.id,
);
Contributing
Contributions are welcome! Please read our contributing guidelines first.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
License
This project is licensed under the MIT License - see the LICENSE file for details.
Support
- ๐ซ Create an issue
- ๐ฌ Discussions
- โญ Star this repo if it helped you!
Update CHANGELOG.md:
## 0.1.0
### โจ Features
- Initial release of endless scroll pagination package
- Cursor-based pagination support
- Pull-to-refresh functionality
- Customizable loading, error, and empty state builders
- Real-time item management (add, remove, update)
- Smart loading triggers (scroll threshold or invisible items)
- Comprehensive error handling with retry mechanism
- Grid layout support
- Full test coverage
### ๐ฑ Examples
- Social media feed example
- Photo grid example
- Error handling demonstration
- REST and GraphQL API integration examples
### ๐งช Testing
- Unit tests for PaginationController
- Widget tests for EndlessScrollView
- Integration tests for real-world scenarios