scrolled_pagination 1.4.1
scrolled_pagination: ^1.4.1 copied to clipboard
A reusable MVC-style infinite-scroll pagination toolkit for Flutter with multiple providers (Future/Stream/sync), page or cursor-based pagination, reach-end state, item operations, batch transactions, [...]
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 eight 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.
Features #
- Many providers —
futureProvider,streamProvider,syncProviderall normalise to one shape. - Page & cursor pagination — return
nextPageParamfor cursors, or let the controller auto-increment anintpage. - Eight builders —
itemBuilder,separatorBuilder,firstPageLoadingBuilder,loadMoreBuilder,emptyBuilder,errorBuilder,noMoreBuilder,reachEndBuilder. - Reach-end state —
hasReachedEndflipstrueonce nothing more can be loaded (recomputed after everyloadMore/refresh);loadMore()is then a no-op. Detected from a customdetectReachEnd, thehasMoreflag, an empty next page, or a short page. An optionalreachEndBuilderfooter shows a custom "No more items" end marker. - 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. - Multi-section Sliver engine —
ScrolledPaginationViewbuilds ONECustomScrollViewfrom many independentScrolledPaginationSections (future / stream / hybrid · list / grid · static / custom). Each section owns its state and rebuilds alone; hybrid sections merge realtime stream updates into a paginated list without duplicating or clearing it;addBatchadds many items at once with configurable insert + duplicate strategies. See Multi-section Sliver view. - 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(),
reachEndBuilder: (context) => const NoMoreItems(), // optional · shown when hasReachedEnd
);
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 (hasReachedEnd) |
reachEndBuilder → 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.
Multi-section Sliver view #
Everything above renders one list with PaginatedScrollView. When a single
screen needs several independent lists / grids in one scroll — a club home,
a profile, a mixed home feed — use ScrolledPaginationView. It builds a single
CustomScrollView out of ScrolledPaginationSections; each section owns its
state and is wrapped in its own ListenableBuilder, so a realtime update or a
"load more" in one section rebuilds only that section.
final controller = ScrolledPaginationController();
ScrolledPaginationView(
controller: controller,
slivers: [
// 1 · Future-paginated list
ScrolledPaginationSection.futureList<Post>(
future: postsRepository.loadInitialPosts, // Future<List<Post>>
loadMore: postsRepository.loadMorePosts, // returns [] at the end
idOf: (post) => post.id,
itemBuilder: (context, post) => PostCard(post: post),
),
// 2 · Realtime stream list (own subscription, own rebuilds)
ScrolledPaginationSection.streamList<Event>(
stream: eventsStream, // Stream<List<Event>>
idOf: (event) => event.id,
itemBuilder: (context, event) => EventCard(event: event),
),
// 3 · Hybrid: Future for paging + Stream for realtime
ScrolledPaginationSection.hybridList<News>(
initialFuture: newsRepository.loadInitialNews,
loadMore: newsRepository.loadMoreNews,
realtimeStream: newsRealtimeStream,
idOf: (news) => news.id,
mergeStrategy: RealtimeMergeStrategy.prepend, // new items on top
itemBuilder: (context, news) => NewsCard(news: news),
),
// 4 · Hybrid grid (gallery)
ScrolledPaginationSection.hybridGrid<Album>(
initialFuture: albumRepository.loadInitialAlbums,
loadMore: albumRepository.loadMoreAlbums,
realtimeStream: albumsRealtimeStream,
idOf: (album) => album.id,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
itemBuilder: (context, album) => AlbumCard(album: album),
),
// 5 · A plain box wedged between the lists
ScrolledPaginationSection.staticBox(child: const SuggestedClubsSection()),
],
);
Sections #
| Factory | Source | Renders |
|---|---|---|
futureList / futureGrid |
Future initial + loadMore |
paginated list / grid |
streamList / streamGrid |
Stream<List<T>> |
realtime list / grid |
hybridList / hybridGrid |
Future + Stream |
paginated + realtime |
localList |
in-memory List<T> |
static list |
staticBox / staticSliver |
a widget / a sliver | non-data block |
loading / empty / errorState |
— | standalone status block |
customSliver |
your builder | any sliver |
Every data factory takes idOf, an itemBuilder(context, item), and optionally
sortManager, separatorBuilder, sectionHeaderBuilder (+ pinnedHeader),
loadingBuilder, emptyBuilder, errorBuilder, footerBuilder and padding.
Reach a section's controller #
final feed = ScrolledPaginationSection.futureList<Post>(/* … */);
// later, imperatively:
feed.controller!.addBatch(incoming);
feed.controller!.updateFirstWhere((p) => p.id == id, (p) => p.copyWith(liked: true));
feed.controller!.moveToFirst(pinnedPost);
addBatch — many items, one rebuild #
section.controller!.addBatch(
items,
strategy: BatchInsertStrategy.append, // append · prepend · insertAtIndex · sortedInsert · custom
duplicateStrategy: DuplicateStrategy.replace, // skip · replace · update · custom
);
addBatch adds the whole list in one pass and notifies once. Items whose
idOf already exists are resolved by duplicateStrategy (e.g. update merges
via the section's mergeItem); genuinely new items are placed by strategy.
Visible unchanged rows are not rebuilt.
Hybrid merge rules #
For hybridList / hybridGrid the Future owns initial load + pagination and
the Stream owns realtime changes. On each emission, items whose id already
exists are updated in place, new ids are inserted per mergeStrategy
(append · prepend · replaceExistingOnly · sortedInsert · custom). The
paginated list is never cleared and items are never duplicated (identity
by idOf). Pass mergeItem to control how a colliding pair is combined, or
mergeList for a fully custom merge.
Why it stays cheap #
- One
CustomScrollView; no nested scroll views, noshrinkWrap, nochildren: [...]— every section uses lazySliverChildBuilderDelegates. - Each section listens to its own
SectionController, so unrelated sections never rebuild. - Rows are keyed by
ValueKey(idOf(item))with afindChildIndexCallback, so appending a page or merging a realtime update leaves visible rows mounted. - The optional
SortManagerand focus manager follow items by id across inserts,addBatch, pagination and realtime updates.
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