scrolled_pagination 1.0.0
scrolled_pagination: ^1.0.0 copied to clipboard
A reusable, MVC-style infinite-scroll pagination toolkit for Flutter — one controller, many providers (Future/Stream/sync), page or cursor based, with seven builders, item ops, scroll actions and an o [...]
scrolled_pagination #
A reusable, MVC-style infinite-scroll pagination toolkit for Flutter.
One controller (the model) drives any number of views. The controller owns
paging state, item operations, scroll actions and an optional focus manager; the
PaginatedScrollView renders the list through seven swappable
builders and executes the controller's scroll intents.
Works with
Future,Streamand synchronous sources · page or cursor based ·ListView/GridView/ separated /sliver· pull-to-refresh · retry · skeletons · safe on empty lists and out-of-range indices.
Works with
Future,Streamand synchronous sources · page or cursor based ·ListView/GridView/ separated / sliver · pull-to-refresh · retry · skeletons · safe on empty lists and out-of-range indices.
Features #
- Many providers —
futureProvider,streamProvider,syncProviderall normalise to one shape. - Page & cursor pagination — return
nextPageParamfor cursors, or let the controller auto-increment anintpage. - Seven builders —
itemBuilder,separatorBuilder,firstPageLoadingBuilder,loadMoreBuilder,emptyBuilder,errorBuilder,noMoreBuilder. - Eight states —
idle,firstLoading,refreshing,loadingMore,success,empty,error,noMoreData. - Item operations that never rebuild the whole list — add / insert / update / delete / replace / move / swap, by key, index or predicate.
- Scroll actions —
jumbTo(instant),animateTo/moveTo(animated),ensureVisibleAt, withalignment,duration,curveandoffsetoptions. - Focus manager — focus by index/predicate, next/previous, kept and restored across refresh.
- Nine callbacks —
onItemsChanged,onItemInserted,onItemUpdated,onItemDeleted,onItemReplaced,onItemMoved,onFocusChanged,onJumbFailed,onIndexOutOfRange. - Robust — duplicate-request guard, stale-response cancellation on refresh, safe on empty / out-of-range, waits until the scroll view is attached and the row is built.
Getting started #
Add the dependency:
dependencies:
scrolled_pagination: ^1.0.0
Import it:
import 'package:scrolled_pagination/scrolled_pagination.dart';
Usage #
1 · Create a controller (the model) #
final controller = PaginationController<Post>(
pageSize: 20,
initialPageParam: 1, // cursor mode: pass a cursor / null
getKey: (post) => post.id, // stable identity → in-place updates + focus
onError: (e, st) => e, // optional custom error transform
provider: futureProvider<Post>((ctx) async {
final res = await api.feed(page: ctx.pageParam, size: ctx.pageSize);
return PaginationPage(
items: res.data,
nextPageParam: ctx.pageParam as int + 1,
hasMore: res.hasMore,
);
}),
);
Mapping raw JSON to your model is done inside the provider — that keeps the generic type honest end-to-end.
2 · Render it (the view) #
PaginatedScrollView<Post>(
controller: controller,
layout: PaginationLayout.list, // .list · .separated · .grid · .sliver
padding: const EdgeInsets.all(16),
gap: 12,
loadMoreThreshold: 320,
pullToRefresh: true,
itemBuilder: (context, post, index) => PostCard(post: post),
separatorBuilder: (context, index) => const Divider(),
firstPageLoadingBuilder: (context) => const SkeletonList(),
loadMoreBuilder: (context) => const Center(child: CircularProgressIndicator()),
emptyBuilder: (context, refresh) => EmptyState(onRefresh: refresh),
errorBuilder: (context, error, retry) => ErrorCard(error: error, onRetry: retry),
noMoreBuilder: (context) => const CaughtUp(),
);
The view triggers the first load automatically and calls loadMore when the
scroll position is within loadMoreThreshold of the end. Dispose the controller
in your State.dispose().
3 · Item operations #
All operations notify only the affected rows — the rest of the list is not rebuilt.
controller.addItem(post, prepend: true);
controller.insertItem(2, post);
controller.insertItems(0, [a, b, c]);
controller.updateItem(post.id, (p) => p.copyWith(liked: true));
controller.updateWhere((p) => p.pinned, (p) => p.copyWith(seen: true));
controller.updateFirstWhere((p) => p.id == id, (p) => p.copyWith(liked: true));
controller.removeItem(post.id);
controller.deleteWhere((p) => p.archived);
controller.deleteFirstWhere((p) => p.id == id);
controller.replaceAt(1, post);
controller.replaceFirstWhere((p) => p.id == id, post);
controller.replaceWhere((p) => p.draft, (p) => p.copyWith(draft: false));
controller.replaceAll(nextPosts);
controller.moveItem(0, 5);
controller.moveToFirst(4);
controller.moveToLast(4);
controller.swapItems(0, 3);
controller.clear();
4 · Scroll actions #
// instant
await controller.jumbTo(20);
controller.jumbToFirst();
controller.jumbToLast();
controller.jumbWhere((p) => p.id == id);
// animated (moveTo is an alias of animateTo)
controller.animateTo(20, options: const ScrollOptions(
alignment: 0.5, // 0 top · 0.5 center · 1 bottom
duration: Duration(milliseconds: 500),
curve: Curves.easeOut,
offset: -8,
));
controller.animateToFirst();
controller.animateToLast();
// only scroll if the row is off-screen
controller.ensureVisibleAt(8);
controller.ensureVisibleWhere((p) => p.id == id);
Every scroll action returns a Future<bool>, is safe when the list is empty
or the index is out of range (fires onIndexOutOfRange + onJumbFailed), and
waits until the scroll view is attached and the target row is built.
5 · Focus manager (optional) #
final controller = PaginationController<Post>(
// …
openFocusManager: true,
restoreFocusOnRefresh: true, // keep focus across refresh
onFocusChanged: (index, item) => …,
);
controller.focusAt(3);
controller.focusWhere((p) => p.id == id);
controller.focusFirst();
controller.focusLast();
controller.focusNext();
controller.focusPrevious();
controller.clearFocus();
controller.hasFocus; // bool
controller.focusedIndex; // int (-1 when none)
controller.focusedItem; // T?
controller.focusLastOpeningTime; // DateTime?
The focused row is drawn with a ring (override via
PaginatedScrollView.focusedDecoration).
6 · Callbacks #
PaginationController<Post>(
onItemsChanged: (items) => …,
onItemInserted: (index, item) => …,
onItemUpdated: (index, item) => …,
onItemDeleted: (index, item) => …,
onItemReplaced: (index, item) => …,
onItemMoved: (from, to) => …,
onFocusChanged: (index, item) => …,
onJumbFailed: (failure) => …,
onIndexOutOfRange: (index) => …,
);
Providers #
| Adapter | Source | Notes |
|---|---|---|
futureProvider(fn) |
Future<PaginationPage<T>> |
The default — async/await. |
syncProvider(fn) |
PaginationPage<T> |
In-memory slicing, no awaiting. |
streamProvider(fn) |
Stream<PaginationPage<T>> |
Resolves on the first emission. |
provider: syncProvider<Post>((ctx) {
final start = ((ctx.pageParam as int) - 1) * ctx.pageSize;
final slice = all.skip(start).take(ctx.pageSize).toList();
return PaginationPage(items: slice, hasMore: start + slice.length < all.length);
});
States #
| State | When | Builder |
|---|---|---|
idle |
Never loaded | — |
firstLoading |
First page in flight | firstPageLoadingBuilder |
refreshing |
Pull-to-refresh in flight | items stay visible |
loadingMore |
Next page in flight | loadMoreBuilder (footer) |
success |
Items present, more available | itemBuilder |
empty |
Loaded, zero items | emptyBuilder |
error |
A load failed | errorBuilder |
noMoreData |
Items present, nothing left | noMoreBuilder (footer) |
PaginatedScrollView options #
| Option | Default | Description |
|---|---|---|
layout |
list |
list · separated · grid · sliver |
crossAxisCount |
2 |
Columns for grid |
childAspectRatio |
0.78 |
Grid cell ratio |
reverse |
false |
Chat-style (load at top) |
shrinkWrap |
false |
Size to content |
physics |
platform | Custom ScrollPhysics |
padding |
EdgeInsets.all(16) |
Content padding |
gap |
12 |
Spacing between list rows |
loadMoreThreshold |
320 |
Pixels from the end to load more |
pullToRefresh |
true |
Wrap in a RefreshIndicator |
scrollController |
own | Bring your own controller |
Notes & limitations #
- Index-based scrolling uses
Scrollable.ensureVisibleonce the target row is built, nudging the scroll position toward an estimated offset until then. For very large lists where you need pixel-perfect jumbs to far-off-screen indices, pair it with a positioned-list package and feed its controller viascrollController. sliverlayout renders an internalCustomScrollView; pull-to-refresh is disabled inreversemode.
Using with AI coding agents #
Ready-made skill packs live under skill/:
skill/claude_code/SKILL.md— an Agent Skill for Claude Code.skill/chatgpt_codex/AGENTS.md— anAGENTS.mdguide for ChatGPT Codex.
Both document the full API, providers, layouts, recipes and pitfalls so an agent can wire up pagination correctly in one pass.
License #
MIT