chayanforyou

custom_overlay_dropdown

Aug 10th, 2025
451
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Dart 16.15 KB | None | 0 0
  1. import 'dart:async';
  2. import 'dart:math';
  3.  
  4. import 'package:flutter/material.dart';
  5. import 'package:flutter/widgets.dart';
  6.  
  7. /// Keyboard-aware searchable dropdown that positions itself above the keyboard
  8. /// when necessary. Generic over T.
  9. /// Loads all data once at init, search filters locally.
  10. /// Text field is read-only and shows dropdown arrow.
  11. class KeyboardAwareSearchDropdown<T> extends StatefulWidget {
  12.   final Future<List<T>> Function() fetcher;
  13.   final String Function(T item) itemLabel;
  14.   final Widget Function(BuildContext, T) itemBuilder;
  15.   final void Function(T)? onSelected;
  16.   final InputDecoration? decoration;
  17.  
  18.   const KeyboardAwareSearchDropdown({
  19.     super.key,
  20.     required this.fetcher,
  21.     required this.itemLabel,
  22.     required this.itemBuilder,
  23.     this.onSelected,
  24.     this.decoration,
  25.   });
  26.  
  27.   @override
  28.   State<KeyboardAwareSearchDropdown<T>> createState() =>
  29.       _KeyboardAwareSearchDropdownState<T>();
  30. }
  31.  
  32. class _KeyboardAwareSearchDropdownState<T>
  33.     extends State<KeyboardAwareSearchDropdown<T>> with WidgetsBindingObserver {
  34.   final TextEditingController _controller = TextEditingController();
  35.   final FocusNode _focusNode = FocusNode();
  36.   final GlobalKey _fieldKey = GlobalKey();
  37.  
  38.   OverlayEntry? _overlayEntry;
  39.   List<T> _allItems = [];
  40.   List<T> _filteredItems = [];
  41.   bool _loading = true;
  42.   String? _error;
  43.  
  44.   @override
  45.   void initState() {
  46.     super.initState();
  47.     WidgetsBinding.instance.addObserver(this);
  48.  
  49.     _loadAllItems();
  50.  
  51.     _focusNode.addListener(() {
  52.       if (!_focusNode.hasFocus) {
  53.         _hideOverlay();
  54.       }
  55.     });
  56.   }
  57.  
  58.   Future<void> _loadAllItems() async {
  59.     setState(() {
  60.       _loading = true;
  61.       _error = null;
  62.     });
  63.     try {
  64.       final results = await widget.fetcher();
  65.       if (!mounted) return;
  66.       setState(() {
  67.         _allItems = results;
  68.         _filteredItems = List.from(_allItems);
  69.         _loading = false;
  70.       });
  71.     } catch (e) {
  72.       if (!mounted) return;
  73.       setState(() {
  74.         _error = 'Failed to load items';
  75.         _loading = false;
  76.       });
  77.     }
  78.   }
  79.  
  80.   @override
  81.   void dispose() {
  82.     WidgetsBinding.instance.removeObserver(this);
  83.     _hideOverlay();
  84.     _controller.dispose();
  85.     _focusNode.dispose();
  86.     super.dispose();
  87.   }
  88.  
  89.   @override
  90.   void didChangeMetrics() {
  91.     if (_overlayEntry != null) {
  92.       _overlayEntry!.markNeedsBuild();
  93.     }
  94.   }
  95.  
  96.   void _showOverlay() {
  97.     if (_overlayEntry == null) {
  98.       _overlayEntry = _buildOverlayEntry();
  99.       Overlay.of(context).insert(_overlayEntry!);
  100.     } else {
  101.       _overlayEntry!.markNeedsBuild();
  102.     }
  103.   }
  104.  
  105.   void _hideOverlay() {
  106.     _overlayEntry?.remove();
  107.     _overlayEntry = null;
  108.     _filteredItems = List.from(_allItems);
  109.   }
  110.  
  111.   void _filterItems(String query) {
  112.     setState(() {
  113.       _filteredItems = _allItems
  114.           .where((item) => widget
  115.           .itemLabel(item)
  116.           .toLowerCase()
  117.           .contains(query.toLowerCase()))
  118.           .toList();
  119.     });
  120.     _overlayEntry?.markNeedsBuild();
  121.   }
  122.  
  123.   void _selectItem(T item) {
  124.     final label = widget.itemLabel(item);
  125.     _controller.text = label;
  126.     _controller.selection = TextSelection.collapsed(offset: label.length);
  127.     widget.onSelected?.call(item);
  128.     _hideOverlay();
  129.   }
  130.  
  131.   OverlayEntry _buildOverlayEntry() {
  132.     return OverlayEntry(builder: (overlayContext) {
  133.       if (_fieldKey.currentContext == null) return const SizedBox.shrink();
  134.  
  135.       final RenderBox fieldBox =
  136.       _fieldKey.currentContext!.findRenderObject() as RenderBox;
  137.       final Size fieldSize = fieldBox.size;
  138.       final Offset fieldOffset = fieldBox.localToGlobal(Offset.zero);
  139.  
  140.       final MediaQueryData mq = MediaQuery.of(overlayContext);
  141.       final double screenHeight = mq.size.height;
  142.       final double keyboardHeight = mq.viewInsets.bottom;
  143.       final double topPadding = mq.padding.top;
  144.       final double bottomPadding = mq.padding.bottom;
  145.  
  146.       const double minOverlayHeight = 80; // minimum height for usability
  147.       const double maxOverlayHeightCap = 400; // max height limit, can be parameterized
  148.  
  149.       double availableHeight;
  150.  
  151.       if (keyboardHeight > 0) {
  152.         // Keyboard visible: available height is from top padding to keyboard top minus some margin
  153.         final double bottomLimit = screenHeight - keyboardHeight - bottomPadding;
  154.         availableHeight = bottomLimit - topPadding - 8; // 8 px margin for safety
  155.       } else {
  156.         // Keyboard hidden: available height from below text field to bottom safe area minus margin
  157.         availableHeight = screenHeight - (fieldOffset.dy + fieldSize.height) - bottomPadding - 8;
  158.       }
  159.  
  160.       // Clamp height between min and max cap
  161.       final double maxHeight = availableHeight.clamp(minOverlayHeight, maxOverlayHeightCap);
  162.  
  163.       double top;
  164.       if (keyboardHeight > 0) {
  165.         // Place overlay just above keyboard
  166.         final double bottomLimit = screenHeight - keyboardHeight - bottomPadding;
  167.         top = bottomLimit - maxHeight;
  168.       } else {
  169.         // Place overlay just below TextField
  170.         top = fieldOffset.dy + fieldSize.height;
  171.       }
  172.  
  173.       return _buildPositionedOverlay(
  174.           fieldOffset.dx, top, fieldSize.width, maxHeight, overlayContext);
  175.     });
  176.   }
  177.  
  178.   Widget _buildPositionedOverlay(
  179.       double left, double top, double width, double height, BuildContext overlayContext) {
  180.     Widget dropdownContent;
  181.     if (_loading) {
  182.       dropdownContent = const SizedBox(
  183.         height: 56,
  184.         child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
  185.       );
  186.     } else if (_error != null) {
  187.       dropdownContent = ListTile(
  188.         leading: const Icon(Icons.error_outline),
  189.         title: Text(_error!),
  190.       );
  191.     } else if (_filteredItems.isEmpty) {
  192.       dropdownContent = const ListTile(title: Text('No results'));
  193.     } else {
  194.       dropdownContent = ListView.separated(
  195.         padding: EdgeInsets.zero,
  196.         shrinkWrap: true,
  197.         itemCount: _filteredItems.length,
  198.         separatorBuilder: (_, __) => const Divider(height: 1),
  199.         itemBuilder: (context, index) {
  200.           final item = _filteredItems[index];
  201.           return InkWell(
  202.             onTap: () => _selectItem(item),
  203.             child: widget.itemBuilder(context, item),
  204.           );
  205.         },
  206.       );
  207.     }
  208.  
  209.     return Positioned.fill(
  210.       child: GestureDetector(
  211.         behavior: HitTestBehavior.translucent,
  212.         onTap: () {
  213.           _hideOverlay();
  214.         },
  215.         child: Stack(
  216.           children: [
  217.             Positioned(
  218.               left: left,
  219.               top: top,
  220.               width: width,
  221.               height: height,
  222.               child: Material(
  223.                 elevation: 6,
  224.                 borderRadius: BorderRadius.circular(8),
  225.                 child: Column(
  226.                   children: [
  227.                     Padding(
  228.                       padding: const EdgeInsets.all(8.0),
  229.                       child: TextField(
  230.                         autofocus: true,
  231.                         decoration: const InputDecoration(
  232.                           hintText: 'Search...',
  233.                           prefixIcon: Icon(Icons.search),
  234.                           border: OutlineInputBorder(),
  235.                         ),
  236.                         onChanged: _filterItems,
  237.                       ),
  238.                     ),
  239.                     Expanded(child: dropdownContent),
  240.                   ],
  241.                 ),
  242.               ),
  243.             ),
  244.           ],
  245.         ),
  246.       ),
  247.     );
  248.   }
  249.  
  250.   // OverlayEntry _buildOverlayEntry() {
  251.   //   return OverlayEntry(builder: (overlayContext) {
  252.   //     if (_fieldKey.currentContext == null) return const SizedBox.shrink();
  253.   //
  254.   //     final RenderBox fieldBox =
  255.   //     _fieldKey.currentContext!.findRenderObject() as RenderBox;
  256.   //     final Size fieldSize = fieldBox.size;
  257.   //     final Offset fieldOffset = fieldBox.localToGlobal(Offset.zero);
  258.   //
  259.   //     final MediaQueryData mq = MediaQuery.of(overlayContext);
  260.   //     final double screenHeight = mq.size.height;
  261.   //     final double keyboardHeight = mq.viewInsets.bottom;
  262.   //     final double topPadding = mq.padding.top;
  263.   //     final double bottomLimit = screenHeight - keyboardHeight; // top of keyboard
  264.   //
  265.   //     // We want to place overlay **just above keyboard**, filling max available space.
  266.   //     // Calculate max height above keyboard (exclude top padding)
  267.   //     final double maxOverlayHeight = bottomLimit - topPadding;
  268.   //
  269.   //     // Overlay height limited by maxHeight and max available height
  270.   //     final double overlayHeight = min(widget.maxHeight, maxOverlayHeight);
  271.   //
  272.   //     // Position the overlay to have its bottom exactly at keyboard top
  273.   //     final double top = bottomLimit - overlayHeight;
  274.   //
  275.   //     Widget dropdownContent;
  276.   //     if (_loading) {
  277.   //       dropdownContent = const SizedBox(
  278.   //         height: 56,
  279.   //         child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
  280.   //       );
  281.   //     } else if (_error != null) {
  282.   //       dropdownContent = ListTile(
  283.   //         leading: const Icon(Icons.error_outline),
  284.   //         title: Text(_error!),
  285.   //       );
  286.   //     } else if (_filteredItems.isEmpty) {
  287.   //       dropdownContent = const ListTile(title: Text('No results'));
  288.   //     } else {
  289.   //       dropdownContent = ListView.separated(
  290.   //         padding: EdgeInsets.zero,
  291.   //         shrinkWrap: true,
  292.   //         itemCount: _filteredItems.length,
  293.   //         separatorBuilder: (_, __) => const Divider(height: 1),
  294.   //         itemBuilder: (context, index) {
  295.   //           final item = _filteredItems[index];
  296.   //           return InkWell(
  297.   //             onTap: () => _selectItem(item),
  298.   //             child: widget.itemBuilder(context, item),
  299.   //           );
  300.   //         },
  301.   //       );
  302.   //     }
  303.   //
  304.   //     return Positioned.fill(
  305.   //       child: GestureDetector(
  306.   //         behavior: HitTestBehavior.translucent,
  307.   //         onTap: () {
  308.   //           _hideOverlay();
  309.   //         },
  310.   //         child: Stack(
  311.   //           children: [
  312.   //             Positioned(
  313.   //               left: fieldOffset.dx,
  314.   //               top: top,
  315.   //               width: fieldSize.width,
  316.   //               height: overlayHeight,
  317.   //               child: Material(
  318.   //                 elevation: 6,
  319.   //                 borderRadius: BorderRadius.circular(8),
  320.   //                 child: Column(
  321.   //                   children: [
  322.   //                     // Search field inside dropdown
  323.   //                     Padding(
  324.   //                       padding: const EdgeInsets.all(8.0),
  325.   //                       child: TextField(
  326.   //                         autofocus: true,
  327.   //                         decoration: const InputDecoration(
  328.   //                           hintText: 'Search...',
  329.   //                           prefixIcon: Icon(Icons.search),
  330.   //                           border: OutlineInputBorder(),
  331.   //                         ),
  332.   //                         onChanged: _filterItems,
  333.   //                       ),
  334.   //                     ),
  335.   //                     Expanded(child: dropdownContent),
  336.   //                   ],
  337.   //                 ),
  338.   //               ),
  339.   //             ),
  340.   //           ],
  341.   //         ),
  342.   //       ),
  343.   //     );
  344.   //   });
  345.   // }
  346.  
  347.   // OverlayEntry _buildOverlayEntry() {
  348.   //   return OverlayEntry(builder: (overlayContext) {
  349.   //     if (_fieldKey.currentContext == null) return const SizedBox.shrink();
  350.   //
  351.   //     final RenderBox fieldBox =
  352.   //     _fieldKey.currentContext!.findRenderObject() as RenderBox;
  353.   //     final Size fieldSize = fieldBox.size;
  354.   //     final Offset fieldOffset = fieldBox.localToGlobal(Offset.zero);
  355.   //
  356.   //     final MediaQueryData mq = MediaQuery.of(overlayContext);
  357.   //     final double screenHeight = mq.size.height;
  358.   //     final double keyboardHeight = mq.viewInsets.bottom;
  359.   //     final double topPadding = mq.padding.top;
  360.   //     final double bottomLimit = screenHeight - keyboardHeight;
  361.   //
  362.   //     final double spaceBelow =
  363.   //         bottomLimit - (fieldOffset.dy + fieldSize.height);
  364.   //     final double spaceAbove = fieldOffset.dy - topPadding;
  365.   //
  366.   //     final double maxAvailBelow = max(0, spaceBelow);
  367.   //     final double maxAvailAbove = max(0, spaceAbove);
  368.   //     final double desiredHeight =
  369.   //     min(widget.maxHeight, max(maxAvailBelow, maxAvailAbove));
  370.   //
  371.   //     final bool showAbove = maxAvailBelow < 150 && maxAvailAbove > maxAvailBelow;
  372.   //
  373.   //     double top;
  374.   //     if (showAbove) {
  375.   //       final double usedHeight = min(desiredHeight, maxAvailAbove);
  376.   //       top = fieldOffset.dy - usedHeight;
  377.   //     } else {
  378.   //       top = fieldOffset.dy + fieldSize.height;
  379.   //     }
  380.   //
  381.   //     final double overlayHeight =
  382.   //     showAbove ? min(desiredHeight, maxAvailAbove) : min(desiredHeight, maxAvailBelow);
  383.   //
  384.   //     final double clampedHeight = max(50.0, overlayHeight);
  385.   //
  386.   //     Widget dropdownContent;
  387.   //     if (_loading) {
  388.   //       dropdownContent = const SizedBox(
  389.   //         height: 56,
  390.   //         child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
  391.   //       );
  392.   //     } else if (_error != null) {
  393.   //       dropdownContent = ListTile(
  394.   //         leading: const Icon(Icons.error_outline),
  395.   //         title: Text(_error!),
  396.   //       );
  397.   //     } else if (_filteredItems.isEmpty) {
  398.   //       dropdownContent = const ListTile(title: Text('No results'));
  399.   //     } else {
  400.   //       dropdownContent = ListView.separated(
  401.   //         padding: EdgeInsets.zero,
  402.   //         shrinkWrap: true,
  403.   //         itemCount: _filteredItems.length,
  404.   //         separatorBuilder: (_, __) => const Divider(height: 1),
  405.   //         itemBuilder: (context, index) {
  406.   //           final item = _filteredItems[index];
  407.   //           return InkWell(
  408.   //             onTap: () => _selectItem(item),
  409.   //             child: widget.itemBuilder(context, item),
  410.   //           );
  411.   //         },
  412.   //       );
  413.   //     }
  414.   //
  415.   //     return Positioned.fill(
  416.   //       child: GestureDetector(
  417.   //         behavior: HitTestBehavior.translucent,
  418.   //         onTap: () {
  419.   //           _hideOverlay();
  420.   //         },
  421.   //         child: Stack(
  422.   //           children: [
  423.   //             Positioned(
  424.   //               left: fieldOffset.dx,
  425.   //               top: top,
  426.   //               width: fieldSize.width,
  427.   //               height: clampedHeight,
  428.   //               child: Material(
  429.   //                 elevation: 6,
  430.   //                 borderRadius: BorderRadius.circular(8),
  431.   //                 child: Column(
  432.   //                   children: [
  433.   //                     // Search field inside dropdown
  434.   //                     Padding(
  435.   //                       padding: const EdgeInsets.all(8.0),
  436.   //                       child: TextField(
  437.   //                         autofocus: true,
  438.   //                         decoration: const InputDecoration(
  439.   //                           hintText: 'Search...',
  440.   //                           prefixIcon: Icon(Icons.search),
  441.   //                           border: OutlineInputBorder(),
  442.   //                         ),
  443.   //                         onChanged: _filterItems,
  444.   //                       ),
  445.   //                     ),
  446.   //                     Expanded(child: dropdownContent),
  447.   //                   ],
  448.   //                 ),
  449.   //               ),
  450.   //             ),
  451.   //           ],
  452.   //         ),
  453.   //       ),
  454.   //     );
  455.   //   });
  456.   // }
  457.  
  458.   @override
  459.   Widget build(BuildContext context) {
  460.     return SizedBox(
  461.       child: TextField(
  462.         key: _fieldKey,
  463.         controller: _controller,
  464.         readOnly: true,
  465.         decoration: widget.decoration ??
  466.             InputDecoration(
  467.               border: const OutlineInputBorder(),
  468.               hintText: 'Select...',
  469.               suffixIcon: const Icon(Icons.arrow_drop_down),
  470.             ),
  471.         onTap: () {
  472.           if (!_loading && _error == null && _allItems.isNotEmpty) {
  473.             _showOverlay();
  474.           }
  475.         },
  476.       ),
  477.     );
  478.   }
  479. }
  480.  
Advertisement
Add Comment
Please, Sign In to add comment