list_smith 2.0.1 copy "list_smith: ^2.0.1" to clipboard
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.

CI Coverage Status PRs Welcome Pub Version Pub Points License: BSD-3-Clause GitHub issues GitHub closed issues GitHub pull requests GitHub closed pull requests

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.

list_smith in action: pagination, pull-to-refresh, search, and grouping

Grouping demo on Android
Android · Material
The same demo on iOS
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.
  • withSignal sources reload sequentially and atomically. Page k needs page k-1, so the reload walks in order and any failure keeps the old list whole. concurrency and onError are 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 Dismissible or 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.

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() or invalidate() 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:

  • .sync buckets 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.
  • .async groups in arrival order. It can't reorder across pages, so your fetchPage (and the AsyncSearch fetcher) 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 groupBy parameter, or pass a typed function reference, so the key type infers instead of widening to Object.
  • Hold the Grouping stable on a large .sync list. 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 .sync list 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.

Render latency vs observer delay

Per-frame build cost vs the 60 Hz budget

Per-frame raster cost vs the 60 Hz budget

Sync-search cost vs list size

Sync grouping cost vs list size

De-dup cost vs loaded list size

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.

6
likes
160
points
504
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A developer-first Flutter package that wraps ListView.builder for real-world lists: async pagination, pull-to-refresh, and sync-or-async search.

Homepage
Repository (GitHub)
View/report issues

Topics

#listview #pagination #infinite-scroll #pull-to-refresh #search

License

BSD-3-Clause (license)

Dependencies

collection, custom_refresh_indicator, flutter, meta, multi_value_listenable_builder_typed, pool

More

Packages that depend on list_smith