scrolled_pagination 1.9.0
scrolled_pagination: ^1.9.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, eight builders, hasReachedEnd, item + batch ops, key-b [...]
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,mergedStreamProvider,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. - Static leading / trailing widgets — optional fixed widgets rendered
before the first item / after the last (
setLeadingWidget/setTrailingWidget/clearStaticWidgets, or as view props). They are real widgets, not data items — never counted incount, never shifted by insert / update / delete / sort / refresh — withshowLeadingWhenEmpty/showTrailingWhenEmpty/showLeadingWhenLoading/showTrailingWhenLoadingandtrailingPosition. See Static widgets. - Optional scroll-navigation FABs — opt-in
scrollToFirstFloatingButton/scrollToLastFloatingButtonthat scroll to the first / last item. Disabled by default, independent of the pagination data, with custom icon / tooltip / margin / shape / colours,regular·small·extended·customstyles, auto-hide-at-edge or always-visible modes, and forwardedfloatingActionButtonLocation/floatingActionButtonAnimator. Toggling visibility never rebuilds the list. See Scroll-navigation floating buttons. - 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; multiple realtime sources can be merged / combined per section with debounce, throttle and distinct (rxdart-backed — see Realtime stream merging);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. Index targeting is pixel-accurate, powered byscrollview_observerbehind a thin adapter, with the previous estimate-and-retry as an automatic fallback. - Visible-item tracking —
firstVisibleIndex,lastVisibleIndex,visibleIndices,visibleItems,isIndexVisible(i)and anonVisibleItemsChangedcallback, fed by the scroll observer. Reading them never rebuilds the list — perfect for analytics, prefetch, read-receipts and autoplay. See Visible-item tracking. - 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.7.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.
3f · Static leading / trailing widgets #
Frame the list with fixed widgets that are not part of the data. The
leadingWidget renders before the first item, the trailingWidget after the
last. They are real widgets — never counted in count / items, never moved by
insert / update / delete / replace / sort / refresh — so itemBuilder indexes
and every positional / predicate op keep addressing data items only.
// 1 · As controller config (initial values + options)
final controller = PaginationController<Post>(
// …
leadingWidget: const PinnedBanner(),
trailingWidget: const EndOfFeedCard(),
showLeadingWhenEmpty: true, // keep leading on the empty state
showTrailingWhenEmpty: false,
showLeadingWhenLoading: false, // keep leading during first-page load
showTrailingWhenLoading: false,
trailingPosition: StaticTrailingPosition.afterReachEndBuilder,
// … or .beforeReachEndBuilder (above the reach-end footer)
);
// 2 · Or imperatively at runtime — one notify, the rows are NOT rebuilt
controller.setLeadingWidget(const PinnedBanner());
controller.removeLeadingWidget();
controller.setTrailingWidget(const EndOfFeedCard());
controller.removeTrailingWidget();
controller.clearStaticWidgets();
controller.hasLeadingWidget; // bool
controller.hasTrailingWidget; // bool
// 3 · Or as PaginatedScrollView props (the controller value wins)
PaginatedScrollView<Post>(
controller: controller,
leadingWidget: const PinnedBanner(),
trailingWidget: const EndOfFeedCard(),
showLeadingWhenEmpty: true,
trailingPosition: StaticTrailingPosition.beforeReachEndBuilder,
itemBuilder: (ctx, post, i) => PostCard(post: post),
);
Setting a static widget calls notifyListeners without firing
onItemsChanged or re-sorting: only the static-widget area rebuilds, the data
rows keep their cached widgets, and scroll position is preserved when a static
widget is added or removed. By default the emptyBuilder and
firstPageLoadingBuilder are not replaced — opt in with the show*When…
options. Works across ListView, GridView, separated and SliverList
layouts; every method is null- and empty-list-safe.
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).
5b · Visible-item tracking #
The view observes which rows are on screen (via
scrollview_observer) and pushes
the range into the controller. Read it any time, or react with the
onVisibleItemsChanged callback — neither rebuilds the list, so it stays smooth
while you scroll.
final controller = PaginationController<Post>(
// …
onVisibleItemsChanged: (firstIndex, lastIndex, indices) {
analytics.logImpressions([for (final i in indices) controller.items[i].id]);
if (lastIndex >= controller.count - 5) prefetchNextBatch();
},
);
controller.firstVisibleIndex; // int (-1 until the first observation)
controller.lastVisibleIndex; // int
controller.visibleIndices; // List<int>, ascending
controller.visibleItems; // List<T>, in list order
controller.isIndexVisible(8); // bool
controller.hasVisibilityInfo; // bool — false in headless tests / before layout
This is read-only on the controller (the view feeds it). When no view is
attached — headless provider tests — the range stays "unknown" (-1 / empty),
so your data-layer tests are unaffected.
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.
8 · Scroll-navigation floating action buttons #
Two optional, opt-in floating action buttons that scroll the list to its
first / last item. Both are disabled by default — pass a ScrollEdgeFab to
turn one on. They are independent of the pagination data: tapping one runs a
scroll action on the controller and never touches the items, selection or focus,
and toggling their visibility never rebuilds the list.
PaginatedScrollView<Post>(
controller: controller,
itemBuilder: (ctx, post, i) => PostCard(post: post),
// scroll to the FIRST item — auto-hides while already at the top
scrollToFirstFloatingButton: const ScrollEdgeFab(
icon: Icon(Icons.arrow_upward),
tooltip: 'Back to top',
type: ScrollFabType.small, // regular · small · extended · custom
),
// scroll to the LAST item — an always-visible extended FAB that jumps
scrollToLastFloatingButton: const ScrollEdgeFab(
icon: Icon(Icons.arrow_downward),
label: Text('Latest'),
type: ScrollFabType.extended,
visibility: ScrollFabVisibility.always, // auto (default) · always
animate: false, // jump instead of animate
),
// placement + entrance animation (forwarded to an internal Scaffold)
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
floatingActionButtonAnimator: FloatingActionButtonAnimator.scaling,
floatingActionButtonSpacing: 12, // gap when BOTH buttons show
);
- One or two buttons — set either or both; omit one to render a single FAB.
- Styles —
ScrollFabType.regular·small·extended(icon +label) ·custom(supplybuilder: (ctx, onPressed) => YourWidget(...)). - Customisation —
icon,label,tooltip,margin,extendedPadding,heroTag,backgroundColor,foregroundColor,shape,elevation. - Behaviour —
animate(animate vs. jump) and per-buttonscrollOptions(alignment / duration / curve / offset). - Visibility —
ScrollFabVisibility.auto(default) hides the first button while already at the top and the last button while already at the end;alwayskeeps it visible.floatingActionButtonHideThresholdtunes the edge slack. - No list rebuilds — the FAB overlay is a sibling of the list (hosted by a
transparent
Scaffold); only it re-renders when the edge state flips, so pagination state, scroll position, focus and selection are preserved. Works across List, Grid, Separated, Reverse and Sliver layouts.
The buttons call controller.animateToFirst() / jumpToFirst() and
animateToLast() / jumpToLast() under the hood — you can also call those
directly:
controller.jumpToFirst(); // instant (alias of jumbToFirst)
controller.animateToFirst(); // animated
controller.jumpToLast(); // instant (alias of jumbToLast)
controller.animateToLast(); // animated
The same scrollToFirstFloatingButton / scrollToLastFloatingButton props are
available on ScrolledPaginationView (the multi-section engine), where they
scroll the shared CustomScrollView to the top / bottom.
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. |
mergedStreamProvider([fn, fn]) |
several Stream<PaginationPage<T>> |
One page assembled from many backends (rxdart combineLatest; de-dupes by idOf). |
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 |
leadingWidget |
null |
Static widget before the first item (controller value wins) |
trailingWidget |
null |
Static widget after the last item (controller value wins) |
showLeadingWhenEmpty / showTrailingWhenEmpty |
controller | Keep the static widget on the empty state |
showLeadingWhenLoading / showTrailingWhenLoading |
controller | Keep the static widget during first-page load |
trailingPosition |
controller | beforeReachEndBuilder · afterReachEndBuilder — trailing widget vs. reach-end footer |
scrollToFirstFloatingButton |
null |
ScrollEdgeFab? — opt-in FAB that scrolls to the first item (disabled when null) |
scrollToLastFloatingButton |
null |
ScrollEdgeFab? — opt-in FAB that scrolls to the last item (disabled when null) |
floatingActionButtonLocation |
Scaffold default | Where the FAB(s) sit |
floatingActionButtonAnimator |
Scaffold default | How the FAB group enters / leaves |
floatingActionButtonSpacing |
12 |
Gap between the two buttons when both show |
floatingActionButtonHideThreshold |
24 |
Edge slack (px) within which an auto FAB hides |
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.
Realtime stream merging #
A section can listen to more than one realtime source. Pass extraStreams
(and optionally realtimeOptions) to streamList / streamGrid / hybridList
/ hybridGrid. The sources are combined and cleaned up by
rxdart, kept behind an internal
RealtimeStreamService — you never import rxdart yourself, and you still merge
the combined emissions into the list by id (no duplicates, no clears).
ScrolledPaginationSection.streamList<Message>(
stream: primaryStream, // first source
extraStreams: [presenceStream, edits], // merged with it
idOf: (m) => m.id,
realtimeOptions: const RealtimeStreamOptions(
mergeMode: RealtimeStreamMergeMode.merge, // or combineLatest / concat
debounce: Duration(milliseconds: 120), // coalesce bursts
distinct: true, // drop deep-equal repeats
),
itemBuilder: (context, m) => MessageTile(message: m),
);
RealtimeStreamMergeMode |
Rx combinator | When |
|---|---|---|
merge (default) |
MergeStream |
several listeners on the same collection |
combineLatest |
CombineLatestStream |
each source owns a different slice; stitched into one list |
concat |
ConcatStream |
a seed stream, then a live stream |
RealtimeStreamOptions also exposes throttle (leading-edge rate-limit) and
distinct (suppress duplicate emissions). Swap the whole source set at runtime
with controller.replaceRealtimeStreams([...]) — switchMap cancels the old
combination automatically, so nothing leaks. A single source with no options
keeps the lightweight Stream.listen path (no rxdart involved). For one
logical page assembled from several backends, use mergedStreamProvider at the
controller level instead.
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 is pixel-accurate:
jumbTo/animateTo/ensureVisibleAtand focus scrolling resolve the target row throughscrollview_observer. If the package cannot serve a request yet (the target sliver is not mounted), the controller falls back toScrollable.ensureVisiblewith an estimate-and-retry nudge — so far-off-screen jumbs still work, just less precisely until the row is reachable. For very large dynamic-height lists, raisecacheExtenton a customscrollController's view, or pair with a positioned-list package viascrollController. - The
scrollview_observerdependency is fully isolated underlib/src/observer/(aScrollObserverAdapter+ a one-widget wrapper), and therxdartdependency underlib/src/stream/(aRealtimeStreamService+ a page combiner). ThePaginationControllerandSectionControllernever expose either package's types; swapping or removing a package only touches its folder. sliverlayout renders an internalCustomScrollView; pull-to-refresh is disabled inreversemode (chat lists use the observer's clamping physics to keep position when older pages load).
API reference #
A complete, grouped index of every public API. Names are exact — copy them as
written (note the intentional jumb* spelling).
Providers (providers.dart) #
| API | Signature | Use |
|---|---|---|
futureProvider<T> |
(PaginationContext) → Future<PaginationPage<T>> |
REST / async sources |
syncProvider<T> |
(PaginationContext) → PaginationPage<T> |
in-memory slicing |
streamProvider<T> |
(PaginationContext) → Stream<PaginationPage<T>> |
resolves on first emission |
mergedStreamProvider<T> |
List<(PaginationContext) → Stream<PaginationPage<T>>>, {idOf} |
merges many page-streams (rxdart combineLatest) |
Models, enums & typedefs (models.dart) #
- Classes:
PaginationContext(pageParam, pageSize),PaginationPage<T>(items, nextPageParam?, hasMore?),ScrollOptions(alignment, duration, curve, offset, keepScrollPosition)(+copyWith),JumbFailure(index?, reason),ScrollRequest,BatchInsert<T>(index, item),BatchReplace<T>(key, item),BatchMove(from, to). - Enums:
PaginationStatus(idle,firstLoading,refreshing,loadingMore,success,empty,error,noMoreData),StaticTrailingPosition(beforeReachEndBuilder,afterReachEndBuilder),PaginationLayout(list,separated,grid,sliver). - Typedefs:
PageFetcher<T>,ItemKey<T>,ErrorTransformer,ItemPredicate<T>,ItemUpdater<T>,ReachEndDetector<T>,ShouldRebuildItem<T>,ItemsChanged<T>,ItemCallback<T>,ItemMovedCallback,FocusChangedCallback<T>,JumbFailedCallback,IndexCallback,VisibleItemsChanged,PaginatedItemBuilder<T>,PaginationEmptyBuilder,PaginationErrorBuilder.
PaginationController<T> (ChangeNotifier) #
- Constructor args:
provider,pageSize,initialPageParam,getKey,onError,scrollOptions,openFocusManager,restoreFocusOnRefresh,restoreFocusOnOpen,autoLoad,sortManager,detectReachEnd,leadingWidget,trailingWidget,showLeadingWhenEmpty,showTrailingWhenEmpty,showLeadingWhenLoading,showTrailingWhenLoading,trailingPosition, and the nine callbacks below. - Loading:
loadFirst(),refresh(),loadMore(),retry(). - Item ops:
addItem(item, {prepend}),insertItem(index, item),insertItems(index, items),updateItem(key, updater),updateWhere(test, updater),updateFirstWhere(test, updater),removeItem(key),deleteWhere(test),deleteFirstWhere(test),replaceAt(index, item),replaceFirstWhere(test, item),replaceWhere(test, build),replaceAll(items),clear(). - Reorder:
moveItem(from, to),moveToFirst(index),moveToLast(index),swapItems(a, b). - Batch:
updateAll(updater),insertItemsBatch(List<BatchInsert<T>>),replaceItemsBatch(List<BatchReplace<T>>),deleteItemsBatch(List<Object>),moveItemsBatch(List<BatchMove>),transaction(body). - Sort:
resort()(withsortManager). - Selection:
select(key),deselect(key),toggleSelected(key),selectAll(),clearSelection(),isSelected(item),isKeySelected(key); gettersselectedKeys,selectedItems. - Static widgets:
setLeadingWidget(w),removeLeadingWidget(),setTrailingWidget(w),removeTrailingWidget(),clearStaticWidgets(); gettersleadingWidget,trailingWidget,hasLeadingWidget,hasTrailingWidget. - Scroll — instant:
jumbTo(index, {options}),jumbToFirst({options}),jumbToLast({options}),jumbWhere(test, {options}),jumpToFirst({options})(alias),jumpToLast({options})(alias). - Scroll — animated:
animateTo(index, {options}),animateToFirst({options}),animateToLast({options}),moveTo(index, {options})(alias ofanimateTo),ensureVisibleAt(index, {options, animate}),ensureVisibleWhere(test, {options, animate}). All scroll methods returnFuture<bool>. - Focus (opt-in):
focusAt(index, {scroll}),focusWhere(test, {scroll}),focusFirst({scroll}),focusLast({scroll}),focusNext({scroll}),focusPrevious({scroll}),clearFocus(); gettersfocusedIndex,focusedItem,hasFocus,focusLastOpeningTime. - State getters:
items,count,status,pageParam,hasMore,hasReachedEnd,error,isLoading,keyOf(item). - Visible items (observer-fed, read-only):
firstVisibleIndex,lastVisibleIndex,visibleIndices,visibleItems,isIndexVisible(index),hasVisibilityInfo; callbackonVisibleItemsChanged(first, last, indices). - Callbacks:
onItemsChanged,onItemInserted,onItemUpdated,onItemDeleted,onItemReplaced,onItemMoved,onFocusChanged,onJumbFailed,onIndexOutOfRange,onVisibleItemsChanged.
Scroll-navigation FABs (scroll_edge_fab.dart) #
- Enums:
ScrollFabType(regular,small,extended,custom),ScrollFabVisibility(auto,always). ScrollEdgeFabconfig:icon,label,tooltip,type,visibility,backgroundColor,foregroundColor,shape,heroTag,elevation,margin,extendedPadding,animate,scrollOptions,builder(+copyWith).ScrollEdgeFabBar— the internal widget (built for you by the views).
PaginatedScrollView<T> #
All props are listed in PaginatedScrollView options,
including scrollToFirstFloatingButton, scrollToLastFloatingButton,
floatingActionButtonLocation, floatingActionButtonAnimator,
floatingActionButtonSpacing, floatingActionButtonHideThreshold, the eight
builders, the four layouts, the static-widget props and shouldRebuildItem /
preserveScrollPosition.
SortManager<T> (sort_manager.dart) #
- Constructors:
SortManager(...),.byField,.byDate,.byId,.byIndex,.byPriority,.custom. - Members:
enabled(+enable()/disable()),order,comparator,isActive,compare(a, b),sorted(items). EnumSortOrder(ascending,descending).
ScrolledPaginationControllerFactory #
create<T>({key, userId, builder, force}), get<T>({key, userId}),
dispose<T>({key, userId}), disposeAll().
Bloc bindings (scrolled_pagination_bloc.dart) #
PaginationBloc<T>—add(PaginationEvent); gettercontroller.PaginationCubit<T>— full method-delegate API (loadFirst, item ops, batch, sort, selection, focus, scroll); gettercontroller.PaginationBlocState<T>—status,items,hasMore,pageParam,error,focusedIndex,selectedKeys,count,isLoading,hasFocus,focusedItem,selectedCount(+copyWith, value equality).- Events:
PaginationStarted,PaginationRefreshRequested,PaginationLoadMoreRequested,PaginationRetryRequested,PaginationItemAdded,PaginationItemInserted,PaginationItemsInserted,PaginationItemUpdated,PaginationItemsUpdatedWhere,PaginationItemRemoved,PaginationItemsDeletedWhere,PaginationItemReplacedAt,PaginationItemMoved,PaginationItemsSwapped,PaginationItemsReplacedAll,PaginationListCleared,PaginationAllUpdated,PaginationItemsBatchInserted,PaginationItemsBatchReplaced,PaginationItemsBatchDeleted,PaginationItemsBatchMoved,PaginationTransactionRequested,PaginationSortChanged,PaginationResortRequested,PaginationItemSelected,PaginationItemDeselected,PaginationSelectionToggled,PaginationAllSelected,PaginationSelectionCleared,PaginationFocusRequested,PaginationFocusCleared.
Multi-section Sliver engine (sliver/) #
ScrolledPaginationView— props:slivers,controller,header,footer,onRefresh,pullToRefresh,loadMoreThreshold,scrollPhysics,cacheExtent,padding,reverse,scrollToFirstFloatingButton,scrollToLastFloatingButton,floatingActionButtonLocation,floatingActionButtonAnimator,floatingActionButtonSpacing,floatingActionButtonHideThreshold.ScrolledPaginationController—jumpTo(offset),jumpToFirst(),jumpToLast(),animateTo(offset, {duration, curve}),animateToTop(),animateToBottom(),animateToFirst()(alias),animateToLast()(alias); fieldscrollController.ScrolledPaginationSectionfactories:futureList,streamList,hybridList,localList,futureGrid,streamGrid,hybridGrid,staticBox,staticSliver,loading,empty,errorState,customSliverBuild; membercontroller.SectionController<T>—initialize(),loadMore(),refresh(),retry(),mergeRealtime(list),replaceRealtimeStream(stream),replaceRealtimeStreams(streams),addBatch(items, {strategy, duplicateStrategy, index, sortComparator, insert, merge}),insertItem,insertItems,updateFirstWhere,updateWhere,updateAll,replaceFirstWhere,deleteFirstWhere,deleteWhere,removeById,moveTo,moveToFirst,moveToLast,clear,indexOfId,focusAt,focusWhere,clearFocus; gettersitems,length,isEmpty,status,hasMore,hasReachedEnd,error,isLoading,canLoadMore,focusedIndex,hasFocus,focusedItem,focusLastOpeningTime.- Section enums / typedefs:
BatchInsertStrategy,DuplicateStrategy,RealtimeMergeStrategy,RealtimeStreamMergeMode,RealtimeStreamOptions,SectionLoader<T>,ItemMerger<T>,ListMerger<T>,SectionItemBuilder<T>,SectionSeparatorBuilder,SectionHeaderBuilder,SectionLoadingBuilder,SectionEmptyBuilder,SectionErrorBuilder,SectionFooterBuilder,CustomSliverBuilder.
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