any_dropdown 0.0.6
any_dropdown: ^0.0.6 copied to clipboard
A customizable dropdown widget for Flutter with single-select, multi-select, async options, and fully customizable trigger and menu delegates.
import 'package:any_dropdown/any_dropdown.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
/// Root widget with a Material 3 theme.
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'AnyDropdown Examples',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const ExampleHomePage(),
);
}
}
/// Home page showcasing all dropdown variants.
class ExampleHomePage extends StatefulWidget {
const ExampleHomePage({super.key});
@override
State<ExampleHomePage> createState() => _ExampleHomePageState();
}
class _ExampleHomePageState extends State<ExampleHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('AnyDropdown Examples'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: const [
_SectionHeader(title: 'Basic Usage — Static Options'),
SizedBox(height: 8),
_BasicStaticExample(),
SizedBox(height: 24),
_SectionHeader(title: 'Async Options — Simulated Network Fetch'),
SizedBox(height: 8),
_AsyncExample(),
SizedBox(height: 24),
_SectionHeader(title: 'Custom Trigger & Menu'),
SizedBox(height: 8),
_CustomDelegatesExample(),
SizedBox(height: 24),
_SectionHeader(title: 'Multi-Select — Basic'),
SizedBox(height: 8),
_BasicMultiExample(),
SizedBox(height: 24),
_SectionHeader(title: 'Multi-Select — Custom Delegates'),
SizedBox(height: 8),
_CustomMultiExample(),
SizedBox(height: 24),
_SectionHeader(title: 'Null Safety — Nullable Value'),
SizedBox(height: 8),
_NullableValueExample(),
SizedBox(height: 24),
_SectionHeader(title: 'Error State'),
SizedBox(height: 8),
_ErrorStateExample(),
],
),
);
}
}
/// A bold section header label.
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
);
}
}
// =============================================================================
// 1. Basic static options
// =============================================================================
class _BasicStaticExample extends StatefulWidget {
const _BasicStaticExample();
@override
State<_BasicStaticExample> createState() => _BasicStaticExampleState();
}
class _BasicStaticExampleState extends State<_BasicStaticExample> {
String _selectedFruit = 'Apple';
late final AnyDropdownListOptionProvider<String> _optionProvider =
AnyDropdownListOptionProvider<String>(
optionsFetcher: () => [
'Apple',
'Banana',
'Cherry',
'Durian',
'Elderberry',
],
);
@override
void initState() {
super.initState();
_optionProvider.refreshOptions();
}
@override
void dispose() {
_optionProvider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pick a fruit:'),
const SizedBox(height: 8),
AnyDropdownSingle<String>(
value: _selectedFruit,
optionProvider: _optionProvider,
menuDelegate: AnyDropdownSingleListMenuDelegate<String>(
optionProvider: _optionProvider,
),
triggerDelegate: const AnyDropdownSingleTriggerDelegate<String>(),
onChanged: (value) {
if (value != null) {
setState(() => _selectedFruit = value);
}
},
),
const SizedBox(height: 8),
Text('Selected: $_selectedFruit'),
],
);
}
}
// =============================================================================
// 2. Async options — simulates a network fetch
// =============================================================================
class _AsyncExample extends StatefulWidget {
const _AsyncExample();
@override
State<_AsyncExample> createState() => _AsyncExampleState();
}
class _AsyncExampleState extends State<_AsyncExample> {
String _selectedCity = '';
late final AnyDropdownListOptionProvider<String> _optionProvider =
AnyDropdownListOptionProvider<String>(optionsFetcher: _fetchCities);
/// Simulates a network delay then returns a list of cities.
Future<List<String>> _fetchCities() async {
await Future<void>.delayed(const Duration(seconds: 1));
return [
'New York',
'London',
'Tokyo',
'Paris',
'Sydney',
'Berlin',
'Mumbai',
];
}
@override
void initState() {
super.initState();
_optionProvider.refreshOptions();
}
@override
void dispose() {
_optionProvider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pick a city (async load):'),
const SizedBox(height: 8),
AnyDropdownSingle<String>(
value: _selectedCity,
optionProvider: _optionProvider,
menuDelegate: AnyDropdownSingleListMenuDelegate<String>(
optionProvider: _optionProvider,
),
triggerDelegate: const AnyDropdownSingleTriggerDelegate<String>(),
onChanged: (value) {
if (value != null) {
setState(() => _selectedCity = value);
}
},
),
const SizedBox(height: 8),
Text('Selected: ${_selectedCity.isEmpty ? '(none)' : _selectedCity}'),
],
);
}
}
// =============================================================================
// 3. Custom trigger & menu delegates
// =============================================================================
/// A chip-style trigger with an optional icon and animated dropdown arrow.
class ChipTriggerDelegate<T> extends AnyDropdownSingleTriggerDelegate<T> {
const ChipTriggerDelegate({this.icon});
/// Optional icon shown before the value text.
final IconData? icon;
@override
Widget buildTrigger(
BuildContext context,
T value,
Set<WidgetState> states,
bool isOpen, {
required VoidCallback toggleMenu,
required AnyDropdownSetValueCallback<T> setValueCallback,
}) {
return GestureDetector(
onTap: toggleMenu,
behavior: HitTestBehavior.opaque,
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: isOpen ? Colors.deepPurple.shade50 : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isOpen ? Colors.deepPurple : Colors.grey.shade400,
width: 1.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 18, color: Colors.deepPurple),
const SizedBox(width: 6),
],
Text(
value.toString(),
style: TextStyle(
color: Colors.deepPurple.shade800,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 6),
AnimatedRotation(
turns: isOpen ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: Icon(Icons.arrow_drop_down, color: Colors.deepPurple),
),
],
),
),
);
}
}
/// A Material-styled menu with ink ripple, checkmark on selected item,
/// and a shadow decoration.
class MaterialMenuDelegate<T> extends AnyDropdownSingleListMenuDelegate<T> {
const MaterialMenuDelegate({required super.optionProvider});
@override
Widget buildMenu(
BuildContext context,
Set<WidgetState> states,
MenuController menuController,
T value, {
required AnyDropdownSetValueCallback<T> setValueCallback,
}) {
return ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: optionProvider.options.length,
itemExtent: 44,
itemBuilder: (context, index) {
final option = optionProvider.options[index];
final selected = value == option;
return Material(
type: .transparency,
child: InkWell(
onTap: () {
if (!selected) {
setValueCallback(option);
menuController.close();
}
},
child: Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
color: selected ? Colors.deepPurple.shade50 : null,
child: Row(
children: [
if (selected)
const Icon(Icons.check, size: 18, color: Colors.deepPurple),
const SizedBox(width: 8),
Text(
option.toString(),
style: TextStyle(
fontWeight: selected
? FontWeight.w600
: FontWeight.normal,
),
),
],
),
),
),
);
},
);
}
@override
Widget buildMenuDecoration(
BuildContext context,
Set<WidgetState> states,
Widget menu,
) {
return RepaintBoundary(
child: Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: menu,
),
);
}
}
class _CustomDelegatesExample extends StatefulWidget {
const _CustomDelegatesExample();
@override
State<_CustomDelegatesExample> createState() =>
_CustomDelegatesExampleState();
}
class _CustomDelegatesExampleState extends State<_CustomDelegatesExample> {
String _selectedSize = 'Medium';
late final AnyDropdownListOptionProvider<String> _optionProvider =
AnyDropdownListOptionProvider<String>(
optionsFetcher: () => ['Small', 'Medium', 'Large', 'Extra Large'],
);
@override
void initState() {
super.initState();
_optionProvider.refreshOptions();
}
@override
void dispose() {
_optionProvider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pick a size (custom delegates):'),
const SizedBox(height: 8),
AnyDropdownSingle<String>(
value: _selectedSize,
optionProvider: _optionProvider,
menuDelegate: MaterialMenuDelegate<String>(
optionProvider: _optionProvider,
),
triggerDelegate: const ChipTriggerDelegate<String>(
icon: Icons.straighten,
),
onChanged: (value) {
if (value != null) {
setState(() => _selectedSize = value);
}
},
),
const SizedBox(height: 8),
Text('Selected: $_selectedSize'),
],
);
}
}
// =============================================================================
// 4. Basic multi-select — default delegates
// =============================================================================
class _BasicMultiExample extends StatefulWidget {
const _BasicMultiExample();
@override
State<_BasicMultiExample> createState() => _BasicMultiExampleState();
}
class _BasicMultiExampleState extends State<_BasicMultiExample> {
Set<String> _selectedTags = {'Flutter'};
late final AnyDropdownListOptionProvider<String> _optionProvider =
AnyDropdownListOptionProvider<String>(
optionsFetcher: () => [
'Flutter',
'Dart',
'React',
'Vue',
'Angular',
'Svelte',
],
);
@override
void initState() {
super.initState();
_optionProvider.refreshOptions();
}
@override
void dispose() {
_optionProvider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pick your favorite frameworks:'),
const SizedBox(height: 8),
AnyDropdownMulti<String>(
value: _selectedTags,
optionProvider: _optionProvider,
menuDelegate: AnyDropdownMultiListMenuDelegate<String>(
optionProvider: _optionProvider,
),
triggerDelegate: const AnyDropdownMultiTriggerDelegate<String>(),
onChanged: (value) {
setState(() => _selectedTags = value);
},
),
const SizedBox(height: 8),
Text(
'Selected: ${_selectedTags.isEmpty ? '(none)' : _selectedTags.join(', ')}',
),
],
);
}
}
// =============================================================================
// 5. Multi-select — custom chip trigger & checkbox menu
// =============================================================================
/// A multi-select trigger that renders selected items as [Chip] widgets
/// inside a horizontally scrollable row.
class ChipMultiTriggerDelegate<T> extends AnyDropdownMultiTriggerDelegate<T> {
const ChipMultiTriggerDelegate({this.width = 260});
/// Fixed width of the trigger; overflow scrolls horizontally.
final double width;
@override
Widget buildTrigger(
BuildContext context,
Set<T> value,
Set<WidgetState> states,
bool isOpen, {
required VoidCallback toggleMenu,
required AnyDropdownSetValueCallback<Set<T>> setValueCallback,
}) {
return GestureDetector(
onTap: toggleMenu,
behavior: HitTestBehavior.opaque,
child: Container(
width: width,
height: 40,
padding: const EdgeInsets.only(left: 6, right: 2),
decoration: BoxDecoration(
color: isOpen ? Colors.deepPurple.shade50 : Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isOpen ? Colors.deepPurple : Colors.grey.shade400,
width: 1.5,
),
),
child: Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: value.isEmpty
? Text(
'Select items...',
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 14,
),
)
: Row(
children: value
.map(
(item) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(
item.toString(),
style: const TextStyle(fontSize: 12),
),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
final nextValue = Set.of(value)
..remove(item);
setValueCallback(nextValue);
},
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
labelPadding: const EdgeInsets.symmetric(
horizontal: 8,
),
),
),
)
.toList(),
),
),
),
Icon(
isOpen ? Icons.arrow_drop_up : Icons.arrow_drop_down,
color: Colors.deepPurple,
),
],
),
),
);
}
}
/// A multi-select menu with checkboxes, a Select All button, and a Clear
/// button.
///
/// The menu is constrained to a minimum width ([_kMinMenuWidth]) so the
/// action buttons fit comfortably.
class CheckboxMultiMenuDelegate<T> extends AnyDropdownMultiListMenuDelegate<T> {
const CheckboxMultiMenuDelegate({required super.optionProvider});
static const double _kMinMenuWidth = 200.0;
@override
({
double? left,
double? top,
double? right,
double? bottom,
AnyDropdownPopupMenuDirection popupDirection,
BoxConstraints constraints,
})
calculatePositionAndConstraints(
BuildContext context,
RawMenuOverlayInfo overlayInfo,
) {
final result = super.calculatePositionAndConstraints(context, overlayInfo);
final widerConstraints = BoxConstraints(
minWidth: result.constraints.minWidth < _kMinMenuWidth
? _kMinMenuWidth
: result.constraints.minWidth,
maxWidth: result.constraints.maxWidth < _kMinMenuWidth
? _kMinMenuWidth
: result.constraints.maxWidth,
minHeight: result.constraints.minHeight,
maxHeight: result.constraints.maxHeight,
);
return (
left: result.left,
top: result.top,
right: result.right,
bottom: result.bottom,
popupDirection: result.popupDirection,
constraints: widerConstraints,
);
}
@override
Widget buildMenu(
BuildContext context,
Set<WidgetState> states,
MenuController menuController,
Set<T> value, {
required AnyDropdownSetValueCallback<Set<T>> setValueCallback,
}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
fit: FlexFit.loose,
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: optionProvider.options.length,
itemExtent: 44,
itemBuilder: (context, index) {
final option = optionProvider.options[index];
final selected = value.contains(option);
return Material(
type: MaterialType.transparency,
child: InkWell(
onTap: () {
final nextValue = selected
? (Set.of(value)..remove(option))
: {...value, option};
setValueCallback(nextValue);
},
child: Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
SizedBox(
width: 22,
height: 22,
child: Checkbox(
value: selected,
onChanged: (_) {
final nextValue = selected
? (Set.of(value)..remove(option))
: {...value, option};
setValueCallback(nextValue);
},
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
),
const SizedBox(width: 10),
Text(option.toString()),
],
),
),
),
);
},
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton(
onPressed: () {
final all = optionProvider.options.toSet();
setValueCallback(all);
},
child: const Text('Select All'),
),
TextButton(
onPressed: () {
setValueCallback(const {});
menuController.close();
},
child: const Text('Clear'),
),
],
),
),
],
);
}
@override
Widget buildMenuDecoration(
BuildContext context,
Set<WidgetState> states,
Widget menu,
) {
return RepaintBoundary(
child: Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: menu,
),
);
}
}
class _CustomMultiExample extends StatefulWidget {
const _CustomMultiExample();
@override
State<_CustomMultiExample> createState() => _CustomMultiExampleState();
}
class _CustomMultiExampleState extends State<_CustomMultiExample> {
Set<String> _selectedSkills = {'Dart'};
late final AnyDropdownListOptionProvider<String> _optionProvider =
AnyDropdownListOptionProvider<String>(
optionsFetcher: () => [
'Dart',
'Flutter',
'Swift',
'Kotlin',
'Rust',
'Python',
'TypeScript',
],
);
@override
void initState() {
super.initState();
_optionProvider.refreshOptions();
}
@override
void dispose() {
_optionProvider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pick your skills (custom delegates):'),
const SizedBox(height: 8),
AnyDropdownMulti<String>(
value: _selectedSkills,
optionProvider: _optionProvider,
menuDelegate: CheckboxMultiMenuDelegate<String>(
optionProvider: _optionProvider,
),
triggerDelegate: const ChipMultiTriggerDelegate<String>(),
onChanged: (value) {
setState(() => _selectedSkills = value);
},
),
const SizedBox(height: 8),
Text(
'Selected: ${_selectedSkills.isEmpty ? '(none)' : _selectedSkills.join(', ')}',
),
],
);
}
}
// =============================================================================
// 6. Nullable value — shows optional (nullable) selection
// =============================================================================
class _NullableValueExample extends StatefulWidget {
const _NullableValueExample();
@override
State<_NullableValueExample> createState() => _NullableValueExampleState();
}
class _NullableValueExampleState extends State<_NullableValueExample> {
String? _selectedColor;
late final AnyDropdownListOptionProvider<String> _optionProvider =
AnyDropdownListOptionProvider<String>(
optionsFetcher: () => ['Red', 'Green', 'Blue', 'Yellow', 'Purple'],
);
@override
void initState() {
super.initState();
_optionProvider.refreshOptions();
}
@override
void dispose() {
_optionProvider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pick a color (nullable — initially unselected):'),
const SizedBox(height: 8),
AnyDropdownSingle<String?>(
value: _selectedColor,
optionProvider: _optionProvider,
menuDelegate: AnyDropdownSingleListMenuDelegate<String?>(
optionProvider: _optionProvider,
),
triggerDelegate: const _NullableTriggerDelegate(),
onChanged: (value) {
setState(() => _selectedColor = value);
},
),
const SizedBox(height: 8),
Text('Selected: ${_selectedColor ?? '(none)'}'),
],
);
}
}
/// A trigger that shows a placeholder when the value is `null`.
class _NullableTriggerDelegate
extends AnyDropdownSingleTriggerDelegate<String?> {
const _NullableTriggerDelegate();
@override
Widget buildTrigger(
BuildContext context,
String? value,
Set<WidgetState> states,
bool isOpen, {
required VoidCallback toggleMenu,
required AnyDropdownSetValueCallback<String?> setValueCallback,
}) {
return GestureDetector(
onTap: toggleMenu,
behavior: HitTestBehavior.opaque,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFFFFFFF),
border: Border.all(
color: const Color(0xFF000000),
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(4.0),
),
height: 40.0,
padding: const EdgeInsets.symmetric(horizontal: 12),
alignment: .centerStart,
child: Text(
value ?? 'Select a color...',
style: TextStyle(
color: value == null ? Colors.grey.shade500 : Colors.black,
),
),
),
);
}
}
// =============================================================================
// 7. Error state — shows red border and text when hasError is true
// =============================================================================
class _ErrorStateExample extends StatefulWidget {
const _ErrorStateExample();
@override
State<_ErrorStateExample> createState() => _ErrorStateExampleState();
}
class _ErrorStateExampleState extends State<_ErrorStateExample> {
bool _hasError = true;
String _selectedFruit = 'Apple';
Set<String> _selectedTags = {'Flutter'};
late final AnyDropdownListOptionProvider<String> _fruitProvider =
AnyDropdownListOptionProvider<String>(
optionsFetcher: () => [
'Apple',
'Banana',
'Cherry',
'Durian',
'Elderberry',
],
);
late final AnyDropdownListOptionProvider<String> _tagProvider =
AnyDropdownListOptionProvider<String>(
optionsFetcher: () => ['Flutter', 'Dart', 'React', 'Vue', 'Angular'],
);
@override
void initState() {
super.initState();
_fruitProvider.refreshOptions();
_tagProvider.refreshOptions();
}
@override
void dispose() {
_fruitProvider.dispose();
_tagProvider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('Toggle error state:'),
const SizedBox(width: 12),
Switch(
value: _hasError,
onChanged: (v) => setState(() => _hasError = v),
),
Text(
_hasError ? 'ON (error)' : 'OFF (normal)',
style: TextStyle(
color: _hasError ? Colors.red : Colors.green,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 16),
const Text('Single-select (with error):'),
const SizedBox(height: 8),
AnyDropdownSingle<String>(
value: _selectedFruit,
optionProvider: _fruitProvider,
menuDelegate: AnyDropdownSingleListMenuDelegate<String>(
optionProvider: _fruitProvider,
),
triggerDelegate: const AnyDropdownSingleTriggerDelegate<String>(),
hasError: _hasError,
onChanged: (value) {
if (value != null) {
setState(() => _selectedFruit = value);
}
},
),
const SizedBox(height: 4),
Text('Selected: $_selectedFruit'),
const SizedBox(height: 16),
const Text('Multi-select (with error):'),
const SizedBox(height: 8),
AnyDropdownMulti<String>(
value: _selectedTags,
optionProvider: _tagProvider,
menuDelegate: AnyDropdownMultiListMenuDelegate<String>(
optionProvider: _tagProvider,
),
triggerDelegate: const AnyDropdownMultiTriggerDelegate<String>(),
hasError: _hasError,
onChanged: (value) {
setState(() => _selectedTags = value);
},
),
const SizedBox(height: 4),
Text(
'Selected: ${_selectedTags.isEmpty ? '(none)' : _selectedTags.join(', ')}',
),
],
);
}
}