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 == and hashCode for your models to optimize updates
  • Consider using AutomaticKeepAliveClientMixin for complex items
  • Use RepaintBoundary for 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.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

License

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

Support


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