Flutter SearchAnchor Localization: Material 3 Search for Multilingual Apps
SearchAnchor is a Flutter Material 3 widget that provides a full-screen or docked search experience with suggestions. In multilingual applications, SearchAnchor is essential for building search interfaces with translated hint text and suggestions, filtering translated content in real time as users type, supporting RTL text input and suggestion alignment, and providing accessible search interactions with announcements in the active language.
Understanding SearchAnchor in Localization Context
SearchAnchor renders a Material 3 search bar that expands into a search view with suggestions. For multilingual apps, this enables:
- Translated search hint text and suggestion labels
- Real-time filtering of localized content as users type
- RTL-aware text input and suggestion list alignment
- Accessible search with localized screen reader support
Why SearchAnchor Matters for Multilingual Apps
SearchAnchor provides:
- Material 3 search: Updated search UX with translated hints and suggestions
- Suggestion builder: Dynamic translated suggestions based on user input
- Full-screen mode: Expanded search view for mobile with translated content
- Docked mode: Inline search bar with dropdown suggestions for desktop
Basic SearchAnchor Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedSearchAnchorExample extends StatefulWidget {
const LocalizedSearchAnchorExample({super.key});
@override
State<LocalizedSearchAnchorExample> createState() =>
_LocalizedSearchAnchorExampleState();
}
class _LocalizedSearchAnchorExampleState
extends State<LocalizedSearchAnchorExample> {
final SearchController _searchController = SearchController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cities = [
l10n.newYorkCity,
l10n.londonCity,
l10n.tokyoCity,
l10n.parisCity,
l10n.berlinCity,
l10n.dubaiCity,
l10n.sydneyCity,
l10n.torontoCity,
];
return Scaffold(
appBar: AppBar(title: Text(l10n.findCityTitle)),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
SearchAnchor(
searchController: _searchController,
barHintText: l10n.searchCityHint,
barLeading: const Icon(Icons.search),
suggestionsBuilder: (context, controller) {
final query = controller.text.toLowerCase();
final filtered = cities
.where((city) => city.toLowerCase().contains(query))
.toList();
if (filtered.isEmpty) {
return [
ListTile(
leading: const Icon(Icons.search_off),
title: Text(l10n.noResultsFound),
),
];
}
return filtered.map((city) {
return ListTile(
leading: const Icon(Icons.location_city),
title: Text(city),
onTap: () {
controller.closeView(city);
},
);
}).toList();
},
),
],
),
),
);
}
}
Advanced SearchAnchor Patterns for Localization
Search with Recent and Suggested Results
SearchAnchor showing translated recent searches and trending suggestions.
class RecentSearchAnchor extends StatefulWidget {
const RecentSearchAnchor({super.key});
@override
State<RecentSearchAnchor> createState() => _RecentSearchAnchorState();
}
class _RecentSearchAnchorState extends State<RecentSearchAnchor> {
final SearchController _controller = SearchController();
final List<String> _recentSearches = [];
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final allItems = [
l10n.flutterLocalizationTopic,
l10n.arbFilesTopic,
l10n.rtlLayoutTopic,
l10n.pluralizationTopic,
l10n.dateFormattingTopic,
l10n.numberFormattingTopic,
l10n.currencyFormattingTopic,
l10n.contextAccessTopic,
];
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
floating: true,
title: Text(l10n.learnTitle),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(64),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: SearchAnchor(
searchController: _controller,
barHintText: l10n.searchTopicsHint,
barLeading: const Icon(Icons.search),
suggestionsBuilder: (context, controller) {
final query = controller.text.toLowerCase();
if (query.isEmpty) {
return [
if (_recentSearches.isNotEmpty) ...[
Padding(
padding: const EdgeInsetsDirectional.fromSTEB(
16, 12, 16, 4),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.recentSearchesLabel,
style: Theme.of(context)
.textTheme
.titleSmall,
),
TextButton(
onPressed: () {
setState(
() => _recentSearches.clear());
},
child: Text(l10n.clearLabel),
),
],
),
),
..._recentSearches.map((search) {
return ListTile(
leading:
const Icon(Icons.history, size: 20),
title: Text(search),
trailing: IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () {
setState(() =>
_recentSearches.remove(search));
},
),
onTap: () =>
controller.closeView(search),
);
}),
],
Padding(
padding:
const EdgeInsetsDirectional.fromSTEB(
16, 12, 16, 4),
child: Text(
l10n.suggestedTopicsLabel,
style:
Theme.of(context).textTheme.titleSmall,
),
),
...allItems.take(5).map((item) {
return ListTile(
leading: const Icon(Icons.trending_up,
size: 20),
title: Text(item),
onTap: () {
setState(() {
if (!_recentSearches.contains(item)) {
_recentSearches.insert(0, item);
if (_recentSearches.length > 5) {
_recentSearches.removeLast();
}
}
});
controller.closeView(item);
},
);
}),
];
}
final filtered = allItems
.where(
(item) => item.toLowerCase().contains(query))
.toList();
if (filtered.isEmpty) {
return [
ListTile(
leading: const Icon(Icons.search_off),
title: Text(l10n.noResultsFor(query)),
),
];
}
return filtered.map((item) {
return ListTile(
leading: const Icon(Icons.article),
title: Text(item),
onTap: () {
setState(() {
if (!_recentSearches.contains(item)) {
_recentSearches.insert(0, item);
if (_recentSearches.length > 5) {
_recentSearches.removeLast();
}
}
});
controller.closeView(item);
},
);
}).toList();
},
),
),
),
),
SliverList.builder(
itemCount: allItems.length,
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(allItems[index]),
);
},
),
],
),
);
}
}
Docked Search Bar
SearchAnchor.bar for a docked search experience with translated suggestions.
class DockedSearchExample extends StatefulWidget {
const DockedSearchExample({super.key});
@override
State<DockedSearchExample> createState() => _DockedSearchExampleState();
}
class _DockedSearchExampleState extends State<DockedSearchExample> {
final SearchController _controller = SearchController();
String? _selectedItem;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final products = List.generate(20, (index) {
return '${l10n.productLabel} ${index + 1}';
});
return Scaffold(
appBar: AppBar(title: Text(l10n.productsTitle)),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
SearchAnchor.bar(
searchController: _controller,
barHintText: l10n.searchProductsHint,
suggestionsBuilder: (context, controller) {
final query = controller.text.toLowerCase();
final filtered = products
.where((p) => p.toLowerCase().contains(query))
.toList();
if (filtered.isEmpty) {
return [
ListTile(
title: Text(l10n.noProductsFound),
leading: const Icon(Icons.info_outline),
),
];
}
return filtered.map((product) {
return ListTile(
leading: const Icon(Icons.shopping_bag_outlined),
title: Text(product),
onTap: () {
setState(() => _selectedItem = product);
controller.closeView(product);
},
);
}).toList();
},
),
const SizedBox(height: 24),
if (_selectedItem != null)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Icon(Icons.shopping_bag, size: 48),
const SizedBox(height: 8),
Text(
_selectedItem!,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
l10n.productSelectedMessage,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
],
),
),
);
}
}
Category-Filtered Search
SearchAnchor with translated category chips that pre-filter search results.
class CategoryFilteredSearch extends StatefulWidget {
const CategoryFilteredSearch({super.key});
@override
State<CategoryFilteredSearch> createState() =>
_CategoryFilteredSearchState();
}
class _CategoryFilteredSearchState extends State<CategoryFilteredSearch> {
final SearchController _controller = SearchController();
String _selectedCategory = 'all';
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = {
'all': l10n.allCategoriesLabel,
'widgets': l10n.widgetsCategoryLabel,
'patterns': l10n.patternsCategoryLabel,
'tools': l10n.toolsCategoryLabel,
};
return Scaffold(
appBar: AppBar(title: Text(l10n.searchTitle)),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: SearchAnchor(
searchController: _controller,
barHintText: l10n.searchHint,
barLeading: const Icon(Icons.search),
suggestionsBuilder: (context, controller) {
return [
ListTile(
leading: const Icon(Icons.search),
title: Text(
l10n.searchForMessage(controller.text),
),
subtitle: Text(
l10n.inCategoryLabel(
categories[_selectedCategory]!,
),
),
onTap: () => controller.closeView(controller.text),
),
];
},
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsetsDirectional.only(start: 16),
child: Row(
children: categories.entries.map((entry) {
return Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: ChoiceChip(
label: Text(entry.value),
selected: _selectedCategory == entry.key,
onSelected: (selected) {
if (selected) {
setState(() => _selectedCategory = entry.key);
}
},
),
);
}).toList(),
),
),
],
),
);
}
}
RTL Support and Bidirectional Layouts
SearchAnchor automatically handles RTL text input, suggestion alignment, and icon positioning. The search bar and suggestion list both respect the active text direction.
class BidirectionalSearchAnchor extends StatefulWidget {
const BidirectionalSearchAnchor({super.key});
@override
State<BidirectionalSearchAnchor> createState() =>
_BidirectionalSearchAnchorState();
}
class _BidirectionalSearchAnchorState
extends State<BidirectionalSearchAnchor> {
final SearchController _controller = SearchController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.searchTitle)),
body: Padding(
padding: const EdgeInsetsDirectional.all(16),
child: SearchAnchor(
searchController: _controller,
barHintText: l10n.searchHint,
barLeading: const Icon(Icons.search),
suggestionsBuilder: (context, controller) {
return [
ListTile(
leading: const Icon(Icons.search),
title: Text(l10n.searchSuggestion1),
),
ListTile(
leading: const Icon(Icons.search),
title: Text(l10n.searchSuggestion2),
),
ListTile(
leading: const Icon(Icons.search),
title: Text(l10n.searchSuggestion3),
),
];
},
),
),
);
}
}
Testing SearchAnchor Localization
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
Widget buildTestWidget({Locale locale = const Locale('en')}) {
return MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const LocalizedSearchAnchorExample(),
);
}
testWidgets('SearchAnchor renders with localized hint', (tester) async {
await tester.pumpWidget(buildTestWidget());
await tester.pumpAndSettle();
expect(find.byType(SearchAnchor), findsOneWidget);
});
testWidgets('SearchAnchor works in RTL', (tester) async {
await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}
Best Practices
Provide translated
barHintTextto guide users on what they can search for in their active language.Show translated "no results" messages when the search query doesn't match any items, using parameterized labels like
noResultsFor(query).Display recent searches with translated labels and a localized "Clear" button to let users manage their search history.
Use
SearchAnchor.barfor docked search on wide screens and standardSearchAnchorfor full-screen search on mobile.Combine with
ChoiceChipcategory filters using translated category names to let users narrow search scope before typing.Test search with non-Latin scripts to verify filtering works correctly with Arabic, CJK, and other scripts used in your supported locales.
Conclusion
SearchAnchor provides a Material 3 search experience for Flutter apps. For multilingual apps, it handles translated hint text and suggestion labels, supports real-time filtering of localized content, and automatically adapts to RTL text input and suggestion alignment. By combining SearchAnchor with recent searches, category filters, and docked search bars, you can build search experiences that feel native in every supported language.