scrolled_pagination 1.2.0+1 copy "scrolled_pagination: ^1.2.0+1" to clipboard
scrolled_pagination: ^1.2.0+1 copied to clipboard

A reusable, MVC-style infinite-scroll pagination toolkit for Flutter — one controller, many providers (Future/Stream/sync), page or cursor based, with seven builders, item ops, scroll actions and an o [...]

scrolled_pagination #

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 seven swappable builders and executes the controller's scroll intents.

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.


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, syncProvider all normalise to one shape.
  • Page & cursor pagination — return nextPageParam for cursors, or let the controller auto-increment an int page.
  • Seven buildersitemBuilder, separatorBuilder, firstPageLoadingBuilder, loadMoreBuilder, emptyBuilder, errorBuilder, noMoreBuilder.
  • 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.
  • Bloc bindingsPaginationBloc (events) and PaginationCubit (methods) over the same controller, emitting immutable, value-equal PaginationBlocStates; batch operations emit exactly one state.
  • Scroll actionsjumbTo (instant), animateTo / moveTo (animated), ensureVisibleAt, with alignment, duration, curve and offset options.
  • 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.2.0

Import it:

import 'package:scrolled_pagination/scrolled_pagination.dart';

Usage #

1 · Create a controller (the model) #

final controller = PaginationController<Post>(
  pageSize: 20,
  initialPageParam: 1,                 // cursor mode: pass a cursor / null
  getKey: (post) => post.id,           // stable identity → in-place updates + focus
  onError: (e, st) => e,               // optional custom error transform
  provider: futureProvider<Post>((ctx) async {
    final res = await api.feed(page: ctx.pageParam, size: ctx.pageSize);
    return PaginationPage(
      items: res.data,
      nextPageParam: ctx.pageParam as int + 1,
      hasMore: res.hasMore,
    );
  }),
);

Mapping raw JSON to your model is done inside the provider — that keeps the generic type honest end-to-end.

2 · Render it (the view) #

PaginatedScrollView<Post>(
  controller: controller,
  layout: PaginationLayout.list,        // .list · .separated · .grid · .sliver
  padding: const EdgeInsets.all(16),
  gap: 12,
  loadMoreThreshold: 320,
  pullToRefresh: true,
  itemBuilder: (context, post, index) => PostCard(post: post),
  separatorBuilder: (context, index) => const Divider(),
  firstPageLoadingBuilder: (context) => const SkeletonList(),
  loadMoreBuilder: (context) => const Center(child: CircularProgressIndicator()),
  emptyBuilder: (context, refresh) => EmptyState(onRefresh: refresh),
  errorBuilder: (context, error, retry) => ErrorCard(error: error, onRetry: retry),
  noMoreBuilder: (context) => const CaughtUp(),
);

The view triggers the first load automatically and calls loadMore when the scroll position is within loadMoreThreshold of the end. Dispose the controller in your State.dispose().

3 · Item operations #

All operations notify only the affected rows — the rest of the list is not rebuilt.

controller.addItem(post, prepend: true);
controller.insertItem(2, post);
controller.insertItems(0, [a, b, c]);

controller.updateItem(post.id, (p) => p.copyWith(liked: true));
controller.updateWhere((p) => p.pinned, (p) => p.copyWith(seen: true));
controller.updateFirstWhere((p) => p.id == id, (p) => p.copyWith(liked: true));

controller.removeItem(post.id);
controller.deleteWhere((p) => p.archived);
controller.deleteFirstWhere((p) => p.id == id);

controller.replaceAt(1, post);
controller.replaceFirstWhere((p) => p.id == id, post);
controller.replaceWhere((p) => p.draft, (p) => p.copyWith(draft: false));
controller.replaceAll(nextPosts);

controller.moveItem(0, 5);
controller.moveToFirst(4);
controller.moveToLast(4);
controller.swapItems(0, 3);
controller.clear();

3b · Batch updates — one notify for many changes #

Every batch method applies all changes to the data first, then notifies the UI exactly once. Nothing is emitted per item, so a 50-item batch costs one rebuild pass instead of fifty.

controller.updateAll((p) => p.copyWith(seen: true));

controller.insertItemsBatch([
  BatchInsert(0, a),
  BatchInsert(5, b),
]);

controller.replaceItemsBatch([
  BatchReplace(post1.id, post1Updated),
  BatchReplace(post2.id, post2Updated),
]);

controller.deleteItemsBatch([post1.id, post2.id, post3.id]);

controller.moveItemsBatch([BatchMove(0, 5), BatchMove(2, 0)]);

// compose anything into one transaction — single notify at the end
controller.transaction(() {
  controller.addItem(incoming);
  controller.removeItem(stale.id);
  controller.updateItem(other.id, (p) => p.copyWith(liked: true));
});

When a SortManager is enabled, the final list is re-sorted once, after the whole transaction — not after every step.

3c · SortManager (optional — off by default) #

final sort = SortManager<Post>.byDate((p) => p.createdAt,
    order: SortOrder.descending, enabled: true);

