dropdown_custom 0.5.0
dropdown_custom: ^0.5.0 copied to clipboard
A customizable, zero-dependency Flutter dropdown with single/multi-select, search, grouping, positioning, and many more customizations.
dropdown_custom #
A customizable, zero-dependency dropdown for Flutter. Simple to set up, yet scales to real-world needs: single or multiple selection, search, grouping, per-item enable/disable, custom colors, and free positioning — top, bottom, left, or right with auto-flip.
Works on every Flutter platform: Android · iOS · Web · Windows · macOS · Linux. It is a pure-Dart package (no native code, no platform channels, no third-party dependencies), so it runs anywhere Flutter runs.
Why another dropdown? #
Flutter's built-in dropdown is limited, and most alternatives only open below
the trigger. dropdown_custom focuses on:
- Single or multiple selection — pick one value, or enable multi-select to
collect a
List<T>with checkboxes, removable chips, and an optionalmaxSelectioncap. - Free positioning — open the menu on any side, with
autoflipping when it would run off-screen. - Search, grouping & async — a built-in search box, group headers, and an
async
loaderwith debounce plus a shimmer loading state. - Form-ready — drop it into a
Formwith avalidator; it shows an error message and turns the outline red, and works withForm.validate()/save(). - Keyboard & accessible — arrow/Enter/Esc navigation (even while searching)
and
Semanticson the trigger and every item, out of the box. - Type-safe generics — items are a plain
List<T>; no wrapper class required.onChangedreturns yourT, notdynamic. - Simple by default — the basic case is three lines; every extra feature
(clearable, chips,
DropdownController, styling…) is an optional parameter. - No dependencies — only the Flutter SDK.
Preview #
Getting started #
Add it with a single command:
flutter pub add dropdown_custom
Then import it:
import 'package:dropdown_custom/dropdown_custom.dart';
Usage #
Simplest case #

CustomDropdown<String>(
items: const ['Apple', 'Mango', 'Orange'],
onChanged: (value) => print(value),
)
Clearable (single-select) #

Show a ✕ button on the trigger to reset the selection. Because onChanged
returns a non-null T, clearing is reported through a separate onCleared
callback where you set your value back to null:
CustomDropdown<String>(
items: const ['Apple', 'Mango', 'Orange'],
value: selected,
clearable: true,
onChanged: (value) => setState(() => selected = value),
onCleared: () => setState(() => selected = null),
)
Inside a Form, with validation #

Pass a validator to make the dropdown a FormField: it joins the enclosing
Form, so Form.validate()/save() include it. On a failed validation the
error message appears below the trigger and the field outline turns red
(the theme's error color). Use autovalidateMode to validate as the user
interacts instead of only on submit:
final formKey = GlobalKey<FormState>();
Form(
key: formKey,
child: Column(
children: [
CustomDropdown<City>(
items: cities,
value: selected,
itemLabel: (c) => c.name,
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (c) => c == null ? 'Please pick a city' : null,
onChanged: (c) => setState(() => selected = c),
),
ElevatedButton(
onPressed: () => formKey.currentState!.validate(),
child: const Text('Submit'),
),
],
),
)
For multi-select, validator receives the selected List<T>:
CustomDropdown<City>.multi(
items: cities,
selectedItems: picked,
itemLabel: (c) => c.name,
validator: (list) =>
(list == null || list.isEmpty) ? 'Pick at least one' : null,
onSelectionChanged: (list) => setState(() => picked = list),
)
Over your own model, with search and disabled items #

CustomDropdown<City>(
items: cities,
value: selected,
itemLabel: (c) => c.name,
isItemEnabled: (c) => c.available,
enableSearch: true,
onChanged: (c) => setState(() => selected = c),
)
Grouped #

CustomDropdown<City>(
items: cities,
itemLabel: (c) => c.name,
groupBy: (c) => c.province,
enableSearch: true,
onChanged: (c) => ...,
)
Multi-select #

CustomDropdown<City>.multi(
items: cities,
selectedItems: picked,
itemLabel: (c) => c.name,
groupBy: (c) => c.province,
enableSearch: true,
onSelectionChanged: (list) => setState(() => picked = list),
)
The menu shows a checkbox on each row and stays open while the user toggles
items; onSelectionChanged fires with the full selection on every change.
Add optional "select all" / "clear" actions with showSelectAll: true
(off by default). Both respect the active search filter and skip disabled
items, and their labels are customizable:
CustomDropdown<City>.multi(
items: cities,
selectedItems: picked,
itemLabel: (c) => c.name,
enableSearch: true,
showSelectAll: true,
selectAllLabel: 'Select all',
clearAllLabel: 'Clear',
onSelectionChanged: (list) => setState(() => picked = list),
)
Limit how many items can be picked with maxSelection. Once the limit is
reached, unselected items are disabled (already-selected ones can still be
unchecked), and "select all" stops at the limit:

CustomDropdown<City>.multi(
items: cities,
selectedItems: picked,
itemLabel: (c) => c.name,
maxSelection: 3,
onSelectionChanged: (list) => setState(() => picked = list),
)
By default the trigger shows the selection as comma-joined text (customizable
via selectedItemsLabel). Set showChips: true to show it as removable
chips instead — each chip has a ✕ that removes just that item, without
opening the menu:

CustomDropdown<City>.multi(
items: cities,
selectedItems: picked,
itemLabel: (c) => c.name,
showChips: true,
chipOverflow: ChipOverflow.wrap, // or ChipOverflow.scroll (single line)
chipStyle: const DropdownChipStyle(), // colors default from menuStyle/theme
onSelectionChanged: (list) => setState(() => picked = list),
)
Async loading #

CustomDropdown<User>.async(
loader: (query) => api.searchUsers(query), // Future<List<User>>
itemLabel: (u) => u.name,
debounce: const Duration(milliseconds: 300),
onChanged: (u) => setState(() => selected = u),
)
loader is called with the debounced search query and owns filtering, so
results are shown as-is. The menu handles the loading state, an error state
with a retry action, and the empty state for you.
Choose how the loading state looks with loading — a circular spinner, an
animated skeleton shimmer (both with customizable colors), or your own widget:
CustomDropdown<User>.async(
loader: (query) => api.searchUsers(query),
// 1) circular spinner with a custom color
loading: const DropdownLoading.circular(color: Colors.teal),
// 2) animated skeleton shimmer (zero dependencies)
// loading: DropdownLoading.shimmer(
// baseColor: Colors.grey.shade300,
// highlightColor: Colors.grey.shade100,
// itemCount: 5,
// ),
// 3) fully custom loading widget
// loading: DropdownLoading.custom((context) => const MyLoader()),
onChanged: (u) => ...,
)
The empty and error states are customizable too, so every async state (loading / empty / error) can use your own widgets:
CustomDropdown<User>.async(
loader: (query) => api.searchUsers(query),
emptyBuilder: (context) => const Center(child: Text('No users found')),
errorBuilder: (context, error, retry) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('$error'),
TextButton(onPressed: retry, child: const Text('Try again')),
],
),
onChanged: (u) => ...,
)
emptyBuilder also works on the single- and multi-select constructors (shown
when a search yields no matches).
Positioning #

