scrolled_pagination 1.2.0
scrolled_pagination: ^1.2.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.
- Batch updates —
updateAll,insertItemsBatch,replaceItemsBatch,deleteItemsBatch,moveItemsBatchand a composabletransaction(...): all changes are applied first, the UI is notified exactly once. - Optional
SortManager(disabled by default) — stable sorting by date, id, index, priority or any custom field / comparator, ascending or descending, kept sorted across inserts, updates and batch operations. - Diff-based rendering — stable item keys, a built-row cache and
shouldRebuildItem(old, new)keep existing visible rows mounted; only added / removed / changed rows rebuild. Scroll position, focus and selection survive insert / update / delete / sort. - Key-based selection —
select/deselect/toggleSelected/selectedItems, preserved across every mutation and refresh. - Bloc bindings —
PaginationBloc(events) andPaginationCubit(methods) over the same controller, emitting immutable, value-equalPaginationBlocStates; batch operations emit exactly one state. - 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. - Controller cache —
ScrolledPaginationControllerFactorykeeps one controller alive perkey+ type + user, so re-opened lists are not re-fetched from scratch. - 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.2.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();
3b · Batch updates — one notify for many changes #
Every batch method applies all changes to the data first, then notifies the UI exactly once. Nothing is emitted per item, so a 50-item batch costs one rebuild pass instead of fifty.
controller.updateAll((p) => p.copyWith(seen: true));
controller.insertItemsBatch([
BatchInsert(0, a),
BatchInsert(5, b),
]);
controller.replaceItemsBatch([
BatchReplace(post1.id, post1Updated),
BatchReplace(post2.id, post2Updated),
]);
controller.deleteItemsBatch([post1.id, post2.id, post3.id]);
controller.moveItemsBatch([BatchMove(0, 5), BatchMove(2, 0)]);
// compose anything into one transaction — single notify at the end
controller.transaction(() {
controller.addItem(incoming);
controller.removeItem(stale.id);
controller.updateItem(other.id, (p) => p.copyWith(liked: true));
});
When a SortManager is enabled, the final list is re-sorted once, after
the whole transaction — not after every step.
3c · SortManager (optional — off by default) #
final sort = SortManager<Post>.byDate((p) => p.createdAt,
order: SortOrder.descending, enabled: true);
final controller = PaginationController<Post>(
// …
sortManager: sort, // omit (or keep disabled) → insertion order
);
// other constructors
SortManager<Post>.byId((p) => p.id);
SortManager<Post>.byIndex((p) => p.sequence);
SortManager<Post>.byPriority((p) => p.priority);
SortManager<Post>.byField((p) => p.title);
SortManager<Post>.custom((a, b) => a.score.compareTo(b.score));
// flip direction / comparator at runtime, then re-sort once
sort.order = SortOrder.ascending;
controller.resort();
The sort is stable and runs after load, insert, update, replace and batch operations, so the list stays sorted even when items arrive out of order. Focus and selection follow their items to the new positions.
3d · Selection (key-based) #
controller.select(post.id);
controller.toggleSelected(post.id);
controller.isSelected(post); // or isKeySelected(post.id)
controller.selectedItems; // in current list order
controller.selectAll();
controller.clearSelection();
Selection is stored by key, so it survives insert, update, delete (of other items), sort and refresh. Deleting a selected item prunes its key.
3e · Bloc state management (optional) #
If your app uses bloc / flutter_bloc, import the bloc entrypoint and wrap
the controller in a PaginationBloc (events) or a PaginationCubit
(methods). The controller stays the single source of truth; every
notification becomes exactly one immutable PaginationBlocState — batch
operations included.
import 'package:scrolled_pagination/scrolled_pagination_bloc.dart';
// 1 · Event-driven
final bloc = PaginationBloc<Post>(
controller: PaginationController<Post>(
pageSize: 20,
getKey: (p) => p.id,
sortManager: SortManager<Post>.byDate((p) => p.createdAt, enabled: true),
provider: futureProvider<Post>((ctx) => repo.feed(ctx)),
),
)..add(const PaginationStarted());
bloc.add(PaginationItemUpdated(post.id, (p) => p.copyWith(liked: true)));
bloc.add(const PaginationItemsBatchDeleted(['a', 'b', 'c'])); // ONE state
bloc.add(const PaginationSortChanged(order: SortOrder.descending));
bloc.add(const PaginationItemSelected('a'));
// 2 · Render — the view takes the wrapped controller
BlocProvider.value(
value: bloc,
child: BlocBuilder<PaginationBloc<Post>, PaginationBlocState<Post>>(
builder: (context, state) => PaginatedScrollView<Post>(
controller: context.read<PaginationBloc<Post>>().controller,
itemBuilder: (ctx, post, i) => PostCard(post: post),
),
),
);
// 3 · Or the cubit flavour — same states, direct methods
final cubit = PaginationCubit<Post>(controller: …, autoLoad: true);
cubit.updateAll((p) => p.copyWith(seen: true)); // one emitted state
cubit.deleteItemsBatch(ids); // one emitted state
Notes:
PaginationBlocStateis value-equal, soBlocBuilder/BlocSelectorskip rebuilds when nothing changed.- Scroll actions return
Future<bool>— call them onbloc.controller(or via the cubit's delegates:cubit.animateTo(20)). - The bloc/cubit owns the controller by default (
closeController: true) and disposes it inclose(). - The package depends only on
bloc; addflutter_blocto your app forBlocProvider/BlocBuilder.
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) => …,
);
7 · Reuse controllers across screens (optional) #
ScrolledPaginationControllerFactory is a static cache that keeps one controller
alive per key + type + userId. Reopening the same list (a tab you return to,
a detail page you revisit) reuses the cached controller and refreshes it instead
of building a new one and re-fetching from page one.
final controller = ScrolledPaginationControllerFactory.create<Post>(
key: 'club-feed:$clubId', // logical identity of THIS list
userId: session.userId, // scopes the cache per signed-in user
builder: () => PaginationController<Post>(
pageSize: 20,
getKey: (p) => p.id,
provider: futureProvider<Post>((ctx) => repo.feed(ctx)),
),
);
| Method | Behaviour |
|---|---|
create<T>({key, userId, builder, force = false}) |
force: false reuses the cached controller and calls refresh() (builds + caches if none); force: true disposes the old entry and builds a fresh one. |
get<T>({key, userId}) |
The cached PaginationController<T>?, or null — no side effects. |
dispose<T>({key, userId}) |
Disposes + removes one entry; returns true if it existed. |
disposeAll() |
Disposes every cached controller and clears the cache. |
When a controller is factory-owned, do not dispose it in
State.dispose()— the cache owns its lifecycle. Release entries withdispose<T>or calldisposeAll()on sign-out. The cache key is'$key-$T-$userId', so keepT,keyanduserIdconsistent for the same list.
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 |
shouldRebuildItem |
null |
(old, new) => bool — return false to keep a row alive when its visible data did not change |
preserveScrollPosition |
true |
Keep the first visible row anchored after inserts / deletes / sorts above it |
How rows stay alive (diff-based updates) #
- Every row is keyed by
getKey(item)— stable identity, not index. - A row whose item is
identicalto the previous build is reused as the same widget instance, so Flutter skips it entirely; pagination state lives apart from item state, so loads never invalidate rows either. - When an item instance changes,
shouldRebuildItem(old, new)decides whether the row really rebuilds (default: it does). findChildIndexCallbackmatches moved rows to their existing elements, so internal row state (text fields, animations) survives reorders and sorts.
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
