Flutter ListTile Localization: Complete Guide to Titles, Subtitles, and Trailing Text
ListTile is one of the most commonly used widgets in Flutter. From settings screens to contact lists, from navigation menus to search results, ListTile appears everywhere. Properly localizing ListTile components ensures your app feels native to users worldwide.
Why ListTile Localization Matters
ListTiles often contain critical information that users need to understand quickly. Poorly localized list items can confuse users, reduce engagement, and make your app feel unprofessional. A well-localized ListTile adapts not just text, but also layout direction, date formats, and accessibility labels.
Basic ListTile Localization
Let's start with the fundamentals of localizing a ListTile:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedListTile extends StatelessWidget {
const LocalizedListTile({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListTile(
leading: const Icon(Icons.person),
title: Text(l10n.profileTitle),
subtitle: Text(l10n.profileSubtitle),
trailing: Text(l10n.viewMore),
onTap: () => Navigator.pushNamed(context, '/profile'),
);
}
}
Your ARB file would include:
{
"profileTitle": "Profile",
"@profileTitle": {
"description": "Title for profile list item"
},
"profileSubtitle": "Manage your account settings",
"@profileSubtitle": {
"description": "Subtitle for profile list item"
},
"viewMore": "View",
"@viewMore": {
"description": "Action text for list item trailing"
}
}
Settings Screen with Localized ListTiles
Settings screens are ListTile-heavy. Here's a comprehensive example:
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.settingsTitle),
),
body: ListView(
children: [
_buildSectionHeader(context, l10n.accountSection),
ListTile(
leading: const Icon(Icons.person_outline),
title: Text(l10n.profileSettings),
subtitle: Text(l10n.profileSettingsDescription),
trailing: const Icon(Icons.chevron_right),
onTap: () => _navigateToProfile(context),
),
ListTile(
leading: const Icon(Icons.security),
title: Text(l10n.securitySettings),
subtitle: Text(l10n.securitySettingsDescription),
trailing: const Icon(Icons.chevron_right),
onTap: () => _navigateToSecurity(context),
),
_buildSectionHeader(context, l10n.preferencesSection),
SwitchListTile(
secondary: const Icon(Icons.dark_mode),
title: Text(l10n.darkModeTitle),
subtitle: Text(l10n.darkModeDescription),
value: Theme.of(context).brightness == Brightness.dark,
onChanged: (value) => _toggleDarkMode(context, value),
),
ListTile(
leading: const Icon(Icons.language),
title: Text(l10n.languageSettings),
subtitle: Text(l10n.currentLanguage),
trailing: const Icon(Icons.chevron_right),
onTap: () => _navigateToLanguage(context),
),
_buildSectionHeader(context, l10n.notificationsSection),
SwitchListTile(
secondary: const Icon(Icons.notifications),
title: Text(l10n.pushNotifications),
subtitle: Text(l10n.pushNotificationsDescription),
value: true,
onChanged: (value) => _toggleNotifications(value),
),
],
),
);
}
Widget _buildSectionHeader(BuildContext context, String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
}
Localized ListTiles with Dynamic Content
Many ListTiles display dynamic data that needs locale-aware formatting:
class ContactListTile extends StatelessWidget {
final Contact contact;
const ContactListTile({
super.key,
required this.contact,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
return ListTile(
leading: CircleAvatar(
backgroundImage: contact.avatarUrl != null
? NetworkImage(contact.avatarUrl!)
: null,
child: contact.avatarUrl == null
? Text(contact.initials)
: null,
),
title: Text(contact.fullName),
subtitle: Text(_formatLastContact(context, contact.lastContactDate)),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatMessageCount(context, contact.unreadCount),
style: contact.unreadCount > 0
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (contact.isFavorite)
Icon(
Icons.star,
size: 16,
color: Colors.amber,
semanticLabel: l10n.favoriteContact,
),
],
),
onTap: () => _openContact(context, contact),
);
}
String _formatLastContact(BuildContext context, DateTime date) {
final l10n = AppLocalizations.of(context)!;
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return l10n.lastContactToday;
} else if (difference.inDays == 1) {
return l10n.lastContactYesterday;
} else if (difference.inDays < 7) {
return l10n.lastContactDaysAgo(difference.inDays);
} else {
return DateFormat.MMMd(
Localizations.localeOf(context).toString(),
).format(date);
}
}
String _formatMessageCount(BuildContext context, int count) {
final l10n = AppLocalizations.of(context)!;
if (count == 0) {
return l10n.noNewMessages;
}
return l10n.unreadMessages(count);
}
}
ARB entries for dynamic content:
{
"lastContactToday": "Today",
"@lastContactToday": {
"description": "Shown when last contact was today"
},
"lastContactYesterday": "Yesterday",
"@lastContactYesterday": {
"description": "Shown when last contact was yesterday"
},
"lastContactDaysAgo": "{days} days ago",
"@lastContactDaysAgo": {
"description": "Shown when last contact was multiple days ago",
"placeholders": {
"days": {
"type": "int",
"example": "3"
}
}
},
"noNewMessages": "No new messages",
"@noNewMessages": {
"description": "Shown when there are no unread messages"
},
"unreadMessages": "{count, plural, =1{1 message} other{{count} messages}}",
"@unreadMessages": {
"description": "Unread message count with pluralization",
"placeholders": {
"count": {
"type": "int",
"example": "5"
}
}
},
"favoriteContact": "Favorite contact",
"@favoriteContact": {
"description": "Accessibility label for favorite star icon"
}
}
Three-Line ListTiles with Localization
When you need more information, use three-line ListTiles:
class EmailListTile extends StatelessWidget {
final Email email;
const EmailListTile({
super.key,
required this.email,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListTile(
isThreeLine: true,
leading: CircleAvatar(
backgroundColor: email.isRead
? Colors.grey
: Theme.of(context).colorScheme.primary,
child: Text(
email.senderInitials,
style: TextStyle(
color: email.isRead ? Colors.white70 : Colors.white,
),
),
),
title: Row(
children: [
Expanded(
child: Text(
email.senderName,
style: TextStyle(
fontWeight: email.isRead ? FontWeight.normal : FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
Text(
_formatEmailDate(context, email.receivedAt),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
email.subject,
style: TextStyle(
fontWeight: email.isRead ? FontWeight.normal : FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
email.preview,
style: Theme.of(context).textTheme.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
trailing: email.hasAttachment
? Tooltip(
message: l10n.hasAttachment,
child: const Icon(Icons.attach_file, size: 18),
)
: null,
onTap: () => _openEmail(context, email),
onLongPress: () => _showEmailActions(context, email),
);
}
String _formatEmailDate(BuildContext context, DateTime date) {
final locale = Localizations.localeOf(context).toString();
final now = DateTime.now();
if (now.difference(date).inDays == 0) {
return DateFormat.jm(locale).format(date); // 2:30 PM
} else if (now.year == date.year) {
return DateFormat.MMMd(locale).format(date); // Jan 5
} else {
return DateFormat.yMMMd(locale).format(date); // Jan 5, 2026
}
}
}
RTL Support for ListTiles
ListTile automatically handles RTL, but you should verify your implementation:
class RTLAwareListTile extends StatelessWidget {
const RTLAwareListTile({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRTL = Directionality.of(context) == TextDirection.rtl;
return ListTile(
// Leading and trailing automatically flip in RTL
leading: const Icon(Icons.folder),
title: Text(l10n.documentsFolder),
subtitle: Text(l10n.documentsCount(42)),
// Use directional chevron
trailing: Icon(
isRTL ? Icons.chevron_left : Icons.chevron_right,
),
onTap: () => _openDocuments(context),
);
}
}
For custom layouts within ListTiles:
class CustomLayoutListTile extends StatelessWidget {
final Product product;
const CustomLayoutListTile({
super.key,
required this.product,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final textDirection = Directionality.of(context);
return ListTile(
leading: Image.network(
product.imageUrl,
width: 56,
height: 56,
fit: BoxFit.cover,
),
title: Text(product.localizedName(context)),
subtitle: Row(
children: [
// Price should follow reading direction
Text(
_formatPrice(context, product.price),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
if (product.originalPrice != null) ...[
Text(
_formatPrice(context, product.originalPrice!),
style: const TextStyle(
decoration: TextDecoration.lineThrough,
color: Colors.grey,
),
),
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: Text(
l10n.discountPercentage(product.discountPercent),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
],
],
),
trailing: IconButton(
icon: Icon(
product.isFavorite ? Icons.favorite : Icons.favorite_border,
color: product.isFavorite ? Colors.red : null,
),
onPressed: () => _toggleFavorite(product),
tooltip: product.isFavorite
? l10n.removeFromFavorites
: l10n.addToFavorites,
),
);
}
String _formatPrice(BuildContext context, double price) {
final locale = Localizations.localeOf(context);
final format = NumberFormat.currency(
locale: locale.toString(),
symbol: '\$',
);
return format.format(price);
}
}
Accessibility for Localized ListTiles
Proper accessibility is crucial for inclusive apps:
class AccessibleListTile extends StatelessWidget {
final Notification notification;
const AccessibleListTile({
super.key,
required this.notification,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: _buildSemanticLabel(context),
hint: l10n.tapToViewDetails,
button: true,
child: ListTile(
leading: _buildNotificationIcon(context),
title: Text(
notification.title,
semanticsLabel: notification.title,
),
subtitle: Text(
notification.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatTime(context, notification.timestamp),
style: Theme.of(context).textTheme.bodySmall,
),
if (!notification.isRead)
Container(
margin: const EdgeInsets.only(top: 4),
width: 8,
height: 8,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
),
],
),
onTap: () => _handleTap(context),
),
);
}
String _buildSemanticLabel(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final readStatus = notification.isRead
? l10n.notificationRead
: l10n.notificationUnread;
return '${notification.title}. ${notification.body}. '
'${_formatTime(context, notification.timestamp)}. $readStatus';
}
Widget _buildNotificationIcon(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: l10n.notificationType(notification.type),
child: CircleAvatar(
backgroundColor: _getTypeColor(notification.type),
child: Icon(
_getTypeIcon(notification.type),
color: Colors.white,
size: 20,
),
),
);
}
String _formatTime(BuildContext context, DateTime time) {
final locale = Localizations.localeOf(context).toString();
return DateFormat.jm(locale).format(time);
}
}
Reusable Localized ListTile Components
Create reusable components for consistent localization:
class LocalizedSettingsTile extends StatelessWidget {
final IconData icon;
final String titleKey;
final String? subtitleKey;
final Widget? trailing;
final VoidCallback? onTap;
const LocalizedSettingsTile({
super.key,
required this.icon,
required this.titleKey,
this.subtitleKey,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListTile(
leading: Icon(icon),
title: Text(_getLocalizedString(l10n, titleKey)),
subtitle: subtitleKey != null
? Text(_getLocalizedString(l10n, subtitleKey!))
: null,
trailing: trailing ?? const Icon(Icons.chevron_right),
onTap: onTap,
);
}
String _getLocalizedString(AppLocalizations l10n, String key) {
// In practice, you might use a lookup map or reflection
switch (key) {
case 'profile':
return l10n.profileSettings;
case 'security':
return l10n.securitySettings;
case 'notifications':
return l10n.notificationSettings;
case 'privacy':
return l10n.privacySettings;
case 'help':
return l10n.helpAndSupport;
case 'about':
return l10n.aboutApp;
default:
return key;
}
}
}
// Usage
class SettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
children: [
LocalizedSettingsTile(
icon: Icons.person,
titleKey: 'profile',
onTap: () => Navigator.pushNamed(context, '/profile'),
),
LocalizedSettingsTile(
icon: Icons.security,
titleKey: 'security',
onTap: () => Navigator.pushNamed(context, '/security'),
),
LocalizedSettingsTile(
icon: Icons.notifications,
titleKey: 'notifications',
onTap: () => Navigator.pushNamed(context, '/notifications'),
),
],
);
}
}
ListTile with Localized Selection States
For selectable lists:
class SelectableLanguageTile extends StatelessWidget {
final Locale locale;
final bool isSelected;
final ValueChanged<Locale> onSelect;
const SelectableLanguageTile({
super.key,
required this.locale,
required this.isSelected,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final languageName = _getLanguageName(locale);
final nativeName = _getNativeName(locale);
return ListTile(
leading: Text(
_getFlag(locale),
style: const TextStyle(fontSize: 24),
),
title: Text(languageName),
subtitle: Text(nativeName),
trailing: isSelected
? Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
semanticLabel: l10n.selectedLanguage,
)
: null,
selected: isSelected,
onTap: () => onSelect(locale),
);
}
String _getLanguageName(Locale locale) {
final names = {
'en': 'English',
'es': 'Spanish',
'fr': 'French',
'de': 'German',
'ar': 'Arabic',
'ja': 'Japanese',
'zh': 'Chinese',
};
return names[locale.languageCode] ?? locale.languageCode;
}
String _getNativeName(Locale locale) {
final names = {
'en': 'English',
'es': 'Espanol',
'fr': 'Francais',
'de': 'Deutsch',
'ar': 'العربية',
'ja': '日本語',
'zh': '中文',
};
return names[locale.languageCode] ?? '';
}
String _getFlag(Locale locale) {
final flags = {
'en': '🇺🇸',
'es': '🇪🇸',
'fr': '🇫🇷',
'de': '🇩🇪',
'ar': '🇸🇦',
'ja': '🇯🇵',
'zh': '🇨🇳',
};
return flags[locale.languageCode] ?? '🌐';
}
}
CheckboxListTile and RadioListTile
These variants also need proper localization:
class PreferencesScreen extends StatefulWidget {
const PreferencesScreen({super.key});
@override
State<PreferencesScreen> createState() => _PreferencesScreenState();
}
class _PreferencesScreenState extends State<PreferencesScreen> {
bool _emailNotifications = true;
bool _smsNotifications = false;
String _theme = 'system';
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.preferencesTitle)),
body: ListView(
children: [
// Section: Notifications
ListTile(
title: Text(
l10n.notificationPreferences,
style: Theme.of(context).textTheme.titleMedium,
),
),
CheckboxListTile(
title: Text(l10n.emailNotifications),
subtitle: Text(l10n.emailNotificationsDescription),
value: _emailNotifications,
onChanged: (value) {
setState(() => _emailNotifications = value ?? false);
},
),
CheckboxListTile(
title: Text(l10n.smsNotifications),
subtitle: Text(l10n.smsNotificationsDescription),
value: _smsNotifications,
onChanged: (value) {
setState(() => _smsNotifications = value ?? false);
},
),
const Divider(),
// Section: Theme
ListTile(
title: Text(
l10n.themePreferences,
style: Theme.of(context).textTheme.titleMedium,
),
),
RadioListTile<String>(
title: Text(l10n.themeSystem),
subtitle: Text(l10n.themeSystemDescription),
value: 'system',
groupValue: _theme,
onChanged: (value) {
setState(() => _theme = value ?? 'system');
},
),
RadioListTile<String>(
title: Text(l10n.themeLight),
subtitle: Text(l10n.themeLightDescription),
value: 'light',
groupValue: _theme,
onChanged: (value) {
setState(() => _theme = value ?? 'system');
},
),
RadioListTile<String>(
title: Text(l10n.themeDark),
subtitle: Text(l10n.themeDarkDescription),
value: 'dark',
groupValue: _theme,
onChanged: (value) {
setState(() => _theme = value ?? 'system');
},
),
],
),
);
}
}
Testing Localized ListTiles
Comprehensive tests ensure your ListTiles work in all locales:
import 'package:flutter_test/flutter_test.dart';
void main() {
group('LocalizedListTile', () {
testWidgets('displays correct English text', (tester) async {
await tester.pumpWidget(
const MaterialApp(
locale: Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: ContactListTile(
contact: Contact(
fullName: 'John Doe',
lastContactDate: DateTime.now(),
unreadCount: 3,
),
),
),
),
);
expect(find.text('John Doe'), findsOneWidget);
expect(find.text('3 messages'), findsOneWidget);
expect(find.text('Today'), findsOneWidget);
});
testWidgets('displays correct Arabic text with RTL', (tester) async {
await tester.pumpWidget(
const MaterialApp(
locale: Locale('ar'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SettingsListTile(),
),
),
);
// Verify RTL layout
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(
Directionality.of(tester.element(find.byType(ListTile))),
TextDirection.rtl,
);
});
testWidgets('pluralization works correctly', (tester) async {
// Test 0 messages
await _testMessageCount(tester, 0, 'No new messages');
// Test 1 message
await _testMessageCount(tester, 1, '1 message');
// Test many messages
await _testMessageCount(tester, 5, '5 messages');
});
});
}
Future<void> _testMessageCount(
WidgetTester tester,
int count,
String expected,
) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: ContactListTile(
contact: Contact(
fullName: 'Test',
lastContactDate: DateTime.now(),
unreadCount: count,
),
),
),
),
);
expect(find.text(expected), findsOneWidget);
}
Best Practices Summary
- Always use ARB files for ListTile text content
- Handle RTL automatically - ListTile does this, but verify custom layouts
- Format dates and numbers using the current locale
- Use pluralization for countable items (messages, items, files)
- Add semantic labels for accessibility
- Test in multiple locales including RTL languages
- Create reusable components for consistent localization patterns
- Don't hardcode any visible text - even "View" or "More"
Related Resources
- Flutter Drawer Localization
- Flutter Settings Localization
- Flutter Accessibility Localization
- Flutter RTL Guide
Conclusion
ListTile localization goes beyond translating text. It encompasses proper date and number formatting, RTL support, accessibility labels, and dynamic content handling. By following the patterns in this guide, you'll create ListTiles that feel native to users regardless of their language or region.
Start with basic title and subtitle localization, then progressively add locale-aware formatting, proper accessibility labels, and comprehensive testing to ensure your app works perfectly for global users.