Flutter Autocomplete Localization: Suggestions, Hints, and No-Results Messages
Autocomplete widgets enhance user experience by providing suggestions as users type. Localizing autocomplete components ensures users worldwide can efficiently find what they're looking for with suggestions, hints, and feedback in their language. This guide covers everything you need to know about localizing autocomplete in Flutter.
Understanding Autocomplete Localization Needs
Autocomplete requires localization for:
- Hint text: "Search..." or "Type to search..."
- Suggestions: Localized option labels
- No results message: "No matches found"
- Loading states: "Searching..."
- Error messages: "Failed to load suggestions"
- Accessibility: Screen reader announcements
- Character handling: Unicode, diacritics, RTL text
Basic Autocomplete with Localization
Start with a properly localized autocomplete widget:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedAutocomplete extends StatelessWidget {
const LocalizedAutocomplete({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Autocomplete<String>(
optionsBuilder: (textEditingValue) {
if (textEditingValue.text.isEmpty) {
return const Iterable<String>.empty();
}
return _getLocalizedOptions(context).where((option) {
return option
.toLowerCase()
.contains(textEditingValue.text.toLowerCase());
});
},
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
return TextField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
hintText: l10n.autocompleteHint,
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
),
onSubmitted: (value) => onSubmitted(),
);
},
optionsViewBuilder: (context, onSelected, options) {
return _buildOptionsView(context, onSelected, options, l10n);
},
);
}
List<String> _getLocalizedOptions(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return [
l10n.countryUnitedStates,
l10n.countryUnitedKingdom,
l10n.countryGermany,
l10n.countryFrance,
l10n.countryJapan,
l10n.countryChina,
l10n.countryBrazil,
l10n.countryIndia,
];
}
Widget _buildOptionsView(
BuildContext context,
AutocompleteOnSelected<String> onSelected,
Iterable<String> options,
AppLocalizations l10n,
) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (context, index) {
final option = options.elementAt(index);
return ListTile(
title: Text(option),
onTap: () => onSelected(option),
);
},
),
),
),
);
}
}
ARB file entries:
{
"autocompleteHint": "Search countries...",
"@autocompleteHint": {
"description": "Hint text for country autocomplete"
},
"countryUnitedStates": "United States",
"countryUnitedKingdom": "United Kingdom",
"countryGermany": "Germany",
"countryFrance": "France",
"countryJapan": "Japan",
"countryChina": "China",
"countryBrazil": "Brazil",
"countryIndia": "India"
}
Async Autocomplete with Loading States
Handle async data loading with proper loading messages:
class AsyncLocalizedAutocomplete extends StatefulWidget {
const AsyncLocalizedAutocomplete({super.key});
@override
State<AsyncLocalizedAutocomplete> createState() =>
_AsyncLocalizedAutocompleteState();
}
class _AsyncLocalizedAutocompleteState
extends State<AsyncLocalizedAutocomplete> {
bool _isLoading = false;
String? _error;
List<String> _cachedResults = [];
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.autocompleteLabel,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
Autocomplete<String>(
optionsBuilder: (textEditingValue) async {
return await _searchOptions(textEditingValue.text, l10n);
},
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
return TextField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
hintText: l10n.autocompleteHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
border: const OutlineInputBorder(),
errorText: _error,
),
onSubmitted: (value) => onSubmitted(),
);
},
optionsViewBuilder: (context, onSelected, options) {
if (options.isEmpty && !_isLoading) {
return _buildNoResultsView(l10n);
}
return _buildOptionsView(context, onSelected, options);
},
),
],
);
}
Future<Iterable<String>> _searchOptions(
String query,
AppLocalizations l10n,
) async {
if (query.isEmpty) {
return const Iterable<String>.empty();
}
setState(() {
_isLoading = true;
_error = null;
});
try {
// Simulate API call
await Future.delayed(const Duration(milliseconds: 500));
final results = await _fetchFromApi(query);
setState(() => _isLoading = false);
return results;
} catch (e) {
setState(() {
_isLoading = false;
_error = l10n.autocompleteError;
});
return const Iterable<String>.empty();
}
}
Future<List<String>> _fetchFromApi(String query) async {
// Replace with actual API call
final allOptions = [
'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry',
'Fig', 'Grape', 'Honeydew', 'Kiwi', 'Lemon',
];
return allOptions
.where((o) => o.toLowerCase().contains(query.toLowerCase()))
.toList();
}
Widget _buildNoResultsView(AppLocalizations l10n) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: Container(
width: 300,
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
Icons.search_off,
color: Theme.of(context).hintColor,
),
const SizedBox(width: 12),
Text(
l10n.autocompleteNoResults,
style: TextStyle(color: Theme.of(context).hintColor),
),
],
),
),
),
);
}
Widget _buildOptionsView(
BuildContext context,
AutocompleteOnSelected<String> onSelected,
Iterable<String> options,
) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (context, index) {
final option = options.elementAt(index);
return ListTile(
title: Text(option),
onTap: () => onSelected(option),
);
},
),
),
),
);
}
}
ARB entries:
{
"autocompleteLabel": "Search",
"autocompleteHint": "Start typing to search...",
"autocompleteNoResults": "No results found",
"autocompleteError": "Failed to load suggestions",
"autocompleteLoading": "Searching..."
}
Localized Object Autocomplete
Autocomplete with complex objects and localized display:
class Product {
final String id;
final String nameKey;
final String descriptionKey;
final double price;
const Product({
required this.id,
required this.nameKey,
required this.descriptionKey,
required this.price,
});
}
class ProductAutocomplete extends StatelessWidget {
final ValueChanged<Product> onProductSelected;
const ProductAutocomplete({
super.key,
required this.onProductSelected,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
return Autocomplete<Product>(
optionsBuilder: (textEditingValue) {
if (textEditingValue.text.isEmpty) {
return const Iterable<Product>.empty();
}
return _getProducts().where((product) {
final localizedName = _getLocalizedName(product, l10n);
return localizedName
.toLowerCase()
.contains(textEditingValue.text.toLowerCase());
});
},
displayStringForOption: (product) => _getLocalizedName(product, l10n),
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
return TextField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
hintText: l10n.productSearchHint,
prefixIcon: const Icon(Icons.shopping_bag_outlined),
border: const OutlineInputBorder(),
),
onSubmitted: (value) => onSubmitted(),
);
},
optionsViewBuilder: (context, onSelected, options) {
return _buildProductOptions(context, onSelected, options, l10n, locale);
},
onSelected: onProductSelected,
);
}
List<Product> _getProducts() {
return const [
Product(
id: '1',
nameKey: 'productLaptop',
descriptionKey: 'productLaptopDesc',
price: 999.99,
),
Product(
id: '2',
nameKey: 'productPhone',
descriptionKey: 'productPhoneDesc',
price: 699.99,
),
Product(
id: '3',
nameKey: 'productTablet',
descriptionKey: 'productTabletDesc',
price: 499.99,
),
Product(
id: '4',
nameKey: 'productHeadphones',
descriptionKey: 'productHeadphonesDesc',
price: 199.99,
),
];
}
String _getLocalizedName(Product product, AppLocalizations l10n) {
switch (product.nameKey) {
case 'productLaptop':
return l10n.productLaptop;
case 'productPhone':
return l10n.productPhone;
case 'productTablet':
return l10n.productTablet;
case 'productHeadphones':
return l10n.productHeadphones;
default:
return product.nameKey;
}
}
String _getLocalizedDescription(Product product, AppLocalizations l10n) {
switch (product.descriptionKey) {
case 'productLaptopDesc':
return l10n.productLaptopDesc;
case 'productPhoneDesc':
return l10n.productPhoneDesc;
case 'productTabletDesc':
return l10n.productTabletDesc;
case 'productHeadphonesDesc':
return l10n.productHeadphonesDesc;
default:
return product.descriptionKey;
}
}
Widget _buildProductOptions(
BuildContext context,
AutocompleteOnSelected<Product> onSelected,
Iterable<Product> options,
AppLocalizations l10n,
Locale locale,
) {
final currencyFormat = NumberFormat.currency(
locale: locale.toString(),
symbol: '\$',
);
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300, maxWidth: 400),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (context, index) {
final product = options.elementAt(index);
return ListTile(
leading: const Icon(Icons.inventory_2_outlined),
title: Text(_getLocalizedName(product, l10n)),
subtitle: Text(_getLocalizedDescription(product, l10n)),
trailing: Text(
currencyFormat.format(product.price),
style: Theme.of(context).textTheme.titleMedium,
),
onTap: () => onSelected(product),
);
},
),
),
),
);
}
}
Multi-Language Search with Diacritics
Handle searches across languages with special characters:
class DiacriticAwareAutocomplete extends StatelessWidget {
const DiacriticAwareAutocomplete({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Autocomplete<String>(
optionsBuilder: (textEditingValue) {
if (textEditingValue.text.isEmpty) {
return const Iterable<String>.empty();
}
return _getCities(l10n).where((city) {
return _matchesIgnoringDiacritics(
city,
textEditingValue.text,
);
});
},
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
return TextField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
hintText: l10n.citySearchHint,
prefixIcon: const Icon(Icons.location_city),
border: const OutlineInputBorder(),
helperText: l10n.citySearchHelper,
),
onSubmitted: (value) => onSubmitted(),
);
},
);
}
List<String> _getCities(AppLocalizations l10n) {
return [
'München', // Munich in German
'Zürich', // Zurich with umlaut
'Côte d\'Ivoire',
'São Paulo',
'Москва', // Moscow in Russian
'東京', // Tokyo in Japanese
'القاهرة', // Cairo in Arabic
'Kraków', // Krakow with accent
];
}
bool _matchesIgnoringDiacritics(String option, String query) {
final normalizedOption = _removeDiacritics(option.toLowerCase());
final normalizedQuery = _removeDiacritics(query.toLowerCase());
return normalizedOption.contains(normalizedQuery);
}
String _removeDiacritics(String text) {
// Map of diacritics to base characters
const diacriticsMap = {
'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a',
'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e',
'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i',
'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ö': 'o',
'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u',
'ç': 'c', 'ñ': 'n', 'ß': 'ss',
'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n',
'ó': 'o', 'ś': 's', 'ź': 'z', 'ż': 'z',
};
return text.split('').map((char) {
return diacriticsMap[char] ?? char;
}).join();
}
}
Autocomplete with Categories
Group suggestions by category with localized headers:
class CategorizedAutocomplete extends StatelessWidget {
const CategorizedAutocomplete({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Autocomplete<SearchResult>(
optionsBuilder: (textEditingValue) {
if (textEditingValue.text.isEmpty) {
return const Iterable<SearchResult>.empty();
}
return _searchAll(textEditingValue.text, l10n);
},
displayStringForOption: (result) => result.title,
optionsViewBuilder: (context, onSelected, options) {
return _buildCategorizedOptions(context, onSelected, options, l10n);
},
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
return TextField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
hintText: l10n.globalSearchHint,
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
),
onSubmitted: (value) => onSubmitted(),
);
},
);
}
List<SearchResult> _searchAll(String query, AppLocalizations l10n) {
final results = <SearchResult>[];
final lowerQuery = query.toLowerCase();
// Search products
final products = [
SearchResult(
title: l10n.productLaptop,
category: SearchCategory.products,
icon: Icons.laptop,
),
SearchResult(
title: l10n.productPhone,
category: SearchCategory.products,
icon: Icons.phone_android,
),
];
// Search articles
final articles = [
SearchResult(
title: l10n.articleGettingStarted,
category: SearchCategory.articles,
icon: Icons.article,
),
SearchResult(
title: l10n.articleBestPractices,
category: SearchCategory.articles,
icon: Icons.article,
),
];
// Search users
final users = [
SearchResult(
title: 'John Doe',
category: SearchCategory.users,
icon: Icons.person,
),
SearchResult(
title: 'Jane Smith',
category: SearchCategory.users,
icon: Icons.person,
),
];
for (final item in [...products, ...articles, ...users]) {
if (item.title.toLowerCase().contains(lowerQuery)) {
results.add(item);
}
}
return results;
}
Widget _buildCategorizedOptions(
BuildContext context,
AutocompleteOnSelected<SearchResult> onSelected,
Iterable<SearchResult> options,
AppLocalizations l10n,
) {
// Group by category
final grouped = <SearchCategory, List<SearchResult>>{};
for (final option in options) {
grouped.putIfAbsent(option.category, () => []).add(option);
}
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400, maxWidth: 350),
child: ListView(
padding: EdgeInsets.zero,
shrinkWrap: true,
children: [
for (final entry in grouped.entries) ...[
_buildCategoryHeader(entry.key, l10n, context),
for (final result in entry.value)
ListTile(
leading: Icon(result.icon),
title: Text(result.title),
dense: true,
onTap: () => onSelected(result),
),
],
],
),
),
),
);
}
Widget _buildCategoryHeader(
SearchCategory category,
AppLocalizations l10n,
BuildContext context,
) {
String title;
switch (category) {
case SearchCategory.products:
title = l10n.categoryProducts;
break;
case SearchCategory.articles:
title = l10n.categoryArticles;
break;
case SearchCategory.users:
title = l10n.categoryUsers;
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: Theme.of(context).colorScheme.surfaceVariant,
child: Text(
title,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
);
}
}
enum SearchCategory { products, articles, users }
class SearchResult {
final String title;
final SearchCategory category;
final IconData icon;
const SearchResult({
required this.title,
required this.category,
required this.icon,
});
}
ARB entries:
{
"globalSearchHint": "Search products, articles, users...",
"categoryProducts": "Products",
"categoryArticles": "Articles",
"categoryUsers": "Users",
"productLaptop": "Laptop Pro",
"productPhone": "Smartphone Ultra",
"articleGettingStarted": "Getting Started Guide",
"articleBestPractices": "Best Practices"
}
Accessibility for Autocomplete
Ensure autocomplete is accessible to all users:
class AccessibleAutocomplete extends StatelessWidget {
const AccessibleAutocomplete({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: l10n.autocompleteAccessibilityLabel,
child: Autocomplete<String>(
optionsBuilder: (textEditingValue) {
if (textEditingValue.text.isEmpty) {
return const Iterable<String>.empty();
}
return _getOptions(l10n).where((option) {
return option
.toLowerCase()
.contains(textEditingValue.text.toLowerCase());
});
},
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
return TextField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
hintText: l10n.autocompleteHint,
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
),
onSubmitted: (value) => onSubmitted(),
);
},
optionsViewBuilder: (context, onSelected, options) {
return _buildAccessibleOptions(context, onSelected, options, l10n);
},
),
);
}
List<String> _getOptions(AppLocalizations l10n) {
return [l10n.option1, l10n.option2, l10n.option3];
}
Widget _buildAccessibleOptions(
BuildContext context,
AutocompleteOnSelected<String> onSelected,
Iterable<String> options,
AppLocalizations l10n,
) {
final optionsList = options.toList();
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: optionsList.length,
itemBuilder: (context, index) {
final option = optionsList[index];
return Semantics(
label: l10n.autocompleteOptionAnnouncement(
index + 1,
optionsList.length,
option,
),
child: ListTile(
title: Text(option),
onTap: () {
onSelected(option);
// Announce selection
SemanticsService.announce(
l10n.autocompleteSelectedAnnouncement(option),
Directionality.of(context),
);
},
),
);
},
),
),
),
);
}
}
ARB entries:
{
"autocompleteAccessibilityLabel": "Search with autocomplete suggestions",
"autocompleteOptionAnnouncement": "Option {index} of {total}: {option}",
"@autocompleteOptionAnnouncement": {
"placeholders": {
"index": {"type": "int"},
"total": {"type": "int"},
"option": {"type": "String"}
}
},
"autocompleteSelectedAnnouncement": "Selected {option}",
"@autocompleteSelectedAnnouncement": {
"placeholders": {
"option": {"type": "String"}
}
}
}
Testing Autocomplete Localization
Write comprehensive tests for autocomplete behavior:
void main() {
group('LocalizedAutocomplete Tests', () {
testWidgets('displays localized hint text', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: const Locale('en'),
home: const Scaffold(
body: LocalizedAutocomplete(),
),
),
);
expect(find.text('Search countries...'), findsOneWidget);
});
testWidgets('shows localized no results message', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: const Locale('en'),
home: const Scaffold(
body: AsyncLocalizedAutocomplete(),
),
),
);
// Enter text that won't match
await tester.enterText(find.byType(TextField), 'xyz123');
await tester.pumpAndSettle();
expect(find.text('No results found'), findsOneWidget);
});
testWidgets('filters options in current locale', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: const Locale('de'),
home: const Scaffold(
body: LocalizedAutocomplete(),
),
),
);
// Enter search text
await tester.enterText(find.byType(TextField), 'Deutsch');
await tester.pumpAndSettle();
// Should show German country name
expect(find.text('Deutschland'), findsOneWidget);
});
});
}
Best Practices Summary
- Localize all text: Hints, labels, no-results, errors
- Handle diacritics: Allow searching without accents
- Support RTL: Proper alignment for Arabic/Hebrew
- Group results: Use localized category headers
- Announce changes: Screen reader accessibility
- Show loading states: Localized "Searching..." message
- Format results: Localized numbers, dates, currencies
Conclusion
Localizing autocomplete in Flutter requires attention to hints, suggestions, loading states, and accessibility. By implementing proper localized text, diacritic-aware search, and accessible announcements, you ensure users worldwide can efficiently find what they're looking for in your app.