← Back to Blog

Flutter CupertinoSearchTextField Localization: iOS Search Fields for Multilingual Apps

fluttercupertinosearchtextfieldlocalizationrtl

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

  1. Provide descriptive placeholder text that tells users what they can search for, translated into the active language (e.g., "Search contacts" not just "Search").

  2. Show a localized empty state when no results match the search query, with a clear message and icon to guide the user.

  3. Display result counts using pluralized translations so "1 result" and "5 results" read correctly in every language.

  4. Use onSuffixTap to handle clear button taps and reset both the controller and the query state together.

  5. Combine with CupertinoListSection for search results to maintain consistent iOS list styling with translated section headers.

  6. 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.

Further Reading