endless_scroll_pagination 0.1.0-beta.1
endless_scroll_pagination: ^0.1.0-beta.1 copied to clipboard
A powerful Flutter package for endless scrolling with cursor-based pagination, pull-to-refresh, and customizable loading states.
example/lib/main.dart
import 'package:endless_scroll_pagination/endless_scroll_pagination.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Infinite Scroll Pagination Example',
theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
home: const HomeScreen(),
);
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Infinite Scroll Examples'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildExampleCard(
context,
'Basic Posts Feed',
'Simple infinite scrolling list like social media',
Icons.article,
() => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const PostsFeedExample()),
),
),
const SizedBox(height: 12),
_buildExampleCard(
context,
'Photo Grid',
'Grid layout with infinite scrolling',
Icons.photo_library,
() => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const PhotoGridExample()),
),
),
const SizedBox(height: 12),
_buildExampleCard(
context,
'Error Handling',
'Example with network errors and retry',
Icons.error_outline,
() => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ErrorHandlingExample()),
),
),
],
),
);
}
Widget _buildExampleCard(
BuildContext context,
String title,
String subtitle,
IconData icon,
VoidCallback onTap,
) {
return Card(
child: ListTile(
leading: Icon(icon, size: 32),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(subtitle),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: onTap,
),
);
}
}
// Mock Post model
class Post {
final String id;
final String title;
final String content;
final String author;
final DateTime createdAt;
final int likes;
Post({
required this.id,
required this.title,
required this.content,
required this.author,
required this.createdAt,
required this.likes,
});
}
// Basic Posts Feed Example
class PostsFeedExample extends StatefulWidget {
const PostsFeedExample({super.key});
@override
State<PostsFeedExample> createState() => _PostsFeedExampleState();
}
class _PostsFeedExampleState extends State<PostsFeedExample> {
late PaginationController<Post> _controller;
@override
void initState() {
super.initState();
_controller = PaginationController<Post>(
loadMore: _loadMorePosts,
cursorExtractor: (post) => post.id,
config: const PaginationConfig(pageSize: 10, enablePullToRefresh: true),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<PaginationResult<Post>> _loadMorePosts(String? cursor) async {
// Simulate network delay
await Future.delayed(const Duration(milliseconds: 1500));
final startIndex = cursor != null ? int.parse(cursor) : 0;
final posts = List.generate(10, (index) {
final id = (startIndex + index).toString();
return Post(
id: id,
title: 'Amazing Post #${startIndex + index + 1}',
content: 'This is the content of post ${startIndex + index + 1}. '
'It contains some interesting information that users would love to read!',
author: 'User ${(startIndex + index) % 5 + 1}',
createdAt: DateTime.now().subtract(Duration(hours: startIndex + index)),
likes: (startIndex + index) * 3 + 12,
);
});
return PaginationResult<Post>(
items: posts,
nextCursor: (startIndex + 10).toString(),
hasMore: startIndex < 90, // Stop at 100 posts
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Posts Feed'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
// Add a new post at the top
_controller.addItem(
Post(
id: 'new_${DateTime.now().millisecondsSinceEpoch}',
title: 'New Post Added!',
content: 'This post was added in real-time.',
author: 'You',
createdAt: DateTime.now(),
likes: 0,
),
);
},
),
],
),
body: EndlessScrollView<Post>(
controller: _controller,
itemBuilder: (context, post, index) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: Colors.blue,
child: Text(
post.author[0],
style: const TextStyle(color: Colors.white),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.author,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
Text(
_formatTime(post.createdAt),
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
),
),
],
),
),
],
),
const SizedBox(height: 12),
Text(
post.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(post.content),
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.favorite_border,
size: 20,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Text('${post.likes}'),
const SizedBox(width: 16),
Icon(
Icons.comment_outlined,
size: 20,
color: Colors.grey[600],
),
const SizedBox(width: 4),
const Text('12'),
const SizedBox(width: 16),
Icon(
Icons.share_outlined,
size: 20,
color: Colors.grey[600],
),
],
),
],
),
),
);
},
emptyBuilder: (context) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.article_outlined, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
'No posts yet',
style: TextStyle(fontSize: 18, color: Colors.grey),
),
Text(
'Pull down to refresh',
style: TextStyle(color: Colors.grey),
),
],
),
);
},
),
);
}
String _formatTime(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inMinutes < 1) {
return 'Just now';
} else if (difference.inHours < 1) {
return '${difference.inMinutes}m ago';
} else if (difference.inDays < 1) {
return '${difference.inHours}h ago';
} else {
return '${difference.inDays}d ago';
}
}
}
// Photo Grid Example
class PhotoGridExample extends StatefulWidget {
const PhotoGridExample({super.key});
@override
State<PhotoGridExample> createState() => _PhotoGridExampleState();
}
class _PhotoGridExampleState extends State<PhotoGridExample> {
late PaginationController<Photo> _controller;
@override
void initState() {
super.initState();
_controller = PaginationController<Photo>(
loadMore: _loadMorePhotos,
cursorExtractor: (photo) => photo.id,
config: const PaginationConfig(
pageSize: 20,
enableInvisibleItemsThreshold: true,
invisibleItemsThreshold: 5,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<PaginationResult<Photo>> _loadMorePhotos(String? cursor) async {
await Future.delayed(const Duration(milliseconds: 1000));
final startIndex = cursor != null ? int.parse(cursor) : 0;
final photos = List.generate(20, (index) {
final id = (startIndex + index).toString();
return Photo(
id: id,
url: 'https://picsum.photos/300/300?random=${startIndex + index}',
title: 'Photo ${startIndex + index + 1}',
);
});
return PaginationResult<Photo>(
items: photos,
nextCursor: (startIndex + 20).toString(),
hasMore: startIndex < 200,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Photo Grid')),
body: EndlessScrollView<Photo>(
controller: _controller,
itemBuilder: (context, photo, index) {
// Create a grid layout manually
if (index % 2 == 0) {
// Even index: create a row with two photos
final nextPhoto = index + 1 < _controller.state.items.length
? _controller.state.items[index + 1]
: null;
return Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
children: [
Expanded(child: _buildPhotoCard(photo)),
const SizedBox(width: 4),
Expanded(
child: nextPhoto != null
? _buildPhotoCard(nextPhoto)
: const SizedBox(),
),
],
),
);
} else {
// Odd index: already handled in the previous even index
return const SizedBox.shrink();
}
},
loadingBuilder: (context) {
return const Padding(
padding: EdgeInsets.all(20.0),
child: Center(
child: Column(
children: [
CircularProgressIndicator(),
SizedBox(height: 8),
Text('Loading more photos...'),
],
),
),
);
},
),
);
}
Widget _buildPhotoCard(Photo photo) {
return Card(
child: Column(
children: [
AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(4),
),
color: Colors.grey[300],
),
child: const Center(
child: Icon(Icons.image, size: 50, color: Colors.grey),
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
photo.title,
style: const TextStyle(fontSize: 12),
textAlign: TextAlign.center,
),
),
],
),
);
}
}
class Photo {
final String id;
final String url;
final String title;
Photo({required this.id, required this.url, required this.title});
}
// Error Handling Example
class ErrorHandlingExample extends StatefulWidget {
const ErrorHandlingExample({super.key});
@override
State<ErrorHandlingExample> createState() => _ErrorHandlingExampleState();
}
class _ErrorHandlingExampleState extends State<ErrorHandlingExample> {
late PaginationController<Post> _controller;
int _loadCount = 0;
@override
void initState() {
super.initState();
_controller = PaginationController<Post>(
loadMore: _loadWithErrors,
cursorExtractor: (post) => post.id,
config: const PaginationConfig(
pageSize: 5,
maxRetries: 3,
retryDelay: Duration(seconds: 1),
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<PaginationResult<Post>> _loadWithErrors(String? cursor) async {
_loadCount++;
// Simulate network delay
await Future.delayed(const Duration(milliseconds: 1000));
// Simulate errors for demonstration
if (_loadCount == 2 || _loadCount == 5) {
throw Exception('Network error occurred');
}
final startIndex = cursor != null ? int.parse(cursor) : 0;
final posts = List.generate(5, (index) {
final id = (startIndex + index).toString();
return Post(
id: id,
title: 'Post #${startIndex + index + 1}',
content: 'Content for post ${startIndex + index + 1}',
author: 'Author ${(startIndex + index) % 3 + 1}',
createdAt: DateTime.now(),
likes: (startIndex + index) * 2 + 5,
);
});
return PaginationResult<Post>(
items: posts,
nextCursor: (startIndex + 5).toString(),
hasMore: startIndex < 30,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Error Handling'),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Demo Info'),
content: const Text(
'This demo simulates network errors on the 2nd and 5th load attempts. '
'Try scrolling to trigger loading and see how errors are handled.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
},
),
],
),
body: EndlessScrollView<Post>(
controller: _controller,
itemBuilder: (context, post, index) {
return ListTile(
leading: CircleAvatar(child: Text((index + 1).toString())),
title: Text(post.title),
subtitle: Text('By ${post.author} • ${post.likes} likes'),
);
},
loadingBuilder: (context) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: Column(
children: [
CircularProgressIndicator(),
SizedBox(height: 8),
Text('Loading posts...'),
],
),
),
);
},
errorBuilder: (context, error, retry) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red[50],
border: Border.all(color: Colors.red[200]!),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Icon(Icons.error_outline, color: Colors.red[700], size: 32),
const SizedBox(height: 8),
Text(
'Failed to load posts',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red[700],
),
),
const SizedBox(height: 4),
Text(
error,
style: TextStyle(color: Colors.red[600]),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: retry,
icon: const Icon(Icons.refresh),
label: const Text('Try Again'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[700],
foregroundColor: Colors.white,
),
),
],
),
);
},
emptyBuilder: (context) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
'No posts available',
style: TextStyle(fontSize: 18, color: Colors.grey),
),
],
),
);
},
),
);
}
}