appcraft_list_loading_flutter 1.0.0
appcraft_list_loading_flutter: ^1.0.0 copied to clipboard
Dispatcher for paginated list loading with offset/cursor support, debounced search and pluggable cancellation strategies.
appcraft_list_loading_flutter #
A single-purpose Flutter package for paginated list loading. It provides two
self-contained dispatchers — ACListDispatcher for a plain List<T> response
and ACPageDispatcher for a page-model (DTO) response — each encapsulating the
loading lifecycle (reload / loadMore / cancel / dispose). It also ships
parameter mixins for offset- and cursor-based pagination, the ACPage page
mixin, and ready-to-use strategies for debounced search and load cancellation.
Suitable for any list with pagination, a search field and pull-to-refresh —
without imposing any specific state-management library (both dispatchers extend
ChangeNotifier).
Features #
- Offset pagination via
ACListDispatcherandACOffsetParamsMixin. - Page-model (DTO) responses with explicit
hasMoreviaACPageDispatcher+ any DTO with theACPagemixin. - Cursor pagination via a custom
cursorfield on your params class +ACPageDispatcher+ anACPageDTO. Usedispatcher.lastResult?.<your_cursor_field>to feed the nextloadMore. - Debounced search with
minLengthvia the defaultACSearchDebouncer(composed on top ofACDebouncer), or your ownACSearchStrategy. - A standalone, action-neutral
ACDebouncerfor debouncing any action (read-tracking, autosave, analytics throttling, ...) — no manualTimer. - Cancellation strategies: the
ACCancelStrategycontract and a readyACOperationCancelStrategyimplementation on top ofpackage:async. - Integration with
ChangeNotifier— subscribe viaListenableBuilder,AnimatedBuilderoraddListener. - External list control via
mutate(realtime/optimistic/seed) with a manualhasMoresetter, on both dispatchers. - Bidirectional, anchor-based pagination for chat/feed via
ACAnchoredDispatcher(a composition of twoACPageDispatchers):loadAround/loadOlder/loadNewer, separateitemsOlder/itemsNewerfor a jump-freeCustomScrollView(center:)plus a mergeditems, fully per-side state and realtimemutateOlder/mutateNewer.
Removed in
1.0.0: the parser-basedACDispatcher,ACCustomDispatcher,ACParser,ACResultParserand theACResultmodel have been removed. Use the self-containedACListDispatcher/ACPageDispatcherand theACPagemixin instead. See the API Reference. Upgrading from 0.2.0 or 0.3.0? Follow the step-by-step migration guide.
Installation #
flutter pub add appcraft_list_loading_flutter
Usage #
1. Basic — ACListDispatcher #
The simplest scenario: the loader returns a plain List<T>, offset-based
pagination, no search. hasMore is computed as
result.length >= params.limit. ACListDispatcher is the recommended
dispatcher for this case.
Migration note: the older
ACDefaultDispatcher/ACDefaultParserclasses were removed in1.0.0. Their public contract was identical toACListDispatcher— migration is just renaming the class. See the migration guide.
import 'package:appcraft_list_loading_flutter/appcraft_list_loading_flutter.dart';
final class UserListParams
with ACParamsMixin, ACOffsetParamsMixin {
const UserListParams({this.offset, this.limit, this.query});
@override
final int? offset;
@override
final int? limit;
@override
final String? query;
}
final dispatcher = ACListDispatcher<UserListParams, User>();
await dispatcher.reload(
params: const UserListParams(offset: 0, limit: 20),
load: (p) => api.fetchUsers(offset: p.offset, limit: p.limit),
);
// Load the next page:
await dispatcher.loadMore(
params: UserListParams(offset: dispatcher.items.length, limit: 20),
load: (p) => api.fetchUsers(offset: p.offset, limit: p.limit),
);
Before the first load — hasMore == false
A fresh dispatcher reports hasMore == false until the first load happens, so
hasMore alone already means "there is a loaded page and it may have more":
final dispatcher = ACListDispatcher<UserListParams, User>();
print(dispatcher.hasMore); // false (before any reload)
// No extra gate is needed — this used to require `lastResult != null && hasMore`:
if (dispatcher.hasMore) const BottomLoader(); // won't show before the first load
Consequently loadMore() before the first reload is a no-op (the
hasMore == false guard). Use reload to perform the first load, or
loadMore(force: true) to bypass the guard explicitly:
await dispatcher.loadMore(params: p, load: api.fetch); // no-op (hasMore == false)
await dispatcher.loadMore(params: p, load: api.fetch, force: true); // loads (bypasses the guard)
After the first reload the value is computed as before (offset rule /
page.hasMore) — the post-load computation is unchanged.
Forcing a load past the end — loadMore(force: true)
Reached the end (hasMore == false) but new items may have appeared upstream
(for example after posting a message) and you want to pull them in without a
full reload and without losing the accumulated list? Pass force: true:
if (dispatcher.hasMore) {
await dispatcher.loadMore(params: nextParams, load: api.fetch); // as usual
} else {
await dispatcher.loadMore(params: nextParams, load: api.fetch, force: true); // pull anyway
}
force: truebypasses only thehasMore == falsecheck; it is one-shot — after a successful loadhasMoreis recomputed as usual.- The other guards are kept: during an active load and after
disposeit is a no-op, andisLoadingMoreistruewhile it runs (like a normalloadMore). - Without
force(orforce: false) the behaviour is unchanged. It lives in the baseACLoadingDispatcher, so both dispatchers and subclasses inherit it.
Manual list control — mutate & hasMore
For live feeds (realtime append, optimistic update, seed) you can change the
accumulated list from outside via mutate. The callback receives the mutable
list; a single notifyListeners() fires on success (batched — many operations,
one notification). The items getter stays unmodifiable, so mutate is the
only sanctioned write path.
// realtime: an incoming item over WebSocket
socket.onMessage((msg) => dispatcher.mutate((items) => items.add(msg)));
// optimistic: show "sending", then replace with the server object
dispatcher.mutate((items) => items.add(pending));
final sent = await api.send(text);
dispatcher.mutate((items) => items // two ops → one notification
..removeWhere((m) => m.localId == pending.localId)
..add(sent));
// remove
dispatcher.mutate((items) => items.removeWhere((m) => m.id == deletedId));
// seed from cache and control pagination manually
dispatcher.mutate((items) => items.addAll(cached));
dispatcher.hasMore = true; // enable loadMore; set false to stop it
Notes: mutate is a no-op after dispose; a callback exception propagates
without notifying; mutate/hasMore = do not cancel an active load (a
concurrent reload will overwrite manual changes — call cancel() first to
seed safely). The hasMore setter does not notify listeners. Direct
dispatcher.items.add(...) throws UnsupportedError — use mutate.
2. DTO with ACPage — ACPageDispatcher #
If the backend returns a DTO with an explicit hasMore flag (and/or a
cursor), the DTO mixes in ACPage<T> and ACPageDispatcher reads items
and hasMore from it automatically — no separate parser is needed. Any extra
fields (cursor, metadata) are read back through dispatcher.lastResult.
Migration note: the older
ACCustomDispatcher/ACResultParser/ACParser/ACDispatcherclasses and theACResultmodel were removed in1.0.0. Migration is mechanical: change the DTO mixinACResult<T>→ACPage<T>(the members are identical) and the dispatcher classACCustomDispatcher→ACPageDispatcher. Method signatures, getters and behaviour are unchanged;mutateand thehasMoresetter are added on top. See the migration guide.
import 'package:appcraft_list_loading_flutter/appcraft_list_loading_flutter.dart';
final class UserPageDto with ACPage<User> {
const UserPageDto({
required this.items,
required this.hasMore,
this.nextCursor,
});
@override
final List<User> items;
@override
final bool hasMore;
final String? nextCursor;
}
final class UserCursorParams with ACParamsMixin {
const UserCursorParams({this.limit, this.cursor, this.query});
@override
final int? limit;
final String? cursor;
@override
final String? query;
}
final dispatcher =
ACPageDispatcher<UserCursorParams, UserPageDto, User>();
await dispatcher.reload(
params: const UserCursorParams(limit: 20),
load: (p) => api.fetchUsersPage(cursor: p.cursor, limit: p.limit),
);
// Carry the next-page cursor through the dispatcher's lastResult getter:
await dispatcher.loadMore(
params: UserCursorParams(
limit: 20,
cursor: dispatcher.lastResult?.nextCursor,
),
load: (p) => api.fetchUsersPage(cursor: p.cursor, limit: p.limit),
);
ACPageDispatcher also supports external list control — mutate for
realtime / optimistic / seed writes (a single batched notification) and a
manual hasMore setter — with the same rules as ACListDispatcher. See
Manual list control.
3. Bidirectional chat/feed pagination — ACAnchoredDispatcher #
For a chat or a feed that opens around an anchor (first unread message, a
deep-linked item) and then grows in both directions — back in time (older)
and forward in time (newer) — use ACAnchoredDispatcher. It is built as a
composition of two ACPageDispatchers (one per side), so each side reuses the
full loading engine (cancellation, staleness guards, isLoading, lastResult,
error channels, retry, mutate); this class only orchestrates the anchor
load and exposes a merged list view. It extends ChangeNotifier.
The around-load returns an ACAnchoredPage<T> — an around-page model that
carries both directional flags at once (hasMoreOlder / hasMoreNewer),
unlike the per-side ACPage whose hasMore means "is there more on this
side". loadOlder / loadNewer keep using a plain ACPage DTO.
import 'package:appcraft_list_loading_flutter/appcraft_list_loading_flutter.dart';
// around-page (for loadAround) — carries both flags
final class ChatAround with ACAnchoredPage<Msg> {
const ChatAround({
required this.items,
required this.hasMoreOlder,
required this.hasMoreNewer,
this.olderCursor,
this.newerCursor,
});
@override
final List<Msg> items;
@override
final bool hasMoreOlder;
@override
final bool hasMoreNewer;
final String? olderCursor; // initial cursors — read back via lastAround
final String? newerCursor;
}
// side-page (for loadOlder/loadNewer) — hasMore = "more on THIS side"
final class ChatPage with ACPage<Msg> {
const ChatPage({required this.items, required this.hasMore, this.cursor});
@override
final List<Msg> items;
@override
final bool hasMore;
final String? cursor;
}
Start around the anchor — loadAround
loadAround runs its own orchestration: it cancels any previous around-load
and both sides, seeds the central page into the newer side, clears the
older side and applies both hasMore flags. The central page is stored in
lastAround. A failing loader still throws (kept in lastErrorAround).
final d = ACAnchoredDispatcher<ChatParams, ChatPage, Msg>();
await d.loadAround(
params: ChatParams(anchorId: firstUnreadId),
load: (p) => api.fetchAround(p.anchorId), // -> ChatAround
);
// d.itemsNewer == the centred window; d.itemsOlder == [];
// d.hasMoreOlder / d.hasMoreNewer come from the response.
Grow up/down — loadOlder / loadNewer
Each side delegates to its dispatcher's loadMore (the !hasMore / isLoading
guards and one-shot force apply). Cursors are managed manually: the initial
ones come from lastAround, subsequent ones from lastResultOlder /
lastResultNewer.
if (d.hasMoreOlder) {
await d.loadOlder(
params: ChatParams(
cursor: d.lastResultOlder?.cursor ??
(d.lastAround as ChatAround?)?.olderCursor,
),
load: (p) => api.fetchOlder(p.cursor), // hasMore = "more older"
);
}
if (d.hasMoreNewer) {
await d.loadNewer(
params: ChatParams(
cursor: d.lastResultNewer?.cursor ??
(d.lastAround as ChatAround?)?.newerCursor,
),
load: (p) => api.fetchNewer(p.cursor), // hasMore = "more newer"
);
}
Two lists for CustomScrollView(center:) — no scroll jumps
itemsOlder (closest-older → oldest) and itemsNewer (anchor → newest) are
exposed separately so they feed two slivers around a center key — growing
the older side does not shift the visible position. A merged, read-only
items == reverse(itemsOlder) ++ itemsNewer is also available for consumers
that prefer a single list.
CustomScrollView(
center: centerKey,
anchor: 0.5,
slivers: [
if (d.isLoadingOlder) const SliverToBoxAdapter(child: TopSpinner()),
SliverList.builder( // TOP: older, grows up, no jumps
itemCount: d.itemsOlder.length,
itemBuilder: (_, i) => MsgTile(d.itemsOlder[i]),
),
SliverList.builder( // BOTTOM: anchor + newer
key: centerKey,
itemCount: d.itemsNewer.length,
itemBuilder: (_, i) => MsgTile(d.itemsNewer[i]),
),
if (d.isLoadingNewer) const SliverToBoxAdapter(child: BottomSpinner()),
],
);
Realtime — mutateNewer / mutateOlder
mutateOlder / mutateNewer delegate to the respective side's mutate — the
only sanctioned write path (the list getters stay unmodifiable). A single
batched notifyListeners() fires per call; a no-op after dispose.
socket.onMessage((m) => d.mutateNewer((items) => items.add(m))); // incoming — down
d.mutateNewer((items) => items // optimistic — one notify
..removeWhere((x) => x.localId == pending.localId)
..add(sent));
Per-side indicators / errors / retry
Loading, error and retry state is fully per side and independent — a load
or a failure on one side never touches the other:
isLoadingOlder / isLoadingNewer / isLoadingAround,
loadingOlderListenable / loadingNewerListenable / loadingAroundListenable,
lastErrorOlder / lastErrorNewer / lastErrorAround (with matching
errorOlderListenable / errorNewerListenable / errorAroundListenable),
retryOlder() / retryNewer(), and lastResultOlder / lastResultNewer /
lastAround.
ValueListenableBuilder<bool>(
valueListenable: d.loadingOlderListenable, // spinner on top
builder: (_, loading, __) => loading ? const TopSpinner() : const SizedBox(),
);
if (d.lastErrorNewer != null) RetryButton(onTap: d.retryNewer); // error at the bottom
cancel() cancels the around-load and both sides; dispose() releases both
sides and is idempotent (any method after dispose is a no-op). The per-side
searchStrategy is not applied — the dispatcher only uses loadMore.
4. Debounced search — ACSearchDebouncer #
ACSearchDebouncer is the default search strategy on both dispatchers
(a fresh dispatcher already debounces search with debounce: 300ms,
minLength: 3), so you only pass one to tweak the timing or gating. It is
built on top of the general-purpose ACDebouncer (see below) but its search
behaviour is unchanged from before.
The search strategy applies only in reload: for a query shorter than
minLength items are cleared, for a changed query loading starts after
debounce. In loadMore the search strategy is ignored.
import 'package:appcraft_list_loading_flutter/appcraft_list_loading_flutter.dart';
final dispatcher = ACListDispatcher<UserListParams, User>(
searchStrategy: ACSearchDebouncer(
debounce: const Duration(milliseconds: 400),
minLength: 2,
),
);
// Every text change triggers a reload — the strategy will collapse
// frequent calls into a single one.
void onQueryChanged(String query) {
dispatcher.reload(
params: UserListParams(offset: 0, limit: 20, query: query),
load: (p) => api.searchUsers(query: p.query, offset: p.offset, limit: p.limit),
);
}
Migration note:
ACDebouncedSearchStrategywas removed in1.0.0. Replace it withACSearchDebouncer— samedebounce/minLengthparameters and identical behaviour, it is a straight rename. TheACSearchStrategycontract stays for custom strategies. See the migration guide.
For a fully custom search behaviour, implement ACSearchStrategy yourself and
pass it as searchStrategy — for example an instant, no-debounce strategy:
final class InstantSearch implements ACSearchStrategy {
@override
Future<void>? schedule(String? query) => Future<void>.value();
@override
void cancel() {}
@override
void dispose() {}
}
final dispatcher =
ACListDispatcher<UserListParams, User>(searchStrategy: InstantSearch());
5. Debouncing any action — ACDebouncer #
ACSearchDebouncer handles search timing, but the underlying ACDebouncer is
exposed as a standalone, action-neutral utility for debouncing any
void Function() — read-tracking, resize handling, draft autosave, analytics
throttling — without wiring up a manual Timer. It is trailing-edge: each
run cancels the previously scheduled action and re-arms, so only the last
action within the duration window fires.
import 'package:appcraft_list_loading_flutter/appcraft_list_loading_flutter.dart';
final _readDebouncer = ACDebouncer(const Duration(milliseconds: 400));
// A frequent stream — e.g. "a message scrolled into view":
void onMessageVisible(Message m) =>
_readDebouncer.run(() => api.markRead(m.id));
// Only the last id within 400ms reaches the API.
@override
void dispose() {
_readDebouncer.dispose(); // required — otherwise a pending Timer may
super.dispose(); // fire the action after dispose
}
durationdefaults to300msand must be non-negative;Duration.zeroschedules the action on the next event-loop tick.isActivereports whether an action is currently pending;cancel()drops the pending action without running it.dispose()is mandatory — it cancels the pending timer so a deferred action never fires after teardown. It is equivalent tocancel().
6. Custom cancel strategy — ACCancelStrategy #
If you need to integrate with your own cancellation system (for example a
Dio CancelToken), implement ACCancelStrategy and pass an instance to
reload / loadMore via the cancelStrategy parameter.
import 'package:appcraft_list_loading_flutter/appcraft_list_loading_flutter.dart';
import 'package:dio/dio.dart';
final class DioCancelStrategy implements ACCancelStrategy {
DioCancelStrategy() : _token = CancelToken();
final CancelToken _token;
bool _completed = false;
CancelToken get token => _token;
@override
Future<T?> run<T>(Future<T> future) async {
try {
final result = await future;
_completed = true;
return result;
} on DioException catch (e) {
if (CancelToken.isCancel(e)) return null;
rethrow;
}
}
@override
Future<void> cancel() async {
if (!_completed && !_token.isCancelled) _token.cancel();
}
@override
bool get isActive => !_completed && !_token.isCancelled;
}
await dispatcher.reload(
params: const UserListParams(offset: 0, limit: 20),
load: (p) => api.fetchUsers(offset: p.offset, limit: p.limit),
cancelStrategy: DioCancelStrategy(),
);
7. Reactive loading indicator — loadingListenable #
Both dispatchers extend ChangeNotifier, but notifyListeners() fires only
when items change — a change of isLoading alone does not wake listeners.
To drive a spinner reactively (without manual setState and without
subscribing to the list) use loadingListenable — a ValueListenable<bool>
that mirrors the overall isLoading flag, notifies on every false ↔ true
transition and deduplicates equal values.
// In a widget — no setState:
ValueListenableBuilder<bool>(
valueListenable: dispatcher.loadingListenable,
builder: (_, isLoading, __) => isLoading
? const LinearProgressIndicator()
: const SizedBox.shrink(),
);
// In a Cubit/Bloc — where setState is unavailable:
dispatcher.loadingListenable.addListener(() {
emit(state.copyWith(isLoading: dispatcher.isLoading));
});
loadingListenable is independent of list changes — you no longer subscribe
to the list just to move a spinner.
Telling reload apart from loadMore — isReloading / isLoadingMore
To render distinct indicators — a full-screen spinner during reload versus a
"loading more" spinner at the bottom during loadMore — read the two granular
flags synchronously:
Widget build(BuildContext context) {
return Column(children: [
if (dispatcher.isReloading) const FullScreenSpinner(), // reload in flight
Expanded(child: ListView(/* dispatcher.items */)),
if (dispatcher.isLoadingMore) const BottomSpinner(), // loadMore in flight
]);
}
isReloading—truewhile areloadis in flight.isLoadingMore—truewhile aloadMoreis in flight.- During any active load exactly one granular flag is
true; starting areloadwhile aloadMoreruns switches toisReloadingwithout the overallisLoadingflickering tofalse.
Compatibility: isLoading is unchanged — it is now the derived
isReloading || isLoadingMore, so existing code keeps working. The granular
flags are read synchronously; there is no separate ValueListenable for them
(the reactive channel is on the overall isLoading only). Everything lives in
the base ACLoadingDispatcher, so ACListDispatcher, ACPageDispatcher and
third-party subclasses get it for free.
8. Built-in error state — lastError / errorListenable / retry() #
A failing load still throws — your try/catch around reload / loadMore
keeps working exactly as before. On top of that the engine now records the
error so a widget can render a "spinner / error / list" state without wiring up
its own error plumbing: lastError exposes the last thrown object
synchronously and errorListenable (a ValueListenable<Object?>) is the
reactive channel. The error clears automatically on the next successful load.
Combine loadingListenable and errorListenable to drive the three states
from one builder:
// In a widget — reactive, combining loading + error:
AnimatedBuilder(
animation: Listenable.merge([dispatcher.loadingListenable, dispatcher.errorListenable]),
builder: (context, _) {
if (dispatcher.isLoading) return const CircularProgressIndicator();
final error = dispatcher.lastError;
if (error != null) {
return Column(children: [
Text('Error: $error'),
ElevatedButton(onPressed: dispatcher.retry, child: const Text('Retry')),
]);
}
return MyList(items: dispatcher.items);
},
);
// In a Cubit/Bloc:
dispatcher.errorListenable.addListener(() {
emit(state.copyWith(error: dispatcher.lastError));
});
lastError— synchronous;errorListenable— reactive, deduplicated by value.- The exception is still propagated —
lastError/errorListenableare additive, not a replacement fortry/catch. - Changing
lastErrordoes not firenotifyListeners()— the reactive channel iserrorListenableonly.
Repeating the last operation — retry()
retry() re-runs the last operation (reload or loadMore) through the public
methods, with the original params, load and — for loadMore — the original
force, so you do not have to remember the arguments at the call site:
try {
await dispatcher.loadMore(params: p, load: api.fetch, force: true);
} catch (_) {
// later, on a button tap:
await dispatcher.retry(); // repeats loadMore with force: true and the same params/load
}
- A fresh
cancelStrategyis used; if you need a custom one, callreload/loadMoredirectly. - With no prior operation, or after
dispose,retry()is a no-op.
Introspecting the last operation — lastOperation
lastOperation gives read-only access to the captured operation as a sealed
ACDispatcherOperation, so an exhaustive switch needs no default clause:
switch (dispatcher.lastOperation) {
case null:
label = 'No operation yet';
case ACReloadOperation():
label = 'Retry load';
case ACLoadMoreOperation(:final force):
label = force ? 'Retry forced load-more' : 'Load more';
}
The variants are ACReloadOperation and ACLoadMoreOperation (carrying
force); both expose the common params and load.
Everything lives in the base ACLoadingDispatcher, so ACListDispatcher,
ACPageDispatcher and third-party subclasses inherit it.
Extending the API #
The recommended extension points are the self-contained dispatchers
ACListDispatcher and ACPageDispatcher: both are open for extends and
implements, so you can customize loading, search or cancellation behaviour
without copying the source. The strategy classes are open too.
Open classes:
ACListDispatcherACPageDispatcherACLoadingDispatcher(abstract — the shared loading engine, see below)ACSearchDebouncerACOperationCancelStrategy
The former parser-based extension points — the abstract ACDispatcher /
ACParser base classes and the ACResultParser implementation — were
removed in 1.0.0; custom parsing logic now lives inside a subclass of the
relevant dispatcher instead. The ACSearchStrategy, ACCancelStrategy
contracts and the mixins remain open.
Example: extending the list dispatcher #
class LoggingDispatcher<P extends ACOffsetParamsMixin, T>
extends ACListDispatcher<P, T> {
LoggingDispatcher({super.searchStrategy});
@override
void notifyListeners() {
print('items: ${items.length}, isLoading: $isLoading');
super.notifyListeners();
}
}
When extending, respect the parent contract documented in the corresponding
class' API docs. In particular, ACListDispatcher extends
ChangeNotifier — overrides of dispose() must call super.dispose().
The shared engine — ACLoadingDispatcher #
Both dispatchers are thin subclasses of one abstract engine,
ACLoadingDispatcher<Params, T>. The engine owns the whole loading
lifecycle — search scheduling, cancellation of a previous load, staleness
guards, the isLoading flag (with the granular isReloading / isLoadingMore
and the reactive loadingListenable), the built-in error state
(lastError / errorListenable) with retry() / lastOperation, and the
typed lastResult — behind reload / loadMore / cancel / dispose. A
subclass supplies only the collection state and three hooks: hasMore,
onLoadSuccess and onLoadRejected.
This is an internal refactor:
ACListDispatcherandACPageDispatchernow share this base, but their public API and behaviour are unchanged — existing code needs no migration.
ACLoadingDispatcher is also a public extension point. If neither offset
(List<T>) nor ACPage-model pagination fits your backend, subclass the
engine directly and implement your own hasMore rule — the entire
lifecycle is inherited:
final class MyDispatcher<P extends ACParamsMixin, T>
extends ACLoadingDispatcher<P, List<T>> {
MyDispatcher({super.searchStrategy});
final List<T> _items = <T>[];
bool _hasMore = true;
List<T> get items => List<T>.unmodifiable(_items);
@override
bool get hasMore => _hasMore;
set hasMore(bool value) => _hasMore = value;
void mutate(void Function(List<T> items) update) {
if (isDisposed) return; // isDisposed — @protected from the base
update(_items);
notifyListeners();
}
@override
void onLoadSuccess(List<T> result, P params, {required bool replace}) {
replace ? (_items..clear()..addAll(result)) : _items.addAll(result);
_hasMore = result.isNotEmpty; // your own pagination rule
notifyListeners();
}
@override
void onLoadRejected() {
final wasNonEmpty = _items.isNotEmpty;
_items.clear();
_hasMore = false;
if (wasNonEmpty) notifyListeners();
}
}
reload / loadMore / cancel / dispose / runLoad / isLoading /
lastResult are inherited — never override them. Notifications are the
subclass' responsibility: the engine never calls notifyListeners(); emit
it from the hooks only when the collection actually changes.
API Reference #
Recommended #
ACListDispatcher<P, T>— self-contained dispatcher for offset pagination with a plainList<T>response, withreload,loadMore,cancelanddisposemethods plusitems,isLoading,hasMoreandlastResultgetters. Also exposesmutate(update)for external list mutation (realtime/optimistic/seed) with a single batched notification, and ahasMoresetter for manual pagination control.ACPageDispatcher<P, R, T>— self-contained dispatcher for a page-model (DTO) responseR extends ACPage<T>. Same lifecycle, getters,mutateandhasMoresetter asACListDispatcher;itemsandhasMoreare read directly from the returned page model.lastResultexposes the rawRfrom the most recent successful load (useful for cursor pagination or DTO metadata).ACAnchoredDispatcher<P, R, T>— bidirectional, anchor-centred dispatcher for chat/feed pagination, built as a composition of twoACPageDispatchers.loadAroundseeds the window around an anchor;loadOlder/loadNewergrow each side independently. Exposes the separateitemsOlder/itemsNewer(for aCustomScrollView(center:)without scroll jumps) and a merged read-onlyitems, fully per-side state (isLoadingOlder/Newer/Around,hasMoreOlder/Newer,lastError*/error*Listenable,loading*Listenable,lastResultOlder/Newer,lastAround,retryOlder/Newer), realtimemutateOlder/mutateNewer, pluscanceland an idempotentdispose.ACAnchoredPage<T>— around-page contract mixin (items,hasMoreOlder,hasMoreNewer) returned byACAnchoredDispatcher.loadAround; carries both directional flags at once.ACLoadingDispatcher<Params, T>— the abstract loading engine shared by both dispatchers. Owns the loading lifecycle (reload,loadMore,cancel,dispose,isLoading,lastResult); subclasses provide the collection state and thehasMore/onLoadSuccess/onLoadRejectedhooks. Public extension point for non-standard pagination. Also exposesloadingListenable(aValueListenable<bool>mirroringisLoadingfor reactive spinners) and the synchronous granular flagsisReloading/isLoadingMore, plus the built-in error state —lastError(synchronous),errorListenable(aValueListenable<Object?>),retry()(repeat the last operation) andlastOperation(introspection) — inherited by both dispatchers.ACDispatcherOperation<Params, T>— sealed model of the last operation captured forretry()/lastOperation, with the variantsACReloadOperationandACLoadMoreOperation(the latter carryingforce); both expose the commonparamsandload.ACPage<T>— page-model contract mixin (items,hasMore).ACParamsMixin— base parameters mixin (limit,query).ACOffsetParamsMixin— offset pagination mixin (offset).ACSearchStrategy— search strategy contract (schedule,cancel,dispose); implement it for a fully custom search behaviour.ACSearchDebouncer— default search strategy implementation withdebounceandminLength, composed on top ofACDebouncer.ACDebouncer— standalone, action-neutral debounce utility (run,isActive,cancel,dispose) for deferring anyvoid Function().ACCancelStrategy— cancellation strategy contract (run,cancel,isActive).ACOperationCancelStrategy— cancellation implementation on top ofCancelableOperationfrompackage:async.
Removed in 1.0.0 #
The parser-based API deprecated in 0.3.0 was removed in 1.0.0. The list
below is kept for historical reference and points to the replacement of each
class — see the migration guide for details.
ACDispatcher<P, R, T>— parser-composing core dispatcher. Replaced by the self-containedACListDispatcher/ACPageDispatcher.ACCustomDispatcher<P, R, T>— facade for DTOs. Replaced byACPageDispatcher.ACResult<T>— DTO contract mixin. Replaced byACPage<T>(identical members).ACParser<P, R, T>— parser strategy interface. Parsing now lives inside a dispatcher; no direct replacement.ACResultParser<P, R, T>— parser forACResultDTOs. Folded intoACPageDispatcher.ACDefaultDispatcher<P, T>/ACDefaultParser<P, T>— replaced byACListDispatcher(identical public contract).ACDebouncedSearchStrategy— replaced byACSearchDebouncer(samedebounce/minLengthparameters and behaviour).
Detailed documentation is available in the dartdoc on pub.dev.
Example #
A complete working example is available in the example/
folder. It demonstrates offset pagination, debounced search,
pull-to-refresh and infinite scroll on a single screen.
To run it:
cd example
flutter pub get
flutter run
License #
MIT — see LICENSE.