Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- import 'dart:async';
- import 'package:flutter/material.dart';
- import 'package:flutter_vector_icons/flutter_vector_icons.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;
- final bool showSearch;
- const CustomDropdown({
- super.key,
- required this.items,
- required this.itemLabel,
- this.itemBuilder,
- this.dropdownBuilder,
- this.onSelected,
- this.decoration,
- this.showSearch = false,
- }) : 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>> with WidgetsBindingObserver {
- final TextEditingController _controller = TextEditingController();
- final GlobalKey _fieldKey = GlobalKey();
- final OverlayPortalController _overlayController = OverlayPortalController();
- final ValueNotifier<double> _keyboardHeight = ValueNotifier(0);
- final ValueNotifier<bool> _isOpen = ValueNotifier(false);
- final ValueNotifier<_DropdownState<T>> _state = ValueNotifier(_DropdownState<T>());
- @override
- void initState() {
- super.initState();
- WidgetsBinding.instance.addObserver(this);
- }
- @override
- void dispose() {
- WidgetsBinding.instance.removeObserver(this);
- _controller.dispose();
- _state.dispose();
- _isOpen.dispose();
- _keyboardHeight.dispose();
- super.dispose();
- }
- @override
- void didChangeMetrics() {
- final bottomInset = View.of(context).viewInsets.bottom / View.of(context).devicePixelRatio;
- _keyboardHeight.value = bottomInset;
- }
- /// 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,
- );
- }
- }
- /// Toggle the dropdown overlay
- void _toggleOverlay() {
- if (_isOpen.value) {
- _hideOverlay();
- } else {
- _showOverlay();
- }
- }
- /// Shows the dropdown overlay
- void _showOverlay() {
- _loadItems();
- _overlayController.show();
- _isOpen.value = true;
- }
- /// Hides the dropdown overlay
- void _hideOverlay() {
- _overlayController.hide();
- _isOpen.value = false;
- _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 OverlayPortal(
- controller: _overlayController,
- overlayChildBuilder: (context) {
- return ValueListenableBuilder<double>(
- valueListenable: _keyboardHeight,
- builder: (_, keyboardHeight, __) {
- 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 = keyboardHeight > 0
- ? mq.size.height - keyboardHeight - mq.padding.top - 8
- : mq.size.height - fieldOffset.dy - fieldSize.height - mq.padding.bottom - 8;
- final maxHeight = availableHeight.clamp(minOverlayHeight, maxOverlayHeightCap);
- final top = keyboardHeight > 0
- ? mq.size.height - keyboardHeight - 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: _hideOverlay,
- ),
- ),
- // Dropdown content
- Positioned(
- left: fieldOffset.dx,
- top: top,
- width: fieldSize.width,
- height: maxHeight,
- child: Card(
- elevation: 6,
- child: Column(
- children: [
- if (widget.showSearch)
- Padding(
- padding: const EdgeInsets.all(8),
- child: TextField(
- decoration: InputDecoration(
- hintText: 'Search...',
- prefixIcon: const Icon(EvilIcons.search),
- enabledBorder: OutlineInputBorder(
- borderSide: BorderSide(color: Colors.grey.shade300),
- borderRadius: BorderRadius.circular(16),
- ),
- ),
- onChanged: _filterItems,
- ),
- ),
- Expanded(
- child: ValueListenableBuilder<_DropdownState<T>>(
- valueListenable: _state,
- builder: (context, state, _) {
- if (widget.dropdownBuilder != null) {
- return widget.dropdownBuilder!(
- context, state.filteredItems, _selectItem, 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: () => _selectItem(item),
- child: widget.itemBuilder!(context, item),
- );
- },
- );
- },
- ),
- ),
- ],
- ),
- ),
- ),
- ],
- );
- },
- );
- },
- child: TextField(
- key: _fieldKey,
- controller: _controller,
- readOnly: true,
- decoration: widget.decoration ??
- InputDecoration(
- hintText: 'Select...',
- suffixIcon: ValueListenableBuilder<bool>(
- valueListenable: _isOpen,
- builder: (_, isOpen, __) {
- return Icon(
- isOpen ? EvilIcons.chevron_up : EvilIcons.chevron_down,
- );
- },
- ),
- ),
- onTap: _toggleOverlay,
- ),
- );
- }
- }
- /// 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,
- );
- }
- }
Advertisement
Add Comment
Please, Sign In to add comment