Flutter CupertinoSearchTextField Localization: iOS Search Fields for Multilingual Apps
CupertinoSearchTextField is a Flutter widget that renders an iOS-style search text field with a magnifying glass icon, placeholder text, and a clear button. In multilingual applications, CupertinoSearchTextField is essential for displaying translated placeholder hints that guide users in their language, handling RTL text input with correctly mirrored icons and clear buttons, supporting locale-aware search filtering of translated content, and building accessible search fields with announcements in the active language.
Understanding CupertinoSearchTextField in Localization Context
CupertinoSearchTextField renders an iOS-styled rounded search field with built-in prefix icon and suffix clear button. For multilingual apps, this enables:
- Translated placeholder text that tells users what they can search for
- RTL-aware text input with mirrored search icon and clear button
- Locale-sensitive search filtering of translated content lists
- Accessible search field labels announced in the active language
Why CupertinoSearchTextField Matters for Multilingual Apps
CupertinoSearchTextField provides:
- iOS consistency: Search fields matching native iOS patterns in every language
- Built-in UX: Automatic clear button, cancel behavior, and focus management
- Placeholder guidance: Translated hints that explain what the search covers
- Platform feel: iOS users expect Cupertino-style search regardless of language
Basic CupertinoSearchTextField Implementation
import 'package:flutter/cupertino.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedCupertinoSearchExample extends StatefulWidget {
const LocalizedCupertinoSearchExample({super.key});
@override
State<LocalizedCupertinoSearchExample> createState() =>
_LocalizedCupertinoSearchExampleState();
}
class _LocalizedCupertinoSearchExampleState
extends State<LocalizedCupertinoSearchExample> {
String _searchQuery = '';
final _items = [
'Flutter',
'Dart',
'iOS',
'Android',
'Web',
'Desktop',
'Widgets',
'Localization',
];
List<String> get _filteredItems {
if (_searchQuery.isEmpty) return _items;
return _items
.where((item) =>
item.toLowerCase().contains(_searchQuery.toLowerCase()))
.toList();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.searchTitle),
),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: CupertinoSearchTextField(
placeholder: l10n.searchPlaceholder,
onChanged: (value) {
setState(() => _searchQuery = value);
},
),
),
Expanded(
child: _filteredItems.isEmpty
? Center(child: Text(l10n.noResultsMessage))
: ListView.builder(
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
return CupertinoListTile(
title: Text(_filteredItems[index]),
);
},
),
),
],
),
),
);
}
}
Advanced CupertinoSearchTextField Patterns for Localization
Contact Search with Translated Labels
A search field for filtering contacts with localized section headers and empty state messages.
class ContactSearchExample extends StatefulWidget {
const ContactSearchExample({super.key});
@override
State<ContactSearchExample> createState() => _ContactSearchExampleState();
}
class _ContactSearchExampleState extends State<ContactSearchExample> {
final _controller = TextEditingController();
String _query = '';
final _contacts = [
('Ahmed', 'ahmed@example.com'),
('Maria', 'maria@example.com'),
('Yuki', 'yuki@example.com'),
('Hans', 'hans@example.com'),
('Fatima', 'fatima@example.com'),
('Carlos', 'carlos@example.com'),
];
List<(String, String)> get _filtered {
if (_query.isEmpty) return _contacts;
return _contacts
.where((c) =>
c.$1.toLowerCase().contains(_query.toLowerCase()) ||
c.$2.toLowerCase().contains(_query.toLowerCase()))
.toList();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.contactsTitle),
),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: CupertinoSearchTextField(
controller: _controller,
placeholder: l10n.searchContactsPlaceholder,
onChanged: (value) {
setState(() => _query = value);
},
onSuffixTap: () {
_controller.clear();
setState(() => _query = '');
},
),
),
if (_query.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
l10n.searchResultCount(_filtered.length),
style: const TextStyle(
color: CupertinoColors.systemGrey,
fontSize: 13,
),
),
),
),
Expanded(
child: _filtered.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
CupertinoIcons.person_2,
size: 48,
color: CupertinoColors.systemGrey,
),
const SizedBox(height: 12),
Text(
l10n.noContactsFoundMessage,
style: const TextStyle(
color: CupertinoColors.systemGrey,
),
),
],
),
)
: CupertinoListSection.insetGrouped(
header: Text(
_query.isEmpty
? l10n.allContactsHeader
: l10n.searchResultsHeader,
),
children: _filtered.map((contact) {
return CupertinoListTile(
leading: const Icon(CupertinoIcons.person_circle),
title: Text(contact.$1),
subtitle: Text(contact.$2),
trailing: const CupertinoListTileChevron(),
);
}).toList(),
),
),
],
),
),
);
}
}
Settings Search
A search field that filters settings options with translated section names and descriptions.
class SettingsSearchExample extends StatefulWidget {
const SettingsSearchExample({super.key});
@override
State<SettingsSearchExample> createState() => _SettingsSearchExampleState();
}
class _SettingsSearchExampleState extends State<SettingsSearchExample> {
String _query = '';
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final settings = [
(CupertinoIcons.globe, l10n.languageLabel, l10n.languageDescription),
(CupertinoIcons.moon, l10n.darkModeLabel, l10n.darkModeDescription),
(CupertinoIcons.bell, l10n.notificationsLabel, l10n.notificationsDescription),
(CupertinoIcons.lock, l10n.privacyLabel, l10n.privacyDescription),
(CupertinoIcons.cloud, l10n.backupLabel, l10n.backupDescription),
(CupertinoIcons.info, l10n.aboutLabel, l10n.aboutDescription),
];
final filtered = _query.isEmpty
? settings
: settings
.where((s) =>
s.$2.toLowerCase().contains(_query.toLowerCase()) ||
s.$3.toLowerCase().contains(_query.toLowerCase()))
.toList();
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.settingsTitle),
),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: CupertinoSearchTextField(
placeholder: l10n.searchSettingsPlaceholder,
onChanged: (value) {
setState(() => _query = value);
},
),
),
Expanded(
child: filtered.isEmpty
? Center(child: Text(l10n.noSettingsFoundMessage))
: CupertinoListSection.insetGrouped(
children: filtered.map((setting) {
return CupertinoListTile(
leading: Icon(setting.$1),
title: Text(setting.$2),
subtitle: Text(setting.$3),
trailing: const CupertinoListTileChevron(),
);
}).toList(),
),
),
],
),
),
);
}
}
Search with Recent History
A search field with localized recent search history and suggestions.
class SearchWithHistoryExample extends StatefulWidget {
const SearchWithHistoryExample({super.key});
@override
State<SearchWithHistoryExample> createState() =>
_SearchWithHistoryExampleState();
}
class _SearchWithHistoryExampleState extends State<SearchWithHistoryExample> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
String _query = '';
final List<String> _recentSearches = [];
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
void _submitSearch(String query) {
if (query.isNotEmpty && !_recentSearches.contains(query)) {
setState(() {
_recentSearches.insert(0, query);
if (_recentSearches.length > 5) {
_recentSearches.removeLast();
}
});
}
_focusNode.unfocus();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.searchTitle),
),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: CupertinoSearchTextField(
controller: _controller,
focusNode: _focusNode,
placeholder: l10n.searchPlaceholder,
onChanged: (value) {
setState(() => _query = value);
},
onSubmitted: _submitSearch,
),
),
if (_query.isEmpty && _recentSearches.isNotEmpty)
CupertinoListSection.insetGrouped(
header: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l10n.recentSearchesHeader),
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () {
setState(() => _recentSearches.clear());
},
child: Text(
l10n.clearAllLabel,
style: const TextStyle(fontSize: 13),
),
),
],
),
children: _recentSearches.map((search) {
return CupertinoListTile(
leading: const Icon(
CupertinoIcons.clock,
color: CupertinoColors.systemGrey,
),
title: Text(search),
onTap: () {
_controller.text = search;
setState(() => _query = search);
},
);
}).toList(),
),
if (_query.isEmpty && _recentSearches.isEmpty)
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
CupertinoIcons.search,
size: 48,
color: CupertinoColors.systemGrey,
),
const SizedBox(height: 12),
Text(
l10n.searchPromptMessage,
style: const TextStyle(
color: CupertinoColors.systemGrey,
),
),
],
),
),
),
],
),
),
);
}
}
RTL Support and Bidirectional Layouts
CupertinoSearchTextField handles RTL text input automatically. The search icon stays on the leading side and the clear button on the trailing side, adapting to the text direction.
class BidirectionalCupertinoSearch extends StatelessWidget {
const BidirectionalCupertinoSearch({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.searchTitle),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsetsDirectional.all(16),
child: Column(
children: [
CupertinoSearchTextField(
placeholder: l10n.searchPlaceholder,
onChanged: (value) {},
),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
l10n.searchHintMessage,
style: const TextStyle(
color: CupertinoColors.systemGrey,
fontSize: 13,
),
),
),
],
),
),
),
);
}
}
Testing CupertinoSearchTextField Localization
import 'package:flutter/cupertino.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 CupertinoApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const LocalizedCupertinoSearchExample(),
);
}
testWidgets('Search field shows localized placeholder', (tester) async {
await tester.pumpWidget(buildTestWidget());
await tester.pumpAndSettle();
expect(find.byType(CupertinoSearchTextField), findsOneWidget);
});
testWidgets('Search field works in RTL', (tester) async {
await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('No results message is localized', (tester) async {
await tester.pumpWidget(buildTestWidget());
await tester.pumpAndSettle();
await tester.enterText(
find.byType(CupertinoSearchTextField),
'zzzzz',
);
await tester.pumpAndSettle();
// Verify no results state appears
});
}
Best Practices
Provide descriptive placeholder text that tells users what they can search for, translated into the active language (e.g., "Search contacts" not just "Search").
Show a localized empty state when no results match the search query, with a clear message and icon to guide the user.
Display result counts using pluralized translations so "1 result" and "5 results" read correctly in every language.
Use
onSuffixTapto handle clear button taps and reset both the controller and the query state together.Combine with
CupertinoListSectionfor search results to maintain consistent iOS list styling with translated section headers.Test RTL text input to verify the search icon, clear button, and typed text all position and flow correctly in Arabic and Hebrew.
Conclusion
CupertinoSearchTextField provides an iOS-style search field for Flutter apps. For multilingual apps, it handles translated placeholder hints, localized empty states and result counts, and RTL text input with properly positioned icons. By combining it with contact search, settings filtering, and recent search history, you can build search interfaces that feel native on iOS in every supported language.