MultiSelectField
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.
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.
Library
Check out the library on pub.dev.
Installation
Add the dependency to your pubspec.yaml file:
dependencies:
multiselect_field: ^2.0.0
Then, install the dependencies using:
flutter pub get
Or
flutter pub add multiselect_field
Usage
Basic Example
import '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,
);
}
}
Properties
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.scrollbarConfig:ScrollbarConfig. Modify the size, color, margins, etc.
Advanced Example with scrollbarConfig
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
},
),
);
Chip Variant - Compact Dropdown
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 Options
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,
);
Custom Menu Content
MultiSelectField<String>.chip(
label: 'Date',
menuContent: Column(
children: [
ListTile(title: Text('Today')),
ListTile(title: Text('This week')),
Divider(),
CalendarDatePicker(...),
],
),
)
Bottom Sheet Variant
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());
},
)
BottomSheetStyle Options
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,
)
Drawer Variant
Two modes: Scaffold drawer (with scaffoldKey) or Overlay drawer (without it).
Scaffold Mode
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'),
),
),
)
Programmatic Control
Open or close the drawer from anywhere:
final store = MultiSelectKeyStore.of<String>('myFilter');
store.openDrawer();
store.closeDrawer();
// Clean up when done
MultiSelectKeyStore.dispose('myFilter');
Overlay Mode
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(),
)
Close on Select
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),
)
onChanged Callback
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'),
)
Field Width Control (Standard)
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,
)
Icon Spacing (Standard)
Control the gap between the content area and the dropdown arrow:
MultiSelectField<String>(
data: () => choices,
iconSpacing: 8, // 8px gap between label and arrow icon
)
Some screen captures
With grouping list.
Multiple selection.
Multiple selection custom widget.
Text Filtering.
With title and footer.
Video example
Contribution
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.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Libraries
- core/bottom_sheet_multi_select_extension
- core/bottom_sheet_multi_select_field
- core/chip_multi_select_extension
- core/chip_multi_select_field
- core/chip_multiselect_field
- core/drawer_multi_select_field
- core/multi_select
- core/multi_select_key_store
- core/search_multiselect_field
- core/selection_content
- core/standard_multi_select_extension
- core/standard_multi_select_field
- multiselect_field