CustomDropdown<City>(
items: cities,
itemLabel: (c) => c.name,
direction: DropdownDirection.right, // top / bottom / left / right / auto
onChanged: (c) => ...,
)
Styling (field / menu / search — independently) #

Styling is split into three groups so the input field, the menu box, and the search bar can be themed separately:
CustomDropdown<City>(
items: cities,
itemLabel: (c) => c.name,
enableSearch: true,
// The input field (trigger).
fieldStyle: DropdownFieldStyle(
backgroundColor: Colors.white,
borderColor: Colors.teal,
borderWidth: 1.5,
textStyle: const TextStyle(fontSize: 16, color: Colors.black87),
iconColor: Colors.teal,
),
// The dropdown box and its list rows.
menuStyle: DropdownMenuStyle(
backgroundColor: Colors.grey.shade50,
borderColor: Colors.teal.shade100,
itemTextStyle: const TextStyle(fontSize: 14),
selectedTextStyle: const TextStyle(fontWeight: FontWeight.bold),
highlightColor: Colors.teal.shade50,
maxHeight: 280,
),
// The search bar.
searchStyle: DropdownSearchStyle(
fillColor: Colors.grey.shade100,
borderColor: Colors.grey.shade300,
focusedBorderColor: Colors.teal,
hintStyle: const TextStyle(color: Colors.grey),
textStyle: const TextStyle(fontSize: 14),
),
onChanged: (c) => ...,
)
Programmatic control (DropdownController) #

Pass a DropdownController to open, close, or toggle the menu from anywhere,
and to observe its open state (it's a ChangeNotifier):
final controller = DropdownController();
CustomDropdown<City>(
controller: controller,
items: cities,
itemLabel: (c) => c.name,
onChanged: (c) => ...,
)
// From another widget / callback:
controller.open();
controller.close();
controller.toggle();
print(controller.isOpen);
controller.addListener(() => print('open: ${controller.isOpen}'));
Dispose it when done, like any ChangeNotifier. Works on all three
constructors (default, .multi, .async).
Keyboard & accessibility #

The open menu is fully keyboard-navigable (including while typing in the search box):
| Key | Action |
|---|---|
| ↓ / ↑ | Move the highlight (skips group headers and disabled items) |
| Enter | Select the highlighted item (multi-select toggles it) |
| Esc | Close the menu |
The highlight auto-scrolls into view, and opens on the currently selected item.
The trigger and each item expose Semantics for screen readers — the trigger
as a button with its expanded state and current label, and each row with its
selected/checked and enabled state.
Key parameters #
| Parameter | Description |
|---|---|
items |
The List<T> of choices. |
onChanged |
Called with the selected T. |
value |
The currently selected item. |
itemLabel |
Maps an item to its label (defaults to toString()). |
groupBy |
Groups items under headers. |
isItemEnabled |
Disables specific items. |
enableSearch |
Shows a search box. |
searchMatcher |
Custom search predicate. |
showSelectAll |
Multi-select: show "select all" / "clear" actions. |
direction |
top, bottom, left, right, or auto. |
fieldStyle |
Styling for the input field (colors, border, font, padding). |
menuStyle |
Styling for the menu box and rows (colors, border, fonts, size). |
searchStyle |
Styling for the search bar (fill, border, hint, text, icon). |
itemBuilder |
Fully custom item rows. |
enabled |
Enables/disables the whole dropdown. |
Roadmap #
- ✅ Single-select: search, grouping, positioning, custom colors, disable
- ✅ Multi-select (with optional select-all/clear)
- ✅ Async loading
See implementasi_plan.md for the full plan.
License #
MIT