Language: English | Español | Português (BR)
A Flutter widget that provides an infinite scrollable list with built-in support for paginated data loading, state handling, and flexible customization.
Explore the docs »
View on pub.dev
·
Report Bug
·
Request Feature
ScrollInfinity simplifies implementing infinite scroll lists in Flutter. It handles paginated loading, state management (loading, empty, error), and user interactions, letting developers focus on building item UIs.
It offers customization for manual/automatic loading, custom state widgets, and both vertical and horizontal layouts.
| Tool | Version Used |
|---|---|
| Flutter SDK | 3.44.6 |
| Dart SDK | 3.12.2 |
- Infinite scroll with pagination
- Manual or automatic data loading
- Custom "Load More" and "Try Again" builders
- Loading, error, and empty states handling
- Optional scrollbars, header widget, and separators
- Vertical and horizontal scrolling support
- Reversed scroll direction (e.g. chat-style lists)
- Initial items support
- Insert null values at intervals (ads/dividers)
- Retry attempts limit on error
- Real item index mapping with intervals
ScrollInfinityControllerfor external refresh/retry- External
ScrollControllerfor scroll-to-top and offset reading - Pull-to-refresh support
- Configurable load-more trigger threshold
onErrorandonItemsLoadedcallbacks for logging/analyticsonEndOfListcallback andhasReachedEndgetter for the last page- The failure that caused the error state is handed to
tryAgainBuilder - Passthrough of
physics,shrinkWrap,cacheExtent,itemExtent,prototypeItem,addAutomaticKeepAlives,keyboardDismissBehavior, andrestorationIdto the underlyingListView
- Flutter - A UI toolkit by Google for building beautiful, natively compiled applications for mobile, web, and desktop from a single codebase.
- Dart - The programming language used for Flutter, optimized for building fast apps on any platform.
| Requirement | Version |
|---|---|
| Dart SDK | >=3.12.0 <4.0.0 |
| Flutter SDK | >=3.44.0 |
Supports Android, iOS, web, Windows, Linux, and macOS.
Heads up: the minimum SDK was raised to Dart 3.12 / Flutter 3.44. On older SDKs (such as Flutter 3.35)
flutter pub getwill fail to resolve — upgrade the SDK first, or stay onscroll_infinity: 0.5.2.
flutter pub add scroll_infinityPagination requests a new page when the user reaches the list end.
If loadData returns null, the error state is shown.
Note: Use a nullable type T? when using interval, as null values are inserted.
A fully configurable demo with every property wired to UI controls is available in the example directory.
import 'package:flutter/material.dart';
import 'package:scroll_infinity/scroll_infinity.dart';
void main() {
runApp(
const MaterialApp(
debugShowCheckedModeBanner: false,
home: MyApp(),
),
);
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
static const _maxItems = 20;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: ScrollInfinity<int>(
maxItems: _maxItems,
loadData: (page) async {
await Future.delayed(const Duration(seconds: 2));
return List.generate(
_maxItems,
(index) => page * _maxItems + index + 1,
);
},
itemBuilder: (value, index) {
return ListTile(
title: Text('Item $value'),
subtitle: Text('Subtitle $value'),
);
},
),
),
);
}
}import 'package:flutter/material.dart';
import 'package:scroll_infinity/scroll_infinity.dart';
void main() {
runApp(
const MaterialApp(
debugShowCheckedModeBanner: false,
home: MyApp(),
),
);
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
static const _maxItems = 10;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SizedBox(
height: 100.0,
child: ScrollInfinity<int>(
scrollDirection: Axis.horizontal,
maxItems: _maxItems,
loadData: (page) async {
await Future.delayed(const Duration(seconds: 2));
return List.generate(
_maxItems,
(index) => page * _maxItems + index + 1,
);
},
itemBuilder: (value, index) {
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text('Item $value'),
),
);
},
),
),
),
);
}
}import 'package:flutter/material.dart';
import 'package:scroll_infinity/scroll_infinity.dart';
void main() {
runApp(
const MaterialApp(
debugShowCheckedModeBanner: false,
home: MyApp(),
),
);
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
static const _maxItems = 20;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: ScrollInfinity<int?>(
maxItems: _maxItems,
interval: 2,
loadData: (page) async {
await Future.delayed(const Duration(seconds: 2));
return List.generate(
_maxItems,
(index) => page * _maxItems + index + 1,
);
},
itemBuilder: (value, index) {
if (value == null) return const Divider();
return ListTile(
title: Text('Item $value'),
);
},
),
),
);
}
}Use a ScrollInfinityController to trigger refresh()/retry() from outside the widget (e.g. a button) and to read isLoading/hasError. Dispose it in dispose() since you created it.
retry() re-requests the page that failed. It is a no-op when the last fetch succeeded or once maxRetries is exhausted — use refresh() to restart the list instead.
To drive the scroll position instead of pagination, pass a plain ScrollController to scrollController. The list uses an internal one when you omit it, and never disposes the one you pass.
final _scrollController = ScrollController();
void _scrollToTop() {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}final _controller = ScrollInfinityController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScrollInfinity<int>(
controller: _controller,
maxItems: _maxItems,
loadData: _loadData,
itemBuilder: _itemBuilder,
);
}
// Elsewhere, e.g. a FloatingActionButton:
onPressed: () => _controller.refresh(),loadData is a Function, so passing an inline closure (e.g. loadData: (page) => ...) creates a new instance on every rebuild — comparing it by identity would reset the list on every parent setState, which is rarely what you want. Because of that, changing loadData alone does not reset the list.
To load a different data set (e.g. switching category or search query), either:
- Call
_controller.refresh()explicitly when the source changes, or - Give the
ScrollInfinitya newKey(e.g.ValueKey(category)) so Flutter remounts it from scratch.
ScrollInfinity<int>(
key: ValueKey(_category),
maxItems: _maxItems,
loadData: (page) => _loadData(_category, page),
itemBuilder: _itemBuilder,
)Set enablePullToRefresh to wrap the list in a RefreshIndicator that resets pagination back to initialPageIndex.
ScrollInfinity<int>(
enablePullToRefresh: true,
maxItems: _maxItems,
loadData: _loadData,
itemBuilder: _itemBuilder,
)Set automaticLoading to false to show a "Load More" button instead of fetching automatically on scroll. Customize it with loadMoreBuilder.
ScrollInfinity<int>(
automaticLoading: false,
loadMoreBuilder: (action) {
return TextButton(
onPressed: action,
child: const Text('Load more'),
);
},
maxItems: _maxItems,
loadData: _loadData,
itemBuilder: _itemBuilder,
)Use onError to log/report failures — the exception thrown by loadData, or a synthetic Exception when loadData returns null instead of throwing — and onItemsLoaded to observe successfully fetched items (e.g. analytics). Neither affects the build.
onEndOfList fires once the last page arrives, that is, when loadData returns fewer items than maxItems. A reset starts a new cycle that can reach the end again. To read the same state instead of reacting to it, use ScrollInfinityController.hasReachedEnd.
ScrollInfinity<int>(
onError: (error) => log('Failed to load items', error: error),
onItemsLoaded: (items) => analytics.logEvent('items_loaded', {'count': items.length}),
onEndOfList: () => log('All pages loaded'),
maxItems: _maxItems,
loadData: _loadData,
itemBuilder: _itemBuilder,
)Core Data Handling
| Name | Type | Default | Description |
|---|---|---|---|
| loadData | Future<List<T>?> Function(int) |
- | Fetch data for each page |
| itemBuilder | Widget Function(T value, int index) |
- | Builds each item. index is mapped by useRealItemIndex/interval |
| maxItems | int |
- | Max items per request |
| initialItems | List<T>? |
null | Items before first fetch. Does not advance pagination: set initialPageIndex past them to avoid refetching the same page. Applied on init and on reset only |
| initialPageIndex | int |
0 | Starting page index. Changing it resets the list |
| controller | ScrollInfinityController? |
null | External refresh/retry and loading/error state |
| scrollController | ScrollController? |
null | External scroll position (scroll-to-top, offset). Owned by the caller; internal one is used when null |
| onItemsLoaded | void Function(List<T> items)? |
null | Called with fetched items on each successful load |
| onEndOfList | VoidCallback? |
null | Called once the last page has been loaded |
Layout & Appearance
| Name | Type | Default | Description |
|---|---|---|---|
| scrollDirection | Axis |
vertical | Scroll direction |
| reverse | bool |
false | Reverses scroll/growth direction (e.g. chat lists) |
| padding | EdgeInsetsGeometry? |
null | Internal padding |
| header | Widget? |
null | Header widget |
| separatorBuilder | Widget Function(BuildContext, int)? |
null | Separators. index is the raw display position, unaffected by useRealItemIndex/interval |
| scrollbars | bool |
true | Show scrollbars |
| enablePullToRefresh | bool |
false | Wraps list in a RefreshIndicator |
| physics | ScrollPhysics? |
null | Passed to the underlying ListView |
| shrinkWrap | bool |
false | Passed to the underlying ListView |
| cacheExtent | double? |
null | Passed to the underlying ListView |
| itemExtent | double? |
null | Fixed extent for every child, header and footer included. Not allowed with prototypeItem or separatorBuilder |
| prototypeItem | Widget? |
null | Sizes every child after this widget. Same restrictions as itemExtent |
| addAutomaticKeepAlives | bool |
true | Passed to the underlying ListView |
| keyboardDismissBehavior | ScrollViewKeyboardDismissBehavior? |
null | Keyboard dismissal while dragging; falls back to the ambient ScrollBehavior |
| restorationId | String? |
null | Restores the scroll offset (not the loaded pages) |
Behavioral Features
| Name | Type | Default | Description |
|---|---|---|---|
| interval | int? |
null | Null item insertion interval |
| useRealItemIndex | bool |
true | Independent indexing |
| automaticLoading | bool |
true | Auto-fetch on scroll |
| loadMoreThreshold | double |
200 | Distance from list end that triggers the next page |
Error Handling
| Name | Type | Default | Description |
|---|---|---|---|
| maxRetries | int? |
null | Retries allowed after a failed fetch, not counting the initial attempt. 0 makes the failure final; pair it with retryLimitReached: const SizedBox.shrink() to fail silently |
| onError | void Function(Object error)? |
null | Called on failure, with the thrown exception or a synthetic one when loadData returns null |
State-Specific Widgets
| Name | Type | Default | Description |
|---|---|---|---|
| loading | Widget? |
null | Loading state widget |
| empty | Widget? |
null | Empty state widget |
| tryAgainBuilder | Widget Function(Object, VoidCallback)? |
null | "Try Again" widget, built with the failure that caused the error state |
| loadMoreBuilder | Widget Function(VoidCallback)? |
null | "Load More" widget |
| retryLimitReached | Widget? |
null | Retry limit reached widget |
-
Fork the project
-
Create a feature branch:
git checkout -b feature/AmazingFeature
-
Commit changes:
git commit -m 'Add some AmazingFeature' -
Push to branch:
git push origin feature/AmazingFeature
-
Open a Pull Request
Distributed under the MIT License. See the LICENSE file for more information.
Developed by Dário Matias:
- Portfolio: https://dariomatias-dev.com
- GitHub: https://github.com/dariomatias-dev
- Email: matiasdario75@gmail.com
- Instagram: https://instagram.com/dariomatias_dev
- LinkedIn: https://linkedin.com/in/dariomatias-dev