Forked from saltedpotatos/autocomplete_form.dart
Last active
January 7, 2026 20:00
-
-
Save slightfoot/25e817686c61448137ef01a230b04a86 to your computer and use it in GitHub Desktop.
Autocomplete Lists :: HumpdayQandA on 7th January 2026 :: https://www.youtube.com/watch?v=dD-nfl1FY_o
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //Hello FlutterCommunity, long time lurker, first time gist-er | |
| // | |
| //I've been creating a budgeting app and have these combo boxes that | |
| //I can either select an existing account or create a new one. | |
| // | |
| //However, I've been struggling to make keyboard navigation through this form buttery smooth | |
| // | |
| //Adding 100 accounts and trying to navigate by keyboard through the form is not a great experience. | |
| // - hit the down arrow to open the options list and then hit the up arrow to navigate up from the bottom | |
| // | |
| // - can be hard to select an account with the keyboard and get to the next field smoothly. | |
| // Seems to need an extra `esc` to dismiss before I can tab on to the next field | |
| // | |
| // - Creating an account doesn't refresh the options list, although the account does get created. | |
| // Erasing a character will show the new account in the options list | |
| // | |
| //Hopefully I've trimmed this down enough to be useful without introducing new issues when I extracted this widget out | |
| import 'dart:async'; | |
| import 'dart:math' show Random; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/scheduler.dart' show SchedulerBinding; | |
| import 'package:flutter/services.dart' show LogicalKeyboardKey, KeyDownEvent; | |
| import 'package:flutter_hooks/flutter_hooks.dart'; | |
| import 'package:signals_hooks/signals_hooks.dart'; | |
| final _random = Random(); | |
| class Account { | |
| const Account({required this.id, required this.name}); | |
| final String id; | |
| final String name; | |
| } | |
| class AccountOptionsView { | |
| const AccountOptionsView( | |
| this.account, { | |
| this.isCreateNew = false, | |
| }); | |
| final Account account; | |
| final bool isCreateNew; | |
| } | |
| void main() { | |
| SignalsObserver.instance = null; | |
| runApp(const MainApp()); | |
| } | |
| class MainApp extends StatefulHookWidget { | |
| const MainApp({super.key}); | |
| @override | |
| State<MainApp> createState() => _MainAppState(); | |
| } | |
| class _MainAppState extends State<MainApp> { | |
| final budgetId = 'budgetId'; | |
| final BigInt dungId = BigInt.parse('5'); | |
| final String accountId = 'accountId'; | |
| @override | |
| Widget build(BuildContext context) { | |
| final accounts = useSignal([Account(id: '0', name: 'Some Option')]); | |
| final selectedAccount = useSignal<Account?>(null); | |
| final successRate = useSignal(1.0); | |
| final payeeController = useTextEditingController(); | |
| final payeeFocus = useFocusNode(); | |
| final categoryController = useTextEditingController(); | |
| final categoryFocus = useFocusNode(); | |
| final dateFocusNode = useFocusNode(); | |
| DateTime selectedDate = DateTime.now(); | |
| final dateController = useTextEditingController(); | |
| void setDate(DateTime date) { | |
| setState(() { | |
| selectedDate = date; | |
| dateController.text = '${date.toLocal()}'.split(' ')[0]; | |
| }); | |
| } | |
| Future<(Account?, String?)> createAccount({ | |
| required String accountName, | |
| bool? alwaysSucceed, | |
| }) async { | |
| // Simulate success/failure based on successRate | |
| final shouldSucceed = (alwaysSucceed ?? false) || _random.nextDouble() < successRate.value; | |
| if (!shouldSucceed) { | |
| final successPercentage = (successRate.value * 100).toStringAsFixed(0); | |
| return (null, 'Simulated failure (success rate: $successPercentage%)'); | |
| } | |
| try { | |
| final account = Account( | |
| id: 'account-${accounts.value.length}', | |
| name: accountName, | |
| ); | |
| accounts.add(account); | |
| return (account, null); | |
| } catch (error, _) { | |
| return (null, error.toString()); | |
| } | |
| } | |
| void add100Accounts() { | |
| final categoryNames = [ | |
| 'Groceries', | |
| 'Dining Out', | |
| 'Coffee Shops', | |
| 'Fast Food', | |
| 'Restaurants', | |
| 'Gas & Fuel', | |
| 'Car Maintenance', | |
| 'Car Insurance', | |
| 'Public Transportation', | |
| 'Ride Share', | |
| 'Electric Bill', | |
| 'Water Bill', | |
| 'Gas Bill', | |
| 'Internet', | |
| 'Phone Bill', | |
| 'Cable TV', | |
| 'Streaming Services', | |
| 'Rent', | |
| 'Mortgage', | |
| 'Home Insurance', | |
| 'Property Tax', | |
| 'Home Maintenance', | |
| 'Home Improvement', | |
| 'Furniture', | |
| 'Clothing', | |
| 'Shoes', | |
| 'Personal Care', | |
| 'Haircuts', | |
| 'Gym Membership', | |
| 'Sports & Fitness', | |
| 'Entertainment', | |
| 'Movies & Theater', | |
| 'Hobbies', | |
| 'Books & Magazines', | |
| 'Music', | |
| 'Pet Food', | |
| 'Pet Care', | |
| 'Veterinary', | |
| 'Medical', | |
| 'Dental', | |
| 'Pharmacy', | |
| 'Health Insurance', | |
| 'Gifts', | |
| 'Donations', | |
| 'Education', | |
| 'Subscriptions', | |
| 'Software', | |
| 'Office Supplies', | |
| 'Miscellaneous', | |
| 'Emergency Fund', | |
| ]; | |
| for (final name in categoryNames) { | |
| createAccount(accountName: name, alwaysSucceed: true); | |
| } | |
| } | |
| void resetAccounts() { | |
| accounts.set([]); | |
| } | |
| FutureOr<void> onPayeeSelected(Account account) async { | |
| if (account.id.isEmpty) { | |
| accounts.add(Account(id: account.name, name: account.name)); | |
| } else { | |
| // Existing account was selected | |
| selectedAccount.value = account; | |
| } | |
| } | |
| return MaterialApp( | |
| debugShowCheckedModeBanner: false, | |
| home: Scaffold( | |
| body: SingleChildScrollView( | |
| child: Padding( | |
| padding: const EdgeInsets.all(32.0), | |
| child: Column( | |
| spacing: 4, | |
| crossAxisAlignment: .stretch, | |
| children: [ | |
| InputDatePickerFormField( | |
| focusNode: dateFocusNode, | |
| onDateSaved: (date) => setDate(date), | |
| onDateSubmitted: (date) => setDate(date), | |
| fieldLabelText: 'Date', | |
| fieldHintText: 'MM/DD/YYYY', | |
| errorInvalidText: 'No transfers prior to y2k buddy', | |
| errorFormatText: 'Gonna need to see a date here pal', | |
| initialDate: selectedDate, | |
| firstDate: DateTime(2000), | |
| lastDate: DateTime(2100), | |
| ), | |
| Watch.builder( | |
| builder: (context) { | |
| return TransactionCombobox( | |
| textController: payeeController, | |
| focusNode: payeeFocus, | |
| accounts: accounts.value, | |
| hintText: 'Merchant', | |
| labelText: 'Merchant', | |
| color: Colors.blue.shade200, | |
| validator: null, | |
| createNewAccount: (name) async { | |
| final (account, message) = await createAccount( | |
| accountName: name, | |
| ); | |
| if (account != null) { | |
| return (account, null); | |
| } else { | |
| return (null, 'Failed to create account'); | |
| } | |
| }, | |
| onFieldSubmitted: (name) async { | |
| return await createAccount(accountName: name); | |
| }, | |
| onAccountSelected: (n) => onPayeeSelected(n!), | |
| ); | |
| }, | |
| dependencies: [accounts], | |
| ), | |
| Watch.builder( | |
| builder: (context) { | |
| return TransactionCombobox( | |
| textController: categoryController, | |
| focusNode: categoryFocus, | |
| accounts: accounts.value, | |
| hintText: 'Category', | |
| labelText: 'Category', | |
| color: Colors.orange.shade200, | |
| validator: null, | |
| createNewAccount: (name) async { | |
| final (account, message) = await createAccount( | |
| accountName: name, | |
| ); | |
| if (account != null) { | |
| return (account, null); | |
| } else { | |
| return (null, 'Failed to create account'); | |
| } | |
| }, | |
| onFieldSubmitted: (name) async { | |
| return await createAccount(accountName: name); | |
| }, | |
| onAccountSelected: (n) => onPayeeSelected(n!), | |
| ); | |
| }, | |
| dependencies: [accounts], | |
| ), | |
| TextFormField( | |
| decoration: const InputDecoration( | |
| labelText: 'Some Field', | |
| hintText: 'Tab tab tab', | |
| border: OutlineInputBorder(), | |
| ), | |
| ), | |
| Text( | |
| 'Utilities and debugging', | |
| style: Theme.of(context).textTheme.titleMedium, | |
| ), | |
| Watch.builder( | |
| builder: (context) { | |
| final accountSimple = accounts.get().map( | |
| (acc) => '${acc.name}(${acc.id})', | |
| ); | |
| final currentRate = successRate.value; | |
| final percentage = (currentRate * 100).toInt(); | |
| return Row( | |
| mainAxisAlignment: .spaceBetween, | |
| crossAxisAlignment: .start, | |
| children: [ | |
| Flexible( | |
| child: Column( | |
| children: [ | |
| Column( | |
| children: [ | |
| Text('Request Success Rate:'), | |
| Row( | |
| children: [ | |
| Expanded( | |
| child: Slider( | |
| value: currentRate, | |
| min: 0.0, | |
| max: 1.0, | |
| divisions: 20, | |
| label: '$percentage%', | |
| onChanged: (value) { | |
| successRate.value = value; | |
| }, | |
| ), | |
| ), | |
| SizedBox( | |
| width: 50, | |
| child: Text( | |
| '$percentage%', | |
| textAlign: TextAlign.center, | |
| ), | |
| ), | |
| ], | |
| ), | |
| ], | |
| ), | |
| TextButton( | |
| onPressed: resetAccounts, | |
| child: Text('Reset accounts to default'), | |
| ), | |
| TextButton( | |
| onPressed: add100Accounts, | |
| child: Text('Add 100 accounts'), | |
| ), | |
| ], | |
| ), | |
| ), | |
| Expanded( | |
| child: Column( | |
| crossAxisAlignment: .start, | |
| children: [ | |
| for (final account in accountSimple) // | |
| Text(account), | |
| ], | |
| ), | |
| ), | |
| ], | |
| ); | |
| }, | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| class TransactionCombobox extends StatefulWidget { | |
| const TransactionCombobox({ | |
| required this.accounts, | |
| required this.createNewAccount, | |
| required this.onAccountSelected, | |
| required this.onFieldSubmitted, | |
| required this.textController, | |
| required this.focusNode, | |
| this.validator, | |
| this.hintText = 'ComboBox', | |
| this.labelText, | |
| this.color = Colors.grey, | |
| this.icon = Icons.mail_outline_rounded, | |
| this.disabled = false, | |
| super.key, | |
| }); | |
| final List<Account> accounts; | |
| final String hintText; | |
| final String? labelText; | |
| final Future<(Account?, String?)> Function(String name)? createNewAccount; | |
| final Function(String name) onFieldSubmitted; | |
| final FutureOr<void> Function(Account? account) onAccountSelected; | |
| final String? Function(String?)? validator; | |
| final Color color; | |
| final IconData icon; | |
| final TextEditingController textController; | |
| final FocusNode focusNode; | |
| final bool disabled; | |
| @override | |
| State<TransactionCombobox> createState() => _TransactionComboboxState(); | |
| } | |
| class _TransactionComboboxState extends State<TransactionCombobox> { | |
| List<AccountOptionsView> _getOptions(TextEditingValue textEditingValue) { | |
| if (textEditingValue.text.isEmpty) { | |
| return widget | |
| .accounts // | |
| .map((account) => AccountOptionsView(account)) | |
| .toList(); | |
| } | |
| final matches = widget.accounts | |
| .where((Account acc) { | |
| return acc.name.toLowerCase().contains( | |
| textEditingValue.text.toLowerCase(), | |
| ); | |
| }) | |
| .map((account) => AccountOptionsView(account)) | |
| .toList(); | |
| final input = textEditingValue.text.trim(); | |
| if (widget.createNewAccount != null) { | |
| if (input.isNotEmpty && | |
| !widget.accounts.any( | |
| (cat) => cat.name.toLowerCase() == input.toLowerCase(), | |
| )) { | |
| matches.add( | |
| AccountOptionsView( | |
| Account(id: '', name: input), | |
| isCreateNew: true, | |
| ), | |
| ); | |
| } | |
| } | |
| return matches; | |
| } | |
| Future<void> _handleSelection( | |
| AccountOptionsView selection, { | |
| TextEditingController? controller, | |
| }) async { | |
| final effectiveController = controller ?? widget.textController; | |
| late Account finalSelection; | |
| if (selection.isCreateNew) { | |
| final (account, errorMessage) = await widget.createNewAccount!(selection.account.name); | |
| if (account == null || errorMessage != null) { | |
| return; | |
| } | |
| finalSelection = account; | |
| } else { | |
| finalSelection = selection.account; | |
| } | |
| widget.onAccountSelected(finalSelection); | |
| WidgetsBinding.instance.addPostFrameCallback((_) { | |
| if (mounted) { | |
| //See fixme above | |
| effectiveController.text = finalSelection.name; | |
| FocusScope.of(context).nextFocus(); | |
| } | |
| }); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Autocomplete<AccountOptionsView>( | |
| textEditingController: widget.textController, | |
| focusNode: widget.focusNode, | |
| displayStringForOption: (view) => view.account.name, | |
| optionsBuilder: _getOptions, | |
| onSelected: _handleSelection, | |
| fieldViewBuilder: | |
| ( | |
| BuildContext context, | |
| TextEditingController controller, | |
| FocusNode focusNode, | |
| VoidCallback onFieldSubmitted, | |
| ) { | |
| return TextFormField( | |
| controller: controller, | |
| focusNode: focusNode, | |
| onFieldSubmitted: widget.onFieldSubmitted, | |
| decoration: InputDecoration( | |
| labelText: widget.labelText, | |
| hintText: widget.hintText, | |
| labelStyle: widget.disabled | |
| ? TextStyle(color: Colors.black87) | |
| : TextStyle(color: widget.color), | |
| focusedBorder: OutlineInputBorder( | |
| borderSide: BorderSide(color: widget.color, width: 2.0), | |
| ), | |
| enabledBorder: OutlineInputBorder( | |
| borderSide: BorderSide(color: widget.color.withValues()), | |
| ), | |
| disabledBorder: const OutlineInputBorder( | |
| borderSide: BorderSide(color: Colors.grey, width: 1.0), | |
| ), | |
| prefixStyle: TextStyle(color: widget.color), | |
| ), | |
| style: Theme.of( | |
| context, | |
| ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), | |
| ); | |
| }, | |
| optionsViewBuilder: | |
| ( | |
| BuildContext context, | |
| AutocompleteOnSelected<AccountOptionsView> onSelected, | |
| Iterable<AccountOptionsView> options, | |
| ) { | |
| final highlightedIndex = AutocompleteHighlightedOption.of(context); | |
| return _TransactionOptionsView( | |
| focusNode: widget.focusNode, | |
| highlightedIndex: highlightedIndex, | |
| onSelected: onSelected, | |
| options: options, | |
| icon: widget.icon, | |
| ); | |
| }, | |
| ); | |
| } | |
| } | |
| class _TransactionOptionsView extends StatefulWidget { | |
| const _TransactionOptionsView({ | |
| required this.focusNode, | |
| required this.highlightedIndex, | |
| required this.onSelected, | |
| required this.options, | |
| required this.icon, | |
| }); | |
| final FocusNode focusNode; | |
| final int highlightedIndex; | |
| final AutocompleteOnSelected<AccountOptionsView> onSelected; | |
| final Iterable<AccountOptionsView> options; | |
| final IconData icon; | |
| @override | |
| State<_TransactionOptionsView> createState() => _TransactionOptionsViewState(); | |
| } | |
| class _TransactionOptionsViewState extends State<_TransactionOptionsView> { | |
| final ScrollController _scrollController = ScrollController(); | |
| @override | |
| void initState() { | |
| super.initState(); | |
| widget.focusNode.onKeyEvent = _onKeyEvent; | |
| } | |
| KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { | |
| if (node.hasFocus == false) { | |
| return KeyEventResult.ignored; | |
| } | |
| if (event is! KeyDownEvent) { | |
| return KeyEventResult.ignored; | |
| } | |
| if (event.logicalKey == LogicalKeyboardKey.enter) { | |
| final view = widget.options.elementAt(widget.highlightedIndex); | |
| widget.onSelected(view); | |
| return KeyEventResult.handled; | |
| } | |
| return KeyEventResult.ignored; | |
| } | |
| @override | |
| void dispose() { | |
| widget.focusNode.onKeyEvent = null; | |
| super.dispose(); | |
| } | |
| @override | |
| void didUpdateWidget(_TransactionOptionsView oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (widget.highlightedIndex != oldWidget.highlightedIndex) { | |
| SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { | |
| if (!mounted) { | |
| return; | |
| } | |
| final BuildContext? highlightedContext = GlobalObjectKey( | |
| widget.options.elementAt(widget.highlightedIndex), | |
| ).currentContext; | |
| if (highlightedContext == null) { | |
| _scrollController.jumpTo( | |
| widget.highlightedIndex == 0 ? 0.0 : _scrollController.position.maxScrollExtent, | |
| ); | |
| } else { | |
| Scrollable.ensureVisible(highlightedContext, alignment: 0.5); | |
| } | |
| }, debugLabel: 'AutocompleteOptions.ensureVisible'); | |
| } | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return FocusScope( | |
| canRequestFocus: false, | |
| child: Align( | |
| alignment: Alignment.topLeft, | |
| child: Material( | |
| elevation: 4.0, | |
| child: ConstrainedBox( | |
| constraints: const BoxConstraints(maxHeight: 200), | |
| child: ListView.builder( | |
| controller: _scrollController, | |
| shrinkWrap: true, | |
| padding: EdgeInsets.zero, | |
| itemCount: widget.options.length, | |
| itemBuilder: (BuildContext context, int index) { | |
| final view = widget.options.elementAt(index); | |
| final isHighlighted = widget.highlightedIndex == index; | |
| return ListTile( | |
| key: GlobalObjectKey(view), | |
| tileColor: isHighlighted ? Theme.of(context).highlightColor : null, | |
| dense: true, | |
| leading: Icon( | |
| view.isCreateNew ? Icons.add_circle_outline : widget.icon, | |
| size: 20, | |
| color: view.isCreateNew ? Colors.green : null, | |
| ), | |
| title: Text( | |
| view.isCreateNew ? 'Create "${view.account.name}"' : view.account.name, | |
| style: view.isCreateNew ? const TextStyle(color: Colors.green) : null, | |
| ), | |
| onTap: () => widget.onSelected(view), | |
| ); | |
| }, | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment