scrolled_pagination 1.9.0 copy "scrolled_pagination: ^1.9.0" to clipboard
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 #

pub package license: MIT style: flutter_lints demo

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.

Demo screenshot #

Works with Future, Stream and 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 providersfutureProvider, streamProvider, mergedStreamProvider, syncProvider all normalise to one shape.
  • Page & cursor pagination — return nextPageParam for cursors, or let the controller auto-increment an int page.
  • Eight buildersitemBuilder, separatorBuilder, firstPageLoadingBuilder, loadMoreBuilder, emptyBuilder, errorBuilder, noMoreBuilder, reachEndBuilder.
  • Reach-end statehasReachedEnd flips true once nothing more can be loaded (recomputed after every loadMore / refresh); loadMore() is then a no-op. Detected from a custom detectReachEnd, the hasMore flag, an empty next page, or a short page. An optional reachEndBuilder footer shows a custom "No more items" end marker.
  • Eight statesidle, 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 updatesupdateAll, insertItemsBatch, replaceItemsBatch, deleteItemsBatch, moveItemsBatch and a composable transaction(...): 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 selectionselect / 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 in count, never shifted by insert / update / delete / sort / refresh — with showLeadingWhenEmpty / showTrailingWhenEmpty / showLeadingWhenLoading / showTrailingWhenLoading and trailingPosition. See Static widgets.
  • Optional scroll-navigation FABs — opt-in scrollToFirstFloatingButton / scrollToLastFloatingButton that scroll to the first / last item. Disabled by default, independent of the pagination data, with custom icon / tooltip / margin / shape / colours, regular · small · extended · custom styles, auto-hide-at-edge or always-visible modes, and forwarded floatingActionButtonLocation / floatingActionButtonAnimator. Toggling visibility never rebuilds the list. See Scroll-navigation floating buttons.
  • Bloc bindingsPaginationBloc (events) and PaginationCubit (methods) over the same controller, emitting immutable, value-equal PaginationBlocStates; batch operations emit exactly one state.
  • Multi-section Sliver engineScrolledPaginationView builds ONE CustomScrollView from many independent ScrolledPaginationSections (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); addBatch adds many items at once with configurable insert + duplicate strategies. See Multi-section Sliver view.
  • Scroll actionsjumbTo (instant), animateTo / moveTo (animated), ensureVisibleAt, with alignment, duration, curve and offset options. Index targeting is pixel-accurate, powered by scrollview_observer behind a thin adapter, with the previous estimate-and-retry as an automatic fallback.
  • Visible-item trackingfirstVisibleIndex, lastVisibleIndex, visibleIndices, visibleItems, isIndexVisible(i) and an onVisibleItemsChanged callback, 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 callbacksonItemsChanged, onItemInserted, onItemUpdated, onItemDeleted, onItemReplaced, onItemMoved, onFocusChanged, onJumbFailed, onIndexOutOfRange.
  • Controller cacheScrolledPaginationControllerFactory keeps one controller alive per key + 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:

  • PaginationBlocState is value-equal, so BlocBuilder / BlocSelector skip rebuilds when nothing changed.
  • Scroll actions return Future<bool> — call them on bloc.controller (or via the cubit's delegates: cubit.animateTo(20)).
  • The bloc/cubit owns the controller by default (closeController: true) and disposes it in close().
  • The package depends only on bloc; add flutter_bloc to your app for BlocProvider / 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 with dispose<T> or call disposeAll() on sign-out. The cache key is '$key-$T-$userId', so keep T, key and userId consistent 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.
  • StylesScrollFabType.regular · small · extended (icon + label) · custom (supply builder: (ctx, onPressed) => YourWidget(...)).
  • Customisationicon, label, tooltip, margin, extendedPadding, heroTag, backgroundColor, foregroundColor, shape, elevation.
  • Behaviouranimate (animate vs. jump) and per-button scrollOptions (alignment / duration / curve / offset).
  • VisibilityScrollFabVisibility.auto (default) hides the first button while already at the top and the last button while already at the end; always keeps it visible. floatingActionButtonHideThreshold tunes 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) reachEndBuildernoMoreBuilder (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 identical to 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).
  • findChildIndexCallback matches 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, no shrinkWrap, no children: [...] — every section uses lazy SliverChildBuilderDelegates.
  • Each section listens to its own SectionController, so unrelated sections never rebuild.
  • Rows are keyed by ValueKey(idOf(item)) with a findChildIndexCallback, so appending a page or merging a realtime update leaves visible rows mounted.
  • The optional SortManager and focus manager follow items by id across inserts, addBatch, pagination and realtime updates.

Notes & limitations #

  • Index-based scrolling is pixel-accurate: jumbTo / animateTo / ensureVisibleAt and focus scrolling resolve the target row through scrollview_observer. If the package cannot serve a request yet (the target sliver is not mounted), the controller falls back to Scrollable.ensureVisible with 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, raise cacheExtent on a custom scrollController's view, or pair with a positioned-list package via scrollController.
  • The scrollview_observer dependency is fully isolated under lib/src/observer/ (a ScrollObserverAdapter + a one-widget wrapper), and the rxdart dependency under lib/src/stream/ (a RealtimeStreamService + a page combiner). The PaginationController and SectionController never expose either package's types; swapping or removing a package only touches its folder.
  • sliver layout renders an internal CustomScrollView; pull-to-refresh is disabled in reverse mode (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() (with sortManager).
  • Selection: select(key), deselect(key), toggleSelected(key), selectAll(), clearSelection(), isSelected(item), isKeySelected(key); getters selectedKeys, selectedItems.
  • Static widgets: setLeadingWidget(w), removeLeadingWidget(), setTrailingWidget(w), removeTrailingWidget(), clearStaticWidgets(); getters leadingWidget, 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 of animateTo), ensureVisibleAt(index, {options, animate}), ensureVisibleWhere(test, {options, animate}). All scroll methods return Future<bool>.
  • Focus (opt-in): focusAt(index, {scroll}), focusWhere(test, {scroll}), focusFirst({scroll}), focusLast({scroll}), focusNext({scroll}), focusPrevious({scroll}), clearFocus(); getters focusedIndex, 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; callback onVisibleItemsChanged(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).
  • ScrollEdgeFab config: 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). Enum SortOrder (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); getter controller.
  • PaginationCubit<T> — full method-delegate API (loadFirst, item ops, batch, sort, selection, focus, scroll); getter controller.
  • 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.
  • ScrolledPaginationControllerjumpTo(offset), jumpToFirst(), jumpToLast(), animateTo(offset, {duration, curve}), animateToTop(), animateToBottom(), animateToFirst() (alias), animateToLast() (alias); field scrollController.
  • ScrolledPaginationSection factories: futureList, streamList, hybridList, localList, futureGrid, streamGrid, hybridGrid, staticBox, staticSliver, loading, empty, errorState, customSliverBuild; member controller.
  • 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; getters items, 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 — an AGENTS.md guide 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

1
likes
140
points
46
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

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-based dedup (DuplicateResolver are byUpdatedAt/byVersion/custom), SortManager, selection, focus, scroll-to-index, rxdart realtime merging, bloc bindings (PaginationBloc/PaginationCubit), and a multi-section Sliver engine for mixed list + grid feeds.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

bloc, flutter, rxdart, scrollview_observer

More

Packages that depend on scrolled_pagination