Skip to content

Repository files navigation

flutter_bottom_sheet_pickers

pub package pub points GitHub Issues GitHub Forks GitHub Stars GitHub License

Customizable Flutter bottom sheet pickers for single selection, multiple selection, searchable lists, paged lazy loading, up to three-level cascade selection, dates, date ranges, and year-month ranges.

Use it for form fields, filters, region pickers, store pickers, organization trees, and remote option lists that should feel native inside a Flutter bottom sheet. The package depends only on the Flutter SDK and exposes chainable builders, so most pickers can be opened with one short expression.

Features

  • Single and multiple bottom sheet pickers
  • Searchable local option lists
  • Optional local and remote filters for searchable select pickers
  • Paged lazy loading for remote option lists
  • Single and multiple cascade pickers with up to three levels
  • Cascade options from CascadeOption, map-like lists, or adjacency maps
  • Disabled options, optional empty confirmation, and custom option rows
  • Optional checkboxes for multiple selection
  • Date, date range, year-month, and year-month range pickers
  • Calendar helper labels for Gregorian, lunar, Buddhist, Tibetan, Islamic, Yi, and Hebrew calendars
  • Month and year shortcut navigation that automatically respects the allowed date range
  • Built-in cancel, reset, and confirm actions
  • Selected-count text for filtered multiple selection
  • Configurable primary color and action button border radius
  • Built-in labels for English, simplified Chinese, traditional Chinese, Thai, Burmese, Brazilian Portuguese, Canadian French, Italian, and Spanish

Language: English | 中文

Platform Support

Android iOS MacOS Web Linux Windows

Requirements

  • Flutter >=3.13.0 <4.0.0
  • Dart >=3.1.0 <4.0.0

Installation

Add the package to your pubspec.yaml:

dependencies:
  flutter_bottom_sheet_pickers: ^0.1.4

Import it:

import 'package:flutter_bottom_sheet_pickers/flutter_bottom_sheet_pickers.dart';

API overview

Entry point Use case Return value
BottomSheetPickers.single<T>(context) Single selection, searchable single selection, lazy single selection Future<T?>
BottomSheetPickers.multiple<T>(context) Multiple selection, searchable multiple selection, lazy multiple selection Future<List<T>?>
.withFilterSupported(...) Optional local or remote filter menu for searchable select pickers chainable builder
BottomSheetPickers.cascade(context) Up to three-level cascade single selection Future<CascadeSelection?>
BottomSheetPickers.cascade(context).multiple() Up to three-level cascade multiple selection Future<List<CascadeSelection>?>
BottomSheetPickers.calendar(context) Date selection Future<DateTime?>
BottomSheetPickers.dateRange(context) Date range selection Future<DateTimeRange?>
BottomSheetPickers.yearMonth(context) Year-month or year-month range selection Future<YearMonth?> / Future<YearMonthRange?>
BottomSheetPickers.setLocalizations(...) App-wide picker labels void
BottomPickerConfig(...) Local picker labels for one widget subtree Widget

Getting started

Single Picker

final String? selected = await BottomSheetPickers.single<String>(
  context,
  title: "Choose a fruit",
).options(
  ["Apple", "Orange", "Banana"],
  initialValue: "Apple",
).show();

Confirm on Option Tap

final String? selected = await BottomSheetPickers.single<String>(
  context,
  title: "Choose a fruit",
).options(
  ["Apple", "Orange", "Banana"],
).confirmOnTap()
    .show();

Custom Sheet Height

final String? selected = await BottomSheetPickers.single<String>(
  context,
  title: "Choose a fruit",
).height(360)
    .options(["Apple", "Orange", "Banana"])
    .show();

Multiple Picker

final List<String>? selected = await BottomSheetPickers.multiple<String>(
  context,
  title: "Choose tags",
).options(
  ["New", "Popular", "Recommended"],
  initialValue: ["New"],
).show();

Multiple Picker with Checkboxes

final List<String>? selected = await BottomSheetPickers.multiple<String>(
  context,
  title: "Choose tags",
).options(
  ["New", "Popular", "Recommended"],
  initialValue: ["New"],
).checkbox()
    .show();

Search

final String? selected = await BottomSheetPickers.single<String>(
  context,
  title: "Choose a city",
).options(cities)
    .searchSupported(placeholder: "Search city")
    .show();

Lazy Loading

