A small Flutter library that turns Material 3 SearchAnchor into a reusable
picker widget with selection state, optional nested “sub-pickers”, and a clean
configuration surface.
It is designed for cases where you need:
- A searchable list picker (multi or radio selection)
- “Selected first” ordering and stable ordering
- Custom header content (actions, nested pickers, filters, etc.)
- A simple API that hides overlay / lifecycle gotchas
Online demo:
- Example app (web): https://alexqwesa.github.io/generic_search_selector/
Technical deep dive:
- See
technical_overview.mdfor architecture, lifecycle edge cases, and why some fixes exist (dynamic keys, PostFrameCallback, etc.).
SearchAnchorPicker<T>: drop-in picker built on Flutter Material 3- Two selection modes:
PickerMode.multi(checkbox style)PickerMode.radio(single select)
- Fully generic item type
TviaPickerConfig<T> - Header builder with
PickerActionsfor advanced scenarios:- Clear selection
- Sync selection with external state
- Nested sub-pickers (optional)
- Works with any state management approach:
setStateValueNotifier- Riverpod, Provider, BLoC, etc.
Add the package to your pubspec.yaml:
dependencies:
generic_search_selector: # soon
dependencies:
generic_search_selector:
git:
url: https://github.com/Alexqwesa/generic_search_selector.git
ref: mainMinimal multi-select with local state. (Works the same with Riverpod/Bloc/etc — just update your state in onToggle.)
Use triggerBuilder for opening the picker so the trigger can react to picker version changes.
import 'package:flutter/material.dart';
import 'package:generic_search_selector/picker_config.dart';
import 'package:generic_search_selector/search_anchor_picker.dart';
class Person {
Person(this.id, this.name);
final int id;
final String name;
}
class PeoplePickerDemo extends StatefulWidget {
const PeoplePickerDemo({super.key});
@override
State<PeoplePickerDemo> createState() => _PeoplePickerDemoState();
}
class _PeoplePickerDemoState extends State<PeoplePickerDemo> {
final _items = <Person>[
Person(1, 'Alice'),
Person(2, 'Bob'),
Person(3, 'Charlie'),
];
final _selectedIds = <int>{};
late final PickerConfig<Person> _config = PickerConfig<Person>(
title: 'Pick people',
loadItems: (_) async => _items,
idOf: (p) => p.id,
labelOf: (p) => p.name,
searchTermsOf: (p) => [p.name, p.id.toString()],
selectedFirst: true,
);
@override
Widget build(BuildContext context) {
return SearchAnchorPicker<Person>(
config: _config,
mode: PickerMode.multi,
initialSelectedIds: _selectedIds.toList()..sort(),
onToggle: (item, next) async {
setState(() {
next ? _selectedIds.add(item.id) : _selectedIds.remove(item.id);
});
return true;
},
triggerBuilder: (_, open, __) => IconButton(
tooltip: 'Open picker',
onPressed: open,
icon: const Icon(Icons.person_search),
),
maxHeight: 420,
minWidth: 320,
);
}
}You can use your own SearchAnchorPicker for nested pickers, or use the SubPickerTile helper in headerBuilder to create them easily (e.g., for filtering or adding items from another source).
It handles synchronization with the parent picker:
- Removals: If items are removed in the sub-picker, they are automatically removed from the parent's pending selection.
- Additions: Added items are NOT automatically selected in the parent (defaulting to "unselected"), giving you control.
- Popup positioning: Use
menuOffsetto shift the nested popup relative to its trigger, andmenuOffsetAnimationDurationto control how quickly that offset animates in. - Scalability: Closed pickers do not keep a permanent trigger
GlobalKey; popup-only keys are created on demand and cleared when the popup closes.
SubPickerTile<MyItem>(
title: 'Add from Sub-list',
config: subConfig,
parentActions: actions, // Pass parent actions to automate removal cleanup
initialSelectedIds: currentSubIds,
menuOffset: const Offset(40, 12), // from trigger position
menuOffsetAnimationDuration: const Duration(milliseconds: 120),
onFinish: ({required added, required removed}) async {
// 1. Update your data model (e.g. repository) — save-on-close
await myRepo.add(added);
await myRepo.remove(removed);
// 2. Pending selection cleanup (removals) is handled automatically!
}
)Save-on-close (default): SubPickerTile updates checkboxes while the sub-popup is open and calls onFinish once when it closes. That is the library-intended flow for sublist membership.
Save-on-each-click: not the default for SubPickerTile. Use a nested SearchAnchorPicker with onToggle (see below) or OnToggleMode.optimistic.
There are two close callbacks:
SearchAnchorPicker<Person>(
config: config,
initialSelectedIds: selectedIds,
// Delta save: only user row check/uncheck actions.
onFinish: ({required added, required removed}) async {
await api.addMemberships(added);
await api.removeMemberships(removed);
},
// Replace-all save: the full final selection.
onFinishReplaceAll: (finalIds) async {
await api.replaceSelection(finalIds);
},
);Use onFinish for normal add/remove APIs. Use onFinishReplaceAll only when your backend expects the whole final selection.
If onFinishReplaceAll is configured and the final selection is empty, the picker does not save empty on close by default. It shows a confirmation button labeled Save empty; customize it with saveEmptyLabel, or disable empty replace-all saves with showSaveEmptyButton: false.
By default (OnToggleMode.awaitGate), the picker waits for onToggle before moving the checkbox. A slow await (network, DB) freezes the UI until the future completes.
Use OnToggleMode.optimistic when you want immediate checkbox feedback and background persistence (failed saves revert the toggle):
SearchAnchorPicker<Person>(
config: config,
initialSelectedIds: selectedIds,
onToggleMode: OnToggleMode.optimistic,
onToggle: (person, next) async {
try {
await api.setSelected(person.id, next);
return true;
} catch (_) {
return false; // reverts checkbox
}
},
triggerBuilder: (_, open, __) => IconButton(onPressed: open, icon: const Icon(Icons.people)),
);| Mode | Checkbox | onToggle |
|---|---|---|
awaitGate (default) |
After onToggle returns true |
Awaited first; return false to reject |
optimistic |
Immediately (multi mode) | Runs in background; false reverts |
For validation (max items, permissions), keep awaitGate and return false to block the change.
Use SubPickerTile for nested sublist membership and onToggle or root onFinish for main-list assignment. initialSelectedIds is the caller-owned selection state, independent from the current search page. Server-side filtering is supported: a selected ID missing from loadItems is preserved, and onFinish only reports IDs the user checked or unchecked from item rows. Safe bulk header actions like actions.pendingClearLoaded() update pending selection without producing add/delete deltas; use *AsDelta() helpers for explicit bulk add/remove intent.
loadItems is display/search data, not deletion truth. If a selected ID is missing from the current result, the picker keeps it selected.
This makes both full client-side lists and server-side search/pagination safe by default:
PickerConfig<Person>(
loadItems: (_) => api.searchPeople(...), // full list or current page
idOf: (p) => p.id,
labelOf: (p) => p.name,
searchTermsOf: (p) => [p.name],
);Use pendingClearLoaded() / pendingSelectLoaded() for the current loaded result and pendingClearFiltered() / pendingSelectFiltered() for rows matching the current search query. These pending-only helpers do not populate added / removed, and hidden server-side selections stay pending unless they are in the current loaded result.
Use explicit delta helpers when a header button is meant to behave like add/remove intent: clearLoadedAsDelta(), selectLoadedAsDelta(), clearFilteredAsDelta(), selectFilteredAsDelta(), or toggleIdAsDelta(id, next). Use onFinishReplaceAll(finalIds) when you want to save the whole selection as-is. Bulk helpers do not run row-level unselectBehavior; add your own confirmation in the header if bulk removal needs warnings.
headerBuilder: (context, actions) => allUnitsHeader(context, actions, allJsas, ref), footerBuilder: customActions?
- enum PopupThemes{glass , classic}
- don't use external listen - it only needed while popup is open, use additional list as listenable provided by picker (if needed to update listenable.value=[...old])
- editItem callback
- check MediaQuery.of(context).size - if there is enogh space - use some offset for sublist popup (slightly below trigger)
- is window size very small - use fullscreen popups like my SmartAlertDialog(fullscreenBreakpoint: 600, ...
- UX problem: users didn't understand that there is search field in popup - add some hint or icon
- make it more like material 3 SearchAnchor - pass through parameters to SearchAnchor
- allow more small customizations - like icon, trailingIcons of items in list without full itemBuilder
- allow: selectedStyle (bold default)? better selectedWidget?