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.
- 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 | 中文
| Android | iOS | MacOS | Web | Linux | Windows |
|---|---|---|---|---|---|
| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
- Flutter >=3.13.0 <4.0.0
- Dart >=3.1.0 <4.0.0
Add the package to your pubspec.yaml:
dependencies:
flutter_bottom_sheet_pickers: ^0.1.4Import it:
import 'package:flutter_bottom_sheet_pickers/flutter_bottom_sheet_pickers.dart';| 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 |
final String? selected = await BottomSheetPickers.single<String>(
context,
title: "Choose a fruit",
).options(
["Apple", "Orange", "Banana"],
initialValue: "Apple",
).show();final String? selected = await BottomSheetPickers.single<String>(
context,
title: "Choose a fruit",
).options(
["Apple", "Orange", "Banana"],
).confirmOnTap()
.show();final String? selected = await BottomSheetPickers.single<String>(
context,
title: "Choose a fruit",
).height(360)
.options(["Apple", "Orange", "Banana"])
.show();final List<String>? selected = await BottomSheetPickers.multiple<String>(
context,
title: "Choose tags",
).options(
["New", "Popular", "Recommended"],
initialValue: ["New"],
).show();final List<String>? selected = await BottomSheetPickers.multiple<String>(
context,
title: "Choose tags",
).options(
["New", "Popular", "Recommended"],
initialValue: ["New"],
).checkbox()
.show();final String? selected = await BottomSheetPickers.single<String>(
context,
title: "Choose a city",
).options(cities)
.searchSupported(placeholder: "Search city")
.show();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.
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.
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.
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();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();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.
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.
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.
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();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.
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();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.
- 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.
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();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.esFor 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(),
)- The runnable example app lives in
example/. - Before publishing, run
flutter analyze,flutter test, andflutter pub publish --dry-run. - Feel free to file an issue if you have any problem or feature request.