Hexsis Infinity Scroll (Core)

Zero-dependency infinite scroll package for Flutter with built-in widgets for ListView, GridView, and Sliver support.

Features

  • Zero dependencies (only Flutter SDK)
  • 🎨 4 built-in widgets: ListView, GridView, SliverList, SliverGrid
  • 🎯 3 fetcher APIs: Simple, Tuple, or Full control
  • 📱 Pull-to-refresh built-in
  • 🔄 Auto-loading with configurable threshold
  • 🎨 Default UI components: Loading, error, empty states
  • Debounced scroll detection
  • 🛡️ Error handling with retry support

Installation

dependencies:
  hexsis_infinity_scroll: ^0.2.3

Quick Start

Ultra-Simple (Auto-detect pagination)

InfiniteScrollListView<Product>(
  simpleFetcher: (page) async {
    return await api.getProducts(page); // Returns List<Product>
  },
  itemBuilder: (context, product, index) {
    return ProductCard(product);
  },
)

With Tuple (Control hasMore)

InfiniteScrollListView<Product>(
  tupleFetcher: (page) async {
    final products = await api.getProducts(page);
    return (items: products, hasMore: products.length >= 20);
  },
  itemBuilder: (context, product, index) {
    return ProductCard(product);
  },
)

Full Control

InfiniteScrollListView<Product>(
  fullFetcher: (page) async {
    final response = await api.getProducts(page);
    return PaginatedData(
      items: response.products,
      currentPage: response.page,
      totalPages: response.totalPages,
    );
  },
  itemBuilder: (context, product, index) {
    return ProductCard(product);
  },
)

Widgets

InfiniteScrollListView

InfiniteScrollListView<Product>(
  simpleFetcher: fetchProducts,
  itemBuilder: (context, item, index) => ProductCard(item),
  
  // Optional customization
  separatorBuilder: (context, index) => Divider(),
  loadingBuilder: (context) => CustomLoadingWidget(),
  errorBuilder: (context, error, retry) => CustomErrorWidget(error, retry),
  emptyBuilder: (context) => CustomEmptyWidget(),
  loadingMoreBuilder: (context) => CustomLoadingMoreWidget(),
  
  config: InfiniteScrollConfig(
    threshold: 0.8,
    initialPage: 1,
    pageSize: 20,
    autoLoadOnInit: true,
    debounceMilliseconds: 300,
  ),
)

InfiniteScrollGridView

InfiniteScrollGridView<Product>(
  simpleFetcher: fetchProducts,
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
    crossAxisSpacing: 8,
    mainAxisSpacing: 8,
  ),
  itemBuilder: (context, item, index) => ProductCard(item),
)

InfiniteScrollSliverList & InfiniteScrollSliverGrid

Note: Sliver widgets require an external ScrollController from the parent CustomScrollView.

class MyScreen extends StatefulWidget {
  @override
  State createState() => _MyScreenState();
}

class _MyScreenState extends State<MyScreen> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
  }

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

  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      controller: _scrollController,  // Pass to CustomScrollView
      slivers: [
        SliverAppBar(title: Text('Products')),
        InfiniteScrollSliverList<Product>(
          scrollController: _scrollController,  // Required!
          simpleFetcher: fetchProducts,
          itemBuilder: (context, item, index) => ProductCard(item),
        ),
      ],
    );
  }
}

For grid layout:

InfiniteScrollSliverGrid<Product>(
  scrollController: _scrollController,  // Same controller!
  simpleFetcher: fetchProducts,
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
  ),
  itemBuilder: (context, item, index) => ProductCard(item),
)

Why ScrollController? Sliver widgets in CustomScrollView can't use NotificationListener internally. Passing the parent ScrollController enables reliable scroll detection with minimal boilerplate.

Controller API (Advanced)

For manual control, use the controller directly:

class ProductListScreen extends StatefulWidget {
  @override
  State<ProductListScreen> createState() => _ProductListScreenState();
}

class _ProductListScreenState extends State<ProductListScreen> {
  late InfiniteScrollController<Product> _controller;

  @override
  void initState() {
    super.initState();
    _controller = InfiniteScrollController<Product>(
      simpleFetcher: fetchProducts,
      config: InfiniteScrollConfig(threshold: 0.8),
    );
    _controller.addListener(() => setState(() {}));
  }

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

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _controller.scrollController,
      itemCount: _controller.items.length,
      itemBuilder: (context, index) {
        return ProductCard(_controller.items[index]);
      },
    );
  }
}

Configuration

InfiniteScrollConfig(
  threshold: 0.8,              // Percentage for small lists
  thresholdPixels: 500.0,      // Fixed pixels from bottom for large lists
  thresholdBreakpoint: 5000.0, // Switch point between strategies
  useSmartThreshold: true,     // Enable smart threshold logic
  initialPage: 1,              // Starting page
  pageSize: 20,                // Items per page (for auto-detection)
  enablePullToRefresh: true,   // Enable pull-to-refresh
  autoLoadOnInit: true,        // Auto-load first page
  debounceMilliseconds: 300,   // Debounce scroll events
)

Smart Threshold Logic

By default, the package uses smart threshold logic to prevent premature loading in long lists:

  • Small lists (<5000px): Uses percentage (default: 80%)
  • Large lists (≥5000px): Uses fixed pixels from bottom (default: 500px)

This ensures consistent loading behavior regardless of list size.

How It Works

List Size Old Behavior Smart Threshold Distance from Bottom
1000px 800px (80%) 800px (80%) 200px ✅
5000px 4000px (80%) 4500px (fixed) 500px ✅
10000px 8000px (80%) 9500px (fixed) 500px ✅
20000px 16000px (80%) 19500px (fixed) 500px ✅

Why this matters: With the old approach, a 20,000px list would start loading when you're still 4,000px away - wasting API calls and loading data the user will never see. Smart threshold keeps it consistent at 500px.

Customize Smart Threshold

InfiniteScrollConfig(
  thresholdPixels: 300.0,       // Closer to bottom
  thresholdBreakpoint: 3000.0,  // Earlier switch point
)

Disable Smart Threshold

InfiniteScrollConfig(
  useSmartThreshold: false,  // Use classic percentage-only behavior
)

Extension Packages

For state management integration:

  • hexsis_infinity_scroll_bloc - BLoC/Cubit support
  • hexsis_infinity_scroll_riverpod - Riverpod support
  • hexsis_infinity_scroll_hooks - Hooks wrappers

Reliability

Pending fetches are safely ignored when their controller or widget is disposed. The package is covered by behavioral tests for pagination strategies, end-of-data detection, retries, errors, duplicate request prevention, refresh, thresholds, and list, grid, and sliver rendering.

License

MIT License - Copyright (c) 2025 Hexsis Enterprise LLC