← Back to Blog

Flutter TextFormField Localization: Validated Text Input for Multilingual Apps

fluttertextformfieldvalidationformslocalizationrtl

Flutter TextFormField Localization: Validated Text Input for Multilingual Apps

TextFormField is a Flutter widget that combines TextField with FormField, providing built-in validation within a Form context. In multilingual applications, TextFormField is essential for displaying localized validation messages inline, providing translated labels and hints, handling locale-specific input patterns like phone numbers and postal codes, and integrating with Form-level validation in the active language.

Understanding TextFormField in Localization Context

TextFormField wraps a TextField with validation capabilities that integrate with the nearest Form ancestor. For multilingual apps, this enables:

  • Inline localized validation error messages beneath each field
  • Translated label, hint, helper, and prefix/suffix text
  • Locale-specific keyboard types and input formatters
  • Parameterized error messages with field names and constraints

Why TextFormField Matters for Multilingual Apps

TextFormField provides:

  • Inline validation: Display translated error messages directly below the field
  • Rich decoration: Labels, hints, helper text, and icons all support localization
  • Input formatting: Locale-aware formatters for numbers, dates, and currencies
  • Form integration: Participates in batch validation with localized feedback

Basic TextFormField Implementation

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedTextFormFieldExample extends StatefulWidget {
  const LocalizedTextFormFieldExample({super.key});

  @override
  State<LocalizedTextFormFieldExample> createState() =>
      _LocalizedTextFormFieldExampleState();
}

