MultiSelectField is a custom implementation of a multi-select field for Flutter applications. This library provides a flexible and highly configurable solution for projects that require native multi-selection, real-time text filtering, and more advanced features.
- 4 display modes: Standard (dropdown), Chip (compact), BottomSheet (modal), and Drawer (panel).
- Native multi-selection: Single or multiple selection without additional packages.
closeOnSelect: Automatically close the menu after selection (all variants).onChangedcallback: Simple callback that fires only on user interaction, never on default data.FieldWidth: Control field width — fit content, fixed pixels, or full width (Standard variant).iconSpacing: Configurable gap between label and dropdown icon (Standard variant).- Advanced features: Real-time text filtering, select all, group titles, chips display, programmatic control via
MultiSelectKeyStore. - Independence: Zero third-party dependencies.
Check out the library on pub.dev.
Add the dependency to your pubspec.yaml file:
dependencies:
multiselect_field: ^2.3.0Then, install the dependencies using:
flutter pub getflutter pub add multiselect_fieldimport 'package:multiselect_field/multiselect_field.dart';
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiSelectField<Car>(
data: () => [
Choice<Car>(null, 'Ferrari'), // Group title
Choice<Car>('2', '488 GTB', metadata: Car(103, 27.500, 2015)),
Choice<Car>('3', '458 Italia', metadata: Car(99, 22.000, 2009)),
Choice<Car>('4', 'Portofino', metadata: Car(105, 31.000, 2017)),
Choice<Car>('5', 'California T', metadata: Car(102, 25.000, 2016)),
Choice<Car>('6', 'F8 Tributo', metadata: Car(104, 30.000, 2019)),
],
onSelect: (selectedItems, isFromDefault) {
print(selectedItems.map((item) => item.value).toList());
},
useTextFilter: true,
);
}
}data:List<Choice<T>> Function(). Function that returns a list ofChoiceelements for selection.onSelect:Function(List<Choice<T>> ChoiceList). Callback invoked when items are selected.title:Widget Function(bool isEmpty)?. Optional widget that displays a title, depending on whether the selection is empty.footer:Widget?. Optional widget displayed at the bottom.singleSelectWidget:Widget Function(Choice<T> ChoiceList)?. Optional widget for displaying a single selected item.multiSelectWidget:Widget Function(Choice<T> ChoiceList)?. Optional widget for displaying multiple selected items.defaultData:List<Choice<T>>?. Optional function that returns the default list of selected items.singleSelection:bool. Defines if the widget should allow only a single selection. Defaults tofalse.useTextFilter:bool. Enables or disables real-time text filtering.decoration:Decoration?. Custom decoration for the widget.padding:EdgeInsetsGeometry?. Defines the internal padding of the widget.textStyleSingleSelection:TextStyle?. Text style for single selection.labelBuilder:Widget Function(String label)?. Optional builder that fully overrides how the static label is rendered. See Custom label rendering.scrollbarConfig:ScrollbarConfig. Modify the size, color, margins, etc.
When staticLabel: true, the field shows label regardless of the current selection. By default this is a plain Text(label). Pass labelBuilder to fully control how it renders, or use the included MultiSelectLabel widget for common presets via the LabelType enum.
MultiSelectField<String>(
label: 'NUI Marketplace North America',
staticLabel: true,
singleSelection: true,
labelBuilder: (label) => MultiSelectLabel(
label: label,
type: LabelType.wrap, // see LabelType options below
maxLines: 2,
style: Theme.of(context).textTheme.titleSmall,
),
data: () => choices,
)LabelType options:
LabelType.line(default): single line, no wrap.LabelType.wrap: wraps up tomaxLines(default 2) with ellipsis. Width collapses to the longest rendered line so trailing widgets (e.g. dropdown arrows) sit right next to the text instead of being pushed to the parent's max width.LabelType.overflow: single line truncated with ellipsis when the parent constrains width.
You can also supply a fully custom widget when the presets do not fit:
labelBuilder: (label) => Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.filter_alt, size: 14),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
],
),MultiSelectField<String>(
data: () => [
Choice(key: 'apple', value: 'Apple'),
Choice(key: 'banana', value: 'Banana'),
Choice(key: 'orange', value: 'Orange'),
],
scrollbarConfig: ScrollbarConfig(
visible: true,
themeData: ScrollbarThemeData(
thickness: WidgetStateProperty.all(10.0),
thumbColor: WidgetStateProperty.all(Colors.orange),
trackColor: WidgetStateProperty.all(Colors.grey.withValues(alpha: 0.2)),
radius: const Radius.circular(5.0),
thumbVisibility: WidgetStateProperty.all(true),
trackVisibility: WidgetStateProperty.all(true),
),
),
defaultData: [Choice(key: 'banana', value: 'Banana')],
///[isFromDefault] Helps to know if current selected element is from default data or not.
onSelect: (selectedItems, isFromDefaultData) {
// Update selection state
},
title: (isEmpty) => Text(isEmpty ? 'Select a fruit' : 'Selected fruits'),
singleSelection: false,
useTextFilter: true,
decoration: BoxDecoration(
border: Border.all(color: Colors.blue),
borderRadius: BorderRadius.circular(5),
),
padding: EdgeInsets.all(10),
multiSelectWidget: (item) => Chip(
label: Text(item.value),
onDeleted: () {
// Remove selected item
},
),
);Use MultiSelectField.chip() for space-constrained areas like filter bars:
MultiSelectField<String>.chip(
label: 'Status',
chipSize: ChipSize.small,
chipStyle: ChipStyle.withColor(Colors.blue),
singleSelection: true,
data: () => [
Choice(null, 'Priority'), // Group title
Choice('high', 'High'),
Choice('medium', 'Medium'),
Choice('low', 'Low'),
],
onSelect: (selected, _) {
print(selected.map((c) => c.key).toList());
},
)ChipSize.extraSmall // Minimal footprint
ChipSize.small // Compact
ChipSize.medium // Default
ChipSize.large // Prominent
ChipSize.extraLarge // Maximum visibility
// Or create custom sizes:
const mySize = ChipSize(
fontSize: 12,
iconSize: 16,
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
borderRadius: 12,
spacing: 4,
);MultiSelectField<String>.chip(
label: 'Date',
menuContent: Column(
children: [
ListTile(title: Text('Today')),
ListTile(title: Text('This week')),
Divider(),
CalendarDatePicker(...),
],
),
)Use MultiSelectField.bottomSheet() to open selections in a modal bottom sheet:
MultiSelectField<String>.bottomSheet(
label: 'Categories',
data: () => [
Choice(null, 'Fruits'), // Group title
Choice('apple', 'Apple'),
Choice('banana', 'Banana'),
Choice(null, 'Vegetables'),
Choice('carrot', 'Carrot'),
],
bottomSheetStyle: const BottomSheetStyle(
maxHeightFraction: 0.5,
showDragHandle: true,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
menuHeader: const Padding(
padding: EdgeInsets.all(16),
child: Text('Pick your items', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
),
onSelect: (selected, _) {
print(selected.map((c) => c.value).toList());
},
)const BottomSheetStyle(
maxHeightFraction: 0.6, // 60% of screen height (default)
fixedHeight: 400, // Fixed height in pixels (overrides fraction)
backgroundColor: Colors.white,
barrierColor: Colors.black54,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
showDragHandle: true, // Default: true
dragHandleColor: Colors.grey,
dragHandleWidth: 40,
)Enable text filtering inside the bottom sheet with useTextFilter. Use searchMinHeight to control the minimum height of the filtered area:
MultiSelectField<String>.bottomSheet(
label: 'Categories',
data: () => choices,
useTextFilter: true,
searchMinHeight: 400, // Ensures enough space for filtered results
onSelect: (selected, _) => print(selected),
)Two modes: Scaffold drawer (with scaffoldKey) or Overlay drawer (without it).
Place MultiSelectField.drawer() inside your Scaffold's drawer.
Open/close it from anywhere with MultiSelectKeyStore.
final scaffoldKey = GlobalKey<ScaffoldState>();
final filterStore = MultiSelectKeyStore.of<String>('myFilter');
filterStore.registerScaffold(scaffoldKey);
Scaffold(
key: scaffoldKey,
endDrawer: Drawer(
child: MultiSelectField<String>.drawer(
label: 'Filter',
keyDrawer: 'myFilter',
scaffoldKey: scaffoldKey,
data: () => [
Choice(null, 'Condition'), // Group title
Choice('new', 'New'),
Choice('used', 'Used'),
Choice('refurbished', 'Refurbished'),
],
menuHeader: const Padding(
padding: EdgeInsets.all(16),
child: Text('Filters', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
),
onSelect: (selected, _) {
print(selected.map((c) => c.key).toList());
},
),
),
body: Center(
child: ElevatedButton(
onPressed: () => filterStore.openDrawer(),
child: const Text('Open Filters'),
),
),
)Open or close the drawer from anywhere:
final store = MultiSelectKeyStore.of<String>('myFilter');
store.openDrawer();
store.closeDrawer();
// Clean up when done
MultiSelectKeyStore.dispose('myFilter');Without scaffoldKey, it renders a trigger button that opens a standalone
overlay drawer. No Scaffold configuration needed:
MultiSelectField<String>.drawer(
label: 'Filters',
data: () => [
Choice('new', 'New'),
Choice('used', 'Used'),
Choice('refurbished', 'Refurbished'),
],
drawerStyle: const DrawerStyle(
width: 280,
position: DrawerPosition.right,
),
onSelect: (selected, _) {
print(selected.map((c) => c.key).toList());
},
)The overlay respects SafeArea so it never overlaps system notifications. Tap outside to dismiss.
You can also pass a custom child widget as the trigger:
MultiSelectField<String>.drawer(
label: 'Filters',
child: const Icon(Icons.filter_list),
data: () => choices,
onSelect: (selected, _) => applyFilters(),
)By default, the menu stays open after selecting an item (multi-selection friendly). Set closeOnSelect: true to automatically close the menu after each selection. Available in all variants.
// Standard — menu closes after each tap
MultiSelectField<String>(
data: () => choices,
closeOnSelect: true,
onSelect: (selected, _) => print(selected),
)
// Bottom Sheet — sheet dismisses after selection
MultiSelectField<String>.bottomSheet(
label: 'Category',
data: () => choices,
closeOnSelect: true,
onSelect: (selected, _) => print(selected),
)
// Drawer — drawer closes after selection
MultiSelectField<String>.drawer(
label: 'Filter',
scaffoldKey: scaffoldKey,
data: () => choices,
closeOnSelect: true,
onSelect: (selected, _) => print(selected),
)Simple callback that fires only on user interaction, never on default data. Use it when you don't need to distinguish between user and default selections.
MultiSelectField<String>(
data: () => choices,
defaultData: [Choice('1', 'Apple')], // Does NOT trigger onChanged
onChanged: (selectedItems) {
// Only fires when the user taps an item
print('User selected: ${selectedItems.length} items');
},
)You can use onChanged alone, onSelect alone, or both together:
MultiSelectField<String>(
data: () => choices,
onSelect: (items, isDefault) => print('onSelect: isDefault=$isDefault'),
onChanged: (items) => print('onChanged: ${items.length} items'),
)Control the field width without wrapping in SizedBox:
// Shrinks to fit the label/chips — compact inline selector
MultiSelectField<String>(
data: () => choices,
fieldWidth: FieldWidth.fitContent,
iconSpacing: 2,
)
// Fixed width in pixels
MultiSelectField<String>(
data: () => choices,
fieldWidth: FieldWidth.fixed(200),
)
// Default: fills all available width (unchanged behavior)
MultiSelectField<String>(
data: () => choices,
)Control the gap between the content area and the dropdown arrow:
MultiSelectField<String>(
data: () => choices,
iconSpacing: 8, // 8px gap between label and arrow icon
)
Contributions are welcome! If you have ideas for new features or improvements, please open an issue or submit a pull request.
- Fork the repository.
- Create a new branch (
git checkout -b feature/new-feature). - Commit your changes (
git commit -am 'Add new feature'). - Push to the branch (
git push origin feature/new-feature). - Open a pull request.
This project is licensed under the MIT License - see the LICENSE file for details.

