Flutter ListView Localization: Scrollable Lists for Multilingual Apps
ListView is a Flutter widget that displays a scrollable list of children arranged linearly. In multilingual applications, ListView is essential for rendering translated list items of varying heights, building locale-aware settings screens, handling RTL scroll direction for horizontal lists, and efficiently displaying long lists of localized content with lazy loading.
Understanding ListView in Localization Context
ListView creates a scrollable list that can be built from an explicit list of children, a builder callback for lazy construction, or separated items with dividers between them. For multilingual apps, this enables:
- Lazy-built lists of translated content that render efficiently
- Variable-height items that adapt to translation length
- Horizontal lists that reverse direction in RTL locales
- Separated lists with localized divider labels
Why ListView Matters for Multilingual Apps
ListView provides:
- Lazy rendering: Only builds visible translated items for efficient scrolling
- Variable heights: Items grow to fit translated text without fixed constraints
- Builder pattern: Generates localized items on demand from data sources
- Separated variant: Adds dividers or section headers between translated items
Basic ListView Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedListViewExample extends StatelessWidget {
const LocalizedListViewExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.contactsTitle)),
body: ListView.builder(
itemCount: 30,
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text('${l10n.contactName} ${index + 1}'),
subtitle: Text(l10n.contactDescription),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
);
},
),
);
}
}
Advanced ListView Patterns for Localization
Grouped List with Localized Section Headers
ListView.builder with section headers that display translated category names.
class GroupedLocalizedList extends StatelessWidget {
const GroupedLocalizedList({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final sections = [
(l10n.favoritesSection, 3),
(l10n.recentSection, 5),
(l10n.allContactsSection, 15),
];
return Scaffold(
appBar: AppBar(title: Text(l10n.contactsTitle)),
body: ListView.builder(
itemCount: sections.fold<int>(
0, (sum, section) => sum + 1 + section.$2,
),
itemBuilder: (context, index) {
int offset = 0;
for (final (title, count) in sections) {
if (index == offset) {
return Container(
padding: const EdgeInsetsDirectional.fromSTEB(16, 16, 16, 8),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
);
}
if (index <= offset + count) {
final itemIndex = index - offset - 1;
return ListTile(
leading: const CircleAvatar(child: Icon(Icons.person)),
title: Text('${l10n.contactName} ${itemIndex + 1}'),
subtitle: Text(l10n.contactDescription),
);
}
offset += 1 + count;
}
return const SizedBox.shrink();
},
),
);
}
}
ListView.separated with Localized Dividers
Separated lists with translated date headers or category dividers between items.
class SeparatedLocalizedList extends StatelessWidget {
const SeparatedLocalizedList({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.notificationsTitle)),
body: ListView.separated(
itemCount: 20,
separatorBuilder: (context, index) {
if (index == 2 || index == 7) {
return Padding(
padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 16, 4),
child: Text(
index == 2 ? l10n.earlierTodayLabel : l10n.yesterdayLabel,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
return const Divider(height: 1);
},
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
index.isEven ? Icons.message : Icons.notifications,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
title: Text('${l10n.notificationTitle} ${index + 1}'),
subtitle: Text(l10n.notificationBody),
isThreeLine: true,
);
},
),
);
}
}
Horizontal ListView with RTL Support
Horizontal lists that automatically reverse scroll direction in RTL locales.
class HorizontalLocalizedList extends StatelessWidget {
const HorizontalLocalizedList({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsetsDirectional.fromSTEB(16, 16, 16, 8),
child: Text(
l10n.featuredCategoriesTitle,
style: Theme.of(context).textTheme.titleMedium,
),
),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsetsDirectional.only(start: 16),
itemCount: 10,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
child: SizedBox(
width: 100,
child: Card(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.category,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 8),
Text(
'${l10n.categoryLabel} ${index + 1}',
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
);
},
),
),
],
);
}
}
Empty State and Loading with Localized Messages
ListView with translated empty state and loading indicators.
class LocalizedListWithStates extends StatelessWidget {
final List<String> items;
final bool isLoading;
const LocalizedListWithStates({
super.key,
required this.items,
required this.isLoading,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(l10n.loadingMessage),
],
),
);
}
if (items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
l10n.emptyListTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
l10n.emptyListMessage,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
);
}
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(items[index]),
trailing: const Icon(Icons.chevron_right),
);
},
);
}
}
RTL Support and Bidirectional Layouts
ListView automatically respects RTL directionality. Vertical lists align content correctly, and horizontal lists reverse scroll direction.
class BidirectionalListView extends StatelessWidget {
const BidirectionalListView({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.itemsTitle)),
body: ListView.builder(
padding: const EdgeInsetsDirectional.all(16),
itemCount: 20,
itemBuilder: (context, index) {
return Card(
child: Padding(
padding: const EdgeInsetsDirectional.all(12),
child: Row(
children: [
CircleAvatar(child: Text('${index + 1}')),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${l10n.itemTitle} ${index + 1}',
style: Theme.of(context).textTheme.titleSmall,
),
Text(
l10n.itemSubtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const Icon(Icons.chevron_right),
],
),
),
);
},
),
);
}
}
Testing ListView 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 LocalizedListViewExample(),
);
}
testWidgets('ListView renders localized items', (tester) async {
await tester.pumpWidget(buildTestWidget());
await tester.pumpAndSettle();
expect(find.byType(ListView), findsOneWidget);
});
testWidgets('ListView works in RTL', (tester) async {
await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}
Best Practices
Use
ListView.builderfor long localized lists to lazily build only visible items, avoiding upfront rendering of all translations.Use
ListView.separatedwhen you need translated section dividers, date headers, or category labels between items.Use
EdgeInsetsDirectionalfor list padding and item insets to ensure correct spacing in both LTR and RTL layouts.Provide translated empty states with an icon, title, and description so users understand why the list is empty in their language.
Horizontal lists reverse automatically in RTL -- use
EdgeInsetsDirectional.only(start:)for leading padding to adapt correctly.Test scrolling with variable-height translated items to verify that items of different lengths don't cause layout jumps.
Conclusion
ListView is the most commonly used scrollable widget in Flutter, providing lazy rendering of list items for efficient performance. For multilingual apps, it handles translated items of varying heights, section headers with localized category names, RTL-aware horizontal scrolling, and empty/loading states with translated messages. By using the builder and separated variants with directional padding, you can build list experiences that scroll smoothly and display correctly across all supported languages.