MultiSelectField

multiselect_field

License: MIT text_field_validation Dart 3 Flutter 3.10

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).
  • onChanged callback: 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 of Choice elements 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 to false.
  • 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.

Screenshot 2024-09-04 at 9 45 32 PM

Multiple selection.

Screenshot 2024-09-04 at 9 54 57 PM

Multiple selection custom widget.

Screenshot 2024-09-04 at 9 55 49 PM

Text Filtering.

Screenshot 2024-09-04 at 9 56 50 PM Screenshot 2024-09-04 at 9 54 03 PM

Video example

ScreenRecording2024-09-05at12 19 59AM-ezgif com-video-to-gif-converter

Contribution

Contributions are welcome! If you have ideas for new features or improvements, please open an issue or submit a pull request.

  1. Fork the repository.
  2. Create a new branch (git checkout -b feature/new-feature).
  3. Commit your changes (git commit -am 'Add new feature').
  4. Push to the branch (git push origin feature/new-feature).
  5. Open a pull request.

License

This project is licensed under the MIT License - see the LICENSE file for details.