list_smith 2.0.1
list_smith: ^2.0.1 copied to clipboard
A developer-first Flutter package that wraps ListView.builder for real-world lists: async pagination, pull-to-refresh, and sync-or-async search.
- Install
- Why list_smith?
- A quick taste
- 2 kinds of list
- Pagination
- Pull to refresh
- Editing loaded items
- Search
- Grouping
- Make it look like your app
- Watching what it does
- Scroll and layout
- Races and late answers
- Performance
- The example app
- Contributing
list_smith wraps ListView.builder for the lists you actually ship: async pagination,
pull-to-refresh, and search, sync or async. Hand it a data source, an item builder, and a bit of
config. It owns the scrollable, the controller, and every fiddly loading, error and empty state in
between. No ScrollController, no paging controller, nothing to wire up.
![]() Android · Material |
![]() iOS · Cupertino |
Install #
flutter pub add list_smith
Why list_smith? #
Plenty of packages page a list. Most ship Material widgets you then override. list_smith doesn't.
| No design system | Nothing in lib/ imports material.dart or cupertino.dart. Every surface it draws is a plain widgets-layer default, so it looks at home in Material, Cupertino, or your own thing. |
| 1 widget, not 3 | Paging, search and grouping in the same list. Search in memory or paged, and a group split across a page boundary still gets one header. |
| Your fetcher knows why it ran | Each call carries a PageRequest.trigger: first load, next page, pull, retry, query change, invalidate(). Serve cache or hit the network per reason, in one closure. |
| Swap behaviour, not widgets | Sealed seams for refresh, reload, search, cache policy, end detection, empty pages, grouping, group order, edit transitions. Built-ins for each, or write your own. |
| Perf is measured, not claimed | A committed benchmark suite with numbers and charts, so a regression shows up as a number. |
A quick taste #
A function that fetches a page and a builder for each item, plus an itemIdGetter so the list can
tell items apart. That's the whole setup:
ListSmith.async(
fetchPage: PageFetcher((request) => api.fetchArticles(page: request.pageIndex, size: request.pageSize)),
itemIdGetter: (article) => article.id,
itemBuilder: (context, article, index) => ArticleTile(article),
)
That already paginates as you scroll, pulls to refresh, loads, errors with a retry button, and knows
when it has hit the end. No id field? A value with its own ==, like an int, a String or a
record, can be its own id: itemIdGetter: (item) => item.
2 kinds of list #
Which constructor you reach for comes down to where your data lives.
| Constructor | Best for | Handles |
|---|---|---|
ListSmith.async |
data fetched a page at a time (an API) | pagination, pull-to-refresh, and optional async search |
ListSmith.sync |
a list you already hold in memory | client-side search, nothing to paginate |
Each takes only the parameters that make sense for it, so nothing you pass is ever quietly ignored.
Pagination #
ListSmith.async calls fetchPage with a PageRequest, then asks for the next as the user nears
the end. Return that page's items, any Iterable, turned into a list once for you. An empty page
is the end of the road:
ListSmith.async(
pageSize: 30,
fetchPage: PageFetcher((request) => repo.load(request.pageIndex, request.pageSize)),
itemIdGetter: (item) => item.id,
itemBuilder: (context, item, index) => Text(item.title),
)
Knowing why a page was fetched
A caching repository usually wants to treat the reasons differently, since serving a pull-to-refresh
out of the cache rather defeats the pull. request.trigger says which it was.
FetchTrigger |
What happened |
|---|---|
initialLoad |
the 1st page of a cold list |
nextPage |
the user neared the end, so the next page was asked for |
refresh |
a pull-to-refresh, or ListSmithController.refresh() |
retry |
this page's last attempt threw, and Retry was tapped |
queryChanged |
a committed search query changed, entering or leaving search included |
invalidated |
you called invalidate() or reset() on the controller: your data changed |
fetchPage: PageFetcher((request) => repo.load(
request.pageIndex,
request.pageSize,
forceRefresh: switch (request.trigger) {
.refresh || .retry => true,
.initialLoad ||
.nextPage ||
.queryChanged ||
.invalidated => false,
},
)),
It reports the fact and stops there. Bypass, revalidate, or serve stale: your call. invalidated
only ever fires because you called invalidate() or reset(), so a network-only source routes it
like initialLoad.
Where the data ends #
An empty page meaning "the end" is the sensible default. When your backend signals it another way, swap the policy. Pick by how your source behaves, not by mechanism:
| Policy | Reach for it when |
|---|---|
StopOnEmptyPagesPolicy (default) |
your source runs dry (a short, then empty, page). Do nothing. |
FixedPageCountPolicy |
you want a hard cap: a "top 100", or a teaser of N pages. |
ExplicitHasMorePolicy |
your backend returns a hasMore / isLast flag per response. |
StopOnNullSignalPolicy |
your backend is cursor-based, returning null when there's no more. |
The 2 count-based tweaks are one line each:
endPolicy: const StopOnEmptyPagesPolicy(emptyRunBeforeEnd: 3), // tolerate up to 2 empty pages
endPolicy: const FixedPageCountPolicy(pageCount: 5), // stop after 5 pages
The 2 signal-based ones read a value off each fetch, so they need PageFetcher.withSignal (and
SearchPageFetcher.withSignal if the list searches). Stopping on the flag saves the trailing empty
page a count-based policy fetches to find the end:
ListSmith.async(
fetchPage: PageFetcher.withSignal((request) async {
final response = await api.load(request.pageIndex, request.pageSize);
return (response.items, response.hasMore);
}),
itemIdGetter: (item) => item.id,
endPolicy: const ExplicitHasMorePolicy(),
itemBuilder: (context, item, index) => Text(item.title),
)
None of these fit? PaginationEndPolicy is an open contract. Its context carries the per-page
counts, the page size, and the last fetch's signal:
class ShortLastPage extends PaginationEndPolicy {
@override
bool hasReachedEnd(EndContext c) => c.lastPageItemCount < c.pageSize;
}
Cursor pagination
Keyset and cursor APIs don't take a page number. You hand back the cursor the previous page
returned. Same withSignal channel: a page's signal arrives on the next request as previousSignal
(null for the 1st page), and StopOnNullSignalPolicy ends the list when the cursor runs out.
ListSmith.async(
fetchPage: PageFetcher.withSignal((request) async {
final page = await api.list(cursor: request.previousSignal as String?, limit: request.pageSize);
return (page.items, page.nextCursor); // null nextCursor ends it
}),
itemIdGetter: (item) => item.id,
endPolicy: const StopOnNullSignalPolicy(),
itemBuilder: (context, item, index) => Text(item.title),
)
The cursor is opaque to list_smith (an Object?), so cast it back in the closure. Search gets its
own cursor the same way, through SearchPageFetcher.withSignal.
When a page in the middle comes back empty
The policies above decide whether more pages exist. A page with nothing to show is a different problem: nothing on screen means nothing to scroll, so the pager never asks for the pages that do have data. A calendar paged by day hits this on a quiet today.
onEmptyPage closes the gap. AdvanceToFirstNonEmpty pages past empty pages itself, to the 1st
one with items or the true end, showing the loading surface while it goes:
ListSmith.async(
fetchPage: PageFetcher((request) => calendar.dayPage(request.pageIndex, request.pageSize)),
itemIdGetter: (item) => item.id,
// An empty day isn't the end...
endPolicy: const StopOnEmptyPagesPolicy(emptyRunBeforeEnd: 31),
// ...so page straight past empty days to the 1st with entries.
onEmptyPage: const AdvanceToFirstNonEmpty(),
itemBuilder: (context, item, index) => Text(item.title),
)
They go together, since advancing only bites under a policy that continues past an empty page. Cap
the scan with AdvanceToFirstNonEmpty(maxPages: 31) and it gives up after that many empty pages. A
pull re-scans.
De-duplicating overlapping pages
Offset-based sources can hand you the same row twice when the data shifts between fetches: a row is inserted, so page N's tail reappears as page N+1's head. list_smith drops any item whose id already showed up, so the repeat never renders.
Keys compare by value, so an int or String id works. Compose one like '${item.a}:${item.b}'
for multi-field identity.
De-dup runs when a page arrives, never per scroll frame. What it costs is in Performance.
Pull to refresh #
On by default for ListSmith.async. Pull from the list's start (the top of a plain vertical list)
and it resets and reloads from the 1st page. Switch it off with refresh: NoRefresh().
A pull works on the rows, and on the error and empty screens unless you leave them out of
pullableSurfaces, a set of PullableSurface.error and .empty. Never on the 1st-page loader,
whose page is already on its way. A short list takes a pull too, whatever your ScrollController,
physics or scroll direction, unless its physics are NeverScrollableScrollPhysics.
refresh: const PullToRefresh(pullableSurfaces: {.error}), // the empty screen takes no pull
Want your own indicator? Give PullToRefresh an indicatorBuilder. It gets a small snapshot of the
pull (its phase, drag value and pullDirection) and returns just the indicator. list_smith places
it on the edge the pull comes from and only builds it mid-pull, so a spinner inside it can't keep
running while the list sits idle. indicatorExtent sets how much room it gets:
refresh: PullToRefresh(
indicatorBuilder: (context, state) => MyIndicator(state),
indicatorExtent: 80,
),
Retry stays in your fetcher, not here. Wrap fetchPage with something like
retry so a transient blip is handled before the reload sees it:
import 'package:retry/retry.dart';
fetchPage: PageFetcher((request) =>
retry(() => api.load(request.pageIndex, request.pageSize), retryIf: (e) => e is SocketException)),
Keeping the user's scroll depth across a pull
The default ResetToFirstPage clears the list, jumps back to the start, and reloads page 1. If the
user had scrolled deep, they lose their place. ReloadToCurrentDepth re-fetches every page they
had loaded instead:
refresh: const PullToRefresh(
reload: ReloadToCurrentDepth(
concurrency: 4, // fetches in flight: 1 (default) serial, null all at once, K bounded
onError: .commitSucceeded, // best-effort default, or .allOrNothing
),
)
concurrency trades speed against backend load. onError handles a page-fetch that fails after
your fetcher's own retries: commitSucceeded keeps whatever reloaded and leaves the failed page as
it was, allOrNothing commits only if every page succeeds.
Worth knowing:
- Best-effort can seam. A kept-old page beside fresh neighbours can duplicate or gap if the data shifted meanwhile. De-dup drops the duplicates, and gaps heal on the next refresh.
withSignalsources reload sequentially and atomically. Pagekneeds pagek-1, so the reload walks in order and any failure keeps the old list whole.concurrencyandonErrorare ignored there. Scroll depth is still kept.- A page still loading when the pull happens is dropped and asked again. Its answer was aimed at pre-refresh data. Costs one extra request.
Refreshing from code #
A toolbar button, a re-tapped tab, a re-read after a local write, a logout. Pass a
ListSmithController typed with your item, and call the verb that says why.
final controller = ListSmithController<Task>();
ListSmith.async(fetchPage: PageFetcher(...), itemIdGetter: (task) => task.id, itemBuilder: ..., controller: controller)
await controller.refresh(); // fresh data wanted: exactly a pull
await controller.invalidate(); // my data changed: re-read every loaded page, keep my place
await controller.reset(); // start over from page 1: logout, account switch, a filter
| Verb | Runs | Pages report | Meets a load already running |
|---|---|---|---|
refresh() |
the pull's Reload, ResetToFirstPage under NoRefresh |
refresh |
joins a refresh, otherwise runs once more after it |
invalidate() |
ReloadToCurrentDepth, whatever the pull does |
invalidated |
joins it, then runs once more |
reset() |
ResetToFirstPage, always |
invalidated |
cuts in |
In search mode refresh() reloads the search rather than the feed, and reset() keeps the query
so the search restarts. invalidate() keeps the user's place on purpose: a pull snapping to the
top is a convention, a local write doing it is a bug. A feed kept by KeepCachePolicy catches up
once you come back.
No indicator: that belongs to the pull, and your button owns its progress, hence the futures. Each completes once its fresh data shows or its fetch fails. A call that meets a load already running completes with that load, not with the run after it.
invalidate() and reset() are no-ops before any list has attached, since a view-model often hears
a store event before its view builds. refresh() there asserts: only wiring can cause it.
Nothing to dispose, and async-only. To watch the list rather than drive it, use an observer.
Editing loaded items #
A swipe-to-delete, a post you just created, a rename. When you already know what changed, skip the re-read. Notify the list and it shows the change at once, keeping the scroll position.
ListSmith.async(
fetchPage: PageFetcher(...),
itemIdGetter: (task) => task.id, // an edit finds its row by this
itemBuilder: ...,
controller: controller,
)
final saved = await api.save(task);
controller.upsert(saved); // replaces the loaded copy, or adds it if none is loaded
await api.delete(task);
controller.remove(task); // hides every loaded copy
Both are sync and return nothing, since the list takes the change as already true on your server or in your store. Where the item shows:
- A loaded item changes in place.
- A new one goes on top, the newest first. On a grouped list it joins the start of its group or goes on top if that group isn't loaded.
- An item whose group changed moves to the start of its new group.
- While searching, a new item waits for the feed, since only your server knows what matches the query. Changes and removals show in the results too.
An edit lasts until the pages it covers are read again, and a page loaded after it shows your server's copy. So after a failed save, a refresh puts that copy back. Removals never end the list early: the end policy still counts what the server sent, and removing every row on the screen loads the next page.
Rows follow their item, so a row keeps its own state, like an open tile or a swipe halfway done, while rows above it come and go.
remove fits Dismissible.onDismissed as it is:
itemBuilder: (context, task, index) => Dismissible(
key: ValueKey(task.id),
onDismissed: (_) => controller.remove(task),
child: TaskTile(task),
),
The row has to go before your server has answered, so if the delete then fails, a refresh brings it back.
On an offset-paged list, a deletion on your server moves every later row up a place, so the next page skips one. Use cursor paging for a list you edit, for now.
Animating edits #
An edit shows at once unless you pass an editTransition. Then the row an upsert adds animates
in, and the row a remove takes animates out:
ListSmith.async(
fetchPage: PageFetcher(...),
itemIdGetter: (task) => task.id,
itemBuilder: ...,
controller: controller,
editTransition: EditTransition(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) => SizeTransition(sizeFactor: animation, child: child),
),
)
The transitionBuilder runs forward for a row coming in and backwards for one going out, so any
transition works, AnimatedSwitcher.defaultTransitionBuilder included. list_smith brings the
timing, not a look.
- Only edits animate. Page loads, reloads and rows scrolling in show at once.
- A removed row stays until its exit ends, so upserting it meanwhile brings it back.
- A row that shrinks itself, like a
Dismissibleor a Slidable's full swipe, goes at once, with no second animation on top. - With the platform's reduce-motion setting on, edits show at once.
Search #
This is where the 2 constructors part ways the most.
In memory, with ListSmith.sync #
Give .sync the items and a predicate that decides whether an item matches the current query. It
filters client-side and shows a "no results" surface when nothing does.
Most lists want "keep the item when any text field contains the query", so there's a builder for it:
ListSmith<City>.sync(
items: allCities,
searchBy: SyncSearchPredicates.fields([(city) => city.name, (city) => city.country]),
query: searchQuery, // you own the field, more on that below
itemBuilder: (context, city, index) => Text(city.name),
)
Case-insensitive substring over every field you list, null fields skipped so nullable ones need no
?? ''. Type the list (ListSmith<City>.sync) once and every builder's item type resolves.
Same idea, different match: prefix (starts-with, for type-ahead), exact, and allTerms (every
whitespace term must hit a field, so "john smith" finds "Smith, John"). Combine them, or your
own predicate, with SyncSearchPredicates.any (OR) and .every (AND).
Need case-sensitive or diacritic-folded matching? The predicate is yours, a plain
bool Function(item, query):
searchBy: (city, query) => city.name.toLowerCase().contains(query.toLowerCase()),
Or swap in fuzzywuzzy so near-misses still hit:
searchBy: (city, query) => weightedRatio(city.name, query) >= 70, // 0-100, tune the cutoff
No pagination or pull-to-refresh here, since there's nothing to page or refresh over a list already
in memory. And if you don't need search at all, a plain ListView.builder will do.
Paged, with ListSmith.async #
Pass search: AsyncSearch(...) and the list grows a 2nd mode. An empty query shows the normal feed,
a non-empty one switches to paginated search results, and back again once it clears. 1 list, 2
views, with pagination and pull-to-refresh working in both:
ListSmith.async(
fetchPage: PageFetcher((request) => repo.feed(request.pageIndex, request.pageSize)),
itemIdGetter: (item) => item.id,
search: AsyncSearch(
fetchPage: SearchPageFetcher((r) => repo.search(r.query, r.pageIndex, r.pageSize)),
),
query: searchQuery,
itemBuilder: (context, item, index) => Text(item.title),
)
What happens to the feed while you search?
| Policy | Reach for it when |
|---|---|
ReplaceCachePolicy (default) |
a clean reload each way is fine, or the feed should pick up changes it was never told about. |
KeepCachePolicy |
returning to the feed should be instant: its pages and scroll position are kept, no refetch. |
search: AsyncSearch(fetchPage: mySearchFetcher, cachePolicy: const KeepCachePolicy()),
"No refetch" has its exceptions:
- a page still loading when the search started is dropped and asked again
- a pull,
refresh()orinvalidate()while searching re-reads the kept feed in place once you're back, reporting that trigger reset()while searching drops the kept feed too, so it comes back from page 0
Dropping search mid-search lands like clearing the query, so back on the kept feed. Want a fresh
one instead? Call reset() right after.
You keep the search field #
list_smith renders no text field. Keep your own, hold the query in state, pass it in as query:
// inside a StatefulWidget's State:
var _query = '';
@override
Widget build(BuildContext context) => Column(
children: [
// your field: a TextField, a CupertinoTextField, your design system's search bar, wherever
TextField(onChanged: (value) => setState(() => _query = value)),
Expanded(child: ListSmith.async(query: _query, /* fetchPage, itemIdGetter, search, itemBuilder as above */)),
],
);
A ValueNotifier and a ValueListenableBuilder work too, and the field can sit anywhere. Clearing
is _query = '', and list_smith flips back to the feed on its own.
searchDebounce waits for typing to settle, 300ms on async and zero on sync where an in-memory
filter is instant. minSearchLength ignores anything shorter than N characters. The query is
trimmed first, so a field full of spaces counts as empty.
Grouping #
Labelled sections instead of one flat run. Pass a Grouping.by: a groupBy returning each item's
section key, plus a headerBuilder for the header. Works on both constructors:
ListSmith.sync(
items: contacts,
searchBy: (contact, query) => contact.name.toLowerCase().contains(query.toLowerCase()),
query: searchQuery,
grouping: Grouping.by(
groupBy: (Contact contact) => contact.team,
headerBuilder: (context, team) => SectionHeader(team),
),
itemBuilder: (context, contact, index) => Text(contact.name),
)
Grouping runs over whatever is visible, so it composes with search: sections re-form over the matches as you type.
How each path orders its sections, and what to watch
The 2 paths order differently, and the difference matters:
.syncbuckets for you. It holds the whole list, so it gathers each group into one contiguous run: groups in the order they first appear, items kept in order within a group. Your input can arrive any way round..asyncgroups in arrival order. It can't reorder across pages, so yourfetchPage(and theAsyncSearchfetcher) must return items already grouped by key, all of one group before the next. A group spanning a page boundary still gets a single header.
If a key does come back after its section ended, orderPolicy decides. The default
RepairHeadersPolicy draws each header once and asserts in debug. FailOnUnorderedPolicy throws a
StateError in release too, for when a wrong-looking list is worse than a crash:
grouping: Grouping.by(
groupBy: (Contact contact) => contact.team,
headerBuilder: (context, team) => SectionHeader(team),
orderPolicy: const FailOnUnorderedPolicy(),
),
Worth knowing:
- Type the
groupByparameter, or pass a typed function reference, so the key type infers instead of widening toObject. - Hold the
Groupingstable on a large.synclist. One rebuilt every frame re-buckets every frame, so keep it in a field and the result stays cached. What that costs is in Performance.
Make it look like your app #
Every surface list_smith draws (loaders, errors, the empty state, the "that's everything" footer,
the pull indicator) is a neutral widgets-layer default. No CircularProgressIndicator, nothing
from Material or Cupertino, so nothing fights the app you've built. Override the slot for your own.
emptyBuilder (a source with no items) and noResultsBuilder (a search that matched
nothing, it gets the query) sit on the constructor, because every list has them. The rest are
async-only, gathered into an AsyncListSurfaces you define once and reuse for a house style:
ListSmith.async(
fetchPage: PageFetcher(...),
itemIdGetter: (item) => item.id,
itemBuilder: ...,
emptyBuilder: (context) => const Center(child: Text('Nothing here yet')),
surfaces: AsyncListSurfaces(
firstPageLoadingBuilder: (context) => const MySpinner(),
firstPageErrorBuilder: (context, error, onRetry) => MyError(error, onRetry: onRetry),
noMoreItemsBuilder: (context) => const Text("That's everything"),
),
)
Surfaces get exactly the list's visible space. Make yours fit, or wrap it in a
SingleChildScrollView.
The full set of surface slots
On the constructor (any list):
| Slot | Shown when |
|---|---|
emptyBuilder |
the source has no items |
noResultsBuilder |
a search matched nothing (receives the query) |
In AsyncListSurfaces (async lists only):
| Slot | Shown when |
|---|---|
firstPageLoadingBuilder |
the 1st page is loading |
newPageLoadingBuilder |
a further page is loading |
firstPageErrorBuilder |
the 1st page failed (receives the error + a retry call) |
newPageErrorBuilder |
a further page failed (receives the error + a retry call) |
noMoreItemsBuilder |
every page has loaded |
The pull indicator is set separately, on PullToRefresh. The error builders get
(context, error, onRetry), so a custom error view can offer retry without you reaching for a
controller. Only an Exception from your fetcher gets there: an Error is a bug, so it goes on to
your app's error handler instead. Leave any slot out and its neutral default fills in.
Show your own rows while it loads #
Nicer than a spinner: hand the loading slots the row you already build, with a stand-in item.
ListSmith.async(
fetchPage: PageFetcher(...),
itemIdGetter: (article) => article.id,
itemBuilder: (context, article, index) => ArticleTile(article),
surfaces: AsyncListSurfaces(
// Hold the placeholder in a field, or you rebuild it every frame.
newPageLoadingBuilder: (_) => MyShimmer(child: ArticleTile(_placeholderArticle)),
// Sparse source? AdvanceToFirstNonEmpty sits on this slot for the whole scan.
firstPageLoadingBuilder: (_) => MyShimmer(
child: Column(children: List.filled(3, ArticleTile(_placeholderArticle))),
),
),
)
The shading is yours: list_smith ships none. If the row needs something from the enclosing scope, hoist the builder to a local and call it from both slots.
Watching what it does #
Log a load, report an error to your crash tool, count how often people search. Pass an observer
and every callback hands you plain values (a page index, a count, the query, the error), never a
controller or a paging type:
final class MyObserver extends ListSmithObserver {
const MyObserver();
@override
void onError(Exception error, StackTrace stackTrace) => crashReporter.record(error, stackTrace);
}
ListSmith.async(
fetchPage: PageFetcher(...),
itemIdGetter: (item) => item.id,
itemBuilder: ...,
observer: const MyObserver(),
)
Override only what you care about, the rest cost nothing: onPageLoaded, onError, onReload,
onQueryCommitted, onSearchModeChanged. onReload carries the trigger its pages will report and
fires before the 1st of them is asked for, so anything you start there is under way by the time
your fetcher runs. In a hurry? LoggingListSmithObserver() pushes every event through
dart:developer, so it lands in DevTools and stays avoid_print-clean. Overrides run synchronously
while a page loads, so keep them light (see Performance).
Observers are async-only. A
.synclist has no fetch, refresh, or controller to observe, and you already hold the query it filters on.
Scroll and layout #
Padding, physics, a scroll controller, reverse, direction, cache extent: the usual knobs live
together in a ListScrollConfig, clear of the behavioural parameters so neither crowds the other.
scroll: const ListScrollConfig(
padding: EdgeInsets.all(16),
physics: BouncingScrollPhysics(),
),
Races and late answers #
Lists race. The user types while an old query's page is still loading, or pulls to refresh while a page is in the air. list_smith settles those, and each guarantee below has a test behind it.
The guarantees
A query change drops the pages still in flight. They're discarded, not appended. Cursors too, so the next page starts from the new query's cursor and not one an abandoned request returned. Otherwise hits for a query you deleted turn up under the ones you asked for.
Group headers come from the whole loaded list, not per page. A group spanning a page boundary gets one header, and isn't split. For out-of-order pages see Grouping.
A refresh drops the pages still in flight, on both reload strategies, so a page requested before the pull can't land after it and duplicate rows or leave a hole. It's asked again, so you keep what the refresh committed. Same when the feed returns after a search.
Asking twice doesn't fetch twice. A refresh asked while one runs, page 0 included, joins it, so a double-tapped button sends 1 request.
An edit outlives a reload already in flight. A page fetched before your edit shows the edit when it lands, and so does one whose re-fetch failed. Otherwise a row you just deleted would come back mid-reload.
Performance #
Measured on one machine (yours will differ), from the committed benchmark report:
| What | Cost |
|---|---|
| Scrolling | within ~0.07 ms/frame of a plain ListView.builder, neither dropping a frame |
| Per-page bookkeeping (end policy, observer dispatch) | sub-microsecond to a few microseconds |
| A full pull-to-refresh cycle | ~0.4 ms/frame to build, none over the 16.67 ms budget |
| Animating edits (size, fade or slide) | +0.05 to 0.2 ms/frame over the same edits unanimated, none over budget |
| Sync search, per committed query | ~0.4 ms at 1k items, ~4 ms at 10k, ~41 ms at 100k |
| Sync grouping, per committed query | ~0.2 ms at 1k, ~2.4 ms at 10k, ~26 ms at 100k |
| De-dup by id, per page arriving | ~0.3 ms at 1k loaded, ~3.4 ms at 10k, ~40 ms at 100k |
| A 50 ms observer callback | pushes render latency to ~69 ms |
Sync search and grouping are O(n) per query and cross the frame budget around 100k items, so lean
on the debounce or go async. De-dup is off the scroll path, and only crosses the budget past tens of
thousands of items in one live list. Edits run in that same pass, and
edit_layer_scaling measures what they add. Observers
are called synchronously while a page loads: log, count, report, and do heavy work elsewhere.
Numbers are per-machine, so capture your own baseline before trusting a delta. The suite lives in
benchmark/, and run.py compare diffs 2 runs with a Mann-Whitney test.






The example app #
The example/ app is the best place to watch it all work. It opens on the list of
demos.
Contributing #
Issues and pull requests are welcome. Have a look at AGENTS.md for the
conventions and CODESTYLE.md for the code style before you start. How the parts
fit is mapped in doc/how-it-works.md, and the reasoning behind the bigger
design decisions lives in APPENDIX.md.


