import 'dart:async'; import 'package:flutter/material.dart'; typedef ItemFetcher = FutureOr> Function(); typedef ItemLabel = String Function(T item); typedef ItemWidgetBuilder = Widget Function(BuildContext context, T item); typedef ItemSelected = void Function(T item); typedef DropdownBuilder = Widget Function( BuildContext context, List items, void Function(T) selectItem, bool loading, String? error, ); class CustomDropdown extends StatefulWidget { final ItemFetcher items; final ItemLabel itemLabel; final ItemWidgetBuilder? itemBuilder; final ItemSelected? onSelected; final InputDecoration? decoration; final DropdownBuilder? 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> createState() => _CustomDropdownState(); } class _CustomDropdownState extends State> with WidgetsBindingObserver { final TextEditingController _controller = TextEditingController(); final FocusNode _focusNode = FocusNode(); final GlobalKey _fieldKey = GlobalKey(); OverlayEntry? _overlayEntry; List _allItems = []; List _filteredItems = []; bool _loading = true; String? _error; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _loadAllItems(); _focusNode.addListener(() { if (!_focusNode.hasFocus) { _hideOverlay(); } }); } Future _loadAllItems() async { setState(() { _loading = true; _error = null; }); try { final results = await widget.items(); if (!mounted) return; setState(() { _allItems = results; _filteredItems = List.from(_allItems); _loading = false; }); } catch (e) { if (!mounted) return; setState(() { _error = 'Failed to load items'; _loading = false; }); } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _hideOverlay(); _controller.dispose(); _focusNode.dispose(); super.dispose(); } @override void didChangeMetrics() { if (_overlayEntry != null) { _overlayEntry!.markNeedsBuild(); } } void _showOverlay() { if (_overlayEntry == null) { _overlayEntry = _buildOverlayEntry(); Overlay.of(context).insert(_overlayEntry!); } else { _overlayEntry!.markNeedsBuild(); } } void _hideOverlay() { _overlayEntry?.remove(); _overlayEntry = null; _filteredItems = List.from(_allItems); } void _filterItems(String query) { setState(() { _filteredItems = _allItems.where((item) => widget.itemLabel(item).toLowerCase().contains(query.toLowerCase())).toList(); }); _overlayEntry?.markNeedsBuild(); } void _selectItem(T item) { final label = widget.itemLabel(item); _controller.text = label; _controller.selection = TextSelection.collapsed(offset: label.length); widget.onSelected?.call(item); _hideOverlay(); } OverlayEntry _buildOverlayEntry() { return OverlayEntry( builder: (overlayContext) { if (_fieldKey.currentContext == null) return const SizedBox.shrink(); final RenderBox fieldBox = _fieldKey.currentContext!.findRenderObject() as RenderBox; final Size fieldSize = fieldBox.size; final Offset fieldOffset = fieldBox.localToGlobal(Offset.zero); final MediaQueryData mq = MediaQuery.of(overlayContext); final double screenHeight = mq.size.height; final double keyboardHeight = mq.viewInsets.bottom; final double topPadding = mq.padding.top; final double bottomPadding = mq.padding.bottom; const double minOverlayHeight = 80; // minimum height for usability const double maxOverlayHeightCap = 400; // max height limit, can be parameterized double availableHeight; if (keyboardHeight > 0) { // Keyboard visible: available height is from top padding to keyboard top minus some margin final double bottomLimit = screenHeight - keyboardHeight - bottomPadding; availableHeight = bottomLimit - topPadding - 8; // 8 px margin for safety } else { // Keyboard hidden: available height from below text field to bottom safe area minus margin availableHeight = screenHeight - (fieldOffset.dy + fieldSize.height) - bottomPadding - 8; } // Clamp height between min and max cap final double maxHeight = availableHeight.clamp(minOverlayHeight, maxOverlayHeightCap); double top; if (keyboardHeight > 0) { // Place overlay just above keyboard final double bottomLimit = screenHeight - keyboardHeight - bottomPadding; top = bottomLimit - maxHeight; } else { // Place overlay just below TextField top = fieldOffset.dy + fieldSize.height; } return _buildPositionedOverlay(fieldOffset.dx, top, fieldSize.width, maxHeight, overlayContext); }, ); } Widget _buildPositionedOverlay(double left, double top, double width, double height, BuildContext overlayContext) { Widget dropdownContent; if (widget.dropdownBuilder != null) { dropdownContent = widget.dropdownBuilder!( overlayContext, _filteredItems, _selectItem, _loading, _error, ); } else { if (_loading) { dropdownContent = const SizedBox( height: 56, child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ); } else if (_error != null) { dropdownContent = ListTile( leading: const Icon(Icons.error_outline), title: Text(_error!), ); } else if (_filteredItems.isEmpty) { dropdownContent = const ListTile(title: Text('No results')); } else { dropdownContent = ListView.separated( padding: EdgeInsets.zero, shrinkWrap: true, itemCount: _filteredItems.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final item = _filteredItems[index]; return InkWell( onTap: () => _selectItem(item), child: widget.itemBuilder!(context, item), ); }, ); } } return Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { _hideOverlay(); }, child: Stack( children: [ Positioned( left: left, top: top, width: width, height: height, child: Material( elevation: 6, borderRadius: BorderRadius.circular(8), child: Column( children: [ Padding( padding: const EdgeInsets.all(8.0), child: TextField( autofocus: true, decoration: const InputDecoration( hintText: 'Search...', prefixIcon: Icon(Icons.search), border: OutlineInputBorder(), ), onChanged: _filterItems, ), ), Expanded(child: dropdownContent), ], ), ), ), ], ), ), ); } @override Widget build(BuildContext context) { return SizedBox( child: TextField( key: _fieldKey, controller: _controller, readOnly: true, decoration: widget.decoration ?? const InputDecoration( border: OutlineInputBorder(), hintText: 'Select...', suffixIcon: Icon(Icons.keyboard_arrow_down), ), onTap: () { if (!_loading && _error == null && _allItems.isNotEmpty) { _showOverlay(); } }, ), ); } }