final String? selected = await BottomSheetPickers.single<String>(
  context,
  title: "Choose a store",
).lazyLoad(
  parameters: {"country": "TH"},
  lazyRequestFuture: (params) async {
    final pageIndex = params["page_index"] as int;
    final pageSize = params["page_size"] as int;
    final keyword = params["keyword"] as String?;
    return loadStores(pageIndex: pageIndex, pageSize: pageSize, keyword: keyword);
  },
).show();

The lazy loader receives page_index, page_size, and keyword in the parameter map and should return the current page as List<T>. When search is enabled, keyword contains the active search text.

Search with Local Filter

final List<String>? selected = await BottomSheetPickers.multiple<String>(
  context,
  title: "Choose stores",
).options(["PVS Store", "FS Store", "KIOSK Store"])
    .searchSupported(placeholder: "Search store")
    .withFilterSupported(
      PickerFilter<String, String>.local(
        options: const [
          PickerFilterOption(value: null, label: "All"),
          PickerFilterOption(value: "PVS", label: "PVS"),
          PickerFilterOption(value: "FS", label: "FS"),
          PickerFilterOption(value: "KIOSK", label: "KIOSK"),
        ],
        predicate: (option, filter) =>
            filter == null || option.startsWith(filter),
      ),
    )
    .show();

Local filters are applied to the loaded in-memory options. In multiple selection, selected values are preserved when users switch filters, and the picker shows the selected count so cross-filter selections remain visible.

Lazy Loading with Remote Filter

final String? selected = await BottomSheetPickers.single<String>(
  context,
  title: "Choose a store",
).searchSupported(placeholder: "Search store")
    .withFilterSupported(
      PickerFilter<String, int>.remote(
        options: const [
          PickerFilterOption(value: null, label: "All"),
          PickerFilterOption(value: 1, label: "PVS"),
          PickerFilterOption(value: 2, label: "FS"),
          PickerFilterOption(value: 3, label: "KIOSK"),
        ],
        parameterBuilder: (value) =>
            value == null ? {} : {"store_type": value},
      ),
    )
    .lazyLoad(
      lazyRequestFuture: (params) async {
        final storeType = params["store_type"] as int?;
        final keyword = params["keyword"] as String?;
        return loadStores(storeType: storeType, keyword: keyword);
      },
    )
    .show();

Remote filters are merged into the lazy loading parameter map. Use null for an "All" option and return an empty parameter map when no filter should be sent.

Cascade Picker

final CascadeSelection? selected = await BottomSheetPickers.cascade(
  context,
  title: "Choose location",
).options(
  [
    CascadeOption(
      id: "province_a",
      label: "Province A",
      children: [
        CascadeOption(
          id: "city_a",
          label: "City A",
          children: [
            CascadeOption(id: "town_a", label: "Town A"),
          ],
        ),
      ],
    ),
  ],
).initialValue(CascadeSelection.byIds("province_a", "city_a", "town_a"))
    .cascadeAllItemSupported()
    .show();

Multiple Cascade Picker

final List<CascadeSelection>? selected = await BottomSheetPickers.cascade(
  context,
  title: "Choose locations",
).options(options)
    .multiple()
    .initialValues([
      CascadeSelection.byIds("province_a", "city_a", "town_a"),
    ])
    .cascadeAllItemSupported(allItemLabel: "All")
    .show();

Map-like Cascade Data

final options = [
  {
    "id": 1,
    "label": "Province A",
    "value": "province-a",
    "children": [
      {
        "id": 11,
        "label": "City A",
        "children": [
          {"id": 111, "label": "Town A"}
        ]
      }
    ]
  }
];

The parser reads the option label from label, value, name, then id.

You can also pass an adjacency map. Each key is a parent id, and its list contains the child nodes:

final adjacencyOptions = {
  null: [
    {"id": "province_a", "label": "Province A"}
  ],
  "province_a": [
    {"id": "city_a", "label": "City A"}
  ],
  "city_a": [
    {"id": "town_a", "label": "Town A"}
  ],
};

Use CascadeSelection.byIds(...) for initial values. The picker resolves those ids against the option tree and returns a CascadeSelection containing the matched CascadeOption objects.

Disabled options and empty confirmation

final selected = await BottomSheetPickers.multiple<String>(
  context,
  title: "Choose tags",
).options(
  tags,
  disabledValues: ["Archived"],
).allowNoSelection()
    .show();

disabledValues are compared with the option value. For cascade pickers, pass the option ids or values that should be disabled. allowNoSelection() lets users confirm without selecting an item.

Date Picker