class _LocalizedTextFormFieldExampleState
    extends State<LocalizedTextFormFieldExample> {
  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(title: Text(l10n.contactFormTitle)),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Form(
          key: _formKey,
          child: Column(
            children: [
              TextFormField(
                decoration: InputDecoration(
                  labelText: l10n.fullNameLabel,
                  hintText: l10n.fullNameHint,
                  helperText: l10n.fullNameHelper,
                  prefixIcon: const Icon(Icons.person),
                ),
                textCapitalization: TextCapitalization.words,
                validator: (value) {
                  if (value == null || value.trim().isEmpty) {
                    return l10n.nameRequiredError;
                  }
                  if (value.trim().length < 2) {
                    return l10n.nameTooShortError(2);
                  }
                  return null;
                },
              ),
              const SizedBox(height: 16),
              TextFormField(
                decoration: InputDecoration(
                  labelText: l10n.emailLabel,
                  hintText: l10n.emailHint,
                  prefixIcon: const Icon(Icons.email),
                ),
                keyboardType: TextInputType.emailAddress,
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return l10n.emailRequiredError;
                  }
                  final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+$');
                  if (!emailRegex.hasMatch(value)) {
                    return l10n.emailInvalidError;
                  }
                  return null;
                },
              ),
              const SizedBox(height: 16),
              TextFormField(
                decoration: InputDecoration(
                  labelText: l10n.messageLabel,
                  hintText: l10n.messageHint,
                  alignLabelWithHint: true,
                ),
                maxLines: 4,
                maxLength: 500,
                validator: (value) {
                  if (value == null || value.trim().isEmpty) {
                    return l10n.messageRequiredError;
                  }
                  return null;
                },
              ),
              const SizedBox(height: 16),
              SizedBox(
                width: double.infinity,
                child: FilledButton(
                  onPressed: () {
                    if (_formKey.currentState?.validate() == true) {
                      ScaffoldMessenger.of(context).showSnackBar(
                        SnackBar(content: Text(l10n.messageSentSuccess)),
                      );
                    }
                  },
                  child: Text(l10n.sendButton),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Advanced TextFormField Patterns for Localization

Password Field with Localized Strength Indicator

Password fields need translated strength labels and requirement descriptions.

class LocalizedPasswordField extends StatefulWidget {
  const LocalizedPasswordField({super.key});

  @override
  State<LocalizedPasswordField> createState() =>
      _LocalizedPasswordFieldState();
}

class _LocalizedPasswordFieldState extends State<LocalizedPasswordField> {
  bool _obscureText = true;
  String _password = '';

  String _getStrengthLabel(AppLocalizations l10n) {
    if (_password.length < 6) return l10n.passwordStrengthWeak;
    if (_password.length < 10) return l10n.passwordStrengthMedium;
    if (_password.contains(RegExp(r'[A-Z]')) &&
        _password.contains(RegExp(r'[0-9]')) &&
        _password.contains(RegExp(r'[!@#$%^&*]'))) {
      return l10n.passwordStrengthStrong;
    }
    return l10n.passwordStrengthMedium;
  }

  Color _getStrengthColor() {
    if (_password.length < 6) return Colors.red;
    if (_password.length < 10) return Colors.orange;
    return Colors.green;
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        TextFormField(
          decoration: InputDecoration(
            labelText: l10n.passwordLabel,
            hintText: l10n.passwordHint,
            prefixIcon: const Icon(Icons.lock),
            suffixIcon: IconButton(
              icon: Icon(
                _obscureText ? Icons.visibility : Icons.visibility_off,
              ),
              tooltip: _obscureText
                  ? l10n.showPasswordTooltip
                  : l10n.hidePasswordTooltip,
              onPressed: () {
                setState(() => _obscureText = !_obscureText);
              },
            ),
          ),
          obscureText: _obscureText,
          onChanged: (value) => setState(() => _password = value),
          validator: (value) {
            if (value == null || value.isEmpty) {
              return l10n.passwordRequiredError;
            }
            if (value.length < 8) {
              return l10n.passwordTooShortError(8);
            }
            return null;
          },
        ),
        if (_password.isNotEmpty) ...[
          const SizedBox(height: 8),
          Row(
            children: [
              Expanded(
                child: LinearProgressIndicator(
                  value: _password.length / 16,
                  color: _getStrengthColor(),
                  backgroundColor: _getStrengthColor().withValues(alpha: 0.2),
                ),
              ),
              const SizedBox(width: 12),
              Text(
                _getStrengthLabel(l10n),
                style: TextStyle(
                  color: _getStrengthColor(),
                  fontWeight: FontWeight.w600,
                ),
              ),
            ],
          ),
          const SizedBox(height: 4),
          Text(
            l10n.passwordRequirements,
            style: Theme.of(context).textTheme.bodySmall,
          ),
        ],
      ],
    );
  }
}

Locale-Aware Phone Number Field

Phone number fields adapt their formatting and validation based on the active locale.

class LocalizedPhoneField extends StatelessWidget {
  const LocalizedPhoneField({super.key});

  String _getPhoneHint(Locale locale) {
    switch (locale.countryCode) {
      case 'US':
        return '(555) 123-4567';
      case 'GB':
        return '07911 123456';
      case 'DE':
        return '0171 1234567';
      case 'FR':
        return '06 12 34 56 78';
      default:
        return '+1 234 567 8900';
    }
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);

    return TextFormField(
      decoration: InputDecoration(
        labelText: l10n.phoneNumberLabel,
        hintText: _getPhoneHint(locale),
        helperText: l10n.phoneNumberHelper,
        prefixIcon: const Icon(Icons.phone),
      ),
      keyboardType: TextInputType.phone,
      validator: (value) {
        if (value == null || value.isEmpty) {
          return l10n.phoneRequiredError;
        }
        final digits = value.replaceAll(RegExp(r'\D'), '');
        if (digits.length < 7 || digits.length > 15) {
          return l10n.phoneInvalidError;
        }
        return null;
      },
    );
  }
}

Search Field with Localized Suggestions

TextFormField used for search with translated placeholder and suggestion labels.

class LocalizedSearchFormField extends StatefulWidget {
  const LocalizedSearchFormField({super.key});

  @override
  State<LocalizedSearchFormField> createState() =>
      _LocalizedSearchFormFieldState();
}

class _LocalizedSearchFormFieldState extends State<LocalizedSearchFormField> {
  final _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        TextFormField(
          controller: _controller,
          decoration: InputDecoration(
            labelText: l10n.searchLabel,
            hintText: l10n.searchHint,
            prefixIcon: const Icon(Icons.search),
            suffixIcon: _controller.text.isNotEmpty
                ? IconButton(
                    icon: const Icon(Icons.clear),
                    tooltip: l10n.clearSearchTooltip,
                    onPressed: () {
                      _controller.clear();
                      setState(() {});
                    },
                  )
                : null,
            border: const OutlineInputBorder(),
          ),
          onChanged: (_) => setState(() {}),
          textInputAction: TextInputAction.search,
        ),
        if (_controller.text.isEmpty) ...[
          const SizedBox(height: 12),
          Text(
            l10n.recentSearchesTitle,
            style: Theme.of(context).textTheme.titleSmall,
          ),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            children: [
              ActionChip(
                label: Text(l10n.searchSuggestion1),
                onPressed: () {
                  _controller.text = l10n.searchSuggestion1;
                  setState(() {});
                },
              ),
              ActionChip(
                label: Text(l10n.searchSuggestion2),
                onPressed: () {
                  _controller.text = l10n.searchSuggestion2;
                  setState(() {});
                },
              ),
            ],
          ),
        ],
      ],
    );
  }
}

