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