fl_list_wheel
Extended ListWheelScrollView / CupertinoPicker with a unified option set, plus date, time, list and linkage
pickers.
- Web demo: https://wayaer.github.io/fl_list_wheel/example/app/web/index.html#/
- Example source: https://github.com/Wayaer/fl_list_wheel/tree/main/example
- Pub package: https://pub.dev/packages/fl_list_wheel
Contents
- Features
- Requirements
- Install
- Quick start
- Global presenter and tests
- FlPickerOptions
- FlWheelOptions
- Wheels
- FlDatePicker
- FlDateTimePicker
- FlMultiListWheelPicker
- FlMultiListWheelLinkagePicker
- FlMultiListLinkagePicker
- FlSingleListPicker
- FlSingleListWheelPicker
- FlCustomPicker
- Results and closing
- Layout constants
- Web demo
- License
Features
| Widget | Description |
|---|---|
FlListWheel |
ListWheelScrollView and CupertinoPicker behind one API |
FlListWheelState |
Creates / disposes a FixedExtentScrollController, keeps the selection on rebuild |
FlPickerPresenter |
Pluggable presentation: bottom sheet, Cupertino popup or a custom route |
FlPickerSession |
Lets inline pickers close a route you own and return a value |
FlDatePicker |
Year / month / day wheels, any unit combination |
FlDateTimePicker |
Year / month / day / hour / minute / second wheels |
FlMultiListWheelPicker |
Multiple independent wheel columns |
FlMultiListWheelLinkagePicker |
Multiple linked wheel columns (province → city → district) |
FlMultiListLinkagePicker |
Multiple linked non-wheel lists |
FlSingleListPicker |
List or grid selection, single or multiple choice |
FlSingleListWheelPicker |
Single wheel |
FlCustomPicker |
Header + confirm/cancel logic around any custom content |
All pickers share the same FlPickerOptions header and the same show() extension. Wheel-based pickers also share
the same FlWheelOptions styling.
Requirements
- Flutter 3.47 or newer
- Dart 3.13 or newer
Install
dependencies:
fl_list_wheel: ^1.0.0
Quick start
show() needs a FlPickerPresenter to open and close the picker. Assign the global presenter once, for example when
your app starts:
import 'package:fl_list_wheel/fl_list_wheel.dart';
import 'package:flutter/material.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
void main() {
FlListWheel.presenter = FlBottomSheetPickerPresenter(
context: () => navigatorKey.currentContext!
);
runApp(
MaterialApp(
navigatorKey: navigatorKey,
home: const HomePage()
)
);
}
Then open any picker from a page:
final DateTime? date = await FlDatePicker(
options: FlPickerOptions<DateTime>(
title: const Text('Select date'),
cancel: const Text('Cancel'),
confirm: const Text('Done')
)
).show();
show() returns null when the user cancels.
Global presenter and tests
FlListWheel.presenter is a single global object that opens the picker and installs a FlPickerSession so the
confirm/cancel buttons can close the correct route:
- Assign it once before calling
.show(). - Calling
.show()without a presenter completes the returnedFuturewith aFlutterErrorexplaining the setup. - The presenter is shared by the whole process. If your app uses several navigators, point
contextat the navigator you want, or implement your ownFlPickerPresenter. - Call
FlListWheel.reset()to clear the presenter and restore the default wheel options, for example on sign-out. - In widget tests, assign it in
setUpand clear it intearDown:
setUp(() {
FlListWheel.presenter = FlBottomSheetPickerPresenter(
context: () => navigatorKey.currentContext!
);
});
tearDown(() {
FlListWheel.presenter = null;
});
Custom presentation, for example a Cupertino modal popup:
class CupertinoSheetPresenter extends FlPickerPresenter {
const CupertinoSheetPresenter({required this.context});
final BuildContext Function() context;
@override
Future<T?> show<T>(Widget picker) => showCupertinoModalPopup<T>(
context: context(),
builder: (BuildContext routeContext) => FlPickerSession<T>(
close: (T? value) => Navigator.of(routeContext).pop<T>(value),
child: picker
)
);
}
FlBottomSheetPickerPresenter options
| Field | Default | Description |
|---|---|---|
context |
required | Returns the context used to find the navigator |
isScrollControlled |
true |
Allow the sheet to be taller than half the screen |
useRootNavigator |
false |
Open with the root navigator instead of the nearest one |
useSafeArea |
false |
Avoid system intrusions |
backgroundColor |
null |
Sheet background color |
barrierColor |
null |
Barrier color |
shape |
null |
Sheet shape, e.g. top rounded corners |
Per-call presenter
The global presenter is only a default. Pass presenter to a single show() call to override it:
final DateTime? date = await FlDatePicker(
options: FlPickerOptions<DateTime>(title: const Text('Select date'))
).show(
presenter: FlBottomSheetPickerPresenter(
context: () => context,
useRootNavigator: true
)
);
Use this for multiple navigators, a one-off transition, or a different presentation for a single screen. For a custom
picker widget that does not use one of the built-in extensions, call FlListWheel.showPicker<T>(widget) directly; it
uses the global presenter unless you pass presenter.
Inline pickers and FlPickerSession
A picker rendered inline has no session, so its confirm/cancel buttons do not close anything. Wrap it in
FlPickerSession to connect those buttons to a route you control:
final int? result = await showDialog<int>(
context: context,
builder: (BuildContext dialogContext) => Dialog(
child: FlPickerSession<int>(
close: (int? value) => Navigator.of(dialogContext).pop(value),
child: FlSingleListWheelPicker(
itemCount: 6,
itemBuilder: (BuildContext context, int index) => Center(child: Text('Item $index')),
options: FlPickerOptions<int>(
title: const Text('Inline picker'),
cancel: const Text('Cancel'),
confirm: const Text('Done')
)
)
)
)
);
FlPickerSession is also the mechanism used internally by FlBottomSheetPickerPresenter. A custom presenter should
wrap the picker in FlPickerSession the same way so the header buttons can close its route.
FlPickerOptions
FlPickerOptions<T> controls the header and the background shared by every picker:
| Field | Description |
|---|---|
top |
Content above the header, e.g. a drag handle |
cancel |
Left action; tapping it closes the sheet unless verifyCancel returns false |
title |
Center title |
confirm |
Right action; tapping it closes the sheet unless verifyConfirm returns false |
bottom |
Content below the header |
bottomNavigationBar |
Content below the picker body |
titlePadding |
Header padding, defaults to EdgeInsets.all(10) |
contentPadding |
Padding around the picker body |
backgroundColor |
Background color, ignored when decoration is set |
background |
Widget painted behind the picker body |
decoration |
Container decoration; wins over backgroundColor |
verifyConfirm |
bool Function(T? value); return false to keep the sheet open |
verifyCancel |
Same as verifyConfirm for the cancel action |
If options is null the picker renders only its wheels and has no header. Confirm/cancel close the picker through
the FlPickerSession installed by the presenter; inline pickers have no session and never close the surrounding page.
Example with a full-width title and validation:
FlPickerOptions<int>(
top: const SizedBox(height: 8),
title: const Text('Pick a number'),
cancel: const Text('Cancel'),
confirm: const Text('Done'),
titlePadding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
verifyConfirm: (int? value) => value != null
)
FlWheelOptions
FlWheelOptions configures a wheel. Three constructors are available:
const FlWheelOptions(); // Material style, magnification 1.2
const FlWheelOptions.cupertino(); // iOS style with a selection overlay
const FlWheelOptions.custom(
isCupertino: true,
selectionOverlay: CupertinoPickerDefaultSelectionOverlay(),
magnification: 1.1
);
Set a global default and override it per widget:
FlListWheel.wheelOptions = const FlWheelOptions.cupertino();
FlListWheel.builder(
options: const FlWheelOptions(itemExtent: 36, magnification: 1.1),
onSelectedItemChanged: (int index) => debugPrint('$index'),
itemBuilder: (BuildContext context, int index) => Text(values[index]),
itemCount: values.length
);
options / wheelOptions are merged into the global configuration: non-null fields override, null fields keep the
global value. The only exception is isCupertino, which always comes from the global configuration — a plain
FlWheelOptions(...) used to tweak a single wheel never switches the rendering style. Change the style globally via
FlListWheel.wheelOptions instead.
Important fields:
| Field | Applies to | Description |
|---|---|---|
itemExtent |
both | Height of one item, default 30 |
diameterRatio |
both | Cylinder diameter / viewport height; larger is flatter |
squeeze |
both | Spacing between items; smaller means more spacing |
magnification |
both | Scale of the selected item when useMagnifier is true |
useMagnifier |
both | Enlarge the centered item |
offAxisFraction |
both | Horizontal offset of the selection |
physics |
Material / custom | Scroll physics; defaults to FixedExtentScrollPhysics so the wheel always snaps to whole items |
selectionOverlay |
Cupertino | Highlight behind the selected item |
backgroundColor |
Cupertino | Wheel background |
perspective |
Material / custom | 3D perspective strength |
overAndUnderCenterOpacity |
Material / custom | Opacity of off-center items |
renderChildrenOutsideViewport |
Material / custom | Paint items outside the viewport |
clipBehavior |
Material / custom | Clip mode |
scrollBehavior / restorationId |
Material / custom | Scroll configuration |
Material and custom wheels snap by default. To allow free scrolling, pass another physics:
const FlWheelOptions(physics: BouncingScrollPhysics())
Wheels
FlListWheel
FlListWheel.builder(
onSelectedItemChanged: (int index) => debugPrint('builder $index'),
itemBuilder: (BuildContext context, int index) => Text(values[index]),
itemCount: values.length
);
FlListWheel.count(
options: const FlWheelOptions(),
onSelectedItemChanged: (int index) => debugPrint('count $index'),
children: [for (final String value in values) Text(value)]
);
// Infinite looping:
FlListWheel.count(
looping: true,
onSelectedItemChanged: (int index) => debugPrint('looping $index'),
children: [for (final String value in values) Text(value)]
);
Scroll callbacks (onScrollStart, onScrollUpdate, onScrollEnd) receive the current item index, even without a
controller. When onNotification is provided, the three convenience callbacks are skipped.
FlListWheelState
FlListWheelState owns a FixedExtentScrollController when you do not provide one:
FlListWheelState(
count: values.length,
initialItem: 2,
animateDuration: const Duration(milliseconds: 180),
builder: (FixedExtentScrollController controller) => FlListWheel.builder(
controller: controller,
onSelectedItemChanged: (int index) => debugPrint('state $index'),
itemBuilder: (BuildContext context, int index) => Text(values[index]),
itemCount: values.length
)
);
initialItemis clamped to0 .. count - 1.- If you pass your own
controller, it stays yours and is never disposed by the widget. onCreateControllerexposes the controller created internally.initialItemchanges are applied even before the controller is attached.
FlDatePicker
final DateTime? date = await FlDatePicker(
height: 200,
unit: const FlDatePickerUnit.yd(year: ' Y', month: ' M', day: ' D'),
startDate: DateTime.now().subtract(const Duration(days: 365)),
defaultDate: DateTime.now(),
endDate: DateTime.now().add(const Duration(days: 365)),
options: FlPickerOptions<DateTime>(
title: const Text('Select date'),
cancel: const Text('Cancel'),
confirm: const Text('Done')
),
onChanged: (DateTime value) => debugPrint('$value')
).show();
Presets:
| Unit | Visible columns |
|---|---|
FlDatePickerUnit.yd() |
year, month, day |
FlDatePickerUnit.ym() |
year, month |
FlDatePickerUnit.md() |
month, day (no year) |
FlDatePickerUnit.none() |
year, month, day without unit text |
Set a field to null to hide its column, or to an empty string to show the column without a unit label.
Notes:
- The range is built day by day and is limited to
FlDatePicker.maxRangeDays(200 years); wider ranges trigger an assertion in debug mode. UseFlDateTimePickerfor very large ranges. - Without a year column, each month is shown once (the first occurrence in the range).
defaultDateis clamped to[startDate, endDate].- Changing
itemBuilderrebuilds the generated options with the new labels. - The confirmed value is normalized to the selected calendar day at midnight.
dualpads single-digit values to two digits.
FlDateTimePicker
final DateTime? dateTime = await FlDateTimePicker(
height: 200,
unit: const FlDateTimePickerUnit.ydm(
year: 'Y',
month: 'M',
day: 'D',
hour: 'H',
minute: 'M'
),
startDate: DateTime.now().subtract(const Duration(days: 30)),
defaultDate: DateTime.now(),
endDate: DateTime.now().add(const Duration(days: 30)),
options: FlPickerOptions<DateTime>(title: const Text('Select date & time')),
onChanged: (DateTime value) => debugPrint('$value')
).show();
Presets:
| Unit | Visible columns |
|---|---|
all() |
year, month, day, hour, minute, second |
none() |
all six columns, no unit text |
ydm() |
year, month, day, hour, minute |
yh() |
year, month, day, hour |
yd() |
year, month, day |
ym() |
year, month |
md() |
month, day |
mh() |
month, day, hour |
mm() |
month, day, hour, minute |
mhs() |
month, day, hour, minute, second |
ds() |
day, hour, minute, second |
dm() |
day, hour, minute |
dh() |
day, hour |
hs() |
hour, minute, second |
hm() |
hour, minute |
ms() |
minute, second |
Hidden columns keep the corresponding value from defaultDate. The day count is recomputed for the selected month and
the day index is clamped when you switch to a shorter month. The confirmed value is always clamped to
[startDate, endDate].
FlMultiListWheelPicker
Independent wheel columns, no linkage:
final List<int>? position = await FlMultiListWheelPicker(
items: [
FlPickerItem(
itemCount: letters.length,
itemBuilder: (BuildContext context, int index) => Center(child: Text(letters[index]))
),
FlPickerItem(
itemCount: numbers.length,
itemBuilder: (BuildContext context, int index) => Center(child: Text(numbers[index]))
)
],
value: const [0, 0],
isScrollable: false,
height: 260,
onChanged: (List<int> index) => debugPrint('$index'),
options: FlPickerOptions<List<int>>(
title: const Text('Select columns'),
cancel: const Text('Cancel'),
confirm: const Text('Done')
)
).show();
valueis the initial index of each column and is clamped to the current data.- The confirmed result is a
List<int>with one index per column. - If any column has
itemCount: 0, confirming returnsnullbecause that column has no valid index. isScrollable: truewraps the columns in a horizontalSingleChildScrollView; each column usesitemWidthorkPickerDefaultItemWidth.isScrollable: falsewith noitemWidthmakes the columns share the available width.
FlMultiListWheelLinkagePicker
Wheel columns where each selection drives the next column:
final List<FlPickerLinkageItem<String>> items = [
FlPickerLinkageItem<String>(
value: 'Fujian',
child: const Text('Fujian'),
children: [
FlPickerLinkageItem<String>(
value: 'Fuzhou',
child: const Text('Fuzhou'),
children: [
FlPickerLinkageItem<String>(value: 'Gulou', child: const Text('Gulou'))
]
)
]
)
];
final List<int>? position = await FlMultiListWheelLinkagePicker<String>(
items: items,
value: const [0, 0, 0],
height: 280,
isScrollable: false,
onChanged: (List<int> index) => debugPrint('index $index'),
onValueChanged: (List<String> value) => debugPrint('value $value'),
options: FlPickerOptions<List<int>>(
title: const Text('Select area'),
cancel: const Text('Cancel'),
confirm: const Text('Done')
)
).show();
- The confirmed result is the index path (
List<int>), not the values. UseonValueChangedif you need the values. - Changing a column resets every deeper column to its first item.
onChangedis called synchronously when a column stops scrolling, so a confirm tap right after scrolling reads the latest position.- Limit the depth by only building
childrendown to the levels you need.
FlMultiListLinkagePicker
Linked columns built from ordinary lists instead of wheels:
final List<FlPickerListLinkageItem<String>> items = [
FlPickerListLinkageItem<String>(
value: 'Fujian',
child: (bool selected) => ListTile(
title: const Text('Fujian'),
selected: selected
),
children: [
FlPickerListLinkageItem<String>(
value: 'Fuzhou',
child: (bool selected) => ListTile(
title: const Text('Fuzhou'),
selected: selected
)
)
]
)
];
final List<int>? position = await FlMultiListLinkagePicker<String>(
items: items,
value: const [0, 0],
height: 300,
onChanged: (List<int> index) => debugPrint('$index'),
options: FlPickerOptions<List<int>>(
title: const Text('Select product'),
cancel: const Text('Cancel'),
confirm: const Text('Done')
)
).show();
Each item builds its own appearance with child(selected). Tapping an item selects it and opens the next level; the
confirmed result is the completed index path.
Set itemExtent when every row has the same height to let the lists lay out without measuring each child.
FlSingleListPicker
List or grid selection, single or multiple choice:
final List<int>? selected = await FlSingleListPicker(
itemCount: items.length,
initialIndex: const [0, 2],
singleListPickerOptions: const FlSingleListPickerOptions(
allowedMultipleChoice: true,
isCustomGestureTap: true
),
options: FlPickerOptions<List<int>>(
title: const Text('Select items'),
cancel: const Text('Cancel'),
confirm: const Text('Done')
),
itemBuilder: (
BuildContext context,
int index,
bool isSelect,
FlSelectIndexedChangedFunction change
) => ListTile(
title: Text(items[index]),
selected: isSelect,
onTap: () => change(index)
)
).show();
allowedMultipleChoicedefaults totrue; set it tofalsefor single selection.initialIndexentries outside0 .. itemCount - 1are ignored; in single-choice mode only the first valid one is kept.singleListPickerOptions.itemExtentsets a fixed row height, which improves scrolling performance when every row has the same height.- Set
isCustomGestureTap: truewhen your item handles taps itself (as above); otherwise omitonTapand the picker wraps each item in aGestureDetector. - Provide
builderto replace the defaultListView.builder, e.g. with aGridView. - The confirmed result is the list of selected indices.
FlSingleListWheelPicker
final int? index = await FlSingleListWheelPicker(
initialIndex: 3,
itemCount: months.length,
height: 240,
itemBuilder: (BuildContext context, int index) => Center(child: Text(months[index])),
onChanged: (int index) => debugPrint('$index'),
options: FlPickerOptions<int>(
title: const Text('Select a month'),
cancel: const Text('Cancel'),
confirm: const Text('Done')
)
).show();
- The confirmed result is the selected index.
onChangedfires when scrolling ends.- The picker owns the controller unless you pass
controller;initialIndexchanges are applied automatically. initialIndexis clamped to the currentitemCount.
FlCustomPicker
Header and confirm/cancel logic around any content:
final String? result = await FlCustomPicker<String>(
options: FlPickerOptions<String>(
title: const Text('Pick a color'),
cancel: const Text('Cancel'),
confirm: const Text('Done')
),
content: StatefulBuilder(
builder: (BuildContext context, StateSetter setSheetState) => Wrap(
children: [
for (final String color in colors)
ChoiceChip(
label: Text(color),
selected: selectedColor == color,
onSelected: (_) => setSheetState(() => selectedColor = color)
)
]
)
),
confirmTap: () => selectedColor
).show();
confirmTap builds the confirmed value; cancelTap builds the cancel value (usually null). Both go through the
verifyConfirm / verifyCancel callbacks when provided.
Results and closing
show()returns the picker's value, ornullwhen cancelled.verifyConfirm(value)andverifyCancel(value)returnfalseto keep the sheet open.- Closing is handled by
FlPickerSession, whichFlBottomSheetPickerPresenterinstalls around every picker. Custom presenters should do the same. Inline pickers have no session; wrap them inFlPickerSessionif their confirm action should close a surrounding dialog. - An
optionswithcancel/confirmis required if you want the user to close the sheet.
Layout constants
| Constant | Value | Description |
|---|---|---|
kPickerDefaultHeight |
180 |
Default picker height |
kPickerDefaultItemWidth |
90 |
Column width used by horizontally scrollable pickers |
All pickers except FlCustomPicker accept height and width. Wheel pickers also accept itemWidth and
wheelOptions; FlMultiListLinkagePicker accepts itemWidth and itemExtent; FlSingleListPicker sets a fixed row
height with FlSingleListPickerOptions.itemExtent; FlCustomPicker only takes options and content.
Web demo
A full web build of the example app is available at:
https://wayaer.github.io/fl_list_wheel/example/app/web/index.html#/
It covers every widget with inline previews, Material / Cupertino switching and bottom-sheet demos. Each example
section shows the component class name and a short usage snippet, so you can map the demo back to the API at a glance.
The Presentation page demonstrates the global presenter, a per-call Cupertino presenter and an inline
FlPickerSession. The source is in example/.
License
MIT