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
Use parameterized validation messages like
nameTooShortError(minLength)so the same ARB string works for all minimum-length validations.Provide
helperTextin the active language to guide users on expected input format before they encounter validation errors.Adapt phone number hints per locale since formatting expectations vary significantly between countries.
Use
alignLabelWithHint: truefor multiline TextFormField so the label aligns with the first line of translated hint text.Show password strength in the active language with translated labels (Weak, Medium, Strong) and localized requirement descriptions.
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.