final DateTime? selectedDate = await BottomSheetPickers.calendar(
  context,
  title: "Choose date",
  initialDate: DateTime(2026, 7, 7),
  firstDate: DateTime(2026, 1, 1),
  lastDate: DateTime(2026, 12, 31),
).show();

firstDate and lastDate are inclusive bounds. The previous month, next month, previous year, and next year buttons are shown only when the target month is inside those bounds.

Date Range Picker

final DateTimeRange? range = await BottomSheetPickers.dateRange(
  context,
  title: "Choose period",
  initialDateRange: DateTimeRange(
    start: DateTime(2026, 7, 1),
    end: DateTime(2026, 7, 7),
  ),
  firstDate: DateTime(2026, 1, 1),
  lastDate: DateTime(2026, 12, 31),
).show();

Calendar Helper Labels

final DateTime? selectedDate = await BottomSheetPickers.calendar(
  context,
  initialDate: DateTime(2026, 7, 7),
).calendarType(CalendarType.yi)
    .show();

The header and returned value remain Gregorian. When a CalendarType is specified, day cells show small helper labels for that calendar.

Year-Month Picker

final YearMonth? selectedMonth = await BottomSheetPickers.yearMonth(
  context,
  title: "Choose month",
  initialYearMonth: const YearMonth(2026, 7),
  firstYearMonth: const YearMonth(2020, 1),
  lastYearMonth: const YearMonth(2030, 12),
).show();

Year-Month Range Picker

final YearMonthRange? range = await BottomSheetPickers.yearMonth(
  context,
  title: "Choose month range",
  firstYearMonth: const YearMonth(2020, 1),
  lastYearMonth: const YearMonth(2030, 12),
  isRange: true,
).show();

When no initial range is provided, the start defaults to the current year-month and the end is empty until the user selects one. End candidates start from the selected start year-month.

Return Values

  • Confirm in a single picker returns T?.
  • Confirm in a multiple picker returns List<T>?.
  • Confirm in a single cascade picker returns CascadeSelection?.
  • Confirm in a multiple cascade picker returns List<CascadeSelection>?.
  • Confirm in a date picker returns DateTime?.
  • Confirm in a date range picker returns DateTimeRange?.
  • Confirm in a year-month picker returns YearMonth?.
  • Confirm in a year-month range picker returns YearMonthRange?.
  • Cancel, tapping outside the sheet, and system back return null.
  • Reset returns an empty list for multiple pickers and a reset selection for single cascade pickers.

Theme

BottomPickerTheme derives button background, button border, checked color, selected option background, and disabled button background from primaryColor.

final selected = await BottomSheetPickers.single<String>(
  context,
  title: "Choose a fruit",
  themeData: const BottomPickerTheme(
    primaryColor: Color(0xFF1677FF),
    buttonBorderRadius: BorderRadius.all(Radius.circular(12)),
  ),
).options(fruits).show();

Localization

The package does not require a localization delegate. By default, labels are resolved from the current Flutter locale, then the platform locale, then English.

Built-in labels are available through:

BottomPickerLocalizations.en
BottomPickerLocalizations.zh
BottomPickerLocalizations.zhHant
BottomPickerLocalizations.th
BottomPickerLocalizations.my
BottomPickerLocalizations.ptBR
BottomPickerLocalizations.frCA
BottomPickerLocalizations.it
BottomPickerLocalizations.es

For app-level configuration:

BottomSheetPickers.setLocalizations(
  localizations: BottomPickerLocalizations.byLocale(currentLocale),
);

For apps that use their own localization extension:

BottomSheetPickers.setLocalizations(
  builder: (context) => BottomPickerLocalizations(
    cancel: context.i18n("cancel"),
    reset: context.i18n("reset"),
    confirm: context.i18n("confirm"),
    selectedCount: context.i18n("selected_count"),
  ),
);

Use {count} in selectedCount, for example Selected {count}. Unset labels fall back to the built-in labels for the active locale.

Use BottomPickerConfig when only one subtree needs a local override:

BottomPickerConfig(
  localizations: BottomPickerLocalizations.zh,
  child: PageContent(),
)

Additional information

  • The runnable example app lives in example/.
  • Before publishing, run flutter analyze, flutter test, and flutter pub publish --dry-run.
  • Feel free to file an issue if you have any problem or feature request.

About

Customizable Flutter bottom sheet pickers for single selection, multiple selection, searchable lists, paged lazy loading, and up to three-level cascade selection.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages