Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- import 'dart:async';
- import 'dart:math';
- import 'package:flutter/material.dart';
- import 'package:flutter/widgets.dart';
- /// Keyboard-aware searchable dropdown that positions itself above the keyboard
- /// when necessary. Generic over T.
- /// Loads all data once at init, search filters locally.
- /// Text field is read-only and shows dropdown arrow.
- class KeyboardAwareSearchDropdown<T> extends StatefulWidget {
- final Future<List<T>> Function() fetcher;
- final String Function(T item) itemLabel;
- final Widget Function(BuildContext, T) itemBuilder;
- final void Function(T)? onSelected;
- final InputDecoration? decoration;
- const KeyboardAwareSearchDropdown({
- super.key,
- required this.fetcher,
- required this.itemLabel,
- required this.itemBuilder,
- this.onSelected,
- this.decoration,
- });
- @override
- State<KeyboardAwareSearchDropdown<T>> createState() =>
- _KeyboardAwareSearchDropdownState<T>();
- }
- class _KeyboardAwareSearchDropdownState<T>
- extends State<KeyboardAwareSearchDropdown<T>> with WidgetsBindingObserver {
- final TextEditingController _controller = TextEditingController();
- final FocusNode _focusNode = FocusNode();
- final GlobalKey _fieldKey = GlobalKey();
- OverlayEntry? _overlayEntry;
- List<T> _allItems = [];
- List<T> _filteredItems = [];
- bool _loading = true;
- String? _error;
- @override
- void initState() {
- super.initState();
- WidgetsBinding.instance.addObserver(this);
- _loadAllItems();
- _focusNode.addListener(() {
- if (!_focusNode.hasFocus) {
- _hideOverlay();
- }
- });
- }
- Future<void> _loadAllItems() async {
- setState(() {
- _loading = true;
- _error = null;
- });
- try {
- final results = await widget.fetcher();
- 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 (_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),
- ],
- ),
- ),
- ),
- ],
- ),
- ),
- );
- }
- // 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 bottomLimit = screenHeight - keyboardHeight; // top of keyboard
- //
- // // We want to place overlay **just above keyboard**, filling max available space.
- // // Calculate max height above keyboard (exclude top padding)
- // final double maxOverlayHeight = bottomLimit - topPadding;
- //
- // // Overlay height limited by maxHeight and max available height
- // final double overlayHeight = min(widget.maxHeight, maxOverlayHeight);
- //
- // // Position the overlay to have its bottom exactly at keyboard top
- // final double top = bottomLimit - overlayHeight;
- //
- // Widget dropdownContent;
- // 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: fieldOffset.dx,
- // top: top,
- // width: fieldSize.width,
- // height: overlayHeight,
- // child: Material(
- // elevation: 6,
- // borderRadius: BorderRadius.circular(8),
- // child: Column(
- // children: [
- // // Search field inside dropdown
- // 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),
- // ],
- // ),
- // ),
- // ),
- // ],
- // ),
- // ),
- // );
- // });
- // }
- // 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 bottomLimit = screenHeight - keyboardHeight;
- //
- // final double spaceBelow =
- // bottomLimit - (fieldOffset.dy + fieldSize.height);
- // final double spaceAbove = fieldOffset.dy - topPadding;
- //
- // final double maxAvailBelow = max(0, spaceBelow);
- // final double maxAvailAbove = max(0, spaceAbove);
- // final double desiredHeight =
- // min(widget.maxHeight, max(maxAvailBelow, maxAvailAbove));
- //
- // final bool showAbove = maxAvailBelow < 150 && maxAvailAbove > maxAvailBelow;
- //
- // double top;
- // if (showAbove) {
- // final double usedHeight = min(desiredHeight, maxAvailAbove);
- // top = fieldOffset.dy - usedHeight;
- // } else {
- // top = fieldOffset.dy + fieldSize.height;
- // }
- //
- // final double overlayHeight =
- // showAbove ? min(desiredHeight, maxAvailAbove) : min(desiredHeight, maxAvailBelow);
- //
- // final double clampedHeight = max(50.0, overlayHeight);
- //
- // Widget dropdownContent;
- // 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: fieldOffset.dx,
- // top: top,
- // width: fieldSize.width,
- // height: clampedHeight,
- // child: Material(
- // elevation: 6,
- // borderRadius: BorderRadius.circular(8),
- // child: Column(
- // children: [
- // // Search field inside dropdown
- // 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 ??
- InputDecoration(
- border: const OutlineInputBorder(),
- hintText: 'Select...',
- suffixIcon: const Icon(Icons.arrow_drop_down),
- ),
- onTap: () {
- if (!_loading && _error == null && _allItems.isNotEmpty) {
- _showOverlay();
- }
- },
- ),
- );
- }
- }
Advertisement
Add Comment
Please, Sign In to add comment