Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- import 'dart:async';
- import 'package:flutter/material.dart';
- /// Type definitions for the dropdown widget
- typedef ItemFetcher<T> = FutureOr<List<T>> Function();
- typedef ItemLabel<T> = String Function(T item);
- typedef ItemWidgetBuilder<T> = Widget Function(BuildContext context, T item);
- typedef ItemSelected<T> = void Function(T item);
- typedef DropdownBuilder<T> = Widget Function(
- BuildContext context,
- List<T> items,
- void Function(T) selectItem,
- bool loading,
- String? error,
- );
- /// A customizable dropdown widget with asynchronous item loading and filtering
- class CustomDropdown<T> extends StatefulWidget {
- final ItemFetcher<T> items;
- final ItemLabel<T> itemLabel;
- final ItemWidgetBuilder<T>? itemBuilder;
- final ItemSelected<T>? onSelected;
- final InputDecoration? decoration;
- final DropdownBuilder<T>? dropdownBuilder;
- const CustomDropdown({
- super.key,
- required this.items,
- required this.itemLabel,
- this.itemBuilder,
- this.dropdownBuilder,
- this.onSelected,
- this.decoration,
- }) : assert(
- dropdownBuilder != null || itemBuilder != null,
- 'You must provide itemBuilder if dropdownBuilder is null',
- );
- @override
- State<CustomDropdown<T>> createState() => _CustomDropdownState<T>();
- }
- class _CustomDropdownState<T> extends State<CustomDropdown<T>> {
- final TextEditingController _controller = TextEditingController();
- final GlobalKey _fieldKey = GlobalKey();
- final ValueNotifier<_DropdownState<T>> _state = ValueNotifier(_DropdownState<T>());
- OverlayEntry? _overlayEntry;
- @override
- void dispose() {
- _overlayEntry?.remove();
- _controller.dispose();
- _state.dispose();
- super.dispose();
- }
- /// Loads items asynchronously and updates state
- Future<void> _loadItems() async {
- _state.value = _state.value.copyWith(loading: true, error: null);
- try {
- final items = await widget.items();
- if (!mounted) return;
- _state.value = _state.value.copyWith(
- allItems: items,
- filteredItems: items,
- loading: false,
- );
- } catch (e) {
- if (!mounted) return;
- _state.value = _state.value.copyWith(
- error: 'Failed to load items',
- loading: false,
- );
- }
- }
- /// Shows the dropdown overlay
- void _showOverlay() {
- if (_overlayEntry != null) {
- _overlayEntry!.markNeedsBuild();
- return;
- }
- _loadItems();
- _overlayEntry = _DropdownOverlay<T>(
- fieldKey: _fieldKey,
- state: _state,
- itemLabel: widget.itemLabel,
- itemBuilder: widget.itemBuilder,
- dropdownBuilder: widget.dropdownBuilder,
- onSelect: _selectItem,
- onFilter: _filterItems,
- onClose: _hideOverlay,
- ).build();
- Overlay.of(context).insert(_overlayEntry!);
- }
- /// Hides the dropdown overlay
- void _hideOverlay() {
- _overlayEntry?.remove();
- _overlayEntry = null;
- _state.value = _state.value.copyWith(filteredItems: _state.value.allItems);
- }
- /// Filters items based on search query
- void _filterItems(String query) {
- final filtered = _state.value.allItems
- .where((item) => widget.itemLabel(item).toLowerCase().contains(query.toLowerCase()))
- .toList();
- _state.value = _state.value.copyWith(filteredItems: filtered);
- }
- /// Handles item selection
- void _selectItem(T item) {
- final label = widget.itemLabel(item);
- _controller.text = label;
- _controller.selection = TextSelection.collapsed(offset: label.length);
- widget.onSelected?.call(item);
- _hideOverlay();
- }
- @override
- Widget build(BuildContext context) {
- return TextField(
- key: _fieldKey,
- controller: _controller,
- readOnly: true,
- decoration: widget.decoration ??
- const InputDecoration(
- border: OutlineInputBorder(),
- hintText: 'Select...',
- suffixIcon: Icon(Icons.keyboard_arrow_down),
- ),
- onTap: _showOverlay,
- );
- }
- }
- /// State class for dropdown data
- class _DropdownState<T> {
- final List<T> allItems;
- final List<T> filteredItems;
- final bool loading;
- final String? error;
- _DropdownState({
- this.allItems = const [],
- this.filteredItems = const [],
- this.loading = true,
- this.error,
- });
- _DropdownState<T> copyWith({
- List<T>? allItems,
- List<T>? filteredItems,
- bool? loading,
- String? error,
- }) {
- return _DropdownState<T>(
- allItems: allItems ?? this.allItems,
- filteredItems: filteredItems ?? this.filteredItems,
- loading: loading ?? this.loading,
- error: error ?? this.error,
- );
- }
- }
- /// Overlay widget for dropdown content
- class _DropdownOverlay<T> {
- final GlobalKey fieldKey;
- final ValueNotifier<_DropdownState<T>> state;
- final ItemLabel<T> itemLabel;
- final ItemWidgetBuilder<T>? itemBuilder;
- final DropdownBuilder<T>? dropdownBuilder;
- final void Function(T) onSelect;
- final void Function(String) onFilter;
- final VoidCallback onClose;
- _DropdownOverlay({
- required this.fieldKey,
- required this.state,
- required this.itemLabel,
- required this.itemBuilder,
- required this.dropdownBuilder,
- required this.onSelect,
- required this.onFilter,
- required this.onClose,
- });
- OverlayEntry build() {
- return OverlayEntry(
- builder: (context) => _buildOverlayContent(context),
- );
- }
- Widget _buildOverlayContent(BuildContext context) {
- if (fieldKey.currentContext == null) return const SizedBox.shrink();
- final fieldBox = fieldKey.currentContext!.findRenderObject() as RenderBox;
- final fieldSize = fieldBox.size;
- final fieldOffset = fieldBox.localToGlobal(Offset.zero);
- final mq = MediaQuery.of(context);
- const minOverlayHeight = 80.0;
- const maxOverlayHeightCap = 400.0;
- final availableHeight = mq.viewInsets.bottom > 0
- ? mq.size.height - mq.viewInsets.bottom - mq.padding.top - 8
- : mq.size.height - fieldOffset.dy - fieldSize.height - mq.padding.bottom - 8;
- final maxHeight = availableHeight.clamp(minOverlayHeight, maxOverlayHeightCap);
- final top = mq.viewInsets.bottom > 0
- ? mq.size.height - mq.viewInsets.bottom - mq.padding.bottom - maxHeight
- : fieldOffset.dy + fieldSize.height;
- return Stack(
- children: [
- // Background tap handler to close overlay
- Positioned.fill(
- child: GestureDetector(
- behavior: HitTestBehavior.opaque,
- onTap: onClose,
- ),
- ),
- // Dropdown content
- Positioned(
- left: fieldOffset.dx,
- top: top,
- width: fieldSize.width,
- height: maxHeight,
- child: Material(
- color: Colors.white,
- borderRadius: BorderRadius.circular(8),
- child: Column(
- children: [
- Padding(
- padding: const EdgeInsets.all(8),
- child: TextField(
- decoration: const InputDecoration(
- hintText: 'Search...',
- prefixIcon: Icon(Icons.search),
- border: OutlineInputBorder(),
- ),
- onChanged: onFilter,
- ),
- ),
- Expanded(
- child: ValueListenableBuilder<_DropdownState<T>>(
- valueListenable: state,
- builder: (context, state, _) {
- if (dropdownBuilder != null) {
- return dropdownBuilder!(context, state.filteredItems, onSelect, state.loading, state.error);
- }
- if (state.loading) {
- return const Center(child: CircularProgressIndicator());
- }
- if (state.error != null) {
- return ListTile(
- leading: const Icon(Icons.error_outline),
- title: Text(state.error!),
- );
- }
- if (state.filteredItems.isEmpty) {
- return const ListTile(title: Text('No data available'));
- }
- return ListView.separated(
- padding: EdgeInsets.zero,
- shrinkWrap: true,
- itemCount: state.filteredItems.length,
- separatorBuilder: (_, __) => const Divider(height: 1),
- itemBuilder: (context, index) {
- final item = state.filteredItems[index];
- return InkWell(
- onTap: () => onSelect(item),
- child: itemBuilder!(context, item),
- );
- },
- );
- },
- ),
- ),
- ],
- ),
- ),
- ),
- ],
- );
- }
- }
Advertisement
Add Comment
Please, Sign In to add comment