final controller = PaginationController<Post>(
  // …
  sortManager: sort,                // omit (or keep disabled) → insertion order
);

// other constructors
SortManager<Post>.byId((p) => p.id);
SortManager<Post>.byIndex((p) => p.sequence);
SortManager<Post>.byPriority((p) => p.priority);
SortManager<Post>.byField((p) => p.title);
SortManager<Post>.custom((a, b) => a.score.compareTo(b.score));

// flip direction / comparator at runtime, then re-sort once
sort.order = SortOrder.ascending;
controller.resort();

The sort is stable and runs after load, insert, update, replace and batch operations, so the list stays sorted even when items arrive out of order. Focus and selection follow their items to the new positions.

3d · Selection (key-based) #

controller.select(post.id);
controller.toggleSelected(post.id);
controller.isSelected(post);        // or isKeySelected(post.id)
controller.selectedItems;           // in current list order
controller.selectAll();
controller.clearSelection();

Selection is stored by key, so it survives insert, update, delete (of other items), sort and refresh. Deleting a selected item prunes its key.

3e · Bloc state management (optional) #

If your app uses bloc / flutter_bloc, import the bloc entrypoint and wrap the controller in a PaginationBloc (events) or a PaginationCubit (methods). The controller stays the single source of truth; every notification becomes exactly one immutable PaginationBlocState — batch operations included.

import 'package:scrolled_pagination/scrolled_pagination_bloc.dart';

// 1 · Event-driven
final bloc = PaginationBloc<Post>(
  controller: PaginationController<Post>(
    pageSize: 20,
    getKey: (p) => p.id,
    sortManager: SortManager<Post>.byDate((p) => p.createdAt, enabled: true),
    provider: futureProvider<Post>((ctx) => repo.feed(ctx)),
  ),
)..add(const PaginationStarted());

bloc.add(PaginationItemUpdated(post.id, (p) => p.copyWith(liked: true)));
bloc.add(const PaginationItemsBatchDeleted(['a', 'b', 'c'])); // ONE state
bloc.add(const PaginationSortChanged(order: SortOrder.descending));
bloc.add(const PaginationItemSelected('a'));

// 2 · Render — the view takes the wrapped controller
BlocProvider.value(
  value: bloc,
  child: BlocBuilder<PaginationBloc<Post>, PaginationBlocState<Post>>(
    builder: (context, state) => PaginatedScrollView<Post>(
      controller: context.read<PaginationBloc<Post>>().controller,
      itemBuilder: (ctx, post, i) => PostCard(post: post),
    ),
  ),
);

// 3 · Or the cubit flavour — same states, direct methods
final cubit = PaginationCubit<Post>(controller: …, autoLoad: true);
cubit.updateAll((p) => p.copyWith(seen: true));   // one emitted state
cubit.deleteItemsBatch(ids);                      // one emitted state

Notes:

  • 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.

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 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.


Providers #

Adapter Source Notes
futureProvider(fn) Future<PaginationPage<T>> The default — async/await.
syncProvider(fn) PaginationPage<T> In-memory slicing, no awaiting.
streamProvider(fn) Stream<PaginationPage<T>> Resolves on the first emission.
provider: syncProvider<Post>((ctx) {
  final start = ((ctx.pageParam as int) - 1) * ctx.pageSize;
  final slice = all.skip(start).take(ctx.pageSize).toList();
  return PaginationPage(items: slice, hasMore: start + slice.length < all.length);
});

States #

State When Builder
idle Never loaded
firstLoading First page in flight firstPageLoadingBuilder
refreshing Pull-to-refresh in flight items stay visible
loadingMore Next page in flight loadMoreBuilder (footer)
success Items present, more available itemBuilder
empty Loaded, zero items emptyBuilder
error A load failed errorBuilder
noMoreData Items present, nothing left noMoreBuilder (footer)

PaginatedScrollView options #

Option Default Description
layout list list · separated · grid · sliver
crossAxisCount 2 Columns for grid
childAspectRatio 0.78 Grid cell ratio
reverse false Chat-style (load at top)
shrinkWrap false Size to content
physics platform Custom ScrollPhysics
padding EdgeInsets.all(16) Content padding
gap 12 Spacing between list rows
loadMoreThreshold 320 Pixels from the end to load more
pullToRefresh true Wrap in a RefreshIndicator
scrollController own Bring your own controller
shouldRebuildItem null (old, new) => bool — return false to keep a row alive when its visible data did not change
preserveScrollPosition true Keep the first visible row anchored after inserts / deletes / sorts above it

How rows stay alive (diff-based updates) #

  • Every row is keyed by getKey(item)stable identity, not index.
  • A row whose item is 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.

Notes & limitations #

  • Index-based scrolling uses Scrollable.ensureVisible once 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 via scrollController.
  • sliver layout renders an internal CustomScrollView; pull-to-refresh is disabled in reverse mode.

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
0
points
46
downloads

Documentation

Documentation

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, with seven builders, item ops, scroll actions and an optional focus manager.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

bloc, flutter

More

Packages that depend on scrolled_pagination