search_anchor_picker 0.2.3
search_anchor_picker: ^0.2.3 copied to clipboard
A searchable Flutter picker with multi or single selection, nested sub-pickers, and async save callbacks that roll back on failure.
Search Anchor Picker #
A Flutter Material 3 picker that feels like SearchAnchor, with multi-select,
single selection, and optional nested menus.
Features #
- SearchAnchor-compatible popup styling and
SearchViewThemesupport. - Anchored desktop popups and full-screen mobile views by default.
- Multi, single, and single-optional selection modes.
- Nested
SubPickerTilemenus with optional animated offsets. - Optional selected-first ordering, frozen at open so toggles do not reshuffle
the list. Default is on (
selectedFirst: true). - Explicit search modes:
local(default) loads once and filters the snapshot;remotereloadsitemsLoader(query)and trusts that page;hybriddoes both. Missing loaded items are never interpreted as deleted selections. - The picker notifies; it does not persist. Save from
onChange(each accepted toggle) oronClose(whole delta of session). - Optional
PickerUnselectPolicy(allow / blocked / confirm) per row viarelatedListItemStatusOf. - Optional builders for every visual region; built-in widgets are only defaults.
- No permanent trigger
GlobalKey; popup resources are created on demand.
Installation #
dependencies:
search_anchor_picker: ^0.2.0
Basic picker #
final selectedIds = <int>{};
SearchAnchorPicker<Person>(
config: PickerConfig<Person>(
title: 'Pick people',
searchMode: PickerSearchMode.remote,
itemsLoader: (context, query) => searchPeople(query),
idOf: (person) => person.id,
labelOf: (person) => person.name,
),
initialSelectedIds: selectedIds.toList(),
onClose: (result) async {
await api.addPeople(result.added);
await api.removePeople(result.removed);
},
triggerBuilder: (context, open, version) => IconButton(
tooltip: 'Pick people',
onPressed: open,
icon: const Icon(Icons.person_search),
),
);
initialSelectedIds is the external source of truth for the next open. While a
picker is open, explicit row toggles are tracked independently so onClose
observes only actual user add/remove intent. If onClose throws, the popup
stays open: a localized saving wrap is shown, then a prompt offers update
selection or close without saving.
initialSelectedItemCache is an optional display cache for selected IDs.
Providing it shows those selected IDs when they are missing from loaded
items, and as a fallback when itemsLoader fails (the load error stays on
top). For example, itemsLoader may use network calls to resolve IDs to
items, while selected items were already resolved elsewhere. Selection
truth still comes from initialSelectedIds. The cache does not create
selection. Missing cached rows appear in a Selected section; search
still applies only to Results. Unchecking a cached missing row keeps
it visible until close so the user can undo. Persist onClose added /
removed only — the cache is display support.
Reloading items #
Creating a new inline PickerConfig(...) during a parent rebuild does not
reload an open picker. Configuration changes are rebound without treating object
identity as a data revision.
One PickerConfig instance binds to one picker. A second widget with the
same instance throws. copyWith() is an unbound copy, so two pickers can
share loaders via config.copyWith(). open() / close() stay on the
instance that is bound.
Reload deliberately through one of these paths:
- Type or clear the default search field when
searchModeisremoteorhybrid(itemsLoaderis called withquery). - Call
controller.refresh()from custom popup UI. - Provide
config.listenable; notifications reload only while the popup is open. - Change
config.reloadKeyfor declarative revision-based reloads.
PickerConfig<Person>(
searchMode: PickerSearchMode.remote,
reloadKey: resultsRevision,
listenable: repository.changes,
itemsLoader: (context, query) => repository.search(query),
idOf: (person) => person.id,
labelOf: (person) => person.name,
);
A closed picker never subscribes or reloads. Its next open always calls the
latest itemsLoader once.
SearchAnchor styling #
The default view resolves values in the same order as Flutter SearchAnchor:
- Explicit picker parameter.
ThemeData.searchViewTheme/SearchViewTheme.- Material 3 SearchAnchor defaults.
SearchAnchorPicker<Person>(
config: config,
initialSelectedIds: selectedIds,
viewHintText: 'Search people',
viewConstraints: const BoxConstraints(
minWidth: 420,
minHeight: 260,
maxHeight: 560,
),
viewShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
viewBackgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
);
Supported SearchAnchor-style options include isFullScreen, viewLeading,
viewTrailing, viewHintText, surface colors, elevation, side, shape, bar
padding, header styles, divider color, constraints, view padding, shrinkWrap,
keyboard configuration, and open/close/change/submit callbacks.
On Android, iOS, and Fuchsia the default view is full screen. Desktop platforms
use an anchored popup. Set isFullScreen explicitly to override this behavior.
menuOffset only applies to anchored popups and is ignored in full-screen mode.
Anchored popups close when the user taps outside; a full-screen view has no
outside area, so its default search field always provides a localized back
button. Supplying viewLeading replaces that button, and a custom
searchFieldBuilder replaces the whole field; custom versions must expose the
provided close callback when users otherwise have no visible way to leave.
Pass a TextEditingController as queryController and wire it into a custom
field — the picker watches .text while open, including programmatic
writes. Open and close with config.open() / config.close().
config.isAttached is whether a picker is bound; config.isOpen is
whether that overlay is showing.
Optional default widgets #
The core owns search state, selection, popup placement, focus, keyboard handling,
and lifecycle. Visual defaults are exported by both the main package and
package:search_anchor_picker/widgets.dart.
Use focused builders to replace only the region you own:
triggerBuilder,headerBuilder, anditemBuildersearchFieldBuilderandresultsBuilderloadingBuilder,emptyBuilder, anderrorBuilderviewBuilderandviewSurfaceBuilder
Omitting triggerBuilder keeps the compact search icon. For a Material text
field with selected chips, pass DefaultPickerFieldTrigger:
SearchAnchorPicker<Person>(
config: config,
initialSelectedIds: selectedIds.toList(),
onClose: (result) async {
await api.addPeople(result.added);
await api.removePeople(result.removed);
},
triggerBuilder: (context, open, _) => DefaultPickerFieldTrigger<int>(
selectedIds: selectedIds,
labelOf: (id) => names[id] ?? '#$id',
onOpen: open,
onDeleted: (id) => selectedIds.remove(id),
selectionMode: SelectionMode.multi,
),
);
Tapping the field, a chip, or Add / Change opens the picker. Add is used for
multi-select; Change is used for single and singleOptional. Chip delete
icons call onDeleted and do not open the popup. That write is outside the
picker session, so persist it yourself if you also save from onChange or
onClose. Set showChips: false to keep the outlined field and button without
listing selected IDs.
itemBuilder receives
(context, item, isSelected, relatedListItemStatus, source, toggle).
source is PickerItemSource.loaded or
PickerItemSource.initialSelectedItemCache. Custom rows can wrap
DefaultPickerItemTile with title / subtitle /
relatedListItemStatus instead of replacing the checkbox. Calling
toggle still uses the picker's unselect policy and selection logic.
The default empty view localizes “No items” and “No results” for several common
languages using the app's current locale. Set emptyText / noResultsText to
provide application wording, or use emptyBuilder to replace the region.
Default retry and unselect warning/confirmation text uses the same locale set;
Flutter MaterialLocalizations supplies back, clear, search, and cancel labels.
For example, a custom error region still uses the core retry lifecycle:
errorBuilder: (context, error, stackTrace, retry) => Center(
child: FilledButton.icon(
onPressed: retry,
icon: const Icon(Icons.refresh),
label: const Text('Try again'),
),
),
The default popup widgets are not built while the popup is closed.
Related-list status and sub-list membership #
Default behaviour. Rows do not show membership in another list. Unselect is allowed. The checkbox is only this picker's selection: a checked item is not assumed to belong to a directory or parent list, and membership never checks or unchecks the row.
What you may need to change. Show that an item is in another list. Block or
confirm unselect when it is still in use. Keep that status current while the
popup is open, without reloading search results. This does not fire onChange
or onClose on the related picker, so APIs you hooked there will not run. If
you need them, handle that here.
How. Return a PickerRelatedListItemStatus from relatedListItemStatusOf.
auxiliaryMembership is display only: member, notMember, or unknown.
The default row uses a filled, outlined, or search-marked person icon. A
custom iconOf replaces those icons. unselectPolicy is allow, blocked
(keep selected, show an in-use warning), or confirm. Replace the default
warning and confirmation UI with unselectWarningBuilder and
unselectConfirmationBuilder.
Use unknown when a paged or filtered result cannot prove absence. If the
item has an authoritative flag such as isInDirectory, return member or
notMember even when the list is paginated. Missing from one page is not
notMember. unknown is only the icon; it does not block unselect.
onChange / onClose do not receive this status. If you need a fresh
membership or in-use check, do it there from the IDs, then update the
directory source so relatedListItemStatusListenable redraws.
relatedListItemStatusListenable redraws this status while the popup is open
and does not call itemsLoader. Parent checkbox coupling is a separate
SubPickerParentSelectionEffect on SubPickerTile.
PickerConfig<Person>(
relatedListItemStatusListenable: directoryMembership,
relatedListItemStatusOf: (person) => PickerRelatedListItemStatus(
auxiliaryMembership: directoryMembership.value.contains(person.id)
? PickerAuxiliaryMembership.member
: allDirectoryMembershipIdsLoaded
? PickerAuxiliaryMembership.notMember
: PickerAuxiliaryMembership.unknown,
unselectPolicy: person.isInUse
? PickerUnselectPolicy.blocked
: PickerUnselectPolicy.allow,
),
// itemsLoader, idOf, labelOf, and searchTermsOf...
),
Nested pickers #
SubPickerTile is an optional convenience widget for sublist membership:
headerBuilder: (context, controller, items) => [
SubPickerTile<Person>(
title: 'Manage directory membership',
config: directoryConfig,
initialSelectedIds: directoryIds,
menuOffset: const Offset(30, 12),
onClose: (result) async {
await directoryApi.add(result.added);
await directoryApi.remove(result.removed);
},
),
],
Sub-list membership does not change parent selection by default. Opt in when child changes should update the open parent's checkboxes:
SubPickerTile<Person>(
parentController: controller,
parentSelectionEffect: SubPickerParentSelectionEffect.deselectRemoved,
// title, config, initialSelectedIds, and onClose...
)
Choose selectAdded to check added items, deselectRemoved to uncheck removed
items, or mirror for both. none is the default.
These effects update the open parent's checkboxes after a successful
onClose. That is the usual path. If onClose is omitted, the effect
applies after each accepted onChange. When onClose is also set, call
notifyParent() after every successfully handled onChange if the parent
should update immediately. Do not call it selectively within one open
session. If it is never called, the parent effect runs once after a
successful onClose. They do not run while the write is in flight, and
Close without saving leaves the parent unchanged. They do not save parent
selection, update the parent's initialSelectedIds, or include those
changes in the parent's onChange / onClose deltas. The parent search
field shows a small spinner while that write is in flight; close already
waits for in-flight onChange work.
If your backend stores parent selection separately from sub-list membership,
the child's onChange or onClose callback must also call the appropriate
parent API and update the authoritative parent IDs for the next open. Parent
checkbox updates do not require reseeding the whole parent picker.
The picker notifies; it does not persist. Save from onChange (each accepted
toggle) or onClose (whole delta of session). Parent checkboxes follow
successful child writes, including bulk header commands. SubPickerTile
onChange also receives notifyParent:
onChange: (delta, notifyParent) async {
await directoryApi.add(delta.added);
await directoryApi.remove(delta.removed);
notifyParent(); // every successful onChange, or never in this session
},
onClose: (result) {
// If notifyParent was never called, parent sync happens here.
},
Blocked, cancelled, or thrown-onChange changes never update the parent.
Closing waits for in-flight onChange work to settle.
viewOnClose remains the overlay-lifecycle callback.
Server-side search safety #
searchMode chooses how the default search field works. No custom view is
required.
final debounce = Debouncer();
Future<List<Person>> searchPeople(String query) {
if (query.isEmpty) return api.searchPeople(query);
return debounce.run(() => api.searchPeople(query));
}
SearchAnchorPicker(
config: PickerConfig(
searchMode: PickerSearchMode.remote, // reload(query), show that page
itemsLoader: (context, query) => searchPeople(query),
// idOf, labelOf...
),
onQueryChanged: (query) {
// Active query, including programmatic queryController.text writes.
},
)
The picker does not debounce. Wrap a remote itemsLoader (or the API behind it)
with Debouncer or the same idea in the repository so typing does not start
one request per keystroke. Skip the wait when query is empty so open is not
delayed. Stale completions are already ignored.
PickerConfig(
// searchMode: local (default): load once, filter with searchTermsOf
itemsLoader: (context, query) => catalog,
searchTermsOf: (person) => [person.name, person.email],
)
hybrid reloads on each query and then filters that page locally — only when
the server page is a superset you still want to narrow. A selected ID missing
from the current page remains selected.
Header selection helpers record explicit persistence intent:
clearLoaded()/selectLoaded()clearFiltered()/selectFiltered()setSelected(id, selected)
Bulk helpers affect only the current loaded result. Hidden server-side selections are untouched. Each command reports one delta, the same way a row toggle reports one.
Use syncPending(added:, removed:) to apply an already-persisted external delta
to the currently open picker's temporary pending set. It processes additions
and removals symmetrically, but does not mutate initialSelectedIds or caller
state and does not report the change again through onChange or onClose. The caller must
update its authoritative selected IDs separately.
Persistence #
The picker notifies; it does not persist.
onChange is the immediate save: each accepted delta, right after the
checkboxes move. A thrown onChange error restores them. Toggling the same
ID on and off is two onChange calls. onClose is the deferred save: the
net of the session versus the seed captured at open, saved while the overlay stays
open. Select then deselect the same ID any number of times and it is omitted;
an empty delta means do not write. A thrown onClose error is reported and
the user is asked whether to update the selection or close without saving.
Default strings are localized; override the saving wrap with
closeSavingBuilder, the prompt with closeSaveFailedBuilder.
Both may be set. onChange does not consume session intent, so onClose
still reports the same net. If both persist, the API is called for each
mutation and again at close. Persist in only one of them. A later
initialSelectedIds update reseeds checkboxes; it does not change the close
baseline.
Block or confirm unselect with relatedListItemStatusOf /
PickerUnselectPolicy. A blocked or cancelled unselect never moves the
checkbox and never runs onChange. Throw from onChange to restore a
checkbox after a failed write.
Bulk header commands are ordinary mutations: one delta.
Save that delta as a single write. A save that loops the IDs turns one
Select-all tap into a request per person, which is a property of the save, not
of the command. onClose avoids the question, because a whole session
collapses into one net delta.
Persist added and removed, applied to the seed you already hold. A load
error or empty search is not a deletion.
initialSelectedIds is only the seed for the next open, from your selected
IDs.
License #
MIT