RTL Support and Bidirectional Layouts

TextFormField automatically handles RTL text input. Prefix icons move to the right side, suffix icons to the left, and text alignment follows the active directionality.

class BidirectionalTextFormField extends StatelessWidget {
  const BidirectionalTextFormField({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Padding(
      padding: const EdgeInsetsDirectional.all(16),
      child: Column(
        children: [
          TextFormField(
            decoration: InputDecoration(
              labelText: l10n.companyNameLabel,
              prefixIcon: const Icon(Icons.business),
              suffixIcon: const Icon(Icons.verified),
            ),
            textAlign: TextAlign.start,
          ),
          const SizedBox(height: 16),
          TextFormField(
            decoration: InputDecoration(
              labelText: l10n.amountLabel,
              prefixText: Directionality.of(context) == TextDirection.rtl
                  ? null
                  : '\$ ',
              suffixText: Directionality.of(context) == TextDirection.rtl
                  ? ' \$'
                  : null,
            ),
            keyboardType: TextInputType.number,
            textDirection: TextDirection.ltr,
          ),
        ],
      ),
    );
  }
}

Testing TextFormField 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 LocalizedTextFormFieldExample(),
    );
  }

  testWidgets('TextFormField shows localized labels', (tester) async {
    await tester.pumpWidget(buildTestWidget());
    await tester.pumpAndSettle();
    expect(find.byType(TextFormField), findsWidgets);
  });

  testWidgets('TextFormField shows validation errors', (tester) async {
    await tester.pumpWidget(buildTestWidget());
    await tester.pumpAndSettle();

    await tester.tap(find.byType(FilledButton));
    await tester.pumpAndSettle();

    expect(find.byType(TextFormField), findsWidgets);
  });

  testWidgets('TextFormField works in RTL', (tester) async {
    await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
    await tester.pumpAndSettle();
    expect(tester.takeException(), isNull);
  });
}

Best Practices

  1. Use parameterized validation messages like nameTooShortError(minLength) so the same ARB string works for all minimum-length validations.

  2. Provide helperText in the active language to guide users on expected input format before they encounter validation errors.

  3. Adapt phone number hints per locale since formatting expectations vary significantly between countries.

  4. Use alignLabelWithHint: true for multiline TextFormField so the label aligns with the first line of translated hint text.

  5. Show password strength in the active language with translated labels (Weak, Medium, Strong) and localized requirement descriptions.

  6. Test input in RTL locales to verify prefix/suffix icons and text swap positions correctly and numeric inputs remain LTR.

Conclusion

TextFormField is the workhorse of validated input in Flutter forms. For multilingual apps, it provides rich decoration options for translated labels, hints, and helper text, plus inline validation with localized error messages. By combining parameterized error strings, locale-aware phone formatting, translated password strength indicators, and bidirectional layout support, you can build input fields that guide users clearly in every supported language.

